1use 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
46async 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
60const 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
69fn hydrolysis_cef_helper_name(backend_crate_name: &str) -> String {
75 crate::project_model::project_types::cef_helper_binary_name(backend_crate_name)
76}
77
78pub 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
90pub 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
103pub 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 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 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
199pub 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
237pub(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
265pub 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 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
307pub 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 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 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 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#[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 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
591pub async fn prepare_hydrolysis_web_dev_site(project: &Project) -> eyre::Result<PathBuf> {
596 package_hydrolysis_web_site(project, true, true).await
597}
598
599#[derive(Debug)]
601pub struct HydrolysisWebDevServer {
602 address: SocketAddr,
603 shutdown_tx: Option<Sender<()>>,
604 _task: smol::Task<()>,
605}
606
607impl HydrolysisWebDevServer {
608 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 #[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 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 #[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 #[test]
972 fn chromium_without_the_cef_engine_declares_no_helper_bin() {
973 let package_name = "demo-hydrolysis-deadbeef";
974
975 let ctx = demo_context().with_chromium_enabled(true);
978 assert_eq!(rendered_bin_names(&ctx, package_name), [package_name]);
979
980 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 assert!(declares_cef_helper(Some(ResolvedWebViewBackend::Cef)));
989 assert!(!declares_cef_helper(Some(ResolvedWebViewBackend::Wpe)));
990 assert!(!declares_cef_helper(None));
991 }
992}