Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
8 changes: 6 additions & 2 deletions .github/workflows/windows-gpu.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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: |
Expand Down
4 changes: 4 additions & 0 deletions .github/workflows/windows-vulkan.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion app/src-tauri/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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/
121 changes: 113 additions & 8 deletions app/src-tauri/build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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::path::PathBuf> {
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<std::path::PathBuf> {
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<std::path::PathBuf> {
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<std::path::PathBuf> {
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()));
}
}

Expand Down
13 changes: 13 additions & 0 deletions app/src-tauri/msvc-runtime-dlls.txt
Original file line number Diff line number Diff line change
@@ -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
3 changes: 1 addition & 2 deletions app/src-tauri/tauri.conf.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,7 @@
"width": 1040,
"height": 740,
"minWidth": 820,
"minHeight": 640,
"additionalBrowserArgs": "--disable-features=msWebOOUI,msPdfOOUI,msSmartScreenProtection --disable-gpu"
"minHeight": 640
}
],
"security": {
Expand Down
12 changes: 12 additions & 0 deletions app/src-tauri/tauri.windows.conf.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
66 changes: 57 additions & 9 deletions scripts/smoke-windows-installer.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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"
}
Expand All @@ -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
}
}
46 changes: 46 additions & 0 deletions scripts/verify-windows-runtime-config.mjs
Original file line number Diff line number Diff line change
@@ -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`);
Loading
Loading