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