use std::io;
use std::io::Write as _;
use std::path::{Path, PathBuf};
use std::process::Stdio;
use askama::Template;
use color_eyre::eyre::{self, Context, bail};
use serde::{Deserialize, Serialize};
use smol::process::Command;
use waterui_assets_planner::{BUNDLE_META_PREFIX, BundleMountMeta};
use crate::artifact_symbols::{ArtifactSymbols, build_host_rlib};
use crate::build::BuildProgress;
use crate::project::Project;
use crate::project_model::templates::embedded;
#[derive(Deserialize, Serialize, Clone, Copy, PartialEq, Eq, Debug, Default, clap::ValueEnum)]
#[serde(rename_all = "lowercase")]
pub enum PackageManager {
#[default]
Bun,
Pnpm,
Npm,
Yarn,
}
impl PackageManager {
#[must_use]
pub const fn binary(self) -> &'static str {
match self {
Self::Bun => "bun",
Self::Pnpm => "pnpm",
Self::Npm => "npm",
Self::Yarn => "yarn",
}
}
#[must_use]
pub fn run(self, script: &str) -> Command {
let mut command = Command::new(self.binary());
command.arg("run").arg(script);
command
}
#[must_use]
pub fn install(self) -> Command {
let mut command = Command::new(self.binary());
command.arg("install");
command
}
#[must_use]
pub fn create_vite(self, dir: &str, template: Option<&str>) -> Command {
let mut command = Command::new(self.binary());
command.arg("create");
match self {
Self::Npm => command.arg("vite@latest"),
Self::Bun | Self::Pnpm | Self::Yarn => command.arg("vite"),
};
command.arg(dir);
if let Some(template) = template {
if self == Self::Npm {
command.arg("--");
}
command.args(["--template", template]);
}
command
}
pub async fn is_installed(self) -> bool {
crate::utils::which(self.binary()).await.is_ok()
}
#[must_use]
pub const fn install_hint(self) -> &'static str {
match self {
Self::Bun => "curl -fsSL https://bun.sh/install | bash",
Self::Pnpm => "npm install -g pnpm (or see https://pnpm.io/installation)",
Self::Npm => "install Node.js from https://nodejs.org/",
Self::Yarn => {
"npm install -g yarn (or see https://yarnpkg.com/getting-started/install)"
}
}
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct WebConfig {
#[serde(default)]
pub package_manager: PackageManager,
}
pub async fn web_mount(
project: &Project,
sccache_path: Option<&Path>,
progress: Option<&BuildProgress>,
) -> eyre::Result<Option<BundleMountMeta>> {
let rlib = build_host_rlib(
project.root(),
&project.host_target_dir().await?,
sccache_path,
progress,
)
.await?;
let symbols = ArtifactSymbols::read(&rlib)?;
decode_web_mount(&symbols)
}
pub fn decode_web_mount(symbols: &ArtifactSymbols) -> eyre::Result<Option<BundleMountMeta>> {
let mut frontend = None;
for leaf in symbols.leaves_with_prefix(BUNDLE_META_PREFIX) {
let meta = symbols.bundle_mount_meta(&leaf)?;
if meta.project.is_none() {
continue;
}
if frontend.replace(meta).is_some() {
bail!("more than one include_web! mount is declared in the artifact");
}
}
Ok(frontend)
}
pub async fn build_frontend(
package_manager: PackageManager,
meta: &BundleMountMeta,
) -> eyre::Result<()> {
let root = meta
.project
.as_ref()
.expect("build_frontend is only called for mounts that declare a project");
let pm = package_manager.binary();
let status = package_manager
.run("build")
.current_dir(root)
.stdin(Stdio::inherit())
.stdout(Stdio::inherit())
.stderr(Stdio::inherit())
.status()
.await?;
if !status.success() {
bail!("`{pm} run build` failed in {}: {status}", root.display());
}
if !meta.path.is_dir() {
bail!(
"`{pm} run build` did not produce `{}`; set `out_dir` on `include_web!` to the bundler's output directory",
meta.path.display()
);
}
Ok(())
}
pub const DEV_URL_ENV: &str = "WATERUI_DEV_URL";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DevTarget {
Desktop,
IosSimulator,
IosDevice,
Android,
}
pub fn device_facing_url(target: DevTarget, url: &url::Url) -> eyre::Result<url::Url> {
if target != DevTarget::IosDevice {
return Ok(url.clone());
}
let mut url = url.clone();
let host = lan_ipv4()?.to_string();
url.set_host(Some(&host))
.wrap_err_with(|| format!("dev-server URL cannot carry a LAN host: {url}"))?;
Ok(url)
}
fn lan_ipv4() -> eyre::Result<std::net::Ipv4Addr> {
let socket = std::net::UdpSocket::bind((std::net::Ipv4Addr::UNSPECIFIED, 0))
.wrap_err("failed to bind a UDP socket for LAN address detection")?;
socket
.connect((std::net::Ipv4Addr::new(192, 0, 0, 1), 80))
.wrap_err(
"no outbound route — cannot determine this Mac's LAN address for the iOS device",
)?;
match socket.local_addr()?.ip() {
std::net::IpAddr::V4(ip) if !ip.is_loopback() => Ok(ip),
other => Err(eyre::eyre!(
"the outbound interface has no usable LAN IPv4 address ({other}); connect the Mac to the same LAN as the iOS device"
)),
}
}
#[must_use]
pub fn adb_reverse_args(device_id: &str, port: u16) -> Vec<String> {
vec![
"-s".to_string(),
device_id.to_string(),
"reverse".to_string(),
format!("tcp:{port}"),
format!("tcp:{port}"),
]
}
pub fn dev_url_port<'a>(
mut env_vars: impl Iterator<Item = (&'a str, &'a str)>,
) -> eyre::Result<Option<u16>> {
let Some((_, value)) = env_vars.find(|(key, _)| *key == DEV_URL_ENV) else {
return Ok(None);
};
let url: url::Url = value
.parse()
.wrap_err_with(|| format!("{DEV_URL_ENV} is set but is not a URL: {value}"))?;
url.port_or_known_default().map_or_else(
|| Err(eyre::eyre!("{DEV_URL_ENV} has no port to forward: {value}")),
|port| Ok(Some(port)),
)
}
pub fn dev_script(root: &Path) -> eyre::Result<String> {
let package_json_path = root.join("package.json");
let manifest = std::fs::read_to_string(&package_json_path)
.wrap_err_with(|| format!("failed to read {}", package_json_path.display()))?;
let package: serde_json::Value = serde_json::from_str(&manifest)
.wrap_err_with(|| format!("failed to parse {}", package_json_path.display()))?;
package
.get("scripts")
.and_then(|scripts| {
["dev", "serve", "start"]
.iter()
.find(|name| scripts.get(**name).is_some())
})
.map(|name| (*name).to_string())
.ok_or_else(|| {
eyre::eyre!(
"`{}` declares none of the dev scripts `dev`, `serve`, `start`",
package_json_path.display()
)
})
}
#[must_use]
pub fn dev_url_from_line(line: &str) -> Option<url::Url> {
line.split_whitespace().find_map(|token| {
let url = token.parse::<url::Url>().ok()?;
if !matches!(url.scheme(), "http" | "https") {
return None;
}
let loopback = url
.host_str()
.is_some_and(|host| host.eq_ignore_ascii_case("localhost") || host == "127.0.0.1")
|| url.host() == Some(url::Host::Ipv6(std::net::Ipv6Addr::LOCALHOST));
(loopback && url.port().is_some()).then_some(url)
})
}
#[derive(Debug)]
pub struct WebDevServer {
url: url::Url,
child: Option<(std::process::Child, dev_server_tree::DevServerTree)>,
_drain: smol::Task<()>,
}
impl Drop for WebDevServer {
fn drop(&mut self) {
let Some((mut child, tree)) = self.child.take() else {
return;
};
tree.signal(true);
std::thread::spawn(move || {
let mut exited = false;
for _ in 0..40 {
std::thread::sleep(std::time::Duration::from_millis(50));
if matches!(child.try_wait(), Ok(Some(_))) {
exited = true;
break;
}
}
if !exited {
tree.signal(false);
}
let _ = child.wait();
});
}
}
impl WebDevServer {
pub async fn spawn(
package_manager: PackageManager,
root: &Path,
script: &str,
expose_on_lan: bool,
) -> eyre::Result<Self> {
use smol::io::{AsyncBufReadExt, BufReader};
use smol::stream::StreamExt as _;
let pm = package_manager.binary();
let mut command = std::process::Command::new(pm);
command.arg("run").arg(script).current_dir(root);
if expose_on_lan {
if package_manager == PackageManager::Npm {
command.arg("--");
}
command.args(["--host", "0.0.0.0"]);
}
command
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::inherit());
#[cfg(unix)]
{
use std::os::unix::process::CommandExt as _;
command.process_group(0);
}
let mut child = command.spawn().wrap_err_with(|| {
format!("failed to spawn `{pm} run {script}` in {}", root.display())
})?;
let tree = dev_server_tree::DevServerTree::adopt(&child)
.wrap_err_with(|| format!("failed to group the `{pm} run {script}` process tree"))?;
let stdout = child.stdout.take().expect("stdout is piped");
let mut lines = BufReader::new(smol::Unblock::new(stdout)).lines();
let url = loop {
match lines.next().await {
Some(Ok(line)) => {
echo_dev_server_line(&line);
if let Some(url) = dev_url_from_line(&line) {
break url;
}
}
Some(Err(error)) => {
tree.signal(false);
let _ = smol::unblock(move || child.wait()).await;
bail!("failed to read `{pm} run {script}` output: {error}");
}
None => {
let status = child.try_wait().ok().flatten();
tree.signal(false);
let _ = smol::unblock(move || child.wait()).await;
match status {
Some(status) => bail!(
"`{pm} run {script}` exited with {status} without printing a dev-server URL"
),
None => bail!(
"`{pm} run {script}` closed its output without printing a dev-server URL"
),
}
}
}
};
let drain = smol::spawn(async move {
while let Some(line) = lines.next().await {
match line {
Ok(line) => echo_dev_server_line(&line),
Err(_) => break,
}
}
});
Ok(Self {
url,
child: Some((child, tree)),
_drain: drain,
})
}
#[must_use]
pub const fn url(&self) -> &url::Url {
&self.url
}
}
mod dev_server_tree {
use std::io;
#[cfg(unix)]
#[derive(Debug)]
pub struct DevServerTree {
group: nix::unistd::Pid,
}
#[cfg(unix)]
impl DevServerTree {
pub fn adopt(child: &std::process::Child) -> io::Result<Self> {
let pid = nix::unistd::Pid::from_raw(
i32::try_from(child.id()).expect("process identifiers fit in i32"),
);
let group = nix::unistd::getpgid(Some(pid))?;
if group != pid {
return Err(io::Error::other(format!(
"process {pid} belongs to group {group} instead of leading its own"
)));
}
Ok(Self { group })
}
pub fn signal(&self, graceful: bool) {
let signal = if graceful {
nix::sys::signal::Signal::SIGTERM
} else {
nix::sys::signal::Signal::SIGKILL
};
let _ = nix::sys::signal::killpg(self.group, signal);
}
}
#[cfg(windows)]
#[derive(Debug)]
pub struct DevServerTree {
job: windows_sys::Win32::Foundation::HANDLE,
}
#[cfg(windows)]
unsafe impl Send for DevServerTree {}
#[cfg(windows)]
impl DevServerTree {
pub fn adopt(child: &std::process::Child) -> io::Result<Self> {
use std::os::windows::io::AsRawHandle as _;
use windows_sys::Win32::System::JobObjects::{
AssignProcessToJobObject, CreateJobObjectW, JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE,
JOBOBJECT_EXTENDED_LIMIT_INFORMATION, JobObjectExtendedLimitInformation,
SetInformationJobObject,
};
let job = unsafe { CreateJobObjectW(std::ptr::null(), std::ptr::null()) };
if job.is_null() {
return Err(io::Error::last_os_error());
}
let tree = Self { job };
let mut limits: JOBOBJECT_EXTENDED_LIMIT_INFORMATION = unsafe { std::mem::zeroed() };
limits.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE;
let size = u32::try_from(std::mem::size_of::<JOBOBJECT_EXTENDED_LIMIT_INFORMATION>())
.expect("the limit block is far smaller than u32::MAX bytes");
let configured = unsafe {
SetInformationJobObject(
job,
JobObjectExtendedLimitInformation,
(&raw const limits).cast(),
size,
)
};
if configured == 0 {
return Err(io::Error::last_os_error());
}
let assigned = unsafe { AssignProcessToJobObject(job, child.as_raw_handle().cast()) };
if assigned == 0 {
return Err(io::Error::last_os_error());
}
Ok(tree)
}
pub fn signal(&self, graceful: bool) {
use windows_sys::Win32::System::JobObjects::TerminateJobObject;
if graceful {
return;
}
let _ = unsafe { TerminateJobObject(self.job, 1) };
}
}
#[cfg(windows)]
impl Drop for DevServerTree {
fn drop(&mut self) {
use windows_sys::Win32::Foundation::CloseHandle;
let _ = unsafe { CloseHandle(self.job) };
}
}
}
fn echo_dev_server_line(line: &str) {
let _ = writeln!(anstream::stderr().lock(), "{line}");
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum WebSource {
New,
Existing(PathBuf),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ExistingFrontendMode {
Copy,
Reference,
}
#[derive(Debug, Clone, Default)]
pub struct InitAnswers {
pub web: Option<WebSource>,
pub web_mode: Option<ExistingFrontendMode>,
pub package_manager: Option<PackageManager>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum InitAction {
MoveFrontendToWeb {
entries: Vec<PathBuf>,
},
ScaffoldVite,
CopyFrontend {
source: PathBuf,
},
InstallDependencies,
ScaffoldShell {
web_arg: String,
},
}
fn root_only_entries(has_rust_manifest: bool, entry: &str) -> bool {
if matches!(
entry,
".git" | ".github" | ".water" | "Water.toml" | "Water.lock" | "backends" | "target" | "web"
) || entry.starts_with("README")
|| entry.starts_with("LICENSE")
{
return true;
}
has_rust_manifest && matches!(entry, "Cargo.toml" | "Cargo.lock" | "src")
}
#[must_use]
pub fn lockfile_package_manager(entries: &[String]) -> Option<PackageManager> {
if entries.iter().any(|e| e == "bun.lock" || e == "bun.lockb") {
Some(PackageManager::Bun)
} else if entries.iter().any(|e| e == "pnpm-lock.yaml") {
Some(PackageManager::Pnpm)
} else if entries.iter().any(|e| e == "yarn.lock") {
Some(PackageManager::Yarn)
} else if entries.iter().any(|e| e == "package-lock.json") {
Some(PackageManager::Npm)
} else {
None
}
}
pub fn plan_init(
project_root: &Path,
entries: &[String],
answers: &InitAnswers,
) -> eyre::Result<Vec<InitAction>> {
if entries.iter().any(|e| e == "package.json") {
let has_rust_manifest = entries.iter().any(|e| e == "Cargo.toml");
let move_entries = entries
.iter()
.filter(|entry| !root_only_entries(has_rust_manifest, entry))
.map(PathBuf::from)
.collect();
return Ok(vec![
InitAction::MoveFrontendToWeb {
entries: move_entries,
},
InitAction::ScaffoldShell {
web_arg: "web".to_string(),
},
]);
}
match answers.web.clone() {
Some(WebSource::New) | None => Ok(vec![
InitAction::ScaffoldVite,
InitAction::InstallDependencies,
InitAction::ScaffoldShell {
web_arg: "web".to_string(),
},
]),
Some(WebSource::Existing(source)) => {
match answers.web_mode.unwrap_or(ExistingFrontendMode::Copy) {
ExistingFrontendMode::Copy => Ok(vec![
InitAction::CopyFrontend { source },
InitAction::InstallDependencies,
InitAction::ScaffoldShell {
web_arg: "web".to_string(),
},
]),
ExistingFrontendMode::Reference => {
let arg = relative_path_arg(project_root, &source)?;
Ok(vec![InitAction::ScaffoldShell { web_arg: arg }])
}
}
}
}
}
fn relative_path_arg(project_root: &Path, source: &Path) -> eyre::Result<String> {
let root = dunce::canonicalize(project_root)?;
let source = dunce::canonicalize(source)?;
let mut root_components = root.components().peekable();
let mut source_components = source.components().peekable();
while root_components.peek() == source_components.peek() && root_components.peek().is_some() {
root_components.next();
source_components.next();
}
let mut arg = String::new();
for _ in root_components {
if !arg.is_empty() {
arg.push('/');
}
arg.push_str("..");
}
for component in source_components {
if !arg.is_empty() {
arg.push('/');
}
arg.push_str(
component
.as_os_str()
.to_str()
.ok_or_else(|| eyre::eyre!("frontend path is not valid UTF-8"))?,
);
}
if arg.is_empty() {
bail!("the frontend is the project root itself; put its files in `web/`");
}
Ok(arg)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum WebFramework {
Vanilla,
React,
Preact,
Vue,
Svelte,
Solid,
Lit,
Other,
}
impl WebFramework {
#[must_use]
pub const fn display_name(self) -> &'static str {
match self {
Self::Vanilla => "Vanilla",
Self::React => "React",
Self::Preact => "Preact",
Self::Vue => "Vue",
Self::Svelte => "Svelte",
Self::Solid => "Solid",
Self::Lit => "Lit",
Self::Other => "web",
}
}
const fn supports_branding(self) -> bool {
matches!(self, Self::Vanilla | Self::React | Self::Vue | Self::Svelte)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct WebFrontend {
pub framework: WebFramework,
pub typescript: bool,
}
const FRAMEWORK_DEPENDENCIES: &[(&str, WebFramework)] = &[
("react", WebFramework::React),
("preact", WebFramework::Preact),
("vue", WebFramework::Vue),
("svelte", WebFramework::Svelte),
("solid-js", WebFramework::Solid),
("lit", WebFramework::Lit),
];
#[must_use]
pub fn detect_web_frontend(package_json: &str) -> Option<WebFrontend> {
let package: serde_json::Value = serde_json::from_str(package_json).ok()?;
let dependencies = package
.get("dependencies")
.and_then(serde_json::Value::as_object);
let dev_dependencies = package
.get("devDependencies")
.and_then(serde_json::Value::as_object);
let has_marker = |name: &str| {
dependencies.is_some_and(|deps| deps.contains_key(name))
|| dev_dependencies.is_some_and(|deps| deps.contains_key(name))
};
let framework = FRAMEWORK_DEPENDENCIES
.iter()
.find(|(name, _)| has_marker(name))
.map_or_else(
|| {
if dependencies.is_none_or(serde_json::Map::is_empty) {
WebFramework::Vanilla
} else {
WebFramework::Other
}
},
|(_, framework)| *framework,
);
let typescript = dev_dependencies.is_some_and(|deps| deps.contains_key("typescript"));
Some(WebFrontend {
framework,
typescript,
})
}
#[derive(Debug, Default)]
pub struct WebOverlayReport {
pub frontend: Option<WebFrontend>,
pub branded: bool,
pub warnings: Vec<String>,
}
struct WebOverlayContext<'a> {
framework: &'a str,
entry: &'a str,
logo: &'a str,
typescript: bool,
}
macro_rules! web_overlay_templates {
($($name:ident => $path:literal),* $(,)?) => {$(
#[derive(Template)]
#[template(path = $path, escape = "none")]
struct $name<'a> {
ctx: &'a WebOverlayContext<'a>,
}
)*};
}
web_overlay_templates! {
VanillaMainTsTemplate => "src/templates/web/vanilla/main.ts.tpl",
VanillaMainJsTemplate => "src/templates/web/vanilla/main.js.tpl",
ReactAppTsxTemplate => "src/templates/web/react/App.tsx.tpl",
ReactAppJsxTemplate => "src/templates/web/react/App.jsx.tpl",
VueAppTemplate => "src/templates/web/vue/App.vue.tpl",
SvelteAppTemplate => "src/templates/web/svelte/App.svelte.tpl",
}
#[derive(Clone, Copy)]
enum OverlayTemplate {
VanillaTs,
VanillaJs,
ReactTsx,
ReactJsx,
Vue,
Svelte,
}
impl OverlayTemplate {
fn render(self, ctx: &WebOverlayContext) -> io::Result<String> {
let rendered = match self {
Self::VanillaTs => VanillaMainTsTemplate { ctx }.render(),
Self::VanillaJs => VanillaMainJsTemplate { ctx }.render(),
Self::ReactTsx => ReactAppTsxTemplate { ctx }.render(),
Self::ReactJsx => ReactAppJsxTemplate { ctx }.render(),
Self::Vue => VueAppTemplate { ctx }.render(),
Self::Svelte => SvelteAppTemplate { ctx }.render(),
};
rendered.map_err(|error| {
io::Error::other(format!("web overlay template render failed: {error}"))
})
}
}
struct EntryCandidate {
dest: &'static str,
template: OverlayTemplate,
logos: &'static [&'static str],
}
struct OverlaySpec {
entries: &'static [EntryCandidate],
styles: &'static [&'static str],
base_style: Option<&'static str>,
deletions: &'static [&'static [&'static str]],
}
const fn overlay_spec(framework: WebFramework) -> Option<OverlaySpec> {
Some(match framework {
WebFramework::Vanilla => OverlaySpec {
entries: &[
EntryCandidate {
dest: "src/main.ts",
template: OverlayTemplate::VanillaTs,
logos: &["src/assets/typescript.svg", "src/typescript.svg"],
},
EntryCandidate {
dest: "src/main.js",
template: OverlayTemplate::VanillaJs,
logos: &["src/assets/javascript.svg", "src/javascript.svg"],
},
],
styles: &["src/style.css"],
base_style: None,
deletions: &[&["src/counter.ts", "src/counter.js"]],
},
WebFramework::React => OverlaySpec {
entries: &[
EntryCandidate {
dest: "src/App.tsx",
template: OverlayTemplate::ReactTsx,
logos: &["src/assets/react.svg", "src/react.svg"],
},
EntryCandidate {
dest: "src/App.jsx",
template: OverlayTemplate::ReactJsx,
logos: &["src/assets/react.svg", "src/react.svg"],
},
],
styles: &["src/App.css"],
base_style: Some("src/index.css"),
deletions: &[],
},
WebFramework::Vue => OverlaySpec {
entries: &[EntryCandidate {
dest: "src/App.vue",
template: OverlayTemplate::Vue,
logos: &["src/assets/vue.svg", "src/vue.svg"],
}],
styles: &["src/style.css"],
base_style: None,
deletions: &[&["src/components/HelloWorld.vue"]],
},
WebFramework::Svelte => OverlaySpec {
entries: &[EntryCandidate {
dest: "src/App.svelte",
template: OverlayTemplate::Svelte,
logos: &["src/assets/svelte.svg", "src/svelte.svg"],
}],
styles: &["src/app.css"],
base_style: None,
deletions: &[&["src/lib/Counter.svelte"]],
},
_ => return None,
})
}
fn web_template_asset(relative: &str) -> &'static [u8] {
embedded::ROOT
.get_file(format!("web/{relative}"))
.unwrap_or_else(|| panic!("web overlay asset `{relative}` must ship in the CLI"))
.contents()
}
pub fn apply_brand_overlay(web_dir: &Path, display_name: &str) -> io::Result<WebOverlayReport> {
let mut report = WebOverlayReport::default();
let logo = embedded::ROOT
.get_file("icon.svg")
.expect("the WaterUI logo ships in the template bundle");
write_overlay_file(web_dir, "public/waterui.svg", logo.contents())?;
for orphaned in ["public/vite.svg", "public/favicon.svg"] {
let path = web_dir.join(orphaned);
if path.exists() {
std::fs::remove_file(&path)?;
}
}
retitle_index_html(web_dir, display_name, &mut report.warnings)?;
let Some(frontend) = read_frontend(web_dir, &mut report.warnings) else {
return Ok(report);
};
report.frontend = Some(frontend);
if frontend.framework.supports_branding() {
report.branded = brand_framework_page(web_dir, frontend, &mut report.warnings)?;
}
Ok(report)
}
fn write_overlay_file(web_dir: &Path, relative: &str, contents: &[u8]) -> io::Result<()> {
let dest = web_dir.join(relative);
if let Some(parent) = dest.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(dest, contents)
}
fn read_frontend(web_dir: &Path, warnings: &mut Vec<String>) -> Option<WebFrontend> {
if let Ok(manifest) = std::fs::read_to_string(web_dir.join("package.json")) {
detect_web_frontend(&manifest).or_else(|| {
warnings.push(
"web/package.json did not parse — the starter page was left in place".to_string(),
);
None
})
} else {
warnings
.push("web/package.json is missing — the starter page was left in place".to_string());
None
}
}
fn brand_framework_page(
web_dir: &Path,
frontend: WebFrontend,
warnings: &mut Vec<String>,
) -> io::Result<bool> {
let Some(spec) = overlay_spec(frontend.framework) else {
return Ok(false);
};
let Some(entry) = spec
.entries
.iter()
.find(|candidate| web_dir.join(candidate.dest).is_file())
else {
warnings.push(format!(
"{} is missing — the {} starter layout is not recognized; its default page remains",
spec.entries[0].dest,
frontend.framework.display_name(),
));
return Ok(false);
};
let framework = if frontend.framework == WebFramework::Vanilla {
if frontend.typescript {
"TypeScript"
} else {
"JavaScript"
}
} else {
frontend.framework.display_name()
};
let logo = entry
.logos
.iter()
.find(|logo| web_dir.join(logo).is_file())
.map_or_else(
|| {
warnings.push(format!(
"{} is missing — the branded page falls back to the WaterUI mark",
entry.logos[0]
));
"../public/waterui.svg".to_string()
},
|logo| format!("./{}", logo.strip_prefix("src/").unwrap_or(logo)),
);
let ctx = WebOverlayContext {
framework,
entry: entry.dest,
logo: &logo,
typescript: frontend.typescript,
};
write_overlay_file(web_dir, entry.dest, entry.template.render(&ctx)?.as_bytes())?;
for style in spec.styles {
if web_dir.join(style).is_file() {
write_overlay_file(web_dir, style, web_template_asset("brand.css"))?;
} else {
warnings.push(format!("{style} is missing — branded stylesheet skipped"));
}
}
if let Some(base_style) = spec.base_style {
if web_dir.join(base_style).is_file() {
write_overlay_file(web_dir, base_style, web_template_asset("base.css"))?;
} else {
warnings.push(format!(
"{base_style} is missing — baseline stylesheet skipped"
));
}
}
for group in spec.deletions {
let mut removed = false;
for file in *group {
let path = web_dir.join(file);
if path.is_file() {
std::fs::remove_file(path)?;
removed = true;
}
}
if !removed {
warnings.push(format!("{} is missing — nothing to remove", group[0]));
}
}
let sprite = web_dir.join("public/icons.svg");
if sprite.exists() {
std::fs::remove_file(&sprite)?;
}
if frontend.typescript {
write_overlay_file(
web_dir,
"src/waterui.d.ts",
web_template_asset("waterui.d.ts"),
)?;
}
Ok(true)
}
fn retitle_index_html(
web_dir: &Path,
display_name: &str,
warnings: &mut Vec<String>,
) -> io::Result<()> {
let path = web_dir.join("index.html");
if !path.is_file() {
warnings.push("index.html is missing — title and favicon unchanged".to_string());
return Ok(());
}
let mut html = std::fs::read_to_string(&path)?;
match (html.find("<title>"), html.find("</title>")) {
(Some(start), Some(end)) if start + "<title>".len() <= end => {
html.replace_range(
start + "<title>".len()..end,
&escape_html_text(display_name),
);
}
_ => warnings.push("index.html has no <title> to retitle".to_string()),
}
let mut repointed = false;
for favicon in ["/favicon.svg", "./favicon.svg", "/vite.svg", "./vite.svg"] {
let quoted = format!("\"{favicon}\"");
if html.contains("ed) {
html = html.replace("ed, "\"/waterui.svg\"");
repointed = true;
}
}
if !repointed {
if let Some(head_end) = html.find("</head>") {
html.insert_str(
head_end,
" <link rel=\"icon\" type=\"image/svg+xml\" href=\"/waterui.svg\" />\n ",
);
} else {
warnings.push("index.html has no favicon link or </head> to repoint".to_string());
}
}
std::fs::write(&path, html)
}
fn escape_html_text(text: &str) -> String {
text.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
}
#[cfg(test)]
mod tests {
use super::*;
fn entries(names: &[&str]) -> Vec<String> {
names.iter().map(ToString::to_string).collect()
}
#[test]
fn package_manager_serde_round_trip() {
#[derive(Debug, Serialize, Deserialize)]
struct Section {
package_manager: PackageManager,
}
for (pm, name) in [
(PackageManager::Bun, "bun"),
(PackageManager::Pnpm, "pnpm"),
(PackageManager::Npm, "npm"),
(PackageManager::Yarn, "yarn"),
] {
let encoded = toml::to_string(&Section {
package_manager: pm,
})
.unwrap();
assert_eq!(encoded.trim(), format!("package_manager = \"{name}\""));
assert_eq!(
toml::from_str::<Section>(&encoded).unwrap().package_manager,
pm
);
}
let error = toml::from_str::<Section>("package_manager = \"deno\"").unwrap_err();
let message = error.to_string();
for option in ["bun", "pnpm", "npm", "yarn"] {
assert!(
message.contains(option),
"unknown manager error names the options: {message}"
);
}
}
#[test]
fn command_arg_vectors() {
let args = |command: &Command| -> Vec<String> {
std::iter::once(command.get_program().to_string_lossy().into_owned())
.chain(
command
.get_args()
.map(|arg| arg.to_string_lossy().into_owned()),
)
.collect()
};
assert_eq!(
args(&PackageManager::Bun.run("build")),
["bun", "run", "build"]
);
assert_eq!(args(&PackageManager::Pnpm.install()), ["pnpm", "install"]);
assert_eq!(
args(&PackageManager::Yarn.create_vite("web", None)),
["yarn", "create", "vite", "web"]
);
assert_eq!(
args(&PackageManager::Bun.create_vite("web", Some("react-ts"))),
["bun", "create", "vite", "web", "--template", "react-ts"]
);
assert_eq!(
args(&PackageManager::Npm.create_vite("web", Some("vanilla-ts"))),
[
"npm",
"create",
"vite@latest",
"web",
"--",
"--template",
"vanilla-ts"
]
);
}
#[test]
fn cwd_frontend_moves_into_web_keeping_shell_files() {
let plan = plan_init(
Path::new("/project"),
&entries(&[
"package.json",
"bun.lock",
"index.html",
"src",
"Cargo.toml",
"target",
".git",
".github",
"README.md",
"Water.toml",
]),
&InitAnswers::default(),
)
.unwrap();
let InitAction::MoveFrontendToWeb { entries: moved } = &plan[0] else {
panic!("expected the move step first: {plan:?}")
};
let mut moved: Vec<String> = moved
.iter()
.map(|p| p.to_string_lossy().into_owned())
.collect();
moved.sort();
assert_eq!(moved, ["bun.lock", "index.html", "package.json"]);
assert_eq!(
plan[1],
InitAction::ScaffoldShell {
web_arg: "web".to_string()
}
);
}
#[test]
fn pure_frontend_cwd_moves_its_src() {
let plan = plan_init(
Path::new("/project"),
&entries(&["package.json", "src", "vite.config.ts"]),
&InitAnswers::default(),
)
.unwrap();
let InitAction::MoveFrontendToWeb { entries: moved } = &plan[0] else {
panic!("expected the move step first: {plan:?}")
};
assert!(
moved.contains(&PathBuf::from("src")),
"without Cargo.toml, src/ is frontend code: {moved:?}"
);
}
#[test]
fn new_frontend_scaffolds_vite_then_installs() {
let answers = InitAnswers {
web: Some(WebSource::New),
..InitAnswers::default()
};
let plan = plan_init(Path::new("/project"), &entries(&[]), &answers).unwrap();
assert_eq!(
plan,
[
InitAction::ScaffoldVite,
InitAction::InstallDependencies,
InitAction::ScaffoldShell {
web_arg: "web".to_string()
},
]
);
}
#[test]
fn existing_frontend_copy_installs_into_web() {
let answers = InitAnswers {
web: Some(WebSource::Existing(PathBuf::from("/elsewhere/app"))),
web_mode: Some(ExistingFrontendMode::Copy),
..InitAnswers::default()
};
let plan = plan_init(Path::new("/project"), &entries(&[]), &answers).unwrap();
assert_eq!(
plan,
[
InitAction::CopyFrontend {
source: PathBuf::from("/elsewhere/app")
},
InitAction::InstallDependencies,
InitAction::ScaffoldShell {
web_arg: "web".to_string()
},
]
);
}
#[test]
fn existing_frontend_reference_uses_a_relative_arg() {
let temp = tempfile::tempdir().unwrap();
let root = temp.path().join("project");
let sibling = temp.path().join("frontend");
std::fs::create_dir_all(&root).unwrap();
std::fs::create_dir_all(&sibling).unwrap();
let answers = InitAnswers {
web: Some(WebSource::Existing(sibling)),
web_mode: Some(ExistingFrontendMode::Reference),
..InitAnswers::default()
};
let plan = plan_init(&root, &entries(&[]), &answers).unwrap();
assert_eq!(
plan,
[InitAction::ScaffoldShell {
web_arg: "../frontend".to_string()
}]
);
}
#[test]
fn detect_frontend_reads_framework_and_language() {
let assert = |manifest: &str, framework: WebFramework, typescript: bool| {
assert_eq!(
detect_web_frontend(manifest),
Some(WebFrontend {
framework,
typescript
}),
"{manifest}"
);
};
assert(
r#"{"devDependencies":{"typescript":"~5.9","vite":"^7"}}"#,
WebFramework::Vanilla,
true,
);
assert(
r#"{"devDependencies":{"vite":"^7"}}"#,
WebFramework::Vanilla,
false,
);
assert(
r#"{"dependencies":{"react":"^19","react-dom":"^19"},"devDependencies":{"typescript":"~5.9"}}"#,
WebFramework::React,
true,
);
assert(
r#"{"dependencies":{"react":"^19","react-dom":"^19"}}"#,
WebFramework::React,
false,
);
assert(
r#"{"dependencies":{"preact":"^10"},"devDependencies":{"typescript":"~5.9"}}"#,
WebFramework::Preact,
true,
);
assert(
r#"{"dependencies":{"vue":"^3"},"devDependencies":{"typescript":"~5.9","vue-tsc":"^3"}}"#,
WebFramework::Vue,
true,
);
assert(
r#"{"devDependencies":{"svelte":"^5","typescript":"~5.9"}}"#,
WebFramework::Svelte,
true,
);
assert(
r#"{"dependencies":{"solid-js":"^1"}}"#,
WebFramework::Solid,
false,
);
assert(r#"{"dependencies":{"lit":"^3"}}"#, WebFramework::Lit, false);
assert(
r#"{"dependencies":{"@qwik.dev/core":"^2"}}"#,
WebFramework::Other,
false,
);
assert!(detect_web_frontend("not json").is_none());
}
fn write_vanilla_layout(web: &Path) {
std::fs::create_dir_all(web.join("public")).unwrap();
std::fs::create_dir_all(web.join("src/assets")).unwrap();
std::fs::write(
web.join("package.json"),
r#"{"devDependencies":{"typescript":"~6.0","vite":"^8"}}"#,
)
.unwrap();
std::fs::write(
web.join("index.html"),
"<html><head><title>web</title>\
<link rel=\"icon\" type=\"image/svg+xml\" href=\"/favicon.svg\" />\
</head><body><div id=\"app\"></div></body></html>",
)
.unwrap();
std::fs::write(web.join("public/favicon.svg"), "<svg/>").unwrap();
std::fs::write(web.join("public/icons.svg"), "<svg/>").unwrap();
std::fs::write(web.join("src/main.ts"), "// vite starter").unwrap();
std::fs::write(web.join("src/counter.ts"), "// counter").unwrap();
std::fs::write(web.join("src/style.css"), "/* vite */").unwrap();
std::fs::write(web.join("src/assets/typescript.svg"), "<svg/>").unwrap();
}
#[test]
fn overlay_brands_a_vanilla_layout() {
let temp = tempfile::tempdir().unwrap();
let web = temp.path().join("web");
write_vanilla_layout(&web);
let report = apply_brand_overlay(&web, "My App").unwrap();
assert!(report.branded);
assert!(report.warnings.is_empty(), "{:?}", report.warnings);
assert_eq!(
report.frontend,
Some(WebFrontend {
framework: WebFramework::Vanilla,
typescript: true
})
);
assert!(web.join("public/waterui.svg").is_file());
assert!(!web.join("public/favicon.svg").exists());
assert!(!web.join("public/icons.svg").exists());
assert!(!web.join("src/counter.ts").exists());
assert!(web.join("src/waterui.d.ts").is_file());
let main = std::fs::read_to_string(web.join("src/main.ts")).unwrap();
assert!(main.contains("WaterUI + TypeScript"), "{main}");
assert!(main.contains("'./assets/typescript.svg'"), "{main}");
assert!(main.contains("invoke<string>('greet'"), "{main}");
let html = std::fs::read_to_string(web.join("index.html")).unwrap();
assert!(html.contains("<title>My App</title>"), "{html}");
assert!(html.contains("\"/waterui.svg\""), "{html}");
let style = std::fs::read_to_string(web.join("src/style.css")).unwrap();
assert!(style.contains(".page"), "{style}");
}
#[test]
fn overlay_skips_missing_files_with_warnings() {
let temp = tempfile::tempdir().unwrap();
let web = temp.path().join("web");
std::fs::create_dir_all(&web).unwrap();
std::fs::write(
web.join("package.json"),
r#"{"dependencies":{"react":"^19"},"devDependencies":{"typescript":"~5.9"}}"#,
)
.unwrap();
let report = apply_brand_overlay(&web, "App").unwrap();
assert!(!report.branded);
assert!(!report.warnings.is_empty());
assert!(
report.warnings.iter().any(|w| w.contains("src/App.tsx")),
"{:?}",
report.warnings
);
assert!(web.join("public/waterui.svg").is_file());
}
#[test]
#[cfg(unix)]
fn dev_server_tree_signals_the_grandchild_and_refuses_a_shared_group() {
use std::os::unix::process::CommandExt as _;
let mut leader = std::process::Command::new("sh")
.args(["-c", "sleep 30 & wait"])
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.process_group(0)
.spawn()
.expect("sh spawns");
let tree =
dev_server_tree::DevServerTree::adopt(&leader).expect("the child leads its group");
tree.signal(false);
let status = leader.wait().expect("the leader is reaped");
assert!(
!status.success(),
"SIGKILL to the group ends the leader: {status}"
);
let mut shared = std::process::Command::new("sh")
.args(["-c", "exit 0"])
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.expect("sh spawns");
let refused = dev_server_tree::DevServerTree::adopt(&shared);
let _ = shared.wait();
assert!(refused.is_err(), "a child in our own group must be refused");
}
#[test]
fn dev_url_from_line_finds_vite_local_url() {
for line in [
" ➜ Local: http://localhost:5173/",
" ➜ Local: https://localhost:5173/",
"Local: http://127.0.0.1:3000",
"Local: http://[::1]:8080/",
"ready in 42ms http://localhost:5173/app/index.html",
] {
let url = dev_url_from_line(line).unwrap_or_else(|| panic!("no URL in {line:?}"));
assert!(url.port().is_some(), "explicit port required: {line:?}");
}
assert_eq!(
dev_url_from_line(" ➜ Local: http://localhost:5173/")
.unwrap()
.as_str(),
"http://localhost:5173/"
);
}
#[test]
fn dev_url_from_line_rejects_non_loopback_and_portless_urls() {
for line in [
" ➜ Network: http://192.168.1.4:5173/",
" ➜ Network: http://172.20.10.2:5173/",
"see https://localhost:5173.example.com/ for details",
"no url here",
"http://localhost is missing a port",
"VITE v7.0.0 ready in 120 ms",
] {
assert_eq!(dev_url_from_line(line), None, "unexpected URL in {line:?}");
}
}
#[test]
fn device_facing_url_rewrites_loopback_for_ios_device() {
let url: url::Url = "http://localhost:5173/".parse().unwrap();
for target in [
DevTarget::Desktop,
DevTarget::IosSimulator,
DevTarget::Android,
] {
assert_eq!(device_facing_url(target, &url).unwrap(), url);
}
let rewritten = device_facing_url(DevTarget::IosDevice, &url).unwrap();
assert_eq!(rewritten.port(), Some(5173));
let host = rewritten.host_str().expect("a host");
let ip: std::net::Ipv4Addr = host.parse().expect("an IPv4 LAN host");
assert!(!ip.is_loopback());
}
#[test]
fn adb_reverse_args_forward_the_dev_url_port() {
assert_eq!(
adb_reverse_args("emulator-5554", 5173),
["-s", "emulator-5554", "reverse", "tcp:5173", "tcp:5173"]
);
}
#[test]
fn dev_url_port_reads_the_launch_environment() {
assert_eq!(
dev_url_port(std::iter::empty::<(&str, &str)>()).unwrap(),
None
);
assert_eq!(
dev_url_port([("WATERUI_DEV_URL", "http://localhost:5173/")].into_iter()).unwrap(),
Some(5173)
);
assert!(
dev_url_port([("WATERUI_DEV_URL", "not a url")].into_iter()).is_err(),
"a malformed handoff fails loudly"
);
}
#[test]
fn dev_script_probes_dev_serve_start() {
let temp = tempfile::tempdir().unwrap();
let package_json = temp.path().join("package.json");
std::fs::write(
&package_json,
r#"{"scripts":{"build":"vite build","serve":"vite preview"}}"#,
)
.unwrap();
assert_eq!(dev_script(temp.path()).unwrap(), "serve");
std::fs::write(&package_json, r#"{"scripts":{"start":"node server.js"}}"#).unwrap();
assert_eq!(dev_script(temp.path()).unwrap(), "start");
std::fs::write(
&package_json,
r#"{"scripts":{"dev":"vite","serve":"vite preview"}}"#,
)
.unwrap();
assert_eq!(dev_script(temp.path()).unwrap(), "dev");
std::fs::write(&package_json, r#"{"scripts":{"build":"vite build"}}"#).unwrap();
let error = dev_script(temp.path()).unwrap_err().to_string();
for script in ["dev", "serve", "start"] {
assert!(error.contains(script), "error names the probes: {error}");
}
}
}