diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9d5e118..49d2c89 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -33,6 +33,11 @@ jobs: # source (no native deps), so it runs cleanly on Linux even though the app targets macOS/Windows. - name: rustfmt (app crate) run: cargo fmt --manifest-path app/src-tauri/Cargo.toml -- --check + # Exercise the exact dependency-free module used by build.rs with VC143/VC145-shaped fixtures. + - name: Test Windows runtime staging + run: | + rustc --test app/src-tauri/build_support/windows_runtime.rs -o /tmp/windows-runtime-tests + /tmp/windows-runtime-tests - name: clippy run: cargo clippy --all-targets --all-features -- -D warnings diff --git a/.github/workflows/windows-vulkan.yml b/.github/workflows/windows-vulkan.yml index 0b5c2c4..ad42d59 100644 --- a/.github/workflows/windows-vulkan.yml +++ b/.github/workflows/windows-vulkan.yml @@ -14,7 +14,8 @@ on: - ".github/workflows/windows-vulkan.yml" - "app/src-tauri/build.rs" - "app/src-tauri/Cargo.toml" - - "app/src-tauri/msvc-runtime-dlls.txt" + - "app/src-tauri/build_support/**" + - "app/src-tauri/msvc-runtime-required.txt" - "app/src-tauri/tauri*.json" - "crates/wisp-engine-whisper-cpp/**" - "scripts/smoke-windows-installer.ps1" diff --git a/app/src-tauri/build.rs b/app/src-tauri/build.rs index 635818b..cb0b0e0 100644 --- a/app/src-tauri/build.rs +++ b/app/src-tauri/build.rs @@ -1,3 +1,7 @@ +#[cfg(target_os = "windows")] +#[path = "build_support/windows_runtime.rs"] +mod windows_runtime; + fn main() { #[cfg(target_os = "macos")] { @@ -46,7 +50,8 @@ fn stage_windows_runtime_libs() { for dll in SHERPA_DLLS { let src = target_dir.join(dll); let dst = staged.join(dll); - copy_if_changed(&src, &dst); + windows_runtime::copy_if_changed(&src, &dst) + .unwrap_or_else(|e| panic!("stage {}: {e}", src.display())); } stage_msvc_runtime(&staged); @@ -59,7 +64,7 @@ fn stage_windows_runtime_libs() { #[cfg(target_os = "windows")] fn stage_msvc_runtime(staged: &std::path::Path) { println!("cargo:rerun-if-env-changed=VCToolsRedistDir"); - println!("cargo:rerun-if-changed=msvc-runtime-dlls.txt"); + println!("cargo:rerun-if-changed=msvc-runtime-required.txt"); let source = msvc_runtime_dir().unwrap_or_else(|| { panic!( "could not find the x64 MSVC redistributable directory; run Cargo from an MSVC \ @@ -67,18 +72,17 @@ fn stage_msvc_runtime(staged: &std::path::Path) { ) }); - for entry in include_str!("msvc-runtime-dlls.txt").lines() { - let entry = entry.trim(); - if entry.is_empty() || entry.starts_with('#') { - continue; - } - let dll = entry - .split_once(':') - .map(|(name, _)| name) - .unwrap_or(entry); - let src = source.join(dll); - println!("cargo:rerun-if-changed={}", src.display()); - copy_if_changed(&src, &staged.join(dll)); + let required: Vec<_> = include_str!("msvc-runtime-required.txt") + .lines() + .map(str::trim) + .filter(|entry| !entry.is_empty() && !entry.starts_with('#')) + .map(|entry| entry.split_once(':').map(|(name, _)| name).unwrap_or(entry)) + .collect(); + let staged = staged.join("msvc"); + let runtime_dlls = windows_runtime::sync_runtime_dlls(&source, &staged, &required) + .unwrap_or_else(|e| panic!("stage MSVC runtime from {}: {e}", source.display())); + for dll in runtime_dlls { + println!("cargo:rerun-if-changed={}", dll.display()); } } @@ -88,7 +92,7 @@ fn stage_msvc_runtime(staged: &std::path::Path) { fn msvc_runtime_dir() -> Option { std::env::var_os("VCToolsRedistDir") .map(std::path::PathBuf::from) - .and_then(|root| find_crt_dir(&root)) + .and_then(|root| windows_runtime::find_crt_dir(&root)) .or_else(msvc_runtime_dir_from_vswhere) } @@ -122,43 +126,7 @@ fn msvc_runtime_dir_from_vswhere() -> Option { .join("VC") .join("Redist") .join("MSVC"); - newest_crt_dir(&redist_root) -} - -#[cfg(target_os = "windows")] -fn newest_crt_dir(redist_root: &std::path::Path) -> Option { - let mut versions: Vec<_> = std::fs::read_dir(redist_root) - .ok()? - .filter_map(Result::ok) - .filter(|entry| entry.file_type().is_ok_and(|kind| kind.is_dir())) - .map(|entry| entry.path()) - .collect(); - versions.sort_by(|a, b| b.file_name().cmp(&a.file_name())); - versions.into_iter().find_map(|root| find_crt_dir(&root)) -} - -#[cfg(target_os = "windows")] -fn find_crt_dir(redist_root: &std::path::Path) -> Option { - let x64 = redist_root.join("x64"); - std::fs::read_dir(x64) - .ok()? - .filter_map(Result::ok) - .find(|entry| { - entry.file_type().is_ok_and(|kind| kind.is_dir()) - && entry.file_name().to_string_lossy().starts_with("Microsoft.VC") - && entry.file_name().to_string_lossy().ends_with(".CRT") - && entry.path().join("msvcp140.dll").is_file() - }) - .map(|entry| entry.path()) -} - -#[cfg(target_os = "windows")] -fn copy_if_changed(src: &std::path::Path, dst: &std::path::Path) { - let source = std::fs::read(src).unwrap_or_else(|e| panic!("read {}: {e}", src.display())); - let current = std::fs::read(dst).ok(); - if current.as_deref() != Some(source.as_slice()) { - std::fs::write(dst, source).unwrap_or_else(|e| panic!("stage {}: {e}", src.display())); - } + windows_runtime::newest_crt_dir(&redist_root) } /// The cargo `target/` dir, found by walking up from `OUT_DIR` — the same place diff --git a/app/src-tauri/build_support/windows_runtime.rs b/app/src-tauri/build_support/windows_runtime.rs new file mode 100644 index 0000000..7cd6931 --- /dev/null +++ b/app/src-tauri/build_support/windows_runtime.rs @@ -0,0 +1,229 @@ +use std::io; +use std::path::{Path, PathBuf}; + +pub fn sync_runtime_dlls( + source: &Path, + staged: &Path, + required: &[&str], +) -> io::Result> { + let runtime_dlls = discover_runtime_dlls(source)?; + validate_required_runtime(&runtime_dlls, required)?; + std::fs::create_dir_all(staged)?; + remove_stale_runtime_dlls(staged, &runtime_dlls)?; + + for source in &runtime_dlls { + let file_name = source.file_name().expect("discovered DLL has a file name"); + copy_if_changed(source, &staged.join(file_name))?; + } + + Ok(runtime_dlls) +} + +pub fn newest_crt_dir(redist_root: &Path) -> Option { + let mut versions: Vec<_> = std::fs::read_dir(redist_root) + .ok()? + .filter_map(Result::ok) + .filter(|entry| entry.file_type().is_ok_and(|kind| kind.is_dir())) + .map(|entry| entry.path()) + .collect(); + versions.sort_by(|a, b| b.file_name().cmp(&a.file_name())); + versions.into_iter().find_map(|root| find_crt_dir(&root)) +} + +pub fn find_crt_dir(redist_root: &Path) -> Option { + let x64 = redist_root.join("x64"); + std::fs::read_dir(x64) + .ok()? + .filter_map(Result::ok) + .find(|entry| { + entry.file_type().is_ok_and(|kind| kind.is_dir()) + && entry + .file_name() + .to_string_lossy() + .starts_with("Microsoft.VC") + && entry.file_name().to_string_lossy().ends_with(".CRT") + && entry.path().join("msvcp140.dll").is_file() + }) + .map(|entry| entry.path()) +} + +fn discover_runtime_dlls(source: &Path) -> io::Result> { + let mut dlls = Vec::new(); + for entry in std::fs::read_dir(source)? { + let entry = entry?; + if entry.file_type()?.is_file() && has_dll_extension(&entry.path()) { + dlls.push(entry.path()); + } + } + dlls.sort_by_key(|path| file_name_lowercase(path)); + Ok(dlls) +} + +fn validate_required_runtime(runtime_dlls: &[PathBuf], required: &[&str]) -> io::Result<()> { + let present: std::collections::HashSet<_> = runtime_dlls + .iter() + .map(|path| file_name_lowercase(path)) + .collect(); + let missing: Vec<_> = required + .iter() + .filter(|name| !present.contains(&name.to_ascii_lowercase())) + .collect(); + if missing.is_empty() { + return Ok(()); + } + + Err(io::Error::new( + io::ErrorKind::NotFound, + format!( + "MSVC runtime directory {} is missing required files: {}", + runtime_dlls + .first() + .and_then(|path| path.parent()) + .unwrap_or_else(|| Path::new("")) + .display(), + missing.into_iter().copied().collect::>().join(", ") + ), + )) +} + +fn remove_stale_runtime_dlls(staged: &Path, current: &[PathBuf]) -> io::Result<()> { + let current_names: std::collections::HashSet<_> = current + .iter() + .map(|path| file_name_lowercase(path)) + .collect(); + for entry in std::fs::read_dir(staged)? { + let entry = entry?; + if entry.file_type()?.is_file() + && has_dll_extension(&entry.path()) + && !current_names.contains(&file_name_lowercase(&entry.path())) + { + std::fs::remove_file(entry.path())?; + } + } + Ok(()) +} + +fn has_dll_extension(path: &Path) -> bool { + path.extension() + .is_some_and(|extension| extension.eq_ignore_ascii_case("dll")) +} + +fn file_name_lowercase(path: &Path) -> String { + path.file_name() + .expect("runtime path has a file name") + .to_string_lossy() + .to_ascii_lowercase() +} + +pub fn copy_if_changed(source: &Path, destination: &Path) -> io::Result<()> { + let source_bytes = std::fs::read(source)?; + if std::fs::read(destination).ok().as_deref() != Some(source_bytes.as_slice()) { + std::fs::write(destination, source_bytes)?; + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::atomic::{AtomicUsize, Ordering}; + + static NEXT_DIR: AtomicUsize = AtomicUsize::new(0); + const REQUIRED: &[&str] = &[ + "msvcp140.dll", + "msvcp140_1.dll", + "vcruntime140.dll", + "vcruntime140_1.dll", + ]; + + struct TestDir(PathBuf); + + impl TestDir { + fn new(name: &str) -> Self { + let unique = NEXT_DIR.fetch_add(1, Ordering::Relaxed); + let path = std::env::temp_dir().join(format!( + "wisp-runtime-{name}-{}-{unique}", + std::process::id() + )); + std::fs::create_dir(&path).expect("create unique test dir"); + Self(path) + } + + fn join(&self, name: &str) -> PathBuf { + self.0.join(name) + } + } + + impl Drop for TestDir { + fn drop(&mut self) { + std::fs::remove_dir_all(&self.0).expect("clean unique test dir"); + } + } + + fn write_runtime_set(dir: &TestDir, extra: &str) { + write_runtime_set_at(&dir.0, extra); + } + + fn write_runtime_set_at(dir: &Path, extra: &str) { + std::fs::create_dir_all(dir).unwrap(); + for name in REQUIRED { + std::fs::write(dir.join(name), format!("fixture {name}")).unwrap(); + } + std::fs::write(dir.join(extra), b"toolset-specific").unwrap(); + std::fs::write(dir.join("README.txt"), b"not a runtime").unwrap(); + } + + #[test] + fn syncs_the_dlls_present_in_each_toolset_and_removes_stale_files() { + for (toolset, extra) in [ + ("vc143", "vcamp140.dll"), + ("vc145", "msvcp140_atomic_wait.dll"), + ] { + let source = TestDir::new(toolset); + let staged = TestDir::new("staged"); + write_runtime_set(&source, extra); + std::fs::write(staged.join("removed-in-new-toolset.dll"), b"stale").unwrap(); + + let copied = sync_runtime_dlls(&source.0, &staged.0, REQUIRED).unwrap(); + let mut names: Vec<_> = copied + .iter() + .map(|path| path.file_name().unwrap().to_string_lossy().into_owned()) + .collect(); + names.sort(); + + let mut expected: Vec<_> = REQUIRED.iter().map(|name| (*name).to_owned()).collect(); + expected.push(extra.to_owned()); + expected.sort(); + assert_eq!(names, expected); + assert!(!staged.join("removed-in-new-toolset.dll").exists()); + assert!(!staged.join("README.txt").exists()); + assert_eq!( + std::fs::read(staged.join(extra)).unwrap(), + b"toolset-specific" + ); + } + } + + #[test] + fn rejects_a_toolset_missing_a_required_runtime() { + let source = TestDir::new("incomplete"); + let staged = TestDir::new("staged"); + std::fs::write(source.join("msvcp140.dll"), b"only one").unwrap(); + + let error = sync_runtime_dlls(&source.0, &staged.0, REQUIRED).unwrap_err(); + + assert!(error.to_string().contains("vcruntime140.dll")); + } + + #[test] + fn resolves_the_newest_crt_without_assuming_a_toolset_generation() { + let redist = TestDir::new("redist"); + let vc143 = redist.join("14.44.35211/x64/Microsoft.VC143.CRT"); + let vc145 = redist.join("14.51.36231/x64/Microsoft.VC145.CRT"); + write_runtime_set_at(&vc143, "vcamp140.dll"); + write_runtime_set_at(&vc145, "msvcp140_atomic_wait.dll"); + + assert_eq!(newest_crt_dir(&redist.0), Some(vc145.clone())); + assert_eq!(find_crt_dir(&redist.join("14.44.35211")), Some(vc143)); + } +} diff --git a/app/src-tauri/msvc-runtime-dlls.txt b/app/src-tauri/msvc-runtime-dlls.txt deleted file mode 100644 index 9206993..0000000 --- a/app/src-tauri/msvc-runtime-dlls.txt +++ /dev/null @@ -1,13 +0,0 @@ -# App-local x64 Microsoft.VC143.CRT files, with the smallest plausible release size in bytes. -concrt140.dll:100000 -msvcp140.dll:100000 -msvcp140_1.dll:10000 -msvcp140_2.dll:100000 -msvcp140_atomic_wait.dll:10000 -msvcp140_codecvt_ids.dll:10000 -vcamp140.dll:100000 -vccorlib140.dll:100000 -vcomp140.dll:100000 -vcruntime140.dll:50000 -vcruntime140_1.dll:10000 -vcruntime140_threads.dll:10000 diff --git a/app/src-tauri/msvc-runtime-required.txt b/app/src-tauri/msvc-runtime-required.txt new file mode 100644 index 0000000..92d09c7 --- /dev/null +++ b/app/src-tauri/msvc-runtime-required.txt @@ -0,0 +1,6 @@ +# Minimum app-local MSVC ABI required at startup, with the smallest plausible release size in bytes. +# build.rs discovers and stages every DLL that the current Microsoft.VC*.CRT toolset actually ships. +msvcp140.dll:100000 +msvcp140_1.dll:10000 +vcruntime140.dll:50000 +vcruntime140_1.dll:10000 diff --git a/app/src-tauri/tauri.windows.conf.json b/app/src-tauri/tauri.windows.conf.json index fa7e89d..401a9dc 100644 --- a/app/src-tauri/tauri.windows.conf.json +++ b/app/src-tauri/tauri.windows.conf.json @@ -3,18 +3,7 @@ "bundle": { "resources": { "resources/silero_vad.onnx": "resources/silero_vad.onnx", - "windows-runtime/concrt140.dll": "concrt140.dll", - "windows-runtime/msvcp140.dll": "msvcp140.dll", - "windows-runtime/msvcp140_1.dll": "msvcp140_1.dll", - "windows-runtime/msvcp140_2.dll": "msvcp140_2.dll", - "windows-runtime/msvcp140_atomic_wait.dll": "msvcp140_atomic_wait.dll", - "windows-runtime/msvcp140_codecvt_ids.dll": "msvcp140_codecvt_ids.dll", - "windows-runtime/vcamp140.dll": "vcamp140.dll", - "windows-runtime/vccorlib140.dll": "vccorlib140.dll", - "windows-runtime/vcomp140.dll": "vcomp140.dll", - "windows-runtime/vcruntime140.dll": "vcruntime140.dll", - "windows-runtime/vcruntime140_1.dll": "vcruntime140_1.dll", - "windows-runtime/vcruntime140_threads.dll": "vcruntime140_threads.dll", + "windows-runtime/msvc/*.dll": "", "windows-runtime/onnxruntime.dll": "onnxruntime.dll", "windows-runtime/onnxruntime_providers_shared.dll": "onnxruntime_providers_shared.dll", "windows-runtime/sherpa-onnx-c-api.dll": "sherpa-onnx-c-api.dll", diff --git a/scripts/smoke-windows-installer.ps1 b/scripts/smoke-windows-installer.ps1 index 6147ffd..9fa324f 100644 --- a/scripts/smoke-windows-installer.ps1 +++ b/scripts/smoke-windows-installer.ps1 @@ -17,9 +17,11 @@ $previousSmokeTest = $env:WISP_SMOKE_TEST try { # Exercise the actual NSIS install path. Extracting the archive directly can miss install-time # placement/renaming mistakes and does not reproduce the way users launch Wisp. + # NSIS requires /D= to be the final argument and does not accept quotes around it. The + # generated smoke path contains no spaces, so pass the switch verbatim. $installerProcess = Start-Process ` -FilePath $Installer ` - -ArgumentList @("/S", "/D=`"$installDirectory`"") ` + -ArgumentList @("/S", "/D=$installDirectory") ` -PassThru ` -Wait ` -WindowStyle Hidden @@ -51,20 +53,32 @@ try { throw "Packaged Wisp.exe did not render its frontend within 30 seconds" } - # Do not let a current GitHub runner hide a missing redistributable. The process must use the - # app-local MSVC runtime that was built alongside whisper.cpp, not System32's possibly older copy. - $msvcp = $process.Modules | - Where-Object { $_.ModuleName -ieq "msvcp140.dll" } | - Select-Object -First 1 - $expectedMsvcp = Join-Path $installDirectory "msvcp140.dll" - if ($null -eq $msvcp) { + # Do not let a current GitHub runner hide a missing redistributable. Every VC++ runtime module + # that is bundled for the current toolset and loaded at startup must come from the app + # directory. Match against the discovered package set instead of a broad prefix: Windows itself + # supplies msvcp_win.dll as part of the UCRT, and it must remain machine-wide. + $bundledRuntimeNames = @{} + Get-ChildItem -LiteralPath $installDirectory -File -Filter "*.dll" | + Where-Object { + $_.Name -match "^(concrt|msvcp|vcamp|vccorlib|vcomp|vcruntime).*\.dll$" + } | + ForEach-Object { + $bundledRuntimeNames[$_.Name] = $true + } + $runtimeModules = @($process.Modules | Where-Object { + $bundledRuntimeNames.ContainsKey($_.ModuleName) + }) + if (-not ($runtimeModules | Where-Object { $_.ModuleName -ieq "msvcp140.dll" })) { throw "Packaged Wisp.exe did not load msvcp140.dll" } - if (-not [System.IO.Path]::GetFullPath($msvcp.FileName).Equals( - [System.IO.Path]::GetFullPath($expectedMsvcp), - [System.StringComparison]::OrdinalIgnoreCase - )) { - throw "Packaged Wisp.exe loaded the machine-wide VC++ runtime: $($msvcp.FileName)" + foreach ($module in $runtimeModules) { + $expected = Join-Path $installDirectory $module.ModuleName + if (-not [System.IO.Path]::GetFullPath($module.FileName).Equals( + [System.IO.Path]::GetFullPath($expected), + [System.StringComparison]::OrdinalIgnoreCase + )) { + throw "Packaged Wisp.exe loaded a machine-wide VC++ runtime: $($module.FileName)" + } } if (-not $process.CloseMainWindow()) { diff --git a/scripts/verify-windows-runtime-config.mjs b/scripts/verify-windows-runtime-config.mjs index bbae73b..d426f17 100644 --- a/scripts/verify-windows-runtime-config.mjs +++ b/scripts/verify-windows-runtime-config.mjs @@ -3,7 +3,10 @@ import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); -const manifestPath = resolve(root, "app/src-tauri/msvc-runtime-dlls.txt"); +const manifestPath = resolve( + root, + "app/src-tauri/msvc-runtime-required.txt", +); const configPath = resolve(root, "app/src-tauri/tauri.windows.conf.json"); const runtimeNames = readFileSync(manifestPath, "utf8") @@ -20,7 +23,7 @@ if (duplicate) { const config = JSON.parse(readFileSync(configPath, "utf8")); const resources = config.bundle?.resources ?? {}; -const configuredRuntimeNames = Object.entries(resources) +const explicitRuntimeNames = Object.entries(resources) .filter( ([source, target]) => source.startsWith("windows-runtime/") && @@ -29,18 +32,17 @@ const configuredRuntimeNames = Object.entries(resources) .map(([, target]) => target) .sort(); -for (const name of runtimeNames) { - const source = `windows-runtime/${name}`; - if (resources[source] !== name) { - throw new Error(`missing Windows bundle mapping: "${source}": "${name}"`); - } +const runtimeGlob = "windows-runtime/msvc/*.dll"; +if (resources[runtimeGlob] !== "") { + throw new Error(`missing Windows bundle glob mapping: "${runtimeGlob}": ""`); } -const expected = [...runtimeNames].sort(); -if (JSON.stringify(configuredRuntimeNames) !== JSON.stringify(expected)) { +if (explicitRuntimeNames.length > 0) { throw new Error( - `MSVC runtime config drift: expected ${expected.join(", ")}, got ${configuredRuntimeNames.join(", ")}`, + `MSVC runtime config must use the toolset-neutral glob, not explicit files: ${explicitRuntimeNames.join(", ")}`, ); } -console.log(`Verified ${runtimeNames.length} MSVC runtime bundle mappings`); +console.log( + `Verified wildcard MSVC runtime bundling and ${runtimeNames.length} required ABI files`, +); diff --git a/scripts/verify-windows-runtime.sh b/scripts/verify-windows-runtime.sh index 7c2e59a..4d51c39 100755 --- a/scripts/verify-windows-runtime.sh +++ b/scripts/verify-windows-runtime.sh @@ -48,8 +48,11 @@ files=( # load-bearing: relying on an older machine-wide VC++ runtime can crash in C++ static # initialization before Tauri/WebView2 starts. The same manifest drives build.rs. script_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) -msvc_manifest="$script_dir/../app/src-tauri/msvc-runtime-dlls.txt" +msvc_manifest="$script_dir/../app/src-tauri/msvc-runtime-required.txt" while IFS= read -r expected; do + # Git may materialize this manifest with CRLF on Windows. Strip the carriage return before using + # the minimum size in Bash arithmetic. + expected=${expected%$'\r'} [[ -z "$expected" || "$expected" == \#* ]] && continue files+=("$expected") done < "$msvc_manifest" @@ -72,6 +75,12 @@ for expected in "${files[@]}"; do name=${expected%%:*} minimum=${expected##*:} file="$runtime_dir/$name" + # build.rs keeps the discovered MSVC files in a subdirectory so its stale-file cleanup cannot + # remove sherpa DLLs. Tauri flattens that resource directory into the installer root, so accept + # both the pre-package staging layout and the extracted/installed package layout. + if [[ ! -f "$file" && -f "$runtime_dir/msvc/$name" ]]; then + file="$runtime_dir/msvc/$name" + fi if [[ ! -f "$file" ]]; then echo "Missing Windows package file: $name" >&2 exit 1