Skip to main content

waterui_cli/winui/
platform.rs

1//! `WinUI` platform build and package utilities.
2//!
3//! This module provides utility functions for building and packaging `WinUI` apps.
4//! These functions are used by `WinUiBackend` to implement the `Backend` trait.
5
6use std::ffi::OsString;
7use std::path::{Path, PathBuf};
8
9use eyre::bail;
10use futures_util::StreamExt as _;
11use smol::fs;
12use tracing::info;
13
14use crate::{
15    assets,
16    build::{BuildOptions, BuildProgress, RustBuild, RustDynamicLibraries, RustLinkage},
17    device::Artifact,
18    platform::{PackageOptions, TargetPlatform},
19    project::Project,
20    utils::run_command_os,
21    winui::backend::WinUiBackend,
22};
23
24#[cfg(target_os = "windows")]
25const WINUI_INIT_HINT: &str = "water run --platform windows --backend winui";
26#[cfg(not(target_os = "windows"))]
27const WINUI_INIT_HINT: &str = "initialize WinUI backend on Windows";
28
29// ============================================================================
30// Build Utilities
31// ============================================================================
32
33/// Cargo profile directory the `WinUI` backend's artifacts land in.
34///
35/// Build, clean and package must agree on this path, so all three go through here.
36async fn winui_profile_dir(
37    project: &Project,
38    profile: &str,
39    linkage: RustLinkage,
40) -> eyre::Result<PathBuf> {
41    Ok(project
42        .water_target_dir(linkage)
43        .await?
44        .join(TargetPlatform::Windows.triple().to_string())
45        .join(profile))
46}
47
48/// Build `WinUI` binary for the host platform.
49///
50/// # Errors
51/// Returns an error if the backend manifest is missing, the host is unsupported, or Cargo fails.
52pub async fn build_winui(project: &Project, options: BuildOptions) -> eyre::Result<PathBuf> {
53    ensure_windows_host()?;
54
55    let backend_path = project.backend_path::<WinUiBackend>();
56    let cargo_toml = backend_path.join("Cargo.toml");
57
58    if !cargo_toml.exists() {
59        bail!(
60            "WinUI backend not found at {}. Run `{WINUI_INIT_HINT}` to initialize it.",
61            backend_path.display(),
62        );
63    }
64
65    // Stage assets and the Windows icon resource before the backend is built.
66    // The generated `build.rs` expects `app-icon.ico` to exist when targeting Windows.
67    copy_assets_and_fonts(
68        project,
69        &backend_path,
70        options.sccache_path(),
71        options.uses_dev_server(),
72        options.progress(),
73    )
74    .await?;
75
76    let mut build = RustBuild::new(&backend_path, TargetPlatform::Windows.triple())
77        .with_project(project)
78        .with_target_dir(project.water_target_dir(options.linkage()).await?)
79        .with_linkage(
80            options.linkage(),
81            &format!("{}/dev", project.crate_name()),
82            None,
83        )
84        .with_envs(options.cargo_envs().iter().cloned());
85    if let Some(sccache_path) = options.sccache_path() {
86        build = build.with_sccache(sccache_path.to_path_buf());
87    }
88    if let Some(progress) = options.progress() {
89        build = build.with_progress(progress.clone());
90    }
91    build
92        .build_binary(
93            project.winui_backend_crate_name().as_str(),
94            options.is_release(),
95        )
96        .await
97        .map_err(|error| eyre::eyre!("Failed to build WinUI backend with cargo: {error}"))?;
98
99    build
100        .lib_output_dir(options.is_release())
101        .await
102        .map_err(Into::into)
103}
104
105// ============================================================================
106// Clean
107// ============================================================================
108
109/// Clean Cargo build artifacts for `WinUI`.
110///
111/// # Errors
112/// Returns an error if the host is unsupported or `cargo clean` fails.
113pub async fn clean_winui(project: &Project) -> eyre::Result<()> {
114    ensure_windows_host()?;
115
116    let backend_path = project.backend_path::<WinUiBackend>();
117    let cargo_toml = backend_path.join("Cargo.toml");
118    if !cargo_toml.exists() {
119        return Ok(()); // Nothing to clean
120    }
121
122    // The target directories are shared with every other generated backend, so only
123    // this backend's own package is cleaned — its dependency artifacts stay for
124    // the other backends that resolve them identically.
125    for linkage in [RustLinkage::SharedRuntime, RustLinkage::Static] {
126        let backend_target_dir = project.water_target_dir(linkage).await?;
127        if !backend_target_dir.exists() {
128            continue;
129        }
130        let args: Vec<OsString> = vec![
131            "clean".into(),
132            "--manifest-path".into(),
133            cargo_toml.as_os_str().to_owned(),
134            "--target-dir".into(),
135            backend_target_dir.as_os_str().to_owned(),
136            "--package".into(),
137            project.winui_backend_crate_name().as_str().into(),
138        ];
139        run_command_os("cargo", args).await?;
140    }
141
142    Ok(())
143}
144
145// ============================================================================
146// Package
147// ============================================================================
148
149/// Package a `WinUI` app (locate the built binary and stage its resources).
150///
151/// # Errors
152/// Returns an error if the host is unsupported, assets cannot be staged, or the built binary is missing.
153pub async fn package_winui(project: &Project, options: PackageOptions) -> eyre::Result<Artifact> {
154    ensure_windows_host()?;
155
156    let profile = if options.is_debug() {
157        "debug"
158    } else {
159        "release"
160    };
161    let backend_path = project.backend_path::<WinUiBackend>();
162
163    // Copy project assets and dependency fonts
164    copy_assets_and_fonts(
165        project,
166        &backend_path,
167        None,
168        options.uses_dev_server(),
169        options.progress(),
170    )
171    .await?;
172
173    let linkage = if options.uses_shared_rust_runtime() {
174        RustLinkage::SharedRuntime
175    } else {
176        RustLinkage::Static
177    };
178    let target_dir = winui_profile_dir(project, profile, linkage).await?;
179
180    // The binary name is the `WinUI` crate name (project-winui)
181    let binary_name = project.winui_backend_crate_name();
182
183    let binary_path = target_dir.join(format!("{binary_name}.exe"));
184
185    let final_binary_path = if binary_path.exists() {
186        binary_path
187    } else {
188        let alt_binary_name = binary_name.replace('-', "_");
189        let alt_binary_path = target_dir.join(format!("{alt_binary_name}.exe"));
190
191        if alt_binary_path.exists() {
192            alt_binary_path
193        } else {
194            bail!(
195                "Built WinUI binary not found at {}. Did you run build first?",
196                binary_path.display()
197            );
198        }
199    };
200
201    // The shipped binary and everything it resolves beside itself stage
202    // into the project's own managed backend directory — the shared Cargo
203    // profile directory would collide two same-named projects on
204    // `<profile>/<product>`.
205    let runtime_dir =
206        crate::platforming::packaging::dist_dir(&backend_path, "windows", Some(profile));
207    fs::create_dir_all(&runtime_dir).await?;
208
209    // The runtime resolves bundled assets relative to the executable, so the
210    // staged `resources/` directory must sit next to the produced binary.
211    let staged_resources = backend_path.join("resources");
212    if staged_resources.is_dir() {
213        copy_dir(&staged_resources, &runtime_dir.join("resources")).await?;
214    }
215
216    if options.uses_shared_rust_runtime() {
217        RustDynamicLibraries::resolve(&target_dir, &TargetPlatform::Windows.triple(), project)
218            .await?
219            .stage(&runtime_dir)
220            .await?;
221    } else {
222        RustDynamicLibraries::remove_staged(&runtime_dir, &TargetPlatform::Windows.triple())
223            .await?;
224    }
225
226    // Ship the binary under the product name; the tagged Cargo artifact name
227    // is internal to the shared target directory.
228    let packaged_binary = crate::platforming::packaging::stage_binary_as(
229        &final_binary_path,
230        &runtime_dir,
231        &format!("{}.exe", project.winui_binary_name()),
232    )
233    .await?;
234
235    Ok(Artifact::new(project.bundle_identifier(), packaged_binary))
236}
237
238// ============================================================================
239// Platform Support Check
240// ============================================================================
241
242/// Check if a platform is supported by the `WinUI` backend.
243#[must_use]
244pub const fn is_winui_platform(platform: TargetPlatform) -> bool {
245    matches!(platform, TargetPlatform::Windows)
246}
247
248fn ensure_windows_host() -> eyre::Result<()> {
249    if cfg!(target_os = "windows") {
250        Ok(())
251    } else {
252        bail!("WinUI backend is only supported on Windows hosts");
253    }
254}
255
256/// Copy a directory tree recursively.
257async fn copy_dir(source: &Path, destination: &Path) -> eyre::Result<()> {
258    let mut stack = vec![(source.to_path_buf(), destination.to_path_buf())];
259    while let Some((source, destination)) = stack.pop() {
260        fs::create_dir_all(&destination).await?;
261        let mut entries = fs::read_dir(&source).await?;
262        while let Some(entry) = entries.next().await {
263            let entry = entry?;
264            let target = destination.join(entry.file_name());
265            if entry.path().is_dir() {
266                stack.push((entry.path(), target));
267            } else {
268                fs::copy(entry.path(), &target).await?;
269            }
270        }
271    }
272    Ok(())
273}
274
275// ============================================================================
276// Asset and Font Handling
277// ============================================================================
278
279/// Copy project assets and dependency fonts to the `WinUI` resources directory.
280///
281/// For `WinUI`, assets and fonts are placed alongside the binary in a `resources/`
282/// directory. The binary locates them through the runtime's executable-relative
283/// bundle lookup at startup.
284async fn copy_assets_and_fonts(
285    project: &Project,
286    backend_path: &Path,
287    sccache_path: Option<&Path>,
288    dev_server: bool,
289    progress: Option<&BuildProgress>,
290) -> eyre::Result<()> {
291    let resources_dir = backend_path.join("resources");
292    fs::create_dir_all(&resources_dir).await?;
293
294    // Stage project assets using platform-native conventions.
295    let manifest = assets::stage_project_assets_for_gtk(
296        project,
297        &resources_dir,
298        sccache_path,
299        dev_server,
300        progress,
301    )
302    .await?;
303
304    // The generated crate's build script embeds this into the executable's
305    // resources when targeting Windows.
306    fs::write(
307        backend_path.join("app-icon.ico"),
308        assets::project_windows_ico(project)?,
309    )
310    .await?;
311
312    // Scan and resolve dependency fonts
313    let font_declarations = assets::scan_fonts(project).await?;
314    let mut resolved_fonts = assets::resolve_fonts(font_declarations).await?;
315    resolved_fonts.extend(assets::scan_project_font_assets(&manifest)?);
316
317    if !resolved_fonts.is_empty() {
318        // Copy fonts to resources/fonts/
319        let fonts_dest = resources_dir.join("fonts");
320        assets::copy_fonts(&resolved_fonts, &fonts_dest).await?;
321
322        info!("Copied {} fonts to WinUI resources", resolved_fonts.len());
323    }
324
325    Ok(())
326}