From 6a08a7af78fbd55624784efed18de2457194d377 Mon Sep 17 00:00:00 2001 From: ppxd Date: Tue, 28 Jul 2026 10:26:31 +0800 Subject: [PATCH] Bundle the matching MSVC runtime on Windows Windows installers currently rely on the machine-wide VC++ runtime even though whisper.cpp is built with the current release toolchain. Older redistributables can therefore crash during C++ static initialization before Tauri starts. Package the matching app-local runtime and make installer smoke tests prove that it is loaded. --- .github/workflows/ci.yml | 8 ++ .github/workflows/windows-gpu.yml | 8 +- .github/workflows/windows-vulkan.yml | 4 + app/src-tauri/.gitignore | 2 +- app/src-tauri/build.rs | 121 ++++++++++++++++++++-- app/src-tauri/msvc-runtime-dlls.txt | 13 +++ app/src-tauri/tauri.conf.json | 3 +- app/src-tauri/tauri.windows.conf.json | 12 +++ scripts/smoke-windows-installer.ps1 | 66 ++++++++++-- scripts/verify-windows-runtime-config.mjs | 46 ++++++++ scripts/verify-windows-runtime.sh | 11 ++ 11 files changed, 272 insertions(+), 22 deletions(-) create mode 100644 app/src-tauri/msvc-runtime-dlls.txt create mode 100644 scripts/verify-windows-runtime-config.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fe0f7f5..9d5e118 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -81,6 +81,12 @@ jobs: steps: - uses: actions/checkout@v4 - uses: dtolnay/rust-toolchain@stable + # Exposes VCToolsRedistDir so build.rs can app-local deploy the exact VC++ runtime used by the + # release compiler. The packaging smoke test rejects any fallback to the runner's System32. + - name: Set up the MSVC build environment + uses: ilammy/msvc-dev-cmd@v1 + with: + arch: x64 - uses: actions/setup-node@v4 with: node-version: 20 @@ -89,6 +95,8 @@ jobs: run: | npm ci npm run build + - name: Verify Windows runtime config + run: node scripts/verify-windows-runtime-config.mjs # Build the sherpa engine first so sherpa-rs-sys drops the onnxruntime / sherpa DLLs into the # target dir before the app's build.rs stages them for tauri's `resources` check. Cargo doesn't # order one build script before a dependent's, so a single `cargo build` can run the app's diff --git a/.github/workflows/windows-gpu.yml b/.github/workflows/windows-gpu.yml index 626a4b3..bdae33c 100644 --- a/.github/workflows/windows-gpu.yml +++ b/.github/workflows/windows-gpu.yml @@ -27,6 +27,10 @@ jobs: steps: - uses: actions/checkout@v4 - uses: dtolnay/rust-toolchain@stable + - name: Set up the MSVC build environment + uses: ilammy/msvc-dev-cmd@v1 + with: + arch: x64 - uses: actions/setup-node@v4 with: node-version: 20 @@ -37,8 +41,8 @@ jobs: npm run build # Build twice: the first pass compiles the engine + emits its DLLs (and may trip the same # build.rs DLL-staging race the prebuilt CPU job pre-stages around); the second pass, with the - # engine now cached, completes the app once the DLLs are present. CMake + MSVC are preinstalled - # on windows-latest, so no extra toolchain is needed. + # engine now cached, completes the app once the DLLs are present. The MSVC setup above also + # exposes VCToolsRedistDir so build.rs stages the matching app-local VC++ runtime. - name: Build the app with DirectML from source (~2h) shell: pwsh run: | diff --git a/.github/workflows/windows-vulkan.yml b/.github/workflows/windows-vulkan.yml index a78ada5..0b5c2c4 100644 --- a/.github/workflows/windows-vulkan.yml +++ b/.github/workflows/windows-vulkan.yml @@ -14,11 +14,13 @@ 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/tauri*.json" - "crates/wisp-engine-whisper-cpp/**" - "scripts/smoke-windows-installer.ps1" - "scripts/stage-vulkan-loader.sh" - "scripts/verify-windows-runtime.sh" + - "scripts/verify-windows-runtime-config.mjs" env: CARGO_TERM_COLOR: always @@ -57,6 +59,8 @@ jobs: - name: Install the frontend deps working-directory: app run: npm ci + - name: Verify Windows runtime config + run: node scripts/verify-windows-runtime-config.mjs # `tauri build` runs the frontend build (beforeBuildCommand) + the cargo release build + the NSIS # bundler in one step, producing the real Windows GPU installer: default features (sherpa on CPU) # plus whisper-vulkan (whisper on the GPU, CPU fallback). This also proves the exact release diff --git a/app/src-tauri/.gitignore b/app/src-tauri/.gitignore index c3965fe..7394e87 100644 --- a/app/src-tauri/.gitignore +++ b/app/src-tauri/.gitignore @@ -6,5 +6,5 @@ # will have schema files for capabilities auto-completion /gen/schemas -# Staged at build time by build.rs: the sherpa-onnx/onnxruntime DLLs bundled into the Windows app +# Staged at build time by build.rs: sherpa/onnxruntime plus the matching MSVC runtime for Windows /windows-runtime/ diff --git a/app/src-tauri/build.rs b/app/src-tauri/build.rs index a5fe442..635818b 100644 --- a/app/src-tauri/build.rs +++ b/app/src-tauri/build.rs @@ -32,7 +32,7 @@ fn stage_windows_runtime_libs() { // The DLLs sherpa-onnx's C API depends on at runtime, as shipped in the prebuilt win-x64-shared // package sherpa-rs-sys downloads. - const DLLS: &[&str] = &[ + const SHERPA_DLLS: &[&str] = &[ "onnxruntime.dll", "onnxruntime_providers_shared.dll", "sherpa-onnx-c-api.dll", @@ -43,16 +43,121 @@ fn stage_windows_runtime_libs() { let staged = Path::new(env!("CARGO_MANIFEST_DIR")).join("windows-runtime"); std::fs::create_dir_all(&staged).expect("create windows-runtime dir"); - for dll in DLLS { + for dll in SHERPA_DLLS { let src = target_dir.join(dll); let dst = staged.join(dll); - let current = std::fs::metadata(&dst) - .ok() - .zip(std::fs::metadata(&src).ok()) - .is_some_and(|(d, s)| d.len() == s.len()); - if !current { - std::fs::copy(&src, &dst).unwrap_or_else(|e| panic!("stage {}: {e}", src.display())); + copy_if_changed(&src, &dst); + } + + stage_msvc_runtime(&staged); +} + +/// App-local deployment of the MSVC runtime used to compile whisper.cpp. The runtime installed on a +/// user's machine may be older than the release runner's toolset; MSVC only guarantees compatibility +/// when the runtime is at least as new as the build tools. A mismatch can crash in C++ static +/// initialization before Tauri or WebView2 has started. +#[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"); + let source = msvc_runtime_dir().unwrap_or_else(|| { + panic!( + "could not find the x64 MSVC redistributable directory; run Cargo from an MSVC \ + developer shell or install the Visual C++ x64 build tools" + ) + }); + + 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)); + } +} + +/// Resolves the current toolset's x64 CRT directory. `ilammy/msvc-dev-cmd` supplies the env var in +/// release CI; `vswhere` keeps ordinary local Cargo builds working outside a Developer shell. +#[cfg(target_os = "windows")] +fn msvc_runtime_dir() -> Option { + std::env::var_os("VCToolsRedistDir") + .map(std::path::PathBuf::from) + .and_then(|root| find_crt_dir(&root)) + .or_else(msvc_runtime_dir_from_vswhere) +} + +#[cfg(target_os = "windows")] +fn msvc_runtime_dir_from_vswhere() -> Option { + use std::process::Command; + + let program_files_x86 = std::env::var_os("ProgramFiles(x86)")?; + let vswhere = std::path::PathBuf::from(program_files_x86) + .join("Microsoft Visual Studio") + .join("Installer") + .join("vswhere.exe"); + let output = Command::new(vswhere) + .args([ + "-latest", + "-products", + "*", + "-requires", + "Microsoft.VisualStudio.Component.VC.Tools.x86.x64", + "-property", + "installationPath", + ]) + .output() + .ok()?; + if !output.status.success() { + return None; + } + + let installation = String::from_utf8(output.stdout).ok()?; + let redist_root = std::path::Path::new(installation.trim()) + .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())); } } diff --git a/app/src-tauri/msvc-runtime-dlls.txt b/app/src-tauri/msvc-runtime-dlls.txt new file mode 100644 index 0000000..9206993 --- /dev/null +++ b/app/src-tauri/msvc-runtime-dlls.txt @@ -0,0 +1,13 @@ +# 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/tauri.conf.json b/app/src-tauri/tauri.conf.json index 0a60e8c..bf8ecf8 100644 --- a/app/src-tauri/tauri.conf.json +++ b/app/src-tauri/tauri.conf.json @@ -17,8 +17,7 @@ "width": 1040, "height": 740, "minWidth": 820, - "minHeight": 640, - "additionalBrowserArgs": "--disable-features=msWebOOUI,msPdfOOUI,msSmartScreenProtection --disable-gpu" + "minHeight": 640 } ], "security": { diff --git a/app/src-tauri/tauri.windows.conf.json b/app/src-tauri/tauri.windows.conf.json index a337335..fa7e89d 100644 --- a/app/src-tauri/tauri.windows.conf.json +++ b/app/src-tauri/tauri.windows.conf.json @@ -3,6 +3,18 @@ "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/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 bcc40a6..6147ffd 100644 --- a/scripts/smoke-windows-installer.ps1 +++ b/scripts/smoke-windows-installer.ps1 @@ -10,20 +10,30 @@ if (-not (Test-Path -LiteralPath $Installer -PathType Leaf)) { throw "Windows installer not found: $Installer" } -$extractDirectory = Join-Path ([System.IO.Path]::GetTempPath()) "wisp-windows-smoke-$([guid]::NewGuid())" +$installDirectory = Join-Path ([System.IO.Path]::GetTempPath()) "wisp-windows-smoke-$([guid]::NewGuid())" $process = $null $previousSmokeTest = $env:WISP_SMOKE_TEST try { - New-Item -ItemType Directory -Path $extractDirectory | Out-Null - & 7z x -y "-o$extractDirectory" $Installer | Out-Null - if ($LASTEXITCODE -ne 0) { - throw "7z failed to extract $Installer" + # 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. + $installerProcess = Start-Process ` + -FilePath $Installer ` + -ArgumentList @("/S", "/D=`"$installDirectory`"") ` + -PassThru ` + -Wait ` + -WindowStyle Hidden + if ($installerProcess.ExitCode -ne 0) { + throw "Wisp installer failed with code $($installerProcess.ExitCode)" + } + + $executable = Join-Path $installDirectory "Wisp.exe" + if (-not (Test-Path -LiteralPath $executable -PathType Leaf)) { + throw "Installed Wisp.exe not found: $executable" } - $executable = Join-Path $extractDirectory "Wisp.exe" $env:WISP_SMOKE_TEST = "1" - $process = Start-Process -FilePath $executable -WorkingDirectory $extractDirectory -PassThru + $process = Start-Process -FilePath $executable -WorkingDirectory $installDirectory -PassThru $readyDeadline = [DateTime]::UtcNow.AddSeconds(30) do { @@ -41,6 +51,22 @@ 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) { + 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)" + } + if (-not $process.CloseMainWindow()) { throw "Packaged Wisp.exe did not accept a close request" } @@ -56,7 +82,29 @@ finally { Stop-Process -Id $process.Id -Force $process.WaitForExit() } - if (Test-Path -LiteralPath $extractDirectory) { - Remove-Item -LiteralPath $extractDirectory -Recurse -Force + + $uninstaller = Join-Path $installDirectory "uninstall.exe" + if (Test-Path -LiteralPath $uninstaller -PathType Leaf) { + $uninstallProcess = Start-Process ` + -FilePath $uninstaller ` + -ArgumentList "/S" ` + -PassThru ` + -Wait ` + -WindowStyle Hidden + if ($uninstallProcess.ExitCode -ne 0) { + Write-Warning "Wisp uninstaller failed with code $($uninstallProcess.ExitCode)" + } + } + + if (Test-Path -LiteralPath $installDirectory) { + $resolvedInstall = [System.IO.Path]::GetFullPath($installDirectory) + $resolvedTemp = [System.IO.Path]::GetFullPath([System.IO.Path]::GetTempPath()) + if (-not $resolvedInstall.StartsWith( + $resolvedTemp, + [System.StringComparison]::OrdinalIgnoreCase + ) -or [System.IO.Path]::GetFileName($resolvedInstall) -notlike "wisp-windows-smoke-*") { + throw "Refusing to remove unexpected smoke-test directory: $resolvedInstall" + } + Remove-Item -LiteralPath $resolvedInstall -Recurse -Force } } diff --git a/scripts/verify-windows-runtime-config.mjs b/scripts/verify-windows-runtime-config.mjs new file mode 100644 index 0000000..bbae73b --- /dev/null +++ b/scripts/verify-windows-runtime-config.mjs @@ -0,0 +1,46 @@ +import { readFileSync } from "node:fs"; +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 configPath = resolve(root, "app/src-tauri/tauri.windows.conf.json"); + +const runtimeNames = readFileSync(manifestPath, "utf8") + .split(/\r?\n/) + .map((line) => line.trim()) + .filter((line) => line && !line.startsWith("#")) + .map((line) => line.split(":", 1)[0]); +const duplicate = runtimeNames.find( + (name, index) => runtimeNames.indexOf(name) !== index, +); +if (duplicate) { + throw new Error(`duplicate MSVC runtime manifest entry: ${duplicate}`); +} + +const config = JSON.parse(readFileSync(configPath, "utf8")); +const resources = config.bundle?.resources ?? {}; +const configuredRuntimeNames = Object.entries(resources) + .filter( + ([source, target]) => + source.startsWith("windows-runtime/") && + /^(concrt|msvcp|vcamp|vccorlib|vcomp|vcruntime).*\.dll$/i.test(target), + ) + .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 expected = [...runtimeNames].sort(); +if (JSON.stringify(configuredRuntimeNames) !== JSON.stringify(expected)) { + throw new Error( + `MSVC runtime config drift: expected ${expected.join(", ")}, got ${configuredRuntimeNames.join(", ")}`, + ); +} + +console.log(`Verified ${runtimeNames.length} MSVC runtime bundle mappings`); diff --git a/scripts/verify-windows-runtime.sh b/scripts/verify-windows-runtime.sh index 07c4513..7c2e59a 100755 --- a/scripts/verify-windows-runtime.sh +++ b/scripts/verify-windows-runtime.sh @@ -38,11 +38,22 @@ else fi files=( + "cargs.dll:10000" "onnxruntime.dll:1000000" "onnxruntime_providers_shared.dll:1000" "sherpa-onnx-c-api.dll:1000000" ) +# whisper.cpp is compiled with the runner's current MSVC toolset. These app-local copies are +# 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" +while IFS= read -r expected; do + [[ -z "$expected" || "$expected" == \#* ]] && continue + files+=("$expected") +done < "$msvc_manifest" + if [[ $require_vulkan == true ]]; then files+=( "vulkan-1.dll:100000"