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 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
264pub 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 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
306pub 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 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 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 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#[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 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
590pub async fn prepare_hydrolysis_web_dev_site(project: &Project) -> eyre::Result<PathBuf> {
595 package_hydrolysis_web_site(project, true, true).await
596}
597
598#[derive(Debug)]
600pub struct HydrolysisWebDevServer {
601 address: SocketAddr,
602 shutdown_tx: Option<Sender<()>>,
603 _task: smol::Task<()>,
604}
605
606impl HydrolysisWebDevServer {
607 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 #[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 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 #[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 #[test]
971 fn chromium_without_the_cef_engine_declares_no_helper_bin() {
972 let package_name = "demo-hydrolysis-deadbeef";
973
974 let ctx = demo_context().with_chromium_enabled(true);
977 assert_eq!(rendered_bin_names(&ctx, package_name), [package_name]);
978
979 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 assert!(declares_cef_helper(Some(ResolvedWebViewBackend::Cef)));
988 assert!(!declares_cef_helper(Some(ResolvedWebViewBackend::Wpe)));
989 assert!(!declares_cef_helper(None));
990 }
991}