Skip to main content

waterui_cli/hydrolysis/
platform.rs

1//! Hydrolysis platform build and package utilities.
2
3use std::ffi::OsString;
4use std::net::{Ipv4Addr, SocketAddr};
5use std::path::{Path, PathBuf};
6
7use eyre::{Context, bail};
8use futures_util::FutureExt as _;
9use smol::{
10    channel::{Sender, bounded},
11    fs,
12    io::{AsyncReadExt, AsyncWriteExt},
13    net::TcpListener,
14};
15use target_lexicon::Triple;
16use tracing::info;
17
18use crate::{
19    assets, browser_runtime,
20    build::{
21        BuildOptions, BuildProgress, BuiltTarget, RustBuild, RustDynamicLibraries, RustLinkage,
22    },
23    device::Artifact,
24    hydrolysis::backend::HydrolysisBackend,
25    platform::{PackageOptions, TargetPlatform},
26    project::Project,
27    toolchain::{ToolchainError, windows_arm64_llvm::WindowsArm64LlvmToolchain},
28    utils::{command, run_command_os, which},
29};
30#[cfg(target_os = "macos")]
31use crate::{
32    macos_bundle::{
33        MacOsAppNames, MacOsUsageDescription, package_binary_as_app, package_cef_helper_app,
34        sign_macos_app as sign_app,
35    },
36    project::BrowserRuntimePlan,
37};
38
39#[cfg(target_os = "macos")]
40const HYDROLYSIS_INIT_HINT: &str = "water run --platform macos --backend hydrolysis";
41#[cfg(target_os = "linux")]
42const HYDROLYSIS_INIT_HINT: &str = "water run --platform linux --backend hydrolysis";
43#[cfg(target_os = "windows")]
44const HYDROLYSIS_INIT_HINT: &str = "water run --platform windows --backend hydrolysis";
45#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
46const HYDROLYSIS_INIT_HINT: &str = "initialize hydrolysis backend on macOS, Linux, or Windows";
47
48/// Loader search paths the platform's dynamic linker resolves the shared runtime through.
49///
50/// Both situations a backend binary runs in need an entry. `water preview` and an
51/// unpackaged `water run` execute the binary where Cargo left it, with the shared
52/// runtime staged beside it — that is `@executable_path` on macOS and `$ORIGIN` on
53/// Linux. Packaging then moves the runtime into `Contents/Frameworks`, which only
54/// the bundle-relative entry reaches. macOS carried the bundle path alone, so a
55/// binary run in place could not load the runtime at all (#140).
56const fn hydrolysis_loader_search_paths(platform: TargetPlatform) -> &'static [&'static str] {
57    match platform {
58        TargetPlatform::MacOS => &["@executable_path", "@executable_path/../Frameworks"],
59        TargetPlatform::Linux => &["$ORIGIN"],
60        _ => &[],
61    }
62}
63
64/// The CEF subprocess helper binary the generated hydrolysis crate declares.
65///
66/// `templates::hydrolysis` emits the helper `[[bin]]` under exactly this name
67/// — [`cef_helper_binary_name`] of the generated package — so the build and
68/// the packaging lookup must both resolve it through here.
69fn hydrolysis_cef_helper_name(backend_crate_name: &str) -> String {
70    crate::project_model::project_types::cef_helper_binary_name(backend_crate_name)
71}
72
73/// Build hydrolysis binary for the host platform.
74///
75/// # Errors
76/// Returns an error if the platform is unsupported, the backend is missing, or Cargo fails.
77pub async fn build_hydrolysis(
78    project: &Project,
79    platform: TargetPlatform,
80    options: BuildOptions,
81) -> eyre::Result<BuiltTarget> {
82    build_hydrolysis_with_envs_and_features(project, platform, options, &[], &[]).await
83}
84
85/// Build hydrolysis binary for the host platform with extra Cargo environment variables.
86///
87/// # Errors
88/// Returns an error if the platform is unsupported, the backend is missing, or Cargo fails.
89pub async fn build_hydrolysis_with_envs(
90    project: &Project,
91    platform: TargetPlatform,
92    options: BuildOptions,
93    extra_envs: &[(String, OsString)],
94) -> eyre::Result<BuiltTarget> {
95    build_hydrolysis_with_envs_and_features(project, platform, options, extra_envs, &[]).await
96}
97
98/// Build hydrolysis binary for the host platform with extra Cargo environment variables and features.
99///
100/// # Errors
101/// Returns an error if the platform is unsupported, the backend is missing, or Cargo fails.
102pub async fn build_hydrolysis_with_envs_and_features(
103    project: &Project,
104    platform: TargetPlatform,
105    options: BuildOptions,
106    extra_envs: &[(String, OsString)],
107    extra_features: &[&str],
108) -> eyre::Result<BuiltTarget> {
109    if !is_hydrolysis_native_platform(platform) {
110        bail!("Hydrolysis backend is only supported on macOS, Linux, and Windows");
111    }
112
113    let backend_path = project.backend_path::<HydrolysisBackend>();
114    let cargo_toml = backend_path.join("Cargo.toml");
115
116    if !cargo_toml.exists() {
117        bail!(
118            "Hydrolysis backend not found at {}. Run `{HYDROLYSIS_INIT_HINT}` to initialize it.",
119            backend_path.display(),
120        );
121    }
122
123    // Stage assets and the Windows icon resource before the backend is built.
124    // The generated `build.rs` expects `app-icon.ico` to exist when targeting Windows.
125    copy_assets_and_fonts(
126        project,
127        &backend_path,
128        options.sccache_path(),
129        options.uses_dev_server(),
130        options.progress(),
131    )
132    .await?;
133
134    let llvm_envs = WindowsArm64LlvmToolchain
135        .cargo_envs(&crate::toolchain::Host::current())
136        .await
137        .map_err(|error| match error {
138            ToolchainError::Fixable(_) => eyre::eyre!(
139                "Windows ARM64 LLVM toolchain is missing. Run `water doctor --fix` to install it automatically."
140            ),
141            ToolchainError::Unfixable(unfixable) => {
142                eyre::eyre!("Windows ARM64 LLVM toolchain check failed: {unfixable}")
143            }
144        })?;
145
146    let mut build = RustBuild::new(&backend_path, platform.triple())
147        .with_project(project)
148        .with_target_dir(project.water_target_dir(options.linkage()).await?)
149        .with_features(extra_features.iter().copied())
150        .with_linkage(
151            options.linkage(),
152            &format!("{}/dev", project.crate_name()),
153            hydrolysis_loader_search_paths(platform),
154        )
155        .with_envs(llvm_envs)
156        .with_envs(options.cargo_envs().iter().cloned())
157        .with_envs(extra_envs.iter().cloned());
158    if let Some(sccache_path) = options.sccache_path() {
159        build = build.with_sccache(sccache_path.to_path_buf());
160    }
161    if let Some(progress) = options.progress() {
162        build = build.with_progress(progress.clone());
163    }
164    let built_target = build
165        .build_binary(
166            project.hydrolysis_backend_crate_name().as_str(),
167            options.is_release(),
168        )
169        .await
170        .wrap_err("Failed to build hydrolysis backend with cargo")?;
171
172    // The generated manifest declares the CEF helper as a second `[[bin]]`
173    // when the application links the CEF engine crate; a `--bin <main>`
174    // build never emits it, so it needs its own build before packaging can
175    // bundle it. The gate is the manifest's own predicate — a
176    // `waterui-chromium` link alone declares no helper bin, and asking
177    // Cargo for it would fail with `no bin target`.
178    if project.declares_cef_helper().await? {
179        build
180            .build_binary(
181                &hydrolysis_cef_helper_name(project.hydrolysis_backend_crate_name().as_str()),
182                options.is_release(),
183            )
184            .await
185            .wrap_err("Failed to build the hydrolysis CEF helper with cargo")?;
186    }
187
188    Ok(built_target)
189}
190
191/// Stage the shared `WaterUI` runtime and Rust standard library next to a raw
192/// Hydrolysis development binary.
193///
194/// Packaged applications stage these libraries in their platform runtime
195/// directory. Preview and test binaries execute directly from Cargo's profile
196/// directory, whose `@loader_path`/`$ORIGIN` entry resolves this adjacent copy.
197///
198/// # Errors
199/// Returns an error if the binary has no parent directory or the required shared
200/// libraries cannot be resolved and staged.
201pub(crate) async fn stage_hydrolysis_shared_runtime(
202    project: &Project,
203    built: &BuiltTarget,
204    platform: TargetPlatform,
205) -> eyre::Result<()> {
206    if !is_hydrolysis_native_platform(platform) {
207        bail!("Hydrolysis shared runtime can only be staged for macOS, Linux, and Windows");
208    }
209    let runtime_dir = built.artifact.parent().ok_or_else(|| {
210        eyre::eyre!(
211            "Hydrolysis binary path has no output directory: {}",
212            built.artifact.display()
213        )
214    })?;
215    let libraries = RustDynamicLibraries::resolve(built, &platform.triple(), project).await?;
216    synchronize_shared_runtime(runtime_dir, Some(&libraries), &platform.triple()).await
217}
218
219/// Clean Cargo build artifacts for hydrolysis.
220///
221/// # Errors
222/// Returns an error if `cargo clean` fails or generated web output cannot be removed.
223pub async fn clean_hydrolysis(project: &Project) -> eyre::Result<()> {
224    let backend_path = project.backend_path::<HydrolysisBackend>();
225    let cargo_toml = backend_path.join("Cargo.toml");
226
227    if !cargo_toml.exists() {
228        return Ok(());
229    }
230
231    // The target directories are shared with every other generated backend, so only
232    // this backend's own package is cleaned — its dependency artifacts stay for
233    // the other backends that resolve them identically.
234    for linkage in [RustLinkage::SharedRuntime, RustLinkage::Static] {
235        let shared_target_dir = project.water_target_dir(linkage).await?;
236        if !shared_target_dir.exists() {
237            continue;
238        }
239        let clean_args: Vec<OsString> = vec![
240            "clean".into(),
241            "--manifest-path".into(),
242            cargo_toml.as_os_str().to_owned(),
243            "--target-dir".into(),
244            shared_target_dir.as_os_str().to_owned(),
245            "--package".into(),
246            project.hydrolysis_backend_crate_name().as_str().into(),
247        ];
248        run_command_os("cargo", clean_args).await?;
249    }
250    let dist_web = backend_path.join("dist/web");
251    if dist_web.exists() {
252        fs::remove_dir_all(&dist_web).await?;
253    }
254    let dist_web_dev = backend_path.join("dist/web-dev");
255    if dist_web_dev.exists() {
256        fs::remove_dir_all(&dist_web_dev).await?;
257    }
258    Ok(())
259}
260
261/// Package a hydrolysis app.
262///
263/// Linux/Windows return a binary artifact path.
264/// macOS returns a `.app` bundle path.
265///
266/// # Errors
267/// Returns an error if packaging prerequisites are missing, assets cannot be staged, or output artifacts cannot be produced.
268pub async fn package_hydrolysis(
269    project: &Project,
270    platform: TargetPlatform,
271    options: PackageOptions,
272    built: Option<&BuiltTarget>,
273) -> eyre::Result<Artifact> {
274    if platform == TargetPlatform::Web {
275        let site_root = package_hydrolysis_web_site(project, options.is_debug(), false).await?;
276        return Ok(Artifact::new(project.bundle_identifier(), site_root));
277    }
278
279    let built = built.ok_or_else(|| {
280        eyre::eyre!(
281            "Hydrolysis packaging for {platform:?} needs the build result of the native backend binary"
282        )
283    })?;
284
285    if !is_hydrolysis_native_platform(platform) {
286        bail!(
287            "Hydrolysis backend is only supported on macOS, Linux, and Windows for binary packaging"
288        );
289    }
290
291    let profile = if options.is_debug() {
292        "debug"
293    } else {
294        "release"
295    };
296    let backend_path = project.backend_path::<HydrolysisBackend>();
297    copy_assets_and_fonts(
298        project,
299        &backend_path,
300        None,
301        options.uses_dev_server(),
302        options.progress(),
303    )
304    .await?;
305
306    let final_binary_path = &built.artifact;
307    let profile_directory = built.profile_dir.as_path();
308    let runtime_plan = project
309        .browser_runtime_plan(platform, crate::platform::TargetBackend::Hydrolysis)
310        .await?;
311    let shared_libraries = if options.uses_shared_rust_runtime() {
312        Some(RustDynamicLibraries::resolve(built, &platform.triple(), project).await?)
313    } else {
314        None
315    };
316
317    #[cfg(target_os = "macos")]
318    {
319        if platform == TargetPlatform::MacOS {
320            return package_hydrolysis_macos(
321                project,
322                platform,
323                &backend_path,
324                final_binary_path,
325                profile_directory,
326                runtime_plan,
327                shared_libraries.as_ref(),
328            )
329            .await;
330        }
331    }
332
333    // The shipped binary and everything `$ORIGIN` resolves beside it stage
334    // into the project's own managed backend directory — the shared Cargo
335    // profile directory would collide two same-named projects on
336    // `<profile>/<product>`.
337    let runtime_dir = crate::platforming::packaging::dist_dir(
338        &backend_path,
339        crate::browser_runtime::platform_name(platform)?,
340        Some(profile),
341    );
342    fs::create_dir_all(&runtime_dir).await?;
343    synchronize_shared_runtime(&runtime_dir, shared_libraries.as_ref(), &platform.triple()).await?;
344    browser_runtime::stage(runtime_plan, platform, profile_directory, &runtime_dir).await?;
345
346    // Ship the binary under the product name; the tagged Cargo artifact name
347    // is internal to the shared target directory.
348    let binary_name = project.hydrolysis_binary_name();
349    let shipped_name = if platform == TargetPlatform::Windows {
350        format!("{binary_name}.exe")
351    } else {
352        binary_name.to_string()
353    };
354    let packaged_binary = crate::platforming::packaging::stage_binary_as(
355        final_binary_path,
356        &runtime_dir,
357        &shipped_name,
358    )
359    .await?;
360
361    if platform == TargetPlatform::Linux {
362        crate::platforming::linux_share::write_linux_share_material(
363            project,
364            &project.manifest().package.name,
365            binary_name.as_str(),
366            &runtime_dir.join("share"),
367        )
368        .await?;
369    }
370
371    Ok(Artifact::new(project.bundle_identifier(), packaged_binary))
372}
373
374#[cfg(target_os = "macos")]
375async fn package_hydrolysis_macos(
376    project: &Project,
377    platform: TargetPlatform,
378    backend_path: &Path,
379    binary_path: &Path,
380    profile_directory: &Path,
381    runtime_plan: BrowserRuntimePlan,
382    shared_libraries: Option<&RustDynamicLibraries>,
383) -> eyre::Result<Artifact> {
384    let app_name = project
385        .manifest()
386        .package
387        .name
388        .chars()
389        .filter(|character| character.is_alphanumeric() || *character == ' ')
390        .collect::<String>();
391    let app_name = if app_name.is_empty() {
392        "WaterUIHydrolysis".to_string()
393    } else {
394        app_name
395    };
396    let dist_dir = crate::platforming::packaging::dist_dir(backend_path, "macos", None);
397    fs::create_dir_all(&dist_dir).await?;
398    let usage_descriptions = project
399        .manifest()
400        .permissions
401        .iter()
402        .filter(|(_, entry)| entry.is_enabled())
403        .flat_map(|(key, entry)| {
404            key.macos_usage_description_keys()
405                .iter()
406                .map(|&plist_key| MacOsUsageDescription {
407                    plist_key,
408                    description: entry.description().to_string(),
409                })
410        })
411        .collect::<Vec<_>>();
412    let icns = assets::project_macos_icns(project)?;
413    let app_path = package_binary_as_app(
414        binary_path,
415        project.bundle_identifier(),
416        MacOsAppNames {
417            app_name: &app_name,
418            executable_name: project.hydrolysis_binary_name().as_str(),
419        },
420        &usage_descriptions,
421        Some(&backend_path.join("resources")),
422        &icns,
423        &dist_dir,
424    )
425    .await?;
426    synchronize_shared_runtime(
427        &app_path.join("Contents/Frameworks"),
428        shared_libraries,
429        &platform.triple(),
430    )
431    .await?;
432    browser_runtime::stage_macos_app(runtime_plan, profile_directory, &app_path.join("Contents"))
433        .await?;
434    if project.declares_cef_helper().await? {
435        let helper_binary = binary_path
436            .parent()
437            .expect("Hydrolysis application binary must have a profile directory")
438            .join(hydrolysis_cef_helper_name(
439                project.hydrolysis_backend_crate_name().as_str(),
440            ));
441        // The helper apps are named after the shipped executable, so they
442        // derive from the packaged copy — not the tagged Cargo artifact.
443        let main_binary = app_path
444            .join("Contents/MacOS")
445            .join(project.hydrolysis_binary_name().as_str());
446        let _helper_apps = package_cef_helper_app(
447            &app_path,
448            &main_binary,
449            &helper_binary,
450            project.bundle_identifier(),
451        )
452        .await?;
453    }
454    sign_app(
455        &app_path,
456        project.bundle_identifier(),
457        !usage_descriptions.is_empty(),
458    )
459    .await?;
460    Ok(Artifact::new(project.bundle_identifier(), app_path))
461}
462
463async fn synchronize_shared_runtime(
464    destination: &Path,
465    libraries: Option<&RustDynamicLibraries>,
466    triple: &Triple,
467) -> eyre::Result<()> {
468    if let Some(libraries) = libraries {
469        libraries.stage(destination).await
470    } else {
471        RustDynamicLibraries::remove_staged(destination, triple).await
472    }
473}
474
475/// Check if a platform is supported by the hydrolysis backend.
476#[must_use]
477pub const fn is_hydrolysis_platform(platform: TargetPlatform) -> bool {
478    matches!(
479        platform,
480        TargetPlatform::Linux
481            | TargetPlatform::MacOS
482            | TargetPlatform::Windows
483            | TargetPlatform::Web
484    )
485}
486
487const fn is_hydrolysis_native_platform(platform: TargetPlatform) -> bool {
488    matches!(
489        platform,
490        TargetPlatform::Linux | TargetPlatform::MacOS | TargetPlatform::Windows
491    )
492}
493
494async fn copy_assets_and_fonts(
495    project: &Project,
496    backend_path: &Path,
497    sccache_path: Option<&Path>,
498    dev_server: bool,
499    progress: Option<&BuildProgress>,
500) -> eyre::Result<()> {
501    let resources_dir = backend_path.join("resources");
502    fs::create_dir_all(&resources_dir).await?;
503    let manifest = assets::stage_project_assets_for_gtk(
504        project,
505        &resources_dir,
506        sccache_path,
507        dev_server,
508        progress,
509    )
510    .await?;
511
512    // The generated crate's build script embeds this into the executable's
513    // resources when targeting Windows.
514    fs::write(
515        backend_path.join("app-icon.ico"),
516        assets::project_windows_ico(project)?,
517    )
518    .await?;
519
520    let font_declarations = assets::scan_fonts(project, &backend_path.join("Cargo.toml")).await?;
521    let mut resolved_fonts = assets::resolve_fonts(font_declarations).await?;
522    resolved_fonts.extend(assets::scan_project_font_assets(&manifest)?);
523    if !resolved_fonts.is_empty() {
524        let fonts_dest = resources_dir.join("fonts");
525        assets::copy_fonts(&resolved_fonts, &fonts_dest).await?;
526        info!(
527            "Copied {} fonts to hydrolysis resources",
528            resolved_fonts.len()
529        );
530    }
531    Ok(())
532}
533
534/// Build the Hydrolysis web site in debug mode for `water run --platform web`.
535///
536/// # Errors
537/// Returns an error if the web site cannot be packaged.
538pub async fn prepare_hydrolysis_web_dev_site(project: &Project) -> eyre::Result<PathBuf> {
539    package_hydrolysis_web_site(project, true, true).await
540}
541
542/// A lightweight static-file server for packaged Hydrolysis web output.
543#[derive(Debug)]
544pub struct HydrolysisWebDevServer {
545    address: SocketAddr,
546    shutdown_tx: Option<Sender<()>>,
547    _task: smol::Task<()>,
548}
549
550impl HydrolysisWebDevServer {
551    /// Start serving the provided Hydrolysis web site root on a random localhost port.
552    ///
553    /// # Errors
554    /// Returns an error if the local TCP listener cannot be bound.
555    pub async fn start(site_root: PathBuf) -> eyre::Result<Self> {
556        let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
557            .await
558            .wrap_err("Failed to bind Hydrolysis web dev server")?;
559        let address = listener
560            .local_addr()
561            .wrap_err("Failed to resolve Hydrolysis web dev server address")?;
562        let (shutdown_tx, shutdown_rx) = bounded::<()>(1);
563
564        let task = smol::spawn(async move {
565            let shutdown = shutdown_rx.recv().fuse();
566            futures_util::pin_mut!(shutdown);
567
568            loop {
569                let accept = listener.accept().fuse();
570                futures_util::pin_mut!(accept);
571
572                match futures_util::future::select(accept, shutdown.as_mut()).await {
573                    futures_util::future::Either::Left((Ok((mut stream, _peer)), _)) => {
574                        let site_root = site_root.clone();
575                        smol::spawn(async move {
576                            if let Err(error) = serve_http_request(&mut stream, &site_root).await {
577                                tracing::warn!(
578                                    target: "waterui::hydrolysis::web",
579                                    error = %error,
580                                    "Hydrolysis web dev server request failed"
581                                );
582                            }
583                        })
584                        .detach();
585                    }
586                    futures_util::future::Either::Left((Err(error), _)) => {
587                        tracing::warn!(
588                            target: "waterui::hydrolysis::web",
589                            error = %error,
590                            "Hydrolysis web dev server accept failed"
591                        );
592                        break;
593                    }
594                    futures_util::future::Either::Right((_shutdown, _)) => break,
595                }
596            }
597        });
598
599        Ok(Self {
600            address,
601            shutdown_tx: Some(shutdown_tx),
602            _task: task,
603        })
604    }
605
606    /// Get the bound localhost address for this server instance.
607    #[must_use]
608    pub const fn address(&self) -> SocketAddr {
609        self.address
610    }
611}
612
613impl Drop for HydrolysisWebDevServer {
614    fn drop(&mut self) {
615        if let Some(tx) = self.shutdown_tx.take() {
616            let _ = tx.try_send(());
617        }
618    }
619}
620
621async fn package_hydrolysis_web_site(
622    project: &Project,
623    debug: bool,
624    dev_site: bool,
625) -> eyre::Result<PathBuf> {
626    let backend_path = project.backend_path::<HydrolysisBackend>();
627    let cargo_toml = backend_path.join("Cargo.toml");
628    let lib_rs = backend_path.join("src/lib.rs");
629    if !cargo_toml.exists() {
630        bail!(
631            "Hydrolysis backend not found at {}. Run `water backend add hydrolysis` first.",
632            backend_path.display(),
633        );
634    }
635    if !lib_rs.exists() {
636        bail!(
637            "Hydrolysis backend at {} is missing src/lib.rs for web packaging. Re-scaffold the backend and try again.",
638            backend_path.display(),
639        );
640    }
641
642    let site_root = backend_path.join(if dev_site { "dist/web-dev" } else { "dist/web" });
643    if site_root.exists() {
644        fs::remove_dir_all(&site_root).await?;
645    }
646    fs::create_dir_all(&site_root).await?;
647
648    // The shell is written after the bundle so the page knows the wasm size.
649    build_hydrolysis_web_bundle(&backend_path, &site_root, debug).await?;
650    super::web_launch::write_web_shell(project, &site_root).await?;
651    copy_web_assets_and_fonts(project, &backend_path, &site_root).await?;
652
653    Ok(site_root)
654}
655
656async fn build_hydrolysis_web_bundle(
657    backend_path: &Path,
658    site_root: &Path,
659    debug: bool,
660) -> eyre::Result<()> {
661    let wasm_pack = which("wasm-pack")
662        .await
663        .wrap_err("wasm-pack is required to build Hydrolysis web bundles")?;
664    let pkg_dir = site_root.join("pkg");
665    fs::create_dir_all(&pkg_dir).await?;
666
667    let mut wasm_pack_cmd = smol::process::Command::new(wasm_pack);
668    let wasm_pack_cmd = command(&mut wasm_pack_cmd);
669    wasm_pack_cmd
670        .current_dir(backend_path)
671        .arg("build")
672        .arg("--target")
673        .arg("web")
674        .arg("--out-dir")
675        .arg(&pkg_dir)
676        .arg("--out-name")
677        .arg("app");
678    if debug {
679        wasm_pack_cmd.arg("--dev");
680    } else {
681        wasm_pack_cmd.arg("--release");
682    }
683
684    let output = wasm_pack_cmd.output().await?;
685    if !output.status.success() {
686        let stderr = String::from_utf8_lossy(&output.stderr);
687        let stdout = String::from_utf8_lossy(&output.stdout);
688        let details = if stderr.trim().is_empty() {
689            stdout.to_string()
690        } else {
691            stderr.to_string()
692        };
693        bail!(
694            "Failed to build Hydrolysis web bundle with wasm-pack (status {}):\n{}",
695            output.status,
696            details
697        );
698    }
699
700    Ok(())
701}
702
703async fn copy_web_assets_and_fonts(
704    project: &Project,
705    backend_path: &Path,
706    site_root: &Path,
707) -> eyre::Result<()> {
708    assets::stage_project_assets_for_web(project, site_root).await?;
709    assets::stage_hydrolysis_web_fonts(project, backend_path, site_root).await?;
710    Ok(())
711}
712
713async fn serve_http_request(
714    stream: &mut smol::net::TcpStream,
715    site_root: &Path,
716) -> eyre::Result<()> {
717    let mut buffer = vec![0u8; 8192];
718    let bytes_read = stream
719        .read(&mut buffer)
720        .await
721        .wrap_err("Failed to read HTTP request")?;
722    if bytes_read == 0 {
723        return Ok(());
724    }
725
726    let request = std::str::from_utf8(&buffer[..bytes_read])
727        .wrap_err("Hydrolysis web dev server received non-UTF-8 request head")?;
728    let request_line = request
729        .lines()
730        .next()
731        .ok_or_else(|| eyre::eyre!("Hydrolysis web dev server received an empty request"))?;
732    let mut parts = request_line.split_whitespace();
733    let method = parts.next().unwrap_or_default();
734    let path = parts.next().unwrap_or("/");
735    if method != "GET" && method != "HEAD" {
736        write_response(
737            stream,
738            405,
739            "text/plain; charset=utf-8",
740            b"Method Not Allowed",
741        )
742        .await?;
743        return Ok(());
744    }
745
746    let Ok(file_path) = resolve_site_path(site_root, path) else {
747        write_response(stream, 404, "text/plain; charset=utf-8", b"Not Found").await?;
748        return Ok(());
749    };
750    let body = match fs::read(&file_path).await {
751        Ok(body) => body,
752        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
753            write_response(stream, 404, "text/plain; charset=utf-8", b"Not Found").await?;
754            return Ok(());
755        }
756        Err(error) => {
757            return Err(error).wrap_err_with(|| format!("Failed to read {}", file_path.display()));
758        }
759    };
760    let mime = mime_type_for_path(&file_path);
761    if method == "HEAD" {
762        write_headers(stream, 200, mime, body.len()).await?;
763        return Ok(());
764    }
765    write_response(stream, 200, mime, &body).await
766}
767
768fn resolve_site_path(site_root: &Path, request_path: &str) -> eyre::Result<PathBuf> {
769    let request_path = request_path.split('?').next().unwrap_or("/");
770    let trimmed = request_path.trim_start_matches('/');
771    let relative = if trimmed.is_empty() {
772        "index.html"
773    } else {
774        trimmed
775    };
776
777    let mut resolved = site_root.to_path_buf();
778    for segment in relative.split('/') {
779        if segment.is_empty() || segment == "." {
780            continue;
781        }
782        if segment == ".." || segment.contains('\\') {
783            bail!("Hydrolysis web dev server rejected invalid path {request_path}");
784        }
785        resolved.push(segment);
786    }
787
788    if resolved.is_dir() {
789        resolved.push("index.html");
790    }
791    if !resolved.exists() {
792        bail!("Hydrolysis web dev server could not resolve {request_path}");
793    }
794    Ok(resolved)
795}
796
797async fn write_headers(
798    stream: &mut smol::net::TcpStream,
799    status_code: u16,
800    content_type: &str,
801    content_length: usize,
802) -> eyre::Result<()> {
803    let status_text = match status_code {
804        200 => "OK",
805        404 => "Not Found",
806        405 => "Method Not Allowed",
807        _ => "Internal Server Error",
808    };
809    let headers = format!(
810        "HTTP/1.1 {status_code} {status_text}\r\nContent-Type: {content_type}\r\nContent-Length: {content_length}\r\nCache-Control: no-store\r\nConnection: close\r\n\r\n"
811    );
812    stream.write_all(headers.as_bytes()).await?;
813    Ok(())
814}
815
816async fn write_response(
817    stream: &mut smol::net::TcpStream,
818    status_code: u16,
819    content_type: &str,
820    body: &[u8],
821) -> eyre::Result<()> {
822    write_headers(stream, status_code, content_type, body.len()).await?;
823    stream.write_all(body).await?;
824    stream.flush().await?;
825    Ok(())
826}
827
828fn mime_type_for_path(path: &Path) -> &'static str {
829    match path.extension().and_then(std::ffi::OsStr::to_str) {
830        Some("html") => "text/html; charset=utf-8",
831        Some("js") => "text/javascript; charset=utf-8",
832        Some("css") => "text/css; charset=utf-8",
833        Some("json") => "application/json",
834        Some("wasm") => "application/wasm",
835        Some("svg") => "image/svg+xml",
836        Some("png") => "image/png",
837        Some("jpg" | "jpeg") => "image/jpeg",
838        Some("gif") => "image/gif",
839        Some("webp") => "image/webp",
840        Some("avif") => "image/avif",
841        Some("ico") => "image/x-icon",
842        Some("txt") => "text/plain; charset=utf-8",
843        _ => "application/octet-stream",
844    }
845}
846
847#[cfg(test)]
848mod tests {
849    use std::path::Path;
850
851    use crate::{
852        framework::test_fixtures::stable_framework,
853        project::ResolvedWebViewBackend,
854        project_types::{BundleIdentifier, CrateName, declares_cef_helper},
855        templates::TemplateContext,
856    };
857
858    fn demo_context() -> TemplateContext {
859        TemplateContext::for_support_playground(
860            "Demo",
861            CrateName::try_from("demo").expect("crate name must be valid"),
862            BundleIdentifier::try_from("dev.waterui.demo").expect("bundle id must be valid"),
863            None,
864            &stable_framework(),
865            false,
866            None,
867        )
868    }
869
870    fn rendered_bin_names(ctx: &TemplateContext, package_name: &str) -> Vec<String> {
871        let cargo_toml = crate::templates::hydrolysis::rendered_outputs(ctx, package_name)
872            .expect("hydrolysis outputs should render")
873            .into_iter()
874            .find_map(|(path, content)| {
875                (path == Path::new("Cargo.toml"))
876                    .then(|| String::from_utf8(content).expect("Cargo.toml must be UTF-8"))
877            })
878            .expect("hydrolysis Cargo.toml output should exist");
879        cargo_toml
880            .parse::<toml::Table>()
881            .expect("hydrolysis Cargo.toml should parse")["bin"]
882            .as_array()
883            .expect("a hydrolysis manifest should declare binaries")
884            .iter()
885            .filter_map(|bin| bin["name"].as_str().map(str::to_string))
886            .collect()
887    }
888
889    /// `package_hydrolysis_macos` locates the built helper under the name
890    /// [`super::hydrolysis_cef_helper_name`] returns; the generated manifest
891    /// declares the helper `[[bin]]` under `cef_helper_binary_name` of the
892    /// package. Pin the two together so a rename on either side fails here
893    /// instead of at packaging time on a user's machine.
894    #[test]
895    fn cef_helper_lookup_name_is_a_bin_the_manifest_declares() {
896        let ctx = demo_context()
897            .with_webview_enabled(true)
898            .with_browser_engine(Some(ResolvedWebViewBackend::Cef));
899        let package_name = "demo-hydrolysis-deadbeef";
900        let bin_names = rendered_bin_names(&ctx, package_name);
901        let helper_name = super::hydrolysis_cef_helper_name(package_name);
902        assert!(
903            bin_names.contains(&helper_name),
904            "the helper the packager looks up must be a declared bin: {bin_names:?}"
905        );
906        assert!(
907            bin_names.contains(&package_name.to_string()),
908            "the main binary must remain declared too: {bin_names:?}"
909        );
910    }
911
912    /// The build compiles the helper under
913    /// [`crate::project_types::declares_cef_helper`] — the manifest's own
914    /// predicate — not `BrowserRuntimePlan::requires_cef`, which is wider:
915    /// a `waterui-chromium` link without a CEF engine still requires the CEF
916    /// runtime but declares no helper `[[bin]]`, and gating the build on the
917    /// wider predicate fails with Cargo's `no bin target`.
918    #[test]
919    fn chromium_without_the_cef_engine_declares_no_helper_bin() {
920        let package_name = "demo-hydrolysis-deadbeef";
921
922        // Chromium linked, no engine crate: the runtime plan requires CEF
923        // but the manifest declares only the main binary.
924        let ctx = demo_context().with_chromium_enabled(true);
925        assert_eq!(rendered_bin_names(&ctx, package_name), [package_name]);
926
927        // Chromium plus a non-CEF engine is the same shape.
928        let ctx = demo_context()
929            .with_chromium_enabled(true)
930            .with_browser_engine(Some(ResolvedWebViewBackend::Wpe));
931        assert_eq!(rendered_bin_names(&ctx, package_name), [package_name]);
932
933        // The predicate the build and packaging gates consult agrees with
934        // both renders — and still says yes for the CEF engine.
935        assert!(declares_cef_helper(Some(ResolvedWebViewBackend::Cef)));
936        assert!(!declares_cef_helper(Some(ResolvedWebViewBackend::Wpe)));
937        assert!(!declares_cef_helper(None));
938    }
939}