From 867c15cf5de9de7ecbbc678d3aa92643d54b05be Mon Sep 17 00:00:00 2001 From: ppxd Date: Tue, 28 Jul 2026 11:11:25 +0800 Subject: [PATCH 1/3] Discover the current MSVC runtime during Windows builds Hosted runners can change Visual Studio generations and the contents of Microsoft.VC*.CRT. Copy the DLLs the active toolset actually provides, bundle them through a glob, and test VC143- and VC145-shaped layouts so optional runtime changes cannot break packaging again. --- .github/workflows/ci.yml | 5 + .github/workflows/windows-vulkan.yml | 3 +- app/src-tauri/build.rs | 72 ++---- .../build_support/windows_runtime.rs | 229 ++++++++++++++++++ app/src-tauri/msvc-runtime-dlls.txt | 13 - app/src-tauri/msvc-runtime-required.txt | 6 + app/src-tauri/tauri.windows.conf.json | 13 +- scripts/smoke-windows-installer.ps1 | 27 ++- scripts/verify-windows-runtime-config.mjs | 24 +- scripts/verify-windows-runtime.sh | 2 +- 10 files changed, 292 insertions(+), 102 deletions(-) create mode 100644 app/src-tauri/build_support/windows_runtime.rs delete mode 100644 app/src-tauri/msvc-runtime-dlls.txt create mode 100644 app/src-tauri/msvc-runtime-required.txt 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..03ba064 100644 --- a/scripts/smoke-windows-installer.ps1 +++ b/scripts/smoke-windows-installer.ps1 @@ -51,20 +51,23 @@ 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 + # loaded at startup must come from the app directory, regardless of the current MSVC toolset's + # exact DLL set. + $runtimeModules = @($process.Modules | Where-Object { + $_.ModuleName -match "^(concrt|msvcp|vcamp|vccorlib|vcomp|vcruntime).*\.dll$" + }) + 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..3f4c6da 100755 --- a/scripts/verify-windows-runtime.sh +++ b/scripts/verify-windows-runtime.sh @@ -48,7 +48,7 @@ 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 [[ -z "$expected" || "$expected" == \#* ]] && continue files+=("$expected") From bc0ea87f53a0dee48102e6dd61bd7c0d836b52b8 Mon Sep 17 00:00:00 2001 From: ppxd Date: Tue, 28 Jul 2026 11:26:26 +0800 Subject: [PATCH 2/3] Verify staged MSVC runtime layout The build staging directory keeps redistributable DLLs isolated from sherpa files, while Tauri flattens them in packages. Accept both representations so CI checks the same files before and after packaging. --- scripts/verify-windows-runtime.sh | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/scripts/verify-windows-runtime.sh b/scripts/verify-windows-runtime.sh index 3f4c6da..6d2f341 100755 --- a/scripts/verify-windows-runtime.sh +++ b/scripts/verify-windows-runtime.sh @@ -72,6 +72,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 From ab0d7d5b4e38fbc16052d2a3dae221629b8319ab Mon Sep 17 00:00:00 2001 From: ppxd Date: Tue, 28 Jul 2026 11:49:31 +0800 Subject: [PATCH 3/3] Exercise the actual Windows install path Pass the NSIS destination switch in its required unquoted form, distinguish bundled MSVC redistributables from the system UCRT, and normalize CRLF manifest entries before Bash size checks. --- scripts/smoke-windows-installer.ps1 | 19 +++++++++++++++---- scripts/verify-windows-runtime.sh | 3 +++ 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/scripts/smoke-windows-installer.ps1 b/scripts/smoke-windows-installer.ps1 index 03ba064..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 @@ -52,10 +54,19 @@ try { } # Do not let a current GitHub runner hide a missing redistributable. Every VC++ runtime module - # loaded at startup must come from the app directory, regardless of the current MSVC toolset's - # exact DLL set. + # 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 { - $_.ModuleName -match "^(concrt|msvcp|vcamp|vccorlib|vcomp|vcruntime).*\.dll$" + $bundledRuntimeNames.ContainsKey($_.ModuleName) }) if (-not ($runtimeModules | Where-Object { $_.ModuleName -ieq "msvcp140.dll" })) { throw "Packaged Wisp.exe did not load msvcp140.dll" diff --git a/scripts/verify-windows-runtime.sh b/scripts/verify-windows-runtime.sh index 6d2f341..4d51c39 100755 --- a/scripts/verify-windows-runtime.sh +++ b/scripts/verify-windows-runtime.sh @@ -50,6 +50,9 @@ files=( script_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) 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"