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