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