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::build::BuildProgress;
20use crate::project::Project;
21use crate::project_model::templates::embedded;
22
23#[derive(Deserialize, Serialize, Clone, Copy, PartialEq, Eq, Debug, Default, clap::ValueEnum)]
29#[serde(rename_all = "lowercase")]
30pub enum PackageManager {
31 #[default]
33 Bun,
34 Pnpm,
36 Npm,
38 Yarn,
40}
41
42impl PackageManager {
43 #[must_use]
45 pub const fn binary(self) -> &'static str {
46 match self {
47 Self::Bun => "bun",
48 Self::Pnpm => "pnpm",
49 Self::Npm => "npm",
50 Self::Yarn => "yarn",
51 }
52 }
53
54 #[must_use]
56 pub fn run(self, script: &str) -> Command {
57 let mut command = Command::new(self.binary());
58 command.arg("run").arg(script);
59 command
60 }
61
62 #[must_use]
64 pub fn install(self) -> Command {
65 let mut command = Command::new(self.binary());
66 command.arg("install");
67 command
68 }
69
70 #[must_use]
76 pub fn create_vite(self, dir: &str, template: Option<&str>) -> Command {
77 let mut command = Command::new(self.binary());
78 command.arg("create");
79 match self {
80 Self::Npm => command.arg("vite@latest"),
81 Self::Bun | Self::Pnpm | Self::Yarn => command.arg("vite"),
82 };
83 command.arg(dir);
84 if let Some(template) = template {
85 if self == Self::Npm {
88 command.arg("--");
89 }
90 command.args(["--template", template]);
91 }
92 command
93 }
94
95 pub async fn is_installed(self) -> bool {
97 crate::utils::which(self.binary()).await.is_ok()
98 }
99
100 #[must_use]
103 pub const fn install_hint(self) -> &'static str {
104 match self {
105 Self::Bun => "curl -fsSL https://bun.sh/install | bash",
106 Self::Pnpm => "npm install -g pnpm (or see https://pnpm.io/installation)",
107 Self::Npm => "install Node.js from https://nodejs.org/",
108 Self::Yarn => {
109 "npm install -g yarn (or see https://yarnpkg.com/getting-started/install)"
110 }
111 }
112 }
113}
114
115#[derive(Debug, Clone, Default, Serialize, Deserialize)]
120pub struct WebConfig {
121 #[serde(default)]
124 pub package_manager: PackageManager,
125}
126
127pub async fn web_mount(
137 project: &Project,
138 sccache_path: Option<&Path>,
139 progress: Option<&BuildProgress>,
140) -> eyre::Result<Option<BundleMountMeta>> {
141 let rlib = build_host_rlib(
142 project.root(),
143 &project.host_target_dir().await?,
144 sccache_path,
145 progress,
146 )
147 .await?;
148 let symbols = ArtifactSymbols::read(&rlib)?;
149 decode_web_mount(&symbols)
150}
151
152pub fn decode_web_mount(symbols: &ArtifactSymbols) -> eyre::Result<Option<BundleMountMeta>> {
160 let mut frontend = None;
161 for leaf in symbols.leaves_with_prefix(BUNDLE_META_PREFIX) {
162 let meta = symbols.bundle_mount_meta(&leaf)?;
163 if meta.project.is_none() {
164 continue;
165 }
166 if frontend.replace(meta).is_some() {
167 bail!("more than one include_web! mount is declared in the artifact");
168 }
169 }
170 Ok(frontend)
171}
172
173pub async fn build_frontend(
187 package_manager: PackageManager,
188 meta: &BundleMountMeta,
189) -> eyre::Result<()> {
190 let root = meta
191 .project
192 .as_ref()
193 .expect("build_frontend is only called for mounts that declare a project");
194 let pm = package_manager.binary();
195 let status = package_manager
196 .run("build")
197 .current_dir(root)
198 .stdin(Stdio::inherit())
199 .stdout(Stdio::inherit())
200 .stderr(Stdio::inherit())
201 .status()
202 .await?;
203 if !status.success() {
204 bail!("`{pm} run build` failed in {}: {status}", root.display());
205 }
206 if !meta.path.is_dir() {
207 bail!(
208 "`{pm} run build` did not produce `{}`; set `out_dir` on `include_web!` to the bundler's output directory",
209 meta.path.display()
210 );
211 }
212 Ok(())
213}
214
215pub const DEV_URL_ENV: &str = "WATERUI_DEV_URL";
228
229#[derive(Debug, Clone, Copy, PartialEq, Eq)]
231pub enum DevTarget {
232 Desktop,
234 IosSimulator,
236 IosDevice,
238 Android,
240}
241
242pub fn device_facing_url(target: DevTarget, url: &url::Url) -> eyre::Result<url::Url> {
260 if target != DevTarget::IosDevice {
261 return Ok(url.clone());
262 }
263 let mut url = url.clone();
264 let host = lan_ipv4()?.to_string();
265 url.set_host(Some(&host))
266 .wrap_err_with(|| format!("dev-server URL cannot carry a LAN host: {url}"))?;
267 Ok(url)
268}
269
270fn lan_ipv4() -> eyre::Result<std::net::Ipv4Addr> {
277 let socket = std::net::UdpSocket::bind((std::net::Ipv4Addr::UNSPECIFIED, 0))
278 .wrap_err("failed to bind a UDP socket for LAN address detection")?;
279 socket
280 .connect((std::net::Ipv4Addr::new(192, 0, 0, 1), 80))
281 .wrap_err(
282 "no outbound route — cannot determine this Mac's LAN address for the iOS device",
283 )?;
284 match socket.local_addr()?.ip() {
285 std::net::IpAddr::V4(ip) if !ip.is_loopback() => Ok(ip),
286 other => Err(eyre::eyre!(
287 "the outbound interface has no usable LAN IPv4 address ({other}); connect the Mac to the same LAN as the iOS device"
288 )),
289 }
290}
291
292#[must_use]
296pub fn adb_reverse_args(device_id: &str, port: u16) -> Vec<String> {
297 vec![
298 "-s".to_string(),
299 device_id.to_string(),
300 "reverse".to_string(),
301 format!("tcp:{port}"),
302 format!("tcp:{port}"),
303 ]
304}
305
306pub fn dev_url_port<'a>(
315 mut env_vars: impl Iterator<Item = (&'a str, &'a str)>,
316) -> eyre::Result<Option<u16>> {
317 let Some((_, value)) = env_vars.find(|(key, _)| *key == DEV_URL_ENV) else {
318 return Ok(None);
319 };
320 let url: url::Url = value
321 .parse()
322 .wrap_err_with(|| format!("{DEV_URL_ENV} is set but is not a URL: {value}"))?;
323 url.port_or_known_default().map_or_else(
324 || Err(eyre::eyre!("{DEV_URL_ENV} has no port to forward: {value}")),
325 |port| Ok(Some(port)),
326 )
327}
328
329pub fn dev_script(root: &Path) -> eyre::Result<String> {
337 let package_json_path = root.join("package.json");
338 let manifest = std::fs::read_to_string(&package_json_path)
339 .wrap_err_with(|| format!("failed to read {}", package_json_path.display()))?;
340 let package: serde_json::Value = serde_json::from_str(&manifest)
341 .wrap_err_with(|| format!("failed to parse {}", package_json_path.display()))?;
342 package
343 .get("scripts")
344 .and_then(|scripts| {
345 ["dev", "serve", "start"]
346 .iter()
347 .find(|name| scripts.get(**name).is_some())
348 })
349 .map(|name| (*name).to_string())
350 .ok_or_else(|| {
351 eyre::eyre!(
352 "`{}` declares none of the dev scripts `dev`, `serve`, `start`",
353 package_json_path.display()
354 )
355 })
356}
357
358#[must_use]
367pub fn dev_url_from_line(line: &str) -> Option<url::Url> {
368 line.split_whitespace().find_map(|token| {
369 let url = token.parse::<url::Url>().ok()?;
370 if !matches!(url.scheme(), "http" | "https") {
371 return None;
372 }
373 let loopback = url
374 .host_str()
375 .is_some_and(|host| host.eq_ignore_ascii_case("localhost") || host == "127.0.0.1")
376 || url.host() == Some(url::Host::Ipv6(std::net::Ipv6Addr::LOCALHOST));
377 (loopback && url.port().is_some()).then_some(url)
378 })
379}
380
381#[derive(Debug)]
391pub struct WebDevServer {
392 url: url::Url,
393 child: Option<(std::process::Child, dev_server_tree::DevServerTree)>,
394 _drain: smol::Task<()>,
395}
396
397impl Drop for WebDevServer {
398 fn drop(&mut self) {
399 let Some((mut child, tree)) = self.child.take() else {
400 return;
401 };
402 tree.signal(true);
403 std::thread::spawn(move || {
404 let mut exited = false;
405 for _ in 0..40 {
406 std::thread::sleep(std::time::Duration::from_millis(50));
407 if matches!(child.try_wait(), Ok(Some(_))) {
408 exited = true;
409 break;
410 }
411 }
412 if !exited {
413 tree.signal(false);
414 }
415 let _ = child.wait();
416 });
417 }
418}
419
420impl WebDevServer {
421 pub async fn spawn(
443 package_manager: PackageManager,
444 root: &Path,
445 script: &str,
446 expose_on_lan: bool,
447 ) -> eyre::Result<Self> {
448 use smol::io::{AsyncBufReadExt, BufReader};
449 use smol::stream::StreamExt as _;
450
451 let pm = package_manager.binary();
452 let mut command = std::process::Command::new(pm);
455 command.arg("run").arg(script).current_dir(root);
456 if expose_on_lan {
457 if package_manager == PackageManager::Npm {
461 command.arg("--");
462 }
463 command.args(["--host", "0.0.0.0"]);
464 }
465 command
466 .stdin(Stdio::null())
467 .stdout(Stdio::piped())
468 .stderr(Stdio::inherit());
469 #[cfg(unix)]
470 {
471 use std::os::unix::process::CommandExt as _;
472 command.process_group(0);
473 }
474 let mut child = command.spawn().wrap_err_with(|| {
475 format!("failed to spawn `{pm} run {script}` in {}", root.display())
476 })?;
477 let tree = dev_server_tree::DevServerTree::adopt(&child)
478 .wrap_err_with(|| format!("failed to group the `{pm} run {script}` process tree"))?;
479 let stdout = child.stdout.take().expect("stdout is piped");
480 let mut lines = BufReader::new(smol::Unblock::new(stdout)).lines();
481
482 let url = loop {
483 match lines.next().await {
484 Some(Ok(line)) => {
485 echo_dev_server_line(&line);
486 if let Some(url) = dev_url_from_line(&line) {
487 break url;
488 }
489 }
490 Some(Err(error)) => {
491 tree.signal(false);
492 let _ = smol::unblock(move || child.wait()).await;
493 bail!("failed to read `{pm} run {script}` output: {error}");
494 }
495 None => {
496 let status = child.try_wait().ok().flatten();
497 tree.signal(false);
498 let _ = smol::unblock(move || child.wait()).await;
499 match status {
500 Some(status) => bail!(
501 "`{pm} run {script}` exited with {status} without printing a dev-server URL"
502 ),
503 None => bail!(
504 "`{pm} run {script}` closed its output without printing a dev-server URL"
505 ),
506 }
507 }
508 }
509 };
510
511 let drain = smol::spawn(async move {
512 while let Some(line) = lines.next().await {
513 match line {
514 Ok(line) => echo_dev_server_line(&line),
515 Err(_) => break,
516 }
517 }
518 });
519
520 Ok(Self {
521 url,
522 child: Some((child, tree)),
523 _drain: drain,
524 })
525 }
526
527 #[must_use]
529 pub const fn url(&self) -> &url::Url {
530 &self.url
531 }
532}
533
534mod dev_server_tree {
538 use std::io;
539
540 #[cfg(unix)]
542 #[derive(Debug)]
543 pub struct DevServerTree {
544 group: nix::unistd::Pid,
545 }
546
547 #[cfg(unix)]
548 impl DevServerTree {
549 pub fn adopt(child: &std::process::Child) -> io::Result<Self> {
554 let pid = nix::unistd::Pid::from_raw(
555 i32::try_from(child.id()).expect("process identifiers fit in i32"),
556 );
557 let group = nix::unistd::getpgid(Some(pid))?;
558 if group != pid {
559 return Err(io::Error::other(format!(
560 "process {pid} belongs to group {group} instead of leading its own"
561 )));
562 }
563 Ok(Self { group })
564 }
565
566 pub fn signal(&self, graceful: bool) {
569 let signal = if graceful {
570 nix::sys::signal::Signal::SIGTERM
571 } else {
572 nix::sys::signal::Signal::SIGKILL
573 };
574 let _ = nix::sys::signal::killpg(self.group, signal);
575 }
576 }
577
578 #[cfg(windows)]
583 #[derive(Debug)]
584 pub struct DevServerTree {
585 job: windows_sys::Win32::Foundation::HANDLE,
586 }
587
588 #[cfg(windows)]
591 unsafe impl Send for DevServerTree {}
592
593 #[cfg(windows)]
594 impl DevServerTree {
595 pub fn adopt(child: &std::process::Child) -> io::Result<Self> {
598 use std::os::windows::io::AsRawHandle as _;
599
600 use windows_sys::Win32::System::JobObjects::{
601 AssignProcessToJobObject, CreateJobObjectW, JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE,
602 JOBOBJECT_EXTENDED_LIMIT_INFORMATION, JobObjectExtendedLimitInformation,
603 SetInformationJobObject,
604 };
605
606 let job = unsafe { CreateJobObjectW(std::ptr::null(), std::ptr::null()) };
609 if job.is_null() {
610 return Err(io::Error::last_os_error());
611 }
612 let tree = Self { job };
613 let mut limits: JOBOBJECT_EXTENDED_LIMIT_INFORMATION = unsafe { std::mem::zeroed() };
616 limits.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE;
617 let size = u32::try_from(std::mem::size_of::<JOBOBJECT_EXTENDED_LIMIT_INFORMATION>())
618 .expect("the limit block is far smaller than u32::MAX bytes");
619 let configured = unsafe {
622 SetInformationJobObject(
623 job,
624 JobObjectExtendedLimitInformation,
625 (&raw const limits).cast(),
626 size,
627 )
628 };
629 if configured == 0 {
630 return Err(io::Error::last_os_error());
631 }
632 let assigned = unsafe { AssignProcessToJobObject(job, child.as_raw_handle().cast()) };
635 if assigned == 0 {
636 return Err(io::Error::last_os_error());
637 }
638 Ok(tree)
639 }
640
641 pub fn signal(&self, graceful: bool) {
646 use windows_sys::Win32::System::JobObjects::TerminateJobObject;
647
648 if graceful {
649 return;
650 }
651 let _ = unsafe { TerminateJobObject(self.job, 1) };
653 }
654 }
655
656 #[cfg(windows)]
657 impl Drop for DevServerTree {
658 fn drop(&mut self) {
659 use windows_sys::Win32::Foundation::CloseHandle;
660
661 let _ = unsafe { CloseHandle(self.job) };
664 }
665 }
666}
667
668fn echo_dev_server_line(line: &str) {
671 let _ = writeln!(anstream::stderr().lock(), "{line}");
672}
673
674#[derive(Debug, Clone, PartialEq, Eq)]
680pub enum WebSource {
681 New,
683 Existing(PathBuf),
685}
686
687#[derive(Debug, Clone, Copy, PartialEq, Eq)]
689pub enum ExistingFrontendMode {
690 Copy,
692 Reference,
694}
695
696#[derive(Debug, Clone, Default)]
698pub struct InitAnswers {
699 pub web: Option<WebSource>,
701 pub web_mode: Option<ExistingFrontendMode>,
704 pub package_manager: Option<PackageManager>,
706}
707
708#[derive(Debug, Clone, PartialEq, Eq)]
711pub enum InitAction {
712 MoveFrontendToWeb {
714 entries: Vec<PathBuf>,
716 },
717 ScaffoldVite,
719 CopyFrontend {
722 source: PathBuf,
724 },
725 InstallDependencies,
727 ScaffoldShell {
729 web_arg: String,
731 },
732}
733
734fn root_only_entries(has_rust_manifest: bool, entry: &str) -> bool {
737 if matches!(
738 entry,
739 ".git" | ".github" | ".water" | "Water.toml" | "Water.lock" | "backends" | "target" | "web"
740 ) || entry.starts_with("README")
741 || entry.starts_with("LICENSE")
742 {
743 return true;
744 }
745 has_rust_manifest && matches!(entry, "Cargo.toml" | "Cargo.lock" | "src")
748}
749
750#[must_use]
752pub fn lockfile_package_manager(entries: &[String]) -> Option<PackageManager> {
753 if entries.iter().any(|e| e == "bun.lock" || e == "bun.lockb") {
754 Some(PackageManager::Bun)
755 } else if entries.iter().any(|e| e == "pnpm-lock.yaml") {
756 Some(PackageManager::Pnpm)
757 } else if entries.iter().any(|e| e == "yarn.lock") {
758 Some(PackageManager::Yarn)
759 } else if entries.iter().any(|e| e == "package-lock.json") {
760 Some(PackageManager::Npm)
761 } else {
762 None
763 }
764}
765
766pub fn plan_init(
779 project_root: &Path,
780 entries: &[String],
781 answers: &InitAnswers,
782) -> eyre::Result<Vec<InitAction>> {
783 if entries.iter().any(|e| e == "package.json") {
784 let has_rust_manifest = entries.iter().any(|e| e == "Cargo.toml");
785 let move_entries = entries
786 .iter()
787 .filter(|entry| !root_only_entries(has_rust_manifest, entry))
788 .map(PathBuf::from)
789 .collect();
790 return Ok(vec![
791 InitAction::MoveFrontendToWeb {
792 entries: move_entries,
793 },
794 InitAction::ScaffoldShell {
795 web_arg: "web".to_string(),
796 },
797 ]);
798 }
799
800 match answers.web.clone() {
801 Some(WebSource::New) | None => Ok(vec![
802 InitAction::ScaffoldVite,
803 InitAction::InstallDependencies,
804 InitAction::ScaffoldShell {
805 web_arg: "web".to_string(),
806 },
807 ]),
808 Some(WebSource::Existing(source)) => {
809 match answers.web_mode.unwrap_or(ExistingFrontendMode::Copy) {
810 ExistingFrontendMode::Copy => Ok(vec![
811 InitAction::CopyFrontend { source },
812 InitAction::InstallDependencies,
813 InitAction::ScaffoldShell {
814 web_arg: "web".to_string(),
815 },
816 ]),
817 ExistingFrontendMode::Reference => {
818 let arg = relative_path_arg(project_root, &source)?;
819 Ok(vec![InitAction::ScaffoldShell { web_arg: arg }])
820 }
821 }
822 }
823 }
824}
825
826fn relative_path_arg(project_root: &Path, source: &Path) -> eyre::Result<String> {
829 let root = dunce::canonicalize(project_root)?;
830 let source = dunce::canonicalize(source)?;
831 let mut root_components = root.components().peekable();
832 let mut source_components = source.components().peekable();
833 while root_components.peek() == source_components.peek() && root_components.peek().is_some() {
834 root_components.next();
835 source_components.next();
836 }
837 let mut arg = String::new();
838 for _ in root_components {
839 if !arg.is_empty() {
840 arg.push('/');
841 }
842 arg.push_str("..");
843 }
844 for component in source_components {
845 if !arg.is_empty() {
846 arg.push('/');
847 }
848 arg.push_str(
849 component
850 .as_os_str()
851 .to_str()
852 .ok_or_else(|| eyre::eyre!("frontend path is not valid UTF-8"))?,
853 );
854 }
855 if arg.is_empty() {
856 bail!("the frontend is the project root itself; put its files in `web/`");
857 }
858 Ok(arg)
859}
860
861#[derive(Debug, Clone, Copy, PartialEq, Eq)]
870pub enum WebFramework {
871 Vanilla,
873 React,
875 Preact,
877 Vue,
879 Svelte,
881 Solid,
883 Lit,
885 Other,
887}
888
889impl WebFramework {
890 #[must_use]
892 pub const fn display_name(self) -> &'static str {
893 match self {
894 Self::Vanilla => "Vanilla",
895 Self::React => "React",
896 Self::Preact => "Preact",
897 Self::Vue => "Vue",
898 Self::Svelte => "Svelte",
899 Self::Solid => "Solid",
900 Self::Lit => "Lit",
901 Self::Other => "web",
902 }
903 }
904
905 const fn supports_branding(self) -> bool {
907 matches!(self, Self::Vanilla | Self::React | Self::Vue | Self::Svelte)
908 }
909}
910
911#[derive(Debug, Clone, Copy, PartialEq, Eq)]
914pub struct WebFrontend {
915 pub framework: WebFramework,
917 pub typescript: bool,
919}
920
921const FRAMEWORK_DEPENDENCIES: &[(&str, WebFramework)] = &[
923 ("react", WebFramework::React),
924 ("preact", WebFramework::Preact),
925 ("vue", WebFramework::Vue),
926 ("svelte", WebFramework::Svelte),
927 ("solid-js", WebFramework::Solid),
928 ("lit", WebFramework::Lit),
929];
930
931#[must_use]
941pub fn detect_web_frontend(package_json: &str) -> Option<WebFrontend> {
942 let package: serde_json::Value = serde_json::from_str(package_json).ok()?;
943 let dependencies = package
944 .get("dependencies")
945 .and_then(serde_json::Value::as_object);
946 let dev_dependencies = package
947 .get("devDependencies")
948 .and_then(serde_json::Value::as_object);
949 let has_marker = |name: &str| {
950 dependencies.is_some_and(|deps| deps.contains_key(name))
951 || dev_dependencies.is_some_and(|deps| deps.contains_key(name))
952 };
953 let framework = FRAMEWORK_DEPENDENCIES
954 .iter()
955 .find(|(name, _)| has_marker(name))
956 .map_or_else(
957 || {
958 if dependencies.is_none_or(serde_json::Map::is_empty) {
959 WebFramework::Vanilla
960 } else {
961 WebFramework::Other
962 }
963 },
964 |(_, framework)| *framework,
965 );
966 let typescript = dev_dependencies.is_some_and(|deps| deps.contains_key("typescript"));
967 Some(WebFrontend {
968 framework,
969 typescript,
970 })
971}
972
973#[derive(Debug, Default)]
975pub struct WebOverlayReport {
976 pub frontend: Option<WebFrontend>,
978 pub branded: bool,
981 pub warnings: Vec<String>,
983}
984
985struct WebOverlayContext<'a> {
987 framework: &'a str,
990 entry: &'a str,
992 logo: &'a str,
995 typescript: bool,
998}
999
1000macro_rules! web_overlay_templates {
1001 ($($name:ident => $path:literal),* $(,)?) => {$(
1002 #[derive(Template)]
1003 #[template(path = $path, escape = "none")]
1004 struct $name<'a> {
1005 ctx: &'a WebOverlayContext<'a>,
1006 }
1007 )*};
1008}
1009
1010web_overlay_templates! {
1011 VanillaMainTsTemplate => "src/templates/web/vanilla/main.ts.tpl",
1012 VanillaMainJsTemplate => "src/templates/web/vanilla/main.js.tpl",
1013 ReactAppTsxTemplate => "src/templates/web/react/App.tsx.tpl",
1014 ReactAppJsxTemplate => "src/templates/web/react/App.jsx.tpl",
1015 VueAppTemplate => "src/templates/web/vue/App.vue.tpl",
1016 SvelteAppTemplate => "src/templates/web/svelte/App.svelte.tpl",
1017}
1018
1019#[derive(Clone, Copy)]
1021enum OverlayTemplate {
1022 VanillaTs,
1023 VanillaJs,
1024 ReactTsx,
1025 ReactJsx,
1026 Vue,
1027 Svelte,
1028}
1029
1030impl OverlayTemplate {
1031 fn render(self, ctx: &WebOverlayContext) -> io::Result<String> {
1032 let rendered = match self {
1033 Self::VanillaTs => VanillaMainTsTemplate { ctx }.render(),
1034 Self::VanillaJs => VanillaMainJsTemplate { ctx }.render(),
1035 Self::ReactTsx => ReactAppTsxTemplate { ctx }.render(),
1036 Self::ReactJsx => ReactAppJsxTemplate { ctx }.render(),
1037 Self::Vue => VueAppTemplate { ctx }.render(),
1038 Self::Svelte => SvelteAppTemplate { ctx }.render(),
1039 };
1040 rendered.map_err(|error| {
1041 io::Error::other(format!("web overlay template render failed: {error}"))
1042 })
1043 }
1044}
1045
1046struct EntryCandidate {
1049 dest: &'static str,
1052 template: OverlayTemplate,
1054 logos: &'static [&'static str],
1057}
1058
1059struct OverlaySpec {
1061 entries: &'static [EntryCandidate],
1063 styles: &'static [&'static str],
1065 base_style: Option<&'static str>,
1069 deletions: &'static [&'static [&'static str]],
1072}
1073
1074const fn overlay_spec(framework: WebFramework) -> Option<OverlaySpec> {
1075 Some(match framework {
1076 WebFramework::Vanilla => OverlaySpec {
1077 entries: &[
1078 EntryCandidate {
1079 dest: "src/main.ts",
1080 template: OverlayTemplate::VanillaTs,
1081 logos: &["src/assets/typescript.svg", "src/typescript.svg"],
1082 },
1083 EntryCandidate {
1084 dest: "src/main.js",
1085 template: OverlayTemplate::VanillaJs,
1086 logos: &["src/assets/javascript.svg", "src/javascript.svg"],
1087 },
1088 ],
1089 styles: &["src/style.css"],
1090 base_style: None,
1091 deletions: &[&["src/counter.ts", "src/counter.js"]],
1092 },
1093 WebFramework::React => OverlaySpec {
1094 entries: &[
1095 EntryCandidate {
1096 dest: "src/App.tsx",
1097 template: OverlayTemplate::ReactTsx,
1098 logos: &["src/assets/react.svg", "src/react.svg"],
1099 },
1100 EntryCandidate {
1101 dest: "src/App.jsx",
1102 template: OverlayTemplate::ReactJsx,
1103 logos: &["src/assets/react.svg", "src/react.svg"],
1104 },
1105 ],
1106 styles: &["src/App.css"],
1107 base_style: Some("src/index.css"),
1108 deletions: &[],
1109 },
1110 WebFramework::Vue => OverlaySpec {
1111 entries: &[EntryCandidate {
1112 dest: "src/App.vue",
1113 template: OverlayTemplate::Vue,
1114 logos: &["src/assets/vue.svg", "src/vue.svg"],
1115 }],
1116 styles: &["src/style.css"],
1117 base_style: None,
1118 deletions: &[&["src/components/HelloWorld.vue"]],
1119 },
1120 WebFramework::Svelte => OverlaySpec {
1121 entries: &[EntryCandidate {
1122 dest: "src/App.svelte",
1123 template: OverlayTemplate::Svelte,
1124 logos: &["src/assets/svelte.svg", "src/svelte.svg"],
1125 }],
1126 styles: &["src/app.css"],
1127 base_style: None,
1128 deletions: &[&["src/lib/Counter.svelte"]],
1129 },
1130 _ => return None,
1131 })
1132}
1133
1134fn web_template_asset(relative: &str) -> &'static [u8] {
1136 embedded::ROOT
1137 .get_file(format!("web/{relative}"))
1138 .unwrap_or_else(|| panic!("web overlay asset `{relative}` must ship in the CLI"))
1139 .contents()
1140}
1141
1142pub fn apply_brand_overlay(web_dir: &Path, display_name: &str) -> io::Result<WebOverlayReport> {
1162 let mut report = WebOverlayReport::default();
1163
1164 let logo = embedded::ROOT
1165 .get_file("icon.svg")
1166 .expect("the WaterUI logo ships in the template bundle");
1167 write_overlay_file(web_dir, "public/waterui.svg", logo.contents())?;
1168 for orphaned in ["public/vite.svg", "public/favicon.svg"] {
1171 let path = web_dir.join(orphaned);
1172 if path.exists() {
1173 std::fs::remove_file(&path)?;
1174 }
1175 }
1176 retitle_index_html(web_dir, display_name, &mut report.warnings)?;
1177
1178 let Some(frontend) = read_frontend(web_dir, &mut report.warnings) else {
1179 return Ok(report);
1180 };
1181 report.frontend = Some(frontend);
1182 if frontend.framework.supports_branding() {
1183 report.branded = brand_framework_page(web_dir, frontend, &mut report.warnings)?;
1184 }
1185 Ok(report)
1186}
1187
1188fn write_overlay_file(web_dir: &Path, relative: &str, contents: &[u8]) -> io::Result<()> {
1190 let dest = web_dir.join(relative);
1191 if let Some(parent) = dest.parent() {
1192 std::fs::create_dir_all(parent)?;
1193 }
1194 std::fs::write(dest, contents)
1195}
1196
1197fn read_frontend(web_dir: &Path, warnings: &mut Vec<String>) -> Option<WebFrontend> {
1200 if let Ok(manifest) = std::fs::read_to_string(web_dir.join("package.json")) {
1201 detect_web_frontend(&manifest).or_else(|| {
1202 warnings.push(
1203 "web/package.json did not parse — the starter page was left in place".to_string(),
1204 );
1205 None
1206 })
1207 } else {
1208 warnings
1209 .push("web/package.json is missing — the starter page was left in place".to_string());
1210 None
1211 }
1212}
1213
1214fn brand_framework_page(
1217 web_dir: &Path,
1218 frontend: WebFrontend,
1219 warnings: &mut Vec<String>,
1220) -> io::Result<bool> {
1221 let Some(spec) = overlay_spec(frontend.framework) else {
1222 return Ok(false);
1223 };
1224 let Some(entry) = spec
1225 .entries
1226 .iter()
1227 .find(|candidate| web_dir.join(candidate.dest).is_file())
1228 else {
1229 warnings.push(format!(
1230 "{} is missing — the {} starter layout is not recognized; its default page remains",
1231 spec.entries[0].dest,
1232 frontend.framework.display_name(),
1233 ));
1234 return Ok(false);
1235 };
1236
1237 let framework = if frontend.framework == WebFramework::Vanilla {
1241 if frontend.typescript {
1242 "TypeScript"
1243 } else {
1244 "JavaScript"
1245 }
1246 } else {
1247 frontend.framework.display_name()
1248 };
1249 let logo = entry
1253 .logos
1254 .iter()
1255 .find(|logo| web_dir.join(logo).is_file())
1256 .map_or_else(
1257 || {
1258 warnings.push(format!(
1259 "{} is missing — the branded page falls back to the WaterUI mark",
1260 entry.logos[0]
1261 ));
1262 "../public/waterui.svg".to_string()
1263 },
1264 |logo| format!("./{}", logo.strip_prefix("src/").unwrap_or(logo)),
1265 );
1266
1267 let ctx = WebOverlayContext {
1268 framework,
1269 entry: entry.dest,
1270 logo: &logo,
1271 typescript: frontend.typescript,
1272 };
1273 write_overlay_file(web_dir, entry.dest, entry.template.render(&ctx)?.as_bytes())?;
1274
1275 for style in spec.styles {
1276 if web_dir.join(style).is_file() {
1277 write_overlay_file(web_dir, style, web_template_asset("brand.css"))?;
1278 } else {
1279 warnings.push(format!("{style} is missing — branded stylesheet skipped"));
1280 }
1281 }
1282 if let Some(base_style) = spec.base_style {
1283 if web_dir.join(base_style).is_file() {
1284 write_overlay_file(web_dir, base_style, web_template_asset("base.css"))?;
1285 } else {
1286 warnings.push(format!(
1287 "{base_style} is missing — baseline stylesheet skipped"
1288 ));
1289 }
1290 }
1291 for group in spec.deletions {
1292 let mut removed = false;
1293 for file in *group {
1294 let path = web_dir.join(file);
1295 if path.is_file() {
1296 std::fs::remove_file(path)?;
1297 removed = true;
1298 }
1299 }
1300 if !removed {
1301 warnings.push(format!("{} is missing — nothing to remove", group[0]));
1302 }
1303 }
1304 let sprite = web_dir.join("public/icons.svg");
1307 if sprite.exists() {
1308 std::fs::remove_file(&sprite)?;
1309 }
1310 if frontend.typescript {
1311 write_overlay_file(
1312 web_dir,
1313 "src/waterui.d.ts",
1314 web_template_asset("waterui.d.ts"),
1315 )?;
1316 }
1317 Ok(true)
1318}
1319
1320fn retitle_index_html(
1324 web_dir: &Path,
1325 display_name: &str,
1326 warnings: &mut Vec<String>,
1327) -> io::Result<()> {
1328 let path = web_dir.join("index.html");
1329 if !path.is_file() {
1330 warnings.push("index.html is missing — title and favicon unchanged".to_string());
1331 return Ok(());
1332 }
1333 let mut html = std::fs::read_to_string(&path)?;
1334 match (html.find("<title>"), html.find("</title>")) {
1335 (Some(start), Some(end)) if start + "<title>".len() <= end => {
1336 html.replace_range(
1337 start + "<title>".len()..end,
1338 &escape_html_text(display_name),
1339 );
1340 }
1341 _ => warnings.push("index.html has no <title> to retitle".to_string()),
1342 }
1343 let mut repointed = false;
1346 for favicon in ["/favicon.svg", "./favicon.svg", "/vite.svg", "./vite.svg"] {
1347 let quoted = format!("\"{favicon}\"");
1348 if html.contains("ed) {
1349 html = html.replace("ed, "\"/waterui.svg\"");
1350 repointed = true;
1351 }
1352 }
1353 if !repointed {
1354 if let Some(head_end) = html.find("</head>") {
1355 html.insert_str(
1356 head_end,
1357 " <link rel=\"icon\" type=\"image/svg+xml\" href=\"/waterui.svg\" />\n ",
1358 );
1359 } else {
1360 warnings.push("index.html has no favicon link or </head> to repoint".to_string());
1361 }
1362 }
1363 std::fs::write(&path, html)
1364}
1365
1366fn escape_html_text(text: &str) -> String {
1368 text.replace('&', "&")
1369 .replace('<', "<")
1370 .replace('>', ">")
1371}
1372
1373#[cfg(test)]
1374mod tests {
1375 use super::*;
1376
1377 fn entries(names: &[&str]) -> Vec<String> {
1378 names.iter().map(ToString::to_string).collect()
1379 }
1380
1381 #[test]
1382 fn package_manager_serde_round_trip() {
1383 #[derive(Debug, Serialize, Deserialize)]
1386 struct Section {
1387 package_manager: PackageManager,
1388 }
1389 for (pm, name) in [
1390 (PackageManager::Bun, "bun"),
1391 (PackageManager::Pnpm, "pnpm"),
1392 (PackageManager::Npm, "npm"),
1393 (PackageManager::Yarn, "yarn"),
1394 ] {
1395 let encoded = toml::to_string(&Section {
1396 package_manager: pm,
1397 })
1398 .unwrap();
1399 assert_eq!(encoded.trim(), format!("package_manager = \"{name}\""));
1400 assert_eq!(
1401 toml::from_str::<Section>(&encoded).unwrap().package_manager,
1402 pm
1403 );
1404 }
1405 let error = toml::from_str::<Section>("package_manager = \"deno\"").unwrap_err();
1406 let message = error.to_string();
1407 for option in ["bun", "pnpm", "npm", "yarn"] {
1408 assert!(
1409 message.contains(option),
1410 "unknown manager error names the options: {message}"
1411 );
1412 }
1413 }
1414
1415 #[test]
1416 fn command_arg_vectors() {
1417 let args = |command: &Command| -> Vec<String> {
1418 std::iter::once(command.get_program().to_string_lossy().into_owned())
1419 .chain(
1420 command
1421 .get_args()
1422 .map(|arg| arg.to_string_lossy().into_owned()),
1423 )
1424 .collect()
1425 };
1426 assert_eq!(
1427 args(&PackageManager::Bun.run("build")),
1428 ["bun", "run", "build"]
1429 );
1430 assert_eq!(args(&PackageManager::Pnpm.install()), ["pnpm", "install"]);
1431 assert_eq!(
1432 args(&PackageManager::Yarn.create_vite("web", None)),
1433 ["yarn", "create", "vite", "web"]
1434 );
1435 assert_eq!(
1436 args(&PackageManager::Bun.create_vite("web", Some("react-ts"))),
1437 ["bun", "create", "vite", "web", "--template", "react-ts"]
1438 );
1439 assert_eq!(
1440 args(&PackageManager::Npm.create_vite("web", Some("vanilla-ts"))),
1441 [
1442 "npm",
1443 "create",
1444 "vite@latest",
1445 "web",
1446 "--",
1447 "--template",
1448 "vanilla-ts"
1449 ]
1450 );
1451 }
1452
1453 #[test]
1454 fn cwd_frontend_moves_into_web_keeping_shell_files() {
1455 let plan = plan_init(
1456 Path::new("/project"),
1457 &entries(&[
1458 "package.json",
1459 "bun.lock",
1460 "index.html",
1461 "src",
1462 "Cargo.toml",
1463 "target",
1464 ".git",
1465 ".github",
1466 "README.md",
1467 "Water.toml",
1468 ]),
1469 &InitAnswers::default(),
1470 )
1471 .unwrap();
1472 let InitAction::MoveFrontendToWeb { entries: moved } = &plan[0] else {
1473 panic!("expected the move step first: {plan:?}")
1474 };
1475 let mut moved: Vec<String> = moved
1476 .iter()
1477 .map(|p| p.to_string_lossy().into_owned())
1478 .collect();
1479 moved.sort();
1480 assert_eq!(moved, ["bun.lock", "index.html", "package.json"]);
1482 assert_eq!(
1483 plan[1],
1484 InitAction::ScaffoldShell {
1485 web_arg: "web".to_string()
1486 }
1487 );
1488 }
1489
1490 #[test]
1491 fn pure_frontend_cwd_moves_its_src() {
1492 let plan = plan_init(
1493 Path::new("/project"),
1494 &entries(&["package.json", "src", "vite.config.ts"]),
1495 &InitAnswers::default(),
1496 )
1497 .unwrap();
1498 let InitAction::MoveFrontendToWeb { entries: moved } = &plan[0] else {
1499 panic!("expected the move step first: {plan:?}")
1500 };
1501 assert!(
1502 moved.contains(&PathBuf::from("src")),
1503 "without Cargo.toml, src/ is frontend code: {moved:?}"
1504 );
1505 }
1506
1507 #[test]
1508 fn new_frontend_scaffolds_vite_then_installs() {
1509 let answers = InitAnswers {
1510 web: Some(WebSource::New),
1511 ..InitAnswers::default()
1512 };
1513 let plan = plan_init(Path::new("/project"), &entries(&[]), &answers).unwrap();
1514 assert_eq!(
1515 plan,
1516 [
1517 InitAction::ScaffoldVite,
1518 InitAction::InstallDependencies,
1519 InitAction::ScaffoldShell {
1520 web_arg: "web".to_string()
1521 },
1522 ]
1523 );
1524 }
1525
1526 #[test]
1527 fn existing_frontend_copy_installs_into_web() {
1528 let answers = InitAnswers {
1529 web: Some(WebSource::Existing(PathBuf::from("/elsewhere/app"))),
1530 web_mode: Some(ExistingFrontendMode::Copy),
1531 ..InitAnswers::default()
1532 };
1533 let plan = plan_init(Path::new("/project"), &entries(&[]), &answers).unwrap();
1534 assert_eq!(
1535 plan,
1536 [
1537 InitAction::CopyFrontend {
1538 source: PathBuf::from("/elsewhere/app")
1539 },
1540 InitAction::InstallDependencies,
1541 InitAction::ScaffoldShell {
1542 web_arg: "web".to_string()
1543 },
1544 ]
1545 );
1546 }
1547
1548 #[test]
1549 fn existing_frontend_reference_uses_a_relative_arg() {
1550 let temp = tempfile::tempdir().unwrap();
1551 let root = temp.path().join("project");
1552 let sibling = temp.path().join("frontend");
1553 std::fs::create_dir_all(&root).unwrap();
1554 std::fs::create_dir_all(&sibling).unwrap();
1555 let answers = InitAnswers {
1556 web: Some(WebSource::Existing(sibling)),
1557 web_mode: Some(ExistingFrontendMode::Reference),
1558 ..InitAnswers::default()
1559 };
1560 let plan = plan_init(&root, &entries(&[]), &answers).unwrap();
1561 assert_eq!(
1562 plan,
1563 [InitAction::ScaffoldShell {
1564 web_arg: "../frontend".to_string()
1565 }]
1566 );
1567 }
1568
1569 #[test]
1570 fn detect_frontend_reads_framework_and_language() {
1571 let assert = |manifest: &str, framework: WebFramework, typescript: bool| {
1572 assert_eq!(
1573 detect_web_frontend(manifest),
1574 Some(WebFrontend {
1575 framework,
1576 typescript
1577 }),
1578 "{manifest}"
1579 );
1580 };
1581 assert(
1583 r#"{"devDependencies":{"typescript":"~5.9","vite":"^7"}}"#,
1584 WebFramework::Vanilla,
1585 true,
1586 );
1587 assert(
1588 r#"{"devDependencies":{"vite":"^7"}}"#,
1589 WebFramework::Vanilla,
1590 false,
1591 );
1592 assert(
1593 r#"{"dependencies":{"react":"^19","react-dom":"^19"},"devDependencies":{"typescript":"~5.9"}}"#,
1594 WebFramework::React,
1595 true,
1596 );
1597 assert(
1598 r#"{"dependencies":{"react":"^19","react-dom":"^19"}}"#,
1599 WebFramework::React,
1600 false,
1601 );
1602 assert(
1603 r#"{"dependencies":{"preact":"^10"},"devDependencies":{"typescript":"~5.9"}}"#,
1604 WebFramework::Preact,
1605 true,
1606 );
1607 assert(
1608 r#"{"dependencies":{"vue":"^3"},"devDependencies":{"typescript":"~5.9","vue-tsc":"^3"}}"#,
1609 WebFramework::Vue,
1610 true,
1611 );
1612 assert(
1615 r#"{"devDependencies":{"svelte":"^5","typescript":"~5.9"}}"#,
1616 WebFramework::Svelte,
1617 true,
1618 );
1619 assert(
1620 r#"{"dependencies":{"solid-js":"^1"}}"#,
1621 WebFramework::Solid,
1622 false,
1623 );
1624 assert(r#"{"dependencies":{"lit":"^3"}}"#, WebFramework::Lit, false);
1625 assert(
1627 r#"{"dependencies":{"@qwik.dev/core":"^2"}}"#,
1628 WebFramework::Other,
1629 false,
1630 );
1631 assert!(detect_web_frontend("not json").is_none());
1632 }
1633
1634 fn write_vanilla_layout(web: &Path) {
1636 std::fs::create_dir_all(web.join("public")).unwrap();
1637 std::fs::create_dir_all(web.join("src/assets")).unwrap();
1638 std::fs::write(
1639 web.join("package.json"),
1640 r#"{"devDependencies":{"typescript":"~6.0","vite":"^8"}}"#,
1641 )
1642 .unwrap();
1643 std::fs::write(
1644 web.join("index.html"),
1645 "<html><head><title>web</title>\
1646 <link rel=\"icon\" type=\"image/svg+xml\" href=\"/favicon.svg\" />\
1647 </head><body><div id=\"app\"></div></body></html>",
1648 )
1649 .unwrap();
1650 std::fs::write(web.join("public/favicon.svg"), "<svg/>").unwrap();
1651 std::fs::write(web.join("public/icons.svg"), "<svg/>").unwrap();
1652 std::fs::write(web.join("src/main.ts"), "// vite starter").unwrap();
1653 std::fs::write(web.join("src/counter.ts"), "// counter").unwrap();
1654 std::fs::write(web.join("src/style.css"), "/* vite */").unwrap();
1655 std::fs::write(web.join("src/assets/typescript.svg"), "<svg/>").unwrap();
1656 }
1657
1658 #[test]
1659 fn overlay_brands_a_vanilla_layout() {
1660 let temp = tempfile::tempdir().unwrap();
1661 let web = temp.path().join("web");
1662 write_vanilla_layout(&web);
1663
1664 let report = apply_brand_overlay(&web, "My App").unwrap();
1665 assert!(report.branded);
1666 assert!(report.warnings.is_empty(), "{:?}", report.warnings);
1667 assert_eq!(
1668 report.frontend,
1669 Some(WebFrontend {
1670 framework: WebFramework::Vanilla,
1671 typescript: true
1672 })
1673 );
1674
1675 assert!(web.join("public/waterui.svg").is_file());
1676 assert!(!web.join("public/favicon.svg").exists());
1677 assert!(!web.join("public/icons.svg").exists());
1678 assert!(!web.join("src/counter.ts").exists());
1679 assert!(web.join("src/waterui.d.ts").is_file());
1680 let main = std::fs::read_to_string(web.join("src/main.ts")).unwrap();
1681 assert!(main.contains("WaterUI + TypeScript"), "{main}");
1682 assert!(main.contains("'./assets/typescript.svg'"), "{main}");
1683 assert!(main.contains("invoke<string>('greet'"), "{main}");
1684 let html = std::fs::read_to_string(web.join("index.html")).unwrap();
1685 assert!(html.contains("<title>My App</title>"), "{html}");
1686 assert!(html.contains("\"/waterui.svg\""), "{html}");
1687 let style = std::fs::read_to_string(web.join("src/style.css")).unwrap();
1688 assert!(style.contains(".page"), "{style}");
1689 }
1690
1691 #[test]
1692 fn overlay_skips_missing_files_with_warnings() {
1693 let temp = tempfile::tempdir().unwrap();
1694 let web = temp.path().join("web");
1695 std::fs::create_dir_all(&web).unwrap();
1696 std::fs::write(
1699 web.join("package.json"),
1700 r#"{"dependencies":{"react":"^19"},"devDependencies":{"typescript":"~5.9"}}"#,
1701 )
1702 .unwrap();
1703
1704 let report = apply_brand_overlay(&web, "App").unwrap();
1705 assert!(!report.branded);
1706 assert!(!report.warnings.is_empty());
1707 assert!(
1708 report.warnings.iter().any(|w| w.contains("src/App.tsx")),
1709 "{:?}",
1710 report.warnings
1711 );
1712 assert!(web.join("public/waterui.svg").is_file());
1713 }
1714
1715 #[test]
1720 #[cfg(unix)]
1721 fn dev_server_tree_signals_the_grandchild_and_refuses_a_shared_group() {
1722 use std::os::unix::process::CommandExt as _;
1723
1724 let mut leader = std::process::Command::new("sh")
1725 .args(["-c", "sleep 30 & wait"])
1726 .stdin(Stdio::null())
1727 .stdout(Stdio::null())
1728 .stderr(Stdio::null())
1729 .process_group(0)
1730 .spawn()
1731 .expect("sh spawns");
1732 let tree =
1733 dev_server_tree::DevServerTree::adopt(&leader).expect("the child leads its group");
1734 tree.signal(false);
1735 let status = leader.wait().expect("the leader is reaped");
1736 assert!(
1737 !status.success(),
1738 "SIGKILL to the group ends the leader: {status}"
1739 );
1740
1741 let mut shared = std::process::Command::new("sh")
1742 .args(["-c", "exit 0"])
1743 .stdin(Stdio::null())
1744 .stdout(Stdio::null())
1745 .stderr(Stdio::null())
1746 .spawn()
1747 .expect("sh spawns");
1748 let refused = dev_server_tree::DevServerTree::adopt(&shared);
1749 let _ = shared.wait();
1750 assert!(refused.is_err(), "a child in our own group must be refused");
1751 }
1752
1753 #[test]
1754 fn dev_url_from_line_finds_vite_local_url() {
1755 for line in [
1756 " ➜ Local: http://localhost:5173/",
1757 " ➜ Local: https://localhost:5173/",
1758 "Local: http://127.0.0.1:3000",
1759 "Local: http://[::1]:8080/",
1760 "ready in 42ms http://localhost:5173/app/index.html",
1761 ] {
1762 let url = dev_url_from_line(line).unwrap_or_else(|| panic!("no URL in {line:?}"));
1763 assert!(url.port().is_some(), "explicit port required: {line:?}");
1764 }
1765 assert_eq!(
1766 dev_url_from_line(" ➜ Local: http://localhost:5173/")
1767 .unwrap()
1768 .as_str(),
1769 "http://localhost:5173/"
1770 );
1771 }
1772
1773 #[test]
1774 fn dev_url_from_line_rejects_non_loopback_and_portless_urls() {
1775 for line in [
1776 " ➜ Network: http://192.168.1.4:5173/",
1777 " ➜ Network: http://172.20.10.2:5173/",
1778 "see https://localhost:5173.example.com/ for details",
1779 "no url here",
1780 "http://localhost is missing a port",
1781 "VITE v7.0.0 ready in 120 ms",
1782 ] {
1783 assert_eq!(dev_url_from_line(line), None, "unexpected URL in {line:?}");
1784 }
1785 }
1786
1787 #[test]
1788 fn device_facing_url_rewrites_loopback_for_ios_device() {
1789 let url: url::Url = "http://localhost:5173/".parse().unwrap();
1790 for target in [
1791 DevTarget::Desktop,
1792 DevTarget::IosSimulator,
1793 DevTarget::Android,
1794 ] {
1795 assert_eq!(device_facing_url(target, &url).unwrap(), url);
1796 }
1797 let rewritten = device_facing_url(DevTarget::IosDevice, &url).unwrap();
1798 assert_eq!(rewritten.port(), Some(5173));
1799 let host = rewritten.host_str().expect("a host");
1800 let ip: std::net::Ipv4Addr = host.parse().expect("an IPv4 LAN host");
1801 assert!(!ip.is_loopback());
1802 }
1803
1804 #[test]
1805 fn adb_reverse_args_forward_the_dev_url_port() {
1806 assert_eq!(
1807 adb_reverse_args("emulator-5554", 5173),
1808 ["-s", "emulator-5554", "reverse", "tcp:5173", "tcp:5173"]
1809 );
1810 }
1811
1812 #[test]
1813 fn dev_url_port_reads_the_launch_environment() {
1814 assert_eq!(
1815 dev_url_port(std::iter::empty::<(&str, &str)>()).unwrap(),
1816 None
1817 );
1818 assert_eq!(
1819 dev_url_port([("WATERUI_DEV_URL", "http://localhost:5173/")].into_iter()).unwrap(),
1820 Some(5173)
1821 );
1822 assert!(
1823 dev_url_port([("WATERUI_DEV_URL", "not a url")].into_iter()).is_err(),
1824 "a malformed handoff fails loudly"
1825 );
1826 }
1827
1828 #[test]
1829 fn dev_script_probes_dev_serve_start() {
1830 let temp = tempfile::tempdir().unwrap();
1831 let package_json = temp.path().join("package.json");
1832
1833 std::fs::write(
1834 &package_json,
1835 r#"{"scripts":{"build":"vite build","serve":"vite preview"}}"#,
1836 )
1837 .unwrap();
1838 assert_eq!(dev_script(temp.path()).unwrap(), "serve");
1839
1840 std::fs::write(&package_json, r#"{"scripts":{"start":"node server.js"}}"#).unwrap();
1841 assert_eq!(dev_script(temp.path()).unwrap(), "start");
1842
1843 std::fs::write(
1844 &package_json,
1845 r#"{"scripts":{"dev":"vite","serve":"vite preview"}}"#,
1846 )
1847 .unwrap();
1848 assert_eq!(dev_script(temp.path()).unwrap(), "dev");
1849
1850 std::fs::write(&package_json, r#"{"scripts":{"build":"vite build"}}"#).unwrap();
1851 let error = dev_script(temp.path()).unwrap_err().to_string();
1852 for script in ["dev", "serve", "start"] {
1853 assert!(error.contains(script), "error names the probes: {error}");
1854 }
1855 }
1856}