1use std::io;
8use std::io::Write as _;
9use std::path::{Path, PathBuf};
10use std::process::Stdio;
11
12use askama::Template;
13use color_eyre::eyre::{self, Context, bail};
14use serde::{Deserialize, Serialize};
15use smol::process::Command;
16use waterui_assets_planner::{BUNDLE_META_PREFIX, BundleMountMeta};
17
18use crate::artifact_symbols::{ArtifactSymbols, build_host_rlib};
19use crate::project::Project;
20use crate::project_model::templates::embedded;
21
22#[derive(Deserialize, Serialize, Clone, Copy, PartialEq, Eq, Debug, Default, clap::ValueEnum)]
28#[serde(rename_all = "lowercase")]
29pub enum PackageManager {
30 #[default]
32 Bun,
33 Pnpm,
35 Npm,
37 Yarn,
39}
40
41impl PackageManager {
42 #[must_use]
44 pub const fn binary(self) -> &'static str {
45 match self {
46 Self::Bun => "bun",
47 Self::Pnpm => "pnpm",
48 Self::Npm => "npm",
49 Self::Yarn => "yarn",
50 }
51 }
52
53 #[must_use]
55 pub fn run(self, script: &str) -> Command {
56 let mut command = Command::new(self.binary());
57 command.arg("run").arg(script);
58 command
59 }
60
61 #[must_use]
63 pub fn install(self) -> Command {
64 let mut command = Command::new(self.binary());
65 command.arg("install");
66 command
67 }
68
69 #[must_use]
75 pub fn create_vite(self, dir: &str, template: Option<&str>) -> Command {
76 let mut command = Command::new(self.binary());
77 command.arg("create");
78 match self {
79 Self::Npm => command.arg("vite@latest"),
80 Self::Bun | Self::Pnpm | Self::Yarn => command.arg("vite"),
81 };
82 command.arg(dir);
83 if let Some(template) = template {
84 if self == Self::Npm {
87 command.arg("--");
88 }
89 command.args(["--template", template]);
90 }
91 command
92 }
93
94 pub async fn is_installed(self) -> bool {
96 crate::utils::which(self.binary()).await.is_ok()
97 }
98
99 #[must_use]
102 pub const fn install_hint(self) -> &'static str {
103 match self {
104 Self::Bun => "curl -fsSL https://bun.sh/install | bash",
105 Self::Pnpm => "npm install -g pnpm (or see https://pnpm.io/installation)",
106 Self::Npm => "install Node.js from https://nodejs.org/",
107 Self::Yarn => {
108 "npm install -g yarn (or see https://yarnpkg.com/getting-started/install)"
109 }
110 }
111 }
112}
113
114#[derive(Debug, Clone, Default, Serialize, Deserialize)]
119pub struct WebConfig {
120 #[serde(default)]
123 pub package_manager: PackageManager,
124}
125
126pub async fn web_mount(
136 project: &Project,
137 sccache_path: Option<&Path>,
138) -> eyre::Result<Option<BundleMountMeta>> {
139 let rlib = build_host_rlib(project.root(), sccache_path).await?;
140 let symbols = ArtifactSymbols::read(&rlib)?;
141 decode_web_mount(&symbols)
142}
143
144pub fn decode_web_mount(symbols: &ArtifactSymbols) -> eyre::Result<Option<BundleMountMeta>> {
152 let mut frontend = None;
153 for leaf in symbols.leaves_with_prefix(BUNDLE_META_PREFIX) {
154 let meta = BundleMountMeta::from_payload(&symbols.static_bytes(&leaf)?)?;
155 if meta.project.is_none() {
156 continue;
157 }
158 if frontend.replace(meta).is_some() {
159 bail!("more than one include_web! mount is declared in the artifact");
160 }
161 }
162 Ok(frontend)
163}
164
165pub async fn build_frontend(
179 package_manager: PackageManager,
180 meta: &BundleMountMeta,
181) -> eyre::Result<()> {
182 let root = meta
183 .project
184 .as_ref()
185 .expect("build_frontend is only called for mounts that declare a project");
186 let pm = package_manager.binary();
187 let status = package_manager
188 .run("build")
189 .current_dir(root)
190 .stdin(Stdio::inherit())
191 .stdout(Stdio::inherit())
192 .stderr(Stdio::inherit())
193 .status()
194 .await?;
195 if !status.success() {
196 bail!("`{pm} run build` failed in {}: {status}", root.display());
197 }
198 if !meta.path.is_dir() {
199 bail!(
200 "`{pm} run build` did not produce `{}`; set `out_dir` on `include_web!` to the bundler's output directory",
201 meta.path.display()
202 );
203 }
204 Ok(())
205}
206
207pub const DEV_URL_ENV: &str = "WATERUI_DEV_URL";
220
221#[derive(Debug, Clone, Copy, PartialEq, Eq)]
223pub enum DevTarget {
224 Desktop,
226 IosSimulator,
228 IosDevice,
230 Android,
232}
233
234pub fn device_facing_url(target: DevTarget, url: &url::Url) -> eyre::Result<url::Url> {
252 if target != DevTarget::IosDevice {
253 return Ok(url.clone());
254 }
255 let mut url = url.clone();
256 let host = lan_ipv4()?.to_string();
257 url.set_host(Some(&host))
258 .wrap_err_with(|| format!("dev-server URL cannot carry a LAN host: {url}"))?;
259 Ok(url)
260}
261
262fn lan_ipv4() -> eyre::Result<std::net::Ipv4Addr> {
269 let socket = std::net::UdpSocket::bind((std::net::Ipv4Addr::UNSPECIFIED, 0))
270 .wrap_err("failed to bind a UDP socket for LAN address detection")?;
271 socket
272 .connect((std::net::Ipv4Addr::new(192, 0, 0, 1), 80))
273 .wrap_err(
274 "no outbound route — cannot determine this Mac's LAN address for the iOS device",
275 )?;
276 match socket.local_addr()?.ip() {
277 std::net::IpAddr::V4(ip) if !ip.is_loopback() => Ok(ip),
278 other => Err(eyre::eyre!(
279 "the outbound interface has no usable LAN IPv4 address ({other}); connect the Mac to the same LAN as the iOS device"
280 )),
281 }
282}
283
284#[must_use]
288pub fn adb_reverse_args(device_id: &str, port: u16) -> Vec<String> {
289 vec![
290 "-s".to_string(),
291 device_id.to_string(),
292 "reverse".to_string(),
293 format!("tcp:{port}"),
294 format!("tcp:{port}"),
295 ]
296}
297
298pub fn dev_url_port<'a>(
307 mut env_vars: impl Iterator<Item = (&'a str, &'a str)>,
308) -> eyre::Result<Option<u16>> {
309 let Some((_, value)) = env_vars.find(|(key, _)| *key == DEV_URL_ENV) else {
310 return Ok(None);
311 };
312 let url: url::Url = value
313 .parse()
314 .wrap_err_with(|| format!("{DEV_URL_ENV} is set but is not a URL: {value}"))?;
315 url.port_or_known_default().map_or_else(
316 || Err(eyre::eyre!("{DEV_URL_ENV} has no port to forward: {value}")),
317 |port| Ok(Some(port)),
318 )
319}
320
321pub fn dev_script(root: &Path) -> eyre::Result<String> {
329 let package_json_path = root.join("package.json");
330 let manifest = std::fs::read_to_string(&package_json_path)
331 .wrap_err_with(|| format!("failed to read {}", package_json_path.display()))?;
332 let package: serde_json::Value = serde_json::from_str(&manifest)
333 .wrap_err_with(|| format!("failed to parse {}", package_json_path.display()))?;
334 package
335 .get("scripts")
336 .and_then(|scripts| {
337 ["dev", "serve", "start"]
338 .iter()
339 .find(|name| scripts.get(**name).is_some())
340 })
341 .map(|name| (*name).to_string())
342 .ok_or_else(|| {
343 eyre::eyre!(
344 "`{}` declares none of the dev scripts `dev`, `serve`, `start`",
345 package_json_path.display()
346 )
347 })
348}
349
350#[must_use]
359pub fn dev_url_from_line(line: &str) -> Option<url::Url> {
360 line.split_whitespace().find_map(|token| {
361 let url = token.parse::<url::Url>().ok()?;
362 if !matches!(url.scheme(), "http" | "https") {
363 return None;
364 }
365 let loopback = url
366 .host_str()
367 .is_some_and(|host| host.eq_ignore_ascii_case("localhost") || host == "127.0.0.1")
368 || url.host() == Some(url::Host::Ipv6(std::net::Ipv6Addr::LOCALHOST));
369 (loopback && url.port().is_some()).then_some(url)
370 })
371}
372
373#[derive(Debug)]
383pub struct WebDevServer {
384 url: url::Url,
385 child: Option<(std::process::Child, dev_server_tree::DevServerTree)>,
386 _drain: smol::Task<()>,
387}
388
389impl Drop for WebDevServer {
390 fn drop(&mut self) {
391 let Some((mut child, tree)) = self.child.take() else {
392 return;
393 };
394 tree.signal(true);
395 std::thread::spawn(move || {
396 let mut exited = false;
397 for _ in 0..40 {
398 std::thread::sleep(std::time::Duration::from_millis(50));
399 if matches!(child.try_wait(), Ok(Some(_))) {
400 exited = true;
401 break;
402 }
403 }
404 if !exited {
405 tree.signal(false);
406 }
407 let _ = child.wait();
408 });
409 }
410}
411
412impl WebDevServer {
413 pub async fn spawn(
435 package_manager: PackageManager,
436 root: &Path,
437 script: &str,
438 expose_on_lan: bool,
439 ) -> eyre::Result<Self> {
440 use smol::io::{AsyncBufReadExt, BufReader};
441 use smol::stream::StreamExt as _;
442
443 let pm = package_manager.binary();
444 let mut command = std::process::Command::new(pm);
447 command.arg("run").arg(script).current_dir(root);
448 if expose_on_lan {
449 if package_manager == PackageManager::Npm {
453 command.arg("--");
454 }
455 command.args(["--host", "0.0.0.0"]);
456 }
457 command
458 .stdin(Stdio::null())
459 .stdout(Stdio::piped())
460 .stderr(Stdio::inherit());
461 #[cfg(unix)]
462 {
463 use std::os::unix::process::CommandExt as _;
464 command.process_group(0);
465 }
466 let mut child = command.spawn().wrap_err_with(|| {
467 format!("failed to spawn `{pm} run {script}` in {}", root.display())
468 })?;
469 let tree = dev_server_tree::DevServerTree::adopt(&child)
470 .wrap_err_with(|| format!("failed to group the `{pm} run {script}` process tree"))?;
471 let stdout = child.stdout.take().expect("stdout is piped");
472 let mut lines = BufReader::new(smol::Unblock::new(stdout)).lines();
473
474 let url = loop {
475 match lines.next().await {
476 Some(Ok(line)) => {
477 echo_dev_server_line(&line);
478 if let Some(url) = dev_url_from_line(&line) {
479 break url;
480 }
481 }
482 Some(Err(error)) => {
483 tree.signal(false);
484 let _ = smol::unblock(move || child.wait()).await;
485 bail!("failed to read `{pm} run {script}` output: {error}");
486 }
487 None => {
488 let status = child.try_wait().ok().flatten();
489 tree.signal(false);
490 let _ = smol::unblock(move || child.wait()).await;
491 match status {
492 Some(status) => bail!(
493 "`{pm} run {script}` exited with {status} without printing a dev-server URL"
494 ),
495 None => bail!(
496 "`{pm} run {script}` closed its output without printing a dev-server URL"
497 ),
498 }
499 }
500 }
501 };
502
503 let drain = smol::spawn(async move {
504 while let Some(line) = lines.next().await {
505 match line {
506 Ok(line) => echo_dev_server_line(&line),
507 Err(_) => break,
508 }
509 }
510 });
511
512 Ok(Self {
513 url,
514 child: Some((child, tree)),
515 _drain: drain,
516 })
517 }
518
519 #[must_use]
521 pub const fn url(&self) -> &url::Url {
522 &self.url
523 }
524}
525
526mod dev_server_tree {
530 use std::io;
531
532 #[cfg(unix)]
534 #[derive(Debug)]
535 pub struct DevServerTree {
536 group: nix::unistd::Pid,
537 }
538
539 #[cfg(unix)]
540 impl DevServerTree {
541 pub fn adopt(child: &std::process::Child) -> io::Result<Self> {
546 let pid = nix::unistd::Pid::from_raw(
547 i32::try_from(child.id()).expect("process identifiers fit in i32"),
548 );
549 let group = nix::unistd::getpgid(Some(pid))?;
550 if group != pid {
551 return Err(io::Error::other(format!(
552 "process {pid} belongs to group {group} instead of leading its own"
553 )));
554 }
555 Ok(Self { group })
556 }
557
558 pub fn signal(&self, graceful: bool) {
561 let signal = if graceful {
562 nix::sys::signal::Signal::SIGTERM
563 } else {
564 nix::sys::signal::Signal::SIGKILL
565 };
566 let _ = nix::sys::signal::killpg(self.group, signal);
567 }
568 }
569
570 #[cfg(windows)]
575 #[derive(Debug)]
576 pub struct DevServerTree {
577 job: windows_sys::Win32::Foundation::HANDLE,
578 }
579
580 #[cfg(windows)]
583 unsafe impl Send for DevServerTree {}
584
585 #[cfg(windows)]
586 impl DevServerTree {
587 pub fn adopt(child: &std::process::Child) -> io::Result<Self> {
590 use std::os::windows::io::AsRawHandle as _;
591
592 use windows_sys::Win32::System::JobObjects::{
593 AssignProcessToJobObject, CreateJobObjectW, JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE,
594 JOBOBJECT_EXTENDED_LIMIT_INFORMATION, JobObjectExtendedLimitInformation,
595 SetInformationJobObject,
596 };
597
598 let job = unsafe { CreateJobObjectW(std::ptr::null(), std::ptr::null()) };
601 if job.is_null() {
602 return Err(io::Error::last_os_error());
603 }
604 let tree = Self { job };
605 let mut limits: JOBOBJECT_EXTENDED_LIMIT_INFORMATION = unsafe { std::mem::zeroed() };
608 limits.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE;
609 let size = u32::try_from(std::mem::size_of::<JOBOBJECT_EXTENDED_LIMIT_INFORMATION>())
610 .expect("the limit block is far smaller than u32::MAX bytes");
611 let configured = unsafe {
614 SetInformationJobObject(
615 job,
616 JobObjectExtendedLimitInformation,
617 (&raw const limits).cast(),
618 size,
619 )
620 };
621 if configured == 0 {
622 return Err(io::Error::last_os_error());
623 }
624 let assigned = unsafe { AssignProcessToJobObject(job, child.as_raw_handle().cast()) };
627 if assigned == 0 {
628 return Err(io::Error::last_os_error());
629 }
630 Ok(tree)
631 }
632
633 pub fn signal(&self, graceful: bool) {
638 use windows_sys::Win32::System::JobObjects::TerminateJobObject;
639
640 if graceful {
641 return;
642 }
643 let _ = unsafe { TerminateJobObject(self.job, 1) };
645 }
646 }
647
648 #[cfg(windows)]
649 impl Drop for DevServerTree {
650 fn drop(&mut self) {
651 use windows_sys::Win32::Foundation::CloseHandle;
652
653 let _ = unsafe { CloseHandle(self.job) };
656 }
657 }
658}
659
660fn echo_dev_server_line(line: &str) {
663 let _ = writeln!(anstream::stderr().lock(), "{line}");
664}
665
666#[derive(Debug, Clone, PartialEq, Eq)]
672pub enum WebSource {
673 New,
675 Existing(PathBuf),
677}
678
679#[derive(Debug, Clone, Copy, PartialEq, Eq)]
681pub enum ExistingFrontendMode {
682 Copy,
684 Reference,
686}
687
688#[derive(Debug, Clone, Default)]
690pub struct InitAnswers {
691 pub web: Option<WebSource>,
693 pub web_mode: Option<ExistingFrontendMode>,
696 pub package_manager: Option<PackageManager>,
698}
699
700#[derive(Debug, Clone, PartialEq, Eq)]
703pub enum InitAction {
704 MoveFrontendToWeb {
706 entries: Vec<PathBuf>,
708 },
709 ScaffoldVite,
711 CopyFrontend {
714 source: PathBuf,
716 },
717 InstallDependencies,
719 ScaffoldShell {
721 web_arg: String,
723 },
724}
725
726fn root_only_entries(has_rust_manifest: bool, entry: &str) -> bool {
729 if matches!(
730 entry,
731 ".git" | ".github" | ".water" | "Water.toml" | "Water.lock" | "backends" | "target" | "web"
732 ) || entry.starts_with("README")
733 || entry.starts_with("LICENSE")
734 {
735 return true;
736 }
737 has_rust_manifest && matches!(entry, "Cargo.toml" | "Cargo.lock" | "src")
740}
741
742#[must_use]
744pub fn lockfile_package_manager(entries: &[String]) -> Option<PackageManager> {
745 if entries.iter().any(|e| e == "bun.lock" || e == "bun.lockb") {
746 Some(PackageManager::Bun)
747 } else if entries.iter().any(|e| e == "pnpm-lock.yaml") {
748 Some(PackageManager::Pnpm)
749 } else if entries.iter().any(|e| e == "yarn.lock") {
750 Some(PackageManager::Yarn)
751 } else if entries.iter().any(|e| e == "package-lock.json") {
752 Some(PackageManager::Npm)
753 } else {
754 None
755 }
756}
757
758pub fn plan_init(
771 project_root: &Path,
772 entries: &[String],
773 answers: &InitAnswers,
774) -> eyre::Result<Vec<InitAction>> {
775 if entries.iter().any(|e| e == "package.json") {
776 let has_rust_manifest = entries.iter().any(|e| e == "Cargo.toml");
777 let move_entries = entries
778 .iter()
779 .filter(|entry| !root_only_entries(has_rust_manifest, entry))
780 .map(PathBuf::from)
781 .collect();
782 return Ok(vec![
783 InitAction::MoveFrontendToWeb {
784 entries: move_entries,
785 },
786 InitAction::ScaffoldShell {
787 web_arg: "web".to_string(),
788 },
789 ]);
790 }
791
792 match answers.web.clone() {
793 Some(WebSource::New) | None => Ok(vec![
794 InitAction::ScaffoldVite,
795 InitAction::InstallDependencies,
796 InitAction::ScaffoldShell {
797 web_arg: "web".to_string(),
798 },
799 ]),
800 Some(WebSource::Existing(source)) => {
801 match answers.web_mode.unwrap_or(ExistingFrontendMode::Copy) {
802 ExistingFrontendMode::Copy => Ok(vec![
803 InitAction::CopyFrontend { source },
804 InitAction::InstallDependencies,
805 InitAction::ScaffoldShell {
806 web_arg: "web".to_string(),
807 },
808 ]),
809 ExistingFrontendMode::Reference => {
810 let arg = relative_path_arg(project_root, &source)?;
811 Ok(vec![InitAction::ScaffoldShell { web_arg: arg }])
812 }
813 }
814 }
815 }
816}
817
818fn relative_path_arg(project_root: &Path, source: &Path) -> eyre::Result<String> {
821 let root = dunce::canonicalize(project_root)?;
822 let source = dunce::canonicalize(source)?;
823 let mut root_components = root.components().peekable();
824 let mut source_components = source.components().peekable();
825 while root_components.peek() == source_components.peek() && root_components.peek().is_some() {
826 root_components.next();
827 source_components.next();
828 }
829 let mut arg = String::new();
830 for _ in root_components {
831 if !arg.is_empty() {
832 arg.push('/');
833 }
834 arg.push_str("..");
835 }
836 for component in source_components {
837 if !arg.is_empty() {
838 arg.push('/');
839 }
840 arg.push_str(
841 component
842 .as_os_str()
843 .to_str()
844 .ok_or_else(|| eyre::eyre!("frontend path is not valid UTF-8"))?,
845 );
846 }
847 if arg.is_empty() {
848 bail!("the frontend is the project root itself; put its files in `web/`");
849 }
850 Ok(arg)
851}
852
853#[derive(Debug, Clone, Copy, PartialEq, Eq)]
862pub enum WebFramework {
863 Vanilla,
865 React,
867 Preact,
869 Vue,
871 Svelte,
873 Solid,
875 Lit,
877 Other,
879}
880
881impl WebFramework {
882 #[must_use]
884 pub const fn display_name(self) -> &'static str {
885 match self {
886 Self::Vanilla => "Vanilla",
887 Self::React => "React",
888 Self::Preact => "Preact",
889 Self::Vue => "Vue",
890 Self::Svelte => "Svelte",
891 Self::Solid => "Solid",
892 Self::Lit => "Lit",
893 Self::Other => "web",
894 }
895 }
896
897 const fn supports_branding(self) -> bool {
899 matches!(self, Self::Vanilla | Self::React | Self::Vue | Self::Svelte)
900 }
901}
902
903#[derive(Debug, Clone, Copy, PartialEq, Eq)]
906pub struct WebFrontend {
907 pub framework: WebFramework,
909 pub typescript: bool,
911}
912
913const FRAMEWORK_DEPENDENCIES: &[(&str, WebFramework)] = &[
915 ("react", WebFramework::React),
916 ("preact", WebFramework::Preact),
917 ("vue", WebFramework::Vue),
918 ("svelte", WebFramework::Svelte),
919 ("solid-js", WebFramework::Solid),
920 ("lit", WebFramework::Lit),
921];
922
923#[must_use]
933pub fn detect_web_frontend(package_json: &str) -> Option<WebFrontend> {
934 let package: serde_json::Value = serde_json::from_str(package_json).ok()?;
935 let dependencies = package
936 .get("dependencies")
937 .and_then(serde_json::Value::as_object);
938 let dev_dependencies = package
939 .get("devDependencies")
940 .and_then(serde_json::Value::as_object);
941 let has_marker = |name: &str| {
942 dependencies.is_some_and(|deps| deps.contains_key(name))
943 || dev_dependencies.is_some_and(|deps| deps.contains_key(name))
944 };
945 let framework = FRAMEWORK_DEPENDENCIES
946 .iter()
947 .find(|(name, _)| has_marker(name))
948 .map_or_else(
949 || {
950 if dependencies.is_none_or(serde_json::Map::is_empty) {
951 WebFramework::Vanilla
952 } else {
953 WebFramework::Other
954 }
955 },
956 |(_, framework)| *framework,
957 );
958 let typescript = dev_dependencies.is_some_and(|deps| deps.contains_key("typescript"));
959 Some(WebFrontend {
960 framework,
961 typescript,
962 })
963}
964
965#[derive(Debug, Default)]
967pub struct WebOverlayReport {
968 pub frontend: Option<WebFrontend>,
970 pub branded: bool,
973 pub warnings: Vec<String>,
975}
976
977struct WebOverlayContext<'a> {
979 framework: &'a str,
982 entry: &'a str,
984 logo: &'a str,
987 typescript: bool,
990}
991
992macro_rules! web_overlay_templates {
993 ($($name:ident => $path:literal),* $(,)?) => {$(
994 #[derive(Template)]
995 #[template(path = $path, escape = "none")]
996 struct $name<'a> {
997 ctx: &'a WebOverlayContext<'a>,
998 }
999 )*};
1000}
1001
1002web_overlay_templates! {
1003 VanillaMainTsTemplate => "src/templates/web/vanilla/main.ts.tpl",
1004 VanillaMainJsTemplate => "src/templates/web/vanilla/main.js.tpl",
1005 ReactAppTsxTemplate => "src/templates/web/react/App.tsx.tpl",
1006 ReactAppJsxTemplate => "src/templates/web/react/App.jsx.tpl",
1007 VueAppTemplate => "src/templates/web/vue/App.vue.tpl",
1008 SvelteAppTemplate => "src/templates/web/svelte/App.svelte.tpl",
1009}
1010
1011#[derive(Clone, Copy)]
1013enum OverlayTemplate {
1014 VanillaTs,
1015 VanillaJs,
1016 ReactTsx,
1017 ReactJsx,
1018 Vue,
1019 Svelte,
1020}
1021
1022impl OverlayTemplate {
1023 fn render(self, ctx: &WebOverlayContext) -> io::Result<String> {
1024 let rendered = match self {
1025 Self::VanillaTs => VanillaMainTsTemplate { ctx }.render(),
1026 Self::VanillaJs => VanillaMainJsTemplate { ctx }.render(),
1027 Self::ReactTsx => ReactAppTsxTemplate { ctx }.render(),
1028 Self::ReactJsx => ReactAppJsxTemplate { ctx }.render(),
1029 Self::Vue => VueAppTemplate { ctx }.render(),
1030 Self::Svelte => SvelteAppTemplate { ctx }.render(),
1031 };
1032 rendered.map_err(|error| {
1033 io::Error::other(format!("web overlay template render failed: {error}"))
1034 })
1035 }
1036}
1037
1038struct EntryCandidate {
1041 dest: &'static str,
1044 template: OverlayTemplate,
1046 logos: &'static [&'static str],
1049}
1050
1051struct OverlaySpec {
1053 entries: &'static [EntryCandidate],
1055 styles: &'static [&'static str],
1057 base_style: Option<&'static str>,
1061 deletions: &'static [&'static [&'static str]],
1064}
1065
1066const fn overlay_spec(framework: WebFramework) -> Option<OverlaySpec> {
1067 Some(match framework {
1068 WebFramework::Vanilla => OverlaySpec {
1069 entries: &[
1070 EntryCandidate {
1071 dest: "src/main.ts",
1072 template: OverlayTemplate::VanillaTs,
1073 logos: &["src/assets/typescript.svg", "src/typescript.svg"],
1074 },
1075 EntryCandidate {
1076 dest: "src/main.js",
1077 template: OverlayTemplate::VanillaJs,
1078 logos: &["src/assets/javascript.svg", "src/javascript.svg"],
1079 },
1080 ],
1081 styles: &["src/style.css"],
1082 base_style: None,
1083 deletions: &[&["src/counter.ts", "src/counter.js"]],
1084 },
1085 WebFramework::React => OverlaySpec {
1086 entries: &[
1087 EntryCandidate {
1088 dest: "src/App.tsx",
1089 template: OverlayTemplate::ReactTsx,
1090 logos: &["src/assets/react.svg", "src/react.svg"],
1091 },
1092 EntryCandidate {
1093 dest: "src/App.jsx",
1094 template: OverlayTemplate::ReactJsx,
1095 logos: &["src/assets/react.svg", "src/react.svg"],
1096 },
1097 ],
1098 styles: &["src/App.css"],
1099 base_style: Some("src/index.css"),
1100 deletions: &[],
1101 },
1102 WebFramework::Vue => OverlaySpec {
1103 entries: &[EntryCandidate {
1104 dest: "src/App.vue",
1105 template: OverlayTemplate::Vue,
1106 logos: &["src/assets/vue.svg", "src/vue.svg"],
1107 }],
1108 styles: &["src/style.css"],
1109 base_style: None,
1110 deletions: &[&["src/components/HelloWorld.vue"]],
1111 },
1112 WebFramework::Svelte => OverlaySpec {
1113 entries: &[EntryCandidate {
1114 dest: "src/App.svelte",
1115 template: OverlayTemplate::Svelte,
1116 logos: &["src/assets/svelte.svg", "src/svelte.svg"],
1117 }],
1118 styles: &["src/app.css"],
1119 base_style: None,
1120 deletions: &[&["src/lib/Counter.svelte"]],
1121 },
1122 _ => return None,
1123 })
1124}
1125
1126fn web_template_asset(relative: &str) -> &'static [u8] {
1128 embedded::ROOT
1129 .get_file(format!("web/{relative}"))
1130 .unwrap_or_else(|| panic!("web overlay asset `{relative}` must ship in the CLI"))
1131 .contents()
1132}
1133
1134pub fn apply_brand_overlay(web_dir: &Path, display_name: &str) -> io::Result<WebOverlayReport> {
1154 let mut report = WebOverlayReport::default();
1155
1156 let logo = embedded::ROOT
1157 .get_file("icon.svg")
1158 .expect("the WaterUI logo ships in the template bundle");
1159 write_overlay_file(web_dir, "public/waterui.svg", logo.contents())?;
1160 for orphaned in ["public/vite.svg", "public/favicon.svg"] {
1163 let path = web_dir.join(orphaned);
1164 if path.exists() {
1165 std::fs::remove_file(&path)?;
1166 }
1167 }
1168 retitle_index_html(web_dir, display_name, &mut report.warnings)?;
1169
1170 let Some(frontend) = read_frontend(web_dir, &mut report.warnings) else {
1171 return Ok(report);
1172 };
1173 report.frontend = Some(frontend);
1174 if frontend.framework.supports_branding() {
1175 report.branded = brand_framework_page(web_dir, frontend, &mut report.warnings)?;
1176 }
1177 Ok(report)
1178}
1179
1180fn write_overlay_file(web_dir: &Path, relative: &str, contents: &[u8]) -> io::Result<()> {
1182 let dest = web_dir.join(relative);
1183 if let Some(parent) = dest.parent() {
1184 std::fs::create_dir_all(parent)?;
1185 }
1186 std::fs::write(dest, contents)
1187}
1188
1189fn read_frontend(web_dir: &Path, warnings: &mut Vec<String>) -> Option<WebFrontend> {
1192 if let Ok(manifest) = std::fs::read_to_string(web_dir.join("package.json")) {
1193 detect_web_frontend(&manifest).or_else(|| {
1194 warnings.push(
1195 "web/package.json did not parse — the starter page was left in place".to_string(),
1196 );
1197 None
1198 })
1199 } else {
1200 warnings
1201 .push("web/package.json is missing — the starter page was left in place".to_string());
1202 None
1203 }
1204}
1205
1206fn brand_framework_page(
1209 web_dir: &Path,
1210 frontend: WebFrontend,
1211 warnings: &mut Vec<String>,
1212) -> io::Result<bool> {
1213 let Some(spec) = overlay_spec(frontend.framework) else {
1214 return Ok(false);
1215 };
1216 let Some(entry) = spec
1217 .entries
1218 .iter()
1219 .find(|candidate| web_dir.join(candidate.dest).is_file())
1220 else {
1221 warnings.push(format!(
1222 "{} is missing — the {} starter layout is not recognized; its default page remains",
1223 spec.entries[0].dest,
1224 frontend.framework.display_name(),
1225 ));
1226 return Ok(false);
1227 };
1228
1229 let framework = if frontend.framework == WebFramework::Vanilla {
1233 if frontend.typescript {
1234 "TypeScript"
1235 } else {
1236 "JavaScript"
1237 }
1238 } else {
1239 frontend.framework.display_name()
1240 };
1241 let logo = entry
1245 .logos
1246 .iter()
1247 .find(|logo| web_dir.join(logo).is_file())
1248 .map_or_else(
1249 || {
1250 warnings.push(format!(
1251 "{} is missing — the branded page falls back to the WaterUI mark",
1252 entry.logos[0]
1253 ));
1254 "../public/waterui.svg".to_string()
1255 },
1256 |logo| format!("./{}", logo.strip_prefix("src/").unwrap_or(logo)),
1257 );
1258
1259 let ctx = WebOverlayContext {
1260 framework,
1261 entry: entry.dest,
1262 logo: &logo,
1263 typescript: frontend.typescript,
1264 };
1265 write_overlay_file(web_dir, entry.dest, entry.template.render(&ctx)?.as_bytes())?;
1266
1267 for style in spec.styles {
1268 if web_dir.join(style).is_file() {
1269 write_overlay_file(web_dir, style, web_template_asset("brand.css"))?;
1270 } else {
1271 warnings.push(format!("{style} is missing — branded stylesheet skipped"));
1272 }
1273 }
1274 if let Some(base_style) = spec.base_style {
1275 if web_dir.join(base_style).is_file() {
1276 write_overlay_file(web_dir, base_style, web_template_asset("base.css"))?;
1277 } else {
1278 warnings.push(format!(
1279 "{base_style} is missing — baseline stylesheet skipped"
1280 ));
1281 }
1282 }
1283 for group in spec.deletions {
1284 let mut removed = false;
1285 for file in *group {
1286 let path = web_dir.join(file);
1287 if path.is_file() {
1288 std::fs::remove_file(path)?;
1289 removed = true;
1290 }
1291 }
1292 if !removed {
1293 warnings.push(format!("{} is missing — nothing to remove", group[0]));
1294 }
1295 }
1296 let sprite = web_dir.join("public/icons.svg");
1299 if sprite.exists() {
1300 std::fs::remove_file(&sprite)?;
1301 }
1302 if frontend.typescript {
1303 write_overlay_file(
1304 web_dir,
1305 "src/waterui.d.ts",
1306 web_template_asset("waterui.d.ts"),
1307 )?;
1308 }
1309 Ok(true)
1310}
1311
1312fn retitle_index_html(
1316 web_dir: &Path,
1317 display_name: &str,
1318 warnings: &mut Vec<String>,
1319) -> io::Result<()> {
1320 let path = web_dir.join("index.html");
1321 if !path.is_file() {
1322 warnings.push("index.html is missing — title and favicon unchanged".to_string());
1323 return Ok(());
1324 }
1325 let mut html = std::fs::read_to_string(&path)?;
1326 match (html.find("<title>"), html.find("</title>")) {
1327 (Some(start), Some(end)) if start + "<title>".len() <= end => {
1328 html.replace_range(
1329 start + "<title>".len()..end,
1330 &escape_html_text(display_name),
1331 );
1332 }
1333 _ => warnings.push("index.html has no <title> to retitle".to_string()),
1334 }
1335 let mut repointed = false;
1338 for favicon in ["/favicon.svg", "./favicon.svg", "/vite.svg", "./vite.svg"] {
1339 let quoted = format!("\"{favicon}\"");
1340 if html.contains("ed) {
1341 html = html.replace("ed, "\"/waterui.svg\"");
1342 repointed = true;
1343 }
1344 }
1345 if !repointed {
1346 if let Some(head_end) = html.find("</head>") {
1347 html.insert_str(
1348 head_end,
1349 " <link rel=\"icon\" type=\"image/svg+xml\" href=\"/waterui.svg\" />\n ",
1350 );
1351 } else {
1352 warnings.push("index.html has no favicon link or </head> to repoint".to_string());
1353 }
1354 }
1355 std::fs::write(&path, html)
1356}
1357
1358fn escape_html_text(text: &str) -> String {
1360 text.replace('&', "&")
1361 .replace('<', "<")
1362 .replace('>', ">")
1363}
1364
1365#[cfg(test)]
1366mod tests {
1367 use super::*;
1368
1369 fn entries(names: &[&str]) -> Vec<String> {
1370 names.iter().map(ToString::to_string).collect()
1371 }
1372
1373 #[test]
1374 fn package_manager_serde_round_trip() {
1375 #[derive(Debug, Serialize, Deserialize)]
1378 struct Section {
1379 package_manager: PackageManager,
1380 }
1381 for (pm, name) in [
1382 (PackageManager::Bun, "bun"),
1383 (PackageManager::Pnpm, "pnpm"),
1384 (PackageManager::Npm, "npm"),
1385 (PackageManager::Yarn, "yarn"),
1386 ] {
1387 let encoded = toml::to_string(&Section {
1388 package_manager: pm,
1389 })
1390 .unwrap();
1391 assert_eq!(encoded.trim(), format!("package_manager = \"{name}\""));
1392 assert_eq!(
1393 toml::from_str::<Section>(&encoded).unwrap().package_manager,
1394 pm
1395 );
1396 }
1397 let error = toml::from_str::<Section>("package_manager = \"deno\"").unwrap_err();
1398 let message = error.to_string();
1399 for option in ["bun", "pnpm", "npm", "yarn"] {
1400 assert!(
1401 message.contains(option),
1402 "unknown manager error names the options: {message}"
1403 );
1404 }
1405 }
1406
1407 #[test]
1408 fn command_arg_vectors() {
1409 let args = |command: &Command| -> Vec<String> {
1410 std::iter::once(command.get_program().to_string_lossy().into_owned())
1411 .chain(
1412 command
1413 .get_args()
1414 .map(|arg| arg.to_string_lossy().into_owned()),
1415 )
1416 .collect()
1417 };
1418 assert_eq!(
1419 args(&PackageManager::Bun.run("build")),
1420 ["bun", "run", "build"]
1421 );
1422 assert_eq!(args(&PackageManager::Pnpm.install()), ["pnpm", "install"]);
1423 assert_eq!(
1424 args(&PackageManager::Yarn.create_vite("web", None)),
1425 ["yarn", "create", "vite", "web"]
1426 );
1427 assert_eq!(
1428 args(&PackageManager::Bun.create_vite("web", Some("react-ts"))),
1429 ["bun", "create", "vite", "web", "--template", "react-ts"]
1430 );
1431 assert_eq!(
1432 args(&PackageManager::Npm.create_vite("web", Some("vanilla-ts"))),
1433 [
1434 "npm",
1435 "create",
1436 "vite@latest",
1437 "web",
1438 "--",
1439 "--template",
1440 "vanilla-ts"
1441 ]
1442 );
1443 }
1444
1445 #[test]
1446 fn cwd_frontend_moves_into_web_keeping_shell_files() {
1447 let plan = plan_init(
1448 Path::new("/project"),
1449 &entries(&[
1450 "package.json",
1451 "bun.lock",
1452 "index.html",
1453 "src",
1454 "Cargo.toml",
1455 "target",
1456 ".git",
1457 ".github",
1458 "README.md",
1459 "Water.toml",
1460 ]),
1461 &InitAnswers::default(),
1462 )
1463 .unwrap();
1464 let InitAction::MoveFrontendToWeb { entries: moved } = &plan[0] else {
1465 panic!("expected the move step first: {plan:?}")
1466 };
1467 let mut moved: Vec<String> = moved
1468 .iter()
1469 .map(|p| p.to_string_lossy().into_owned())
1470 .collect();
1471 moved.sort();
1472 assert_eq!(moved, ["bun.lock", "index.html", "package.json"]);
1474 assert_eq!(
1475 plan[1],
1476 InitAction::ScaffoldShell {
1477 web_arg: "web".to_string()
1478 }
1479 );
1480 }
1481
1482 #[test]
1483 fn pure_frontend_cwd_moves_its_src() {
1484 let plan = plan_init(
1485 Path::new("/project"),
1486 &entries(&["package.json", "src", "vite.config.ts"]),
1487 &InitAnswers::default(),
1488 )
1489 .unwrap();
1490 let InitAction::MoveFrontendToWeb { entries: moved } = &plan[0] else {
1491 panic!("expected the move step first: {plan:?}")
1492 };
1493 assert!(
1494 moved.contains(&PathBuf::from("src")),
1495 "without Cargo.toml, src/ is frontend code: {moved:?}"
1496 );
1497 }
1498
1499 #[test]
1500 fn new_frontend_scaffolds_vite_then_installs() {
1501 let answers = InitAnswers {
1502 web: Some(WebSource::New),
1503 ..InitAnswers::default()
1504 };
1505 let plan = plan_init(Path::new("/project"), &entries(&[]), &answers).unwrap();
1506 assert_eq!(
1507 plan,
1508 [
1509 InitAction::ScaffoldVite,
1510 InitAction::InstallDependencies,
1511 InitAction::ScaffoldShell {
1512 web_arg: "web".to_string()
1513 },
1514 ]
1515 );
1516 }
1517
1518 #[test]
1519 fn existing_frontend_copy_installs_into_web() {
1520 let answers = InitAnswers {
1521 web: Some(WebSource::Existing(PathBuf::from("/elsewhere/app"))),
1522 web_mode: Some(ExistingFrontendMode::Copy),
1523 ..InitAnswers::default()
1524 };
1525 let plan = plan_init(Path::new("/project"), &entries(&[]), &answers).unwrap();
1526 assert_eq!(
1527 plan,
1528 [
1529 InitAction::CopyFrontend {
1530 source: PathBuf::from("/elsewhere/app")
1531 },
1532 InitAction::InstallDependencies,
1533 InitAction::ScaffoldShell {
1534 web_arg: "web".to_string()
1535 },
1536 ]
1537 );
1538 }
1539
1540 #[test]
1541 fn existing_frontend_reference_uses_a_relative_arg() {
1542 let temp = tempfile::tempdir().unwrap();
1543 let root = temp.path().join("project");
1544 let sibling = temp.path().join("frontend");
1545 std::fs::create_dir_all(&root).unwrap();
1546 std::fs::create_dir_all(&sibling).unwrap();
1547 let answers = InitAnswers {
1548 web: Some(WebSource::Existing(sibling)),
1549 web_mode: Some(ExistingFrontendMode::Reference),
1550 ..InitAnswers::default()
1551 };
1552 let plan = plan_init(&root, &entries(&[]), &answers).unwrap();
1553 assert_eq!(
1554 plan,
1555 [InitAction::ScaffoldShell {
1556 web_arg: "../frontend".to_string()
1557 }]
1558 );
1559 }
1560
1561 #[test]
1562 fn detect_frontend_reads_framework_and_language() {
1563 let assert = |manifest: &str, framework: WebFramework, typescript: bool| {
1564 assert_eq!(
1565 detect_web_frontend(manifest),
1566 Some(WebFrontend {
1567 framework,
1568 typescript
1569 }),
1570 "{manifest}"
1571 );
1572 };
1573 assert(
1575 r#"{"devDependencies":{"typescript":"~5.9","vite":"^7"}}"#,
1576 WebFramework::Vanilla,
1577 true,
1578 );
1579 assert(
1580 r#"{"devDependencies":{"vite":"^7"}}"#,
1581 WebFramework::Vanilla,
1582 false,
1583 );
1584 assert(
1585 r#"{"dependencies":{"react":"^19","react-dom":"^19"},"devDependencies":{"typescript":"~5.9"}}"#,
1586 WebFramework::React,
1587 true,
1588 );
1589 assert(
1590 r#"{"dependencies":{"react":"^19","react-dom":"^19"}}"#,
1591 WebFramework::React,
1592 false,
1593 );
1594 assert(
1595 r#"{"dependencies":{"preact":"^10"},"devDependencies":{"typescript":"~5.9"}}"#,
1596 WebFramework::Preact,
1597 true,
1598 );
1599 assert(
1600 r#"{"dependencies":{"vue":"^3"},"devDependencies":{"typescript":"~5.9","vue-tsc":"^3"}}"#,
1601 WebFramework::Vue,
1602 true,
1603 );
1604 assert(
1607 r#"{"devDependencies":{"svelte":"^5","typescript":"~5.9"}}"#,
1608 WebFramework::Svelte,
1609 true,
1610 );
1611 assert(
1612 r#"{"dependencies":{"solid-js":"^1"}}"#,
1613 WebFramework::Solid,
1614 false,
1615 );
1616 assert(r#"{"dependencies":{"lit":"^3"}}"#, WebFramework::Lit, false);
1617 assert(
1619 r#"{"dependencies":{"@qwik.dev/core":"^2"}}"#,
1620 WebFramework::Other,
1621 false,
1622 );
1623 assert!(detect_web_frontend("not json").is_none());
1624 }
1625
1626 fn write_vanilla_layout(web: &Path) {
1628 std::fs::create_dir_all(web.join("public")).unwrap();
1629 std::fs::create_dir_all(web.join("src/assets")).unwrap();
1630 std::fs::write(
1631 web.join("package.json"),
1632 r#"{"devDependencies":{"typescript":"~6.0","vite":"^8"}}"#,
1633 )
1634 .unwrap();
1635 std::fs::write(
1636 web.join("index.html"),
1637 "<html><head><title>web</title>\
1638 <link rel=\"icon\" type=\"image/svg+xml\" href=\"/favicon.svg\" />\
1639 </head><body><div id=\"app\"></div></body></html>",
1640 )
1641 .unwrap();
1642 std::fs::write(web.join("public/favicon.svg"), "<svg/>").unwrap();
1643 std::fs::write(web.join("public/icons.svg"), "<svg/>").unwrap();
1644 std::fs::write(web.join("src/main.ts"), "// vite starter").unwrap();
1645 std::fs::write(web.join("src/counter.ts"), "// counter").unwrap();
1646 std::fs::write(web.join("src/style.css"), "/* vite */").unwrap();
1647 std::fs::write(web.join("src/assets/typescript.svg"), "<svg/>").unwrap();
1648 }
1649
1650 #[test]
1651 fn overlay_brands_a_vanilla_layout() {
1652 let temp = tempfile::tempdir().unwrap();
1653 let web = temp.path().join("web");
1654 write_vanilla_layout(&web);
1655
1656 let report = apply_brand_overlay(&web, "My App").unwrap();
1657 assert!(report.branded);
1658 assert!(report.warnings.is_empty(), "{:?}", report.warnings);
1659 assert_eq!(
1660 report.frontend,
1661 Some(WebFrontend {
1662 framework: WebFramework::Vanilla,
1663 typescript: true
1664 })
1665 );
1666
1667 assert!(web.join("public/waterui.svg").is_file());
1668 assert!(!web.join("public/favicon.svg").exists());
1669 assert!(!web.join("public/icons.svg").exists());
1670 assert!(!web.join("src/counter.ts").exists());
1671 assert!(web.join("src/waterui.d.ts").is_file());
1672 let main = std::fs::read_to_string(web.join("src/main.ts")).unwrap();
1673 assert!(main.contains("WaterUI + TypeScript"), "{main}");
1674 assert!(main.contains("'./assets/typescript.svg'"), "{main}");
1675 assert!(main.contains("invoke<string>('greet'"), "{main}");
1676 let html = std::fs::read_to_string(web.join("index.html")).unwrap();
1677 assert!(html.contains("<title>My App</title>"), "{html}");
1678 assert!(html.contains("\"/waterui.svg\""), "{html}");
1679 let style = std::fs::read_to_string(web.join("src/style.css")).unwrap();
1680 assert!(style.contains(".page"), "{style}");
1681 }
1682
1683 #[test]
1684 fn overlay_skips_missing_files_with_warnings() {
1685 let temp = tempfile::tempdir().unwrap();
1686 let web = temp.path().join("web");
1687 std::fs::create_dir_all(&web).unwrap();
1688 std::fs::write(
1691 web.join("package.json"),
1692 r#"{"dependencies":{"react":"^19"},"devDependencies":{"typescript":"~5.9"}}"#,
1693 )
1694 .unwrap();
1695
1696 let report = apply_brand_overlay(&web, "App").unwrap();
1697 assert!(!report.branded);
1698 assert!(!report.warnings.is_empty());
1699 assert!(
1700 report.warnings.iter().any(|w| w.contains("src/App.tsx")),
1701 "{:?}",
1702 report.warnings
1703 );
1704 assert!(web.join("public/waterui.svg").is_file());
1705 }
1706
1707 #[test]
1712 #[cfg(unix)]
1713 fn dev_server_tree_signals_the_grandchild_and_refuses_a_shared_group() {
1714 use std::os::unix::process::CommandExt as _;
1715
1716 let mut leader = std::process::Command::new("sh")
1717 .args(["-c", "sleep 30 & wait"])
1718 .stdin(Stdio::null())
1719 .stdout(Stdio::null())
1720 .stderr(Stdio::null())
1721 .process_group(0)
1722 .spawn()
1723 .expect("sh spawns");
1724 let tree =
1725 dev_server_tree::DevServerTree::adopt(&leader).expect("the child leads its group");
1726 tree.signal(false);
1727 let status = leader.wait().expect("the leader is reaped");
1728 assert!(
1729 !status.success(),
1730 "SIGKILL to the group ends the leader: {status}"
1731 );
1732
1733 let mut shared = std::process::Command::new("sh")
1734 .args(["-c", "exit 0"])
1735 .stdin(Stdio::null())
1736 .stdout(Stdio::null())
1737 .stderr(Stdio::null())
1738 .spawn()
1739 .expect("sh spawns");
1740 let refused = dev_server_tree::DevServerTree::adopt(&shared);
1741 let _ = shared.wait();
1742 assert!(refused.is_err(), "a child in our own group must be refused");
1743 }
1744
1745 #[test]
1746 fn dev_url_from_line_finds_vite_local_url() {
1747 for line in [
1748 " ➜ Local: http://localhost:5173/",
1749 " ➜ Local: https://localhost:5173/",
1750 "Local: http://127.0.0.1:3000",
1751 "Local: http://[::1]:8080/",
1752 "ready in 42ms http://localhost:5173/app/index.html",
1753 ] {
1754 let url = dev_url_from_line(line).unwrap_or_else(|| panic!("no URL in {line:?}"));
1755 assert!(url.port().is_some(), "explicit port required: {line:?}");
1756 }
1757 assert_eq!(
1758 dev_url_from_line(" ➜ Local: http://localhost:5173/")
1759 .unwrap()
1760 .as_str(),
1761 "http://localhost:5173/"
1762 );
1763 }
1764
1765 #[test]
1766 fn dev_url_from_line_rejects_non_loopback_and_portless_urls() {
1767 for line in [
1768 " ➜ Network: http://192.168.1.4:5173/",
1769 " ➜ Network: http://172.20.10.2:5173/",
1770 "see https://localhost:5173.example.com/ for details",
1771 "no url here",
1772 "http://localhost is missing a port",
1773 "VITE v7.0.0 ready in 120 ms",
1774 ] {
1775 assert_eq!(dev_url_from_line(line), None, "unexpected URL in {line:?}");
1776 }
1777 }
1778
1779 #[test]
1780 fn device_facing_url_rewrites_loopback_for_ios_device() {
1781 let url: url::Url = "http://localhost:5173/".parse().unwrap();
1782 for target in [
1783 DevTarget::Desktop,
1784 DevTarget::IosSimulator,
1785 DevTarget::Android,
1786 ] {
1787 assert_eq!(device_facing_url(target, &url).unwrap(), url);
1788 }
1789 let rewritten = device_facing_url(DevTarget::IosDevice, &url).unwrap();
1790 assert_eq!(rewritten.port(), Some(5173));
1791 let host = rewritten.host_str().expect("a host");
1792 let ip: std::net::Ipv4Addr = host.parse().expect("an IPv4 LAN host");
1793 assert!(!ip.is_loopback());
1794 }
1795
1796 #[test]
1797 fn adb_reverse_args_forward_the_dev_url_port() {
1798 assert_eq!(
1799 adb_reverse_args("emulator-5554", 5173),
1800 ["-s", "emulator-5554", "reverse", "tcp:5173", "tcp:5173"]
1801 );
1802 }
1803
1804 #[test]
1805 fn dev_url_port_reads_the_launch_environment() {
1806 assert_eq!(
1807 dev_url_port(std::iter::empty::<(&str, &str)>()).unwrap(),
1808 None
1809 );
1810 assert_eq!(
1811 dev_url_port([("WATERUI_DEV_URL", "http://localhost:5173/")].into_iter()).unwrap(),
1812 Some(5173)
1813 );
1814 assert!(
1815 dev_url_port([("WATERUI_DEV_URL", "not a url")].into_iter()).is_err(),
1816 "a malformed handoff fails loudly"
1817 );
1818 }
1819
1820 #[test]
1821 fn dev_script_probes_dev_serve_start() {
1822 let temp = tempfile::tempdir().unwrap();
1823 let package_json = temp.path().join("package.json");
1824
1825 std::fs::write(
1826 &package_json,
1827 r#"{"scripts":{"build":"vite build","serve":"vite preview"}}"#,
1828 )
1829 .unwrap();
1830 assert_eq!(dev_script(temp.path()).unwrap(), "serve");
1831
1832 std::fs::write(&package_json, r#"{"scripts":{"start":"node server.js"}}"#).unwrap();
1833 assert_eq!(dev_script(temp.path()).unwrap(), "start");
1834
1835 std::fs::write(
1836 &package_json,
1837 r#"{"scripts":{"dev":"vite","serve":"vite preview"}}"#,
1838 )
1839 .unwrap();
1840 assert_eq!(dev_script(temp.path()).unwrap(), "dev");
1841
1842 std::fs::write(&package_json, r#"{"scripts":{"build":"vite build"}}"#).unwrap();
1843 let error = dev_script(temp.path()).unwrap_err().to_string();
1844 for script in ["dev", "serve", "start"] {
1845 assert!(error.contains(script), "error names the probes: {error}");
1846 }
1847 }
1848}