Skip to main content

windows_reactor_setup/
lib.rs

1#![doc = include_str!("../readme.md")]
2
3use std::env;
4use std::ffi::OsStr;
5use std::fs;
6use std::path::{Path, PathBuf};
7use std::process::Command;
8
9const RUNTIME_PKG: &str = "Microsoft.WindowsAppSDK.Runtime";
10const RUNTIME_VER: &str = "2.4.0";
11const RUNTIME_FILES: &str = include_str!("../assets/runtime.txt");
12const APP_MANIFEST: &str = include_str!("../assets/app.manifest");
13const NUGET_URL: &str = "https://www.nuget.org/api/v2/package/{name}/{version}";
14const WEBVIEW2_PKG: &str = "Microsoft.Web.WebView2";
15const WEBVIEW2_VER: &str = "1.0.4078.44";
16const WEBVIEW2_CORE_DLL: &str = "Microsoft.Web.WebView2.Core.dll";
17const SELF_CONTAINED_MARKER: &str = "windows-reactor-self-contained";
18
19fn assert_windows() {
20    match env::var("CARGO_CFG_TARGET_OS").as_deref() {
21        Ok("windows") => {}
22        Ok(os) => panic!("unsupported target OS: {os}"),
23        Err(_) => panic!("CARGO_CFG_TARGET_OS not set"),
24    }
25}
26
27/// Configures the app to run completely self-contained.
28pub fn as_self_contained() {
29    assert_windows();
30
31    let out_dir = out_dir();
32    let temp_dir = temp_dir();
33    let runtime = stage_pkg(RUNTIME_PKG, RUNTIME_VER, &temp_dir);
34    let extract = ensure_msix_extracted(&runtime);
35    let dest = target_dir_from_out(&out_dir);
36    copy_runtime_to(&extract, &dest);
37    deploy_webview2(&temp_dir, &dest);
38
39    let manifest_path = out_dir.join("app.manifest");
40    let mut manifest = APP_MANIFEST.to_string();
41    let assembly = manifest.find("<assembly").unwrap();
42    let opening = assembly + manifest[assembly..].find('>').unwrap() + 1;
43    manifest.insert_str(
44        opening,
45        &format!("<description>{SELF_CONTAINED_MARKER}</description>"),
46    );
47    fs::write(&manifest_path, manifest).unwrap_or_else(|e| {
48        panic!(
49            "failed to write manifest to {}: {e}",
50            manifest_path.display()
51        )
52    });
53    let target_env = env::var("CARGO_CFG_TARGET_ENV").expect("CARGO_CFG_TARGET_ENV not set");
54    let target_abi = env::var("CARGO_CFG_TARGET_ABI").unwrap_or_default();
55    match (target_env.as_str(), target_abi.as_str()) {
56        ("msvc", _) => {
57            println!("cargo:rustc-link-arg-bins=/MANIFEST:EMBED");
58            println!(
59                "cargo:rustc-link-arg-bins=/MANIFESTINPUT:{}",
60                manifest_path.display()
61            );
62        }
63        ("gnu", "llvm") => {
64            println!("cargo:rustc-link-arg-bins=-Wl,/MANIFEST:EMBED");
65            println!(
66                "cargo:rustc-link-arg-bins=-Wl,/MANIFESTINPUT:{}",
67                manifest_path.display()
68            );
69        }
70        _ => panic!("unsupported target environment: {target_env}{target_abi}"),
71    }
72}
73
74/// Deploys `Microsoft.Web.WebView2.Core.dll` next to the executable.
75///
76/// The XAML `WebView2` control hosted by `windows-webview`'s `reactor` feature
77/// loads this WinRT projection assembly at runtime. Unlike the COM-only path
78/// (`webview2loader.dll`, supplied by the Evergreen runtime), it is not present
79/// on the machine by default, so a self-contained app must carry it alongside
80/// the other runtime DLLs.
81fn deploy_webview2(temp: &Path, dest: &Path) {
82    let pkg = stage_pkg(WEBVIEW2_PKG, WEBVIEW2_VER, temp);
83    let src = pkg
84        .join(format!("win-{}", target_arch()))
85        .join("native_uap")
86        .join(WEBVIEW2_CORE_DLL);
87    copy_file(&src, dest, WEBVIEW2_CORE_DLL);
88}
89
90fn out_dir() -> PathBuf {
91    PathBuf::from(env::var("OUT_DIR").expect("OUT_DIR not set"))
92}
93
94fn temp_dir() -> PathBuf {
95    let base = if let Some(p) = env::var_os("LOCALAPPDATA") {
96        PathBuf::from(p)
97    } else if let Some(p) = env::var_os("XDG_CACHE_HOME") {
98        PathBuf::from(p)
99    } else if let Some(p) = env::var_os("HOME") {
100        PathBuf::from(p).join(".cache")
101    } else {
102        panic!(
103            "could not determine cache directory: LOCALAPPDATA, XDG_CACHE_HOME, and HOME are all unset"
104        );
105    };
106    let temp = base.join("windows-reactor-setup").join("temp");
107    let _ = fs::create_dir_all(&temp);
108    temp
109}
110
111fn ensure_msix_extracted(runtime: &Path) -> PathBuf {
112    let arch = format!("win10-{}", target_arch());
113    let msix = runtime
114        .join("MSIX")
115        .join(&arch)
116        .join("Microsoft.WindowsAppRuntime.2.msix");
117    let extract = runtime.join(".msix_extract");
118    if !extract.is_dir() {
119        let _ = fs::create_dir_all(&extract);
120        if !msix.is_file() {
121            println!("MSIX not found at {}", msix.display());
122        } else {
123            extract_tar(&msix, &extract, &[]);
124        }
125    }
126    extract
127}
128
129fn copy_runtime_to(src: &Path, dest: &Path) {
130    let Ok(entries) = fs::read_dir(src) else {
131        return;
132    };
133    for entry in entries.flatten() {
134        let path = entry.path();
135        let Some(name) = entry.file_name().into_string().ok() else {
136            continue;
137        };
138        if !RUNTIME_FILES
139            .lines()
140            .any(|l| l.trim().eq_ignore_ascii_case(&name))
141        {
142            continue;
143        }
144        if path.is_file() {
145            copy_file(&path, dest, &name);
146        } else if path.is_dir() {
147            let sub = dest.join(&name);
148            let _ = fs::create_dir_all(&sub);
149            copy_dir_contents(&path, &sub);
150        }
151    }
152}
153
154fn copy_dir_contents(src: &Path, dest: &Path) {
155    let Ok(entries) = fs::read_dir(src) else {
156        return;
157    };
158    for entry in entries.flatten() {
159        let path = entry.path();
160        let Some(name) = entry.file_name().into_string().ok() else {
161            continue;
162        };
163        if path.is_file() {
164            copy_file(&path, dest, &name);
165        } else if path.is_dir() {
166            let sub = dest.join(&name);
167            let _ = fs::create_dir_all(&sub);
168            copy_dir_contents(&path, &sub);
169        }
170    }
171}
172
173fn copy_file(src: &Path, base: &Path, name: &str) {
174    if !src.is_file() {
175        println!("{name} not found at {}", src.display());
176        return;
177    }
178    let _ = fs::create_dir_all(base);
179    let _ = fs::copy(src, base.join(name));
180}
181
182fn stage_pkg(name: &str, ver: &str, temp: &Path) -> PathBuf {
183    let nupkg = temp.join(format!("{name}.{ver}.nupkg"));
184    let extract = temp.join(format!("{name}-{ver}"));
185    if !nupkg.is_file() {
186        dl_nupkg(name, ver, &nupkg);
187    }
188    if !extract.is_dir() {
189        let _ = fs::create_dir_all(&extract);
190        extract_tar(&nupkg, &extract, &["--strip-components=1"]);
191    }
192    extract
193}
194
195fn dl_nupkg(name: &str, ver: &str, dest: &Path) {
196    let url = NUGET_URL.replace("{name}", name).replace("{version}", ver);
197    println!("Downloading {name} {ver}");
198    let curl = env::var_os("SystemRoot")
199        .map(|r| PathBuf::from(r).join("System32\\curl.exe"))
200        .filter(|p| p.is_file());
201    match curl.and_then(|c| {
202        Command::new(&c)
203            .args(["-s", "-L", "-o", dest.to_str().unwrap(), &url])
204            .output()
205            .ok()
206    }) {
207        Some(out) if out.status.success() => {
208            println!("Downloaded {name} {ver}");
209        }
210        _ => {
211            println!("Download failed for {name} {ver}");
212        }
213    }
214}
215
216fn extract_tar(src: &Path, dst: &Path, extra: &[&str]) {
217    println!("Extracting {} to {}", src.display(), dst.display());
218    let tar = env::var_os("SystemRoot")
219        .map(|r| PathBuf::from(r).join("System32\\tar.exe"))
220        .filter(|p| p.is_file());
221    if let Some(t) = tar {
222        let _ = Command::new(&t)
223            .args(["-xf", src.to_str().unwrap(), "-C", dst.to_str().unwrap()])
224            .args(extra)
225            .output();
226    }
227}
228
229fn target_dir_from_out(out: &Path) -> PathBuf {
230    env::var_os("PROFILE")
231        .and_then(|profile| target_dir_for_profile(out, &profile))
232        .unwrap_or_else(|| out.ancestors().nth(3).unwrap_or(out).to_path_buf())
233}
234
235fn target_dir_for_profile(out: &Path, profile: &OsStr) -> Option<PathBuf> {
236    out.ancestors()
237        .find(|path| path.file_name() == Some(profile))
238        .map(Path::to_path_buf)
239}
240
241fn target_arch() -> &'static str {
242    match env::var("CARGO_CFG_TARGET_ARCH").as_deref() {
243        Ok("aarch64") => "arm64",
244        Ok("x86") => "x86",
245        _ => "x64",
246    }
247}
248
249#[cfg(test)]
250mod tests {
251    use super::*;
252
253    #[test]
254    fn finds_profile_in_standard_cargo_out_dir() {
255        let out = Path::new(r"C:\repo\target\debug\build\package-hash\out");
256        assert_eq!(
257            target_dir_for_profile(out, OsStr::new("debug")),
258            Some(PathBuf::from(r"C:\repo\target\debug"))
259        );
260    }
261
262    #[test]
263    fn finds_profile_in_split_package_cargo_out_dir() {
264        let out = Path::new(r"C:\repo\target\debug\build\package\hash\out");
265        assert_eq!(
266            target_dir_for_profile(out, OsStr::new("debug")),
267            Some(PathBuf::from(r"C:\repo\target\debug"))
268        );
269    }
270}