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