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
5 changes: 5 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
3 changes: 2 additions & 1 deletion .github/workflows/windows-vulkan.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
72 changes: 20 additions & 52 deletions app/src-tauri/build.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
#[cfg(target_os = "windows")]
#[path = "build_support/windows_runtime.rs"]
mod windows_runtime;

fn main() {
#[cfg(target_os = "macos")]
{
Expand Down Expand Up @@ -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);
Expand All @@ -59,26 +64,25 @@ 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 \
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));
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());
}
}

Expand All @@ -88,7 +92,7 @@ fn stage_msvc_runtime(staged: &std::path::Path) {
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))
.and_then(|root| windows_runtime::find_crt_dir(&root))
.or_else(msvc_runtime_dir_from_vswhere)
}

Expand Down Expand Up @@ -122,43 +126,7 @@ fn msvc_runtime_dir_from_vswhere() -> Option<std::path::PathBuf> {
.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()));
}
windows_runtime::newest_crt_dir(&redist_root)
}

/// The cargo `target/<profile>` dir, found by walking up from `OUT_DIR` — the same place
Expand Down
229 changes: 229 additions & 0 deletions app/src-tauri/build_support/windows_runtime.rs
Original file line number Diff line number Diff line change
@@ -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<Vec<PathBuf>> {
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<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))
}

pub fn find_crt_dir(redist_root: &Path) -> Option<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())
}

fn discover_runtime_dlls(source: &Path) -> io::Result<Vec<PathBuf>> {
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("<empty>"))
.display(),
missing.into_iter().copied().collect::<Vec<_>>().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));
}
}
13 changes: 0 additions & 13 deletions app/src-tauri/msvc-runtime-dlls.txt

This file was deleted.

6 changes: 6 additions & 0 deletions app/src-tauri/msvc-runtime-required.txt
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading