use std::path::PathBuf;
use clap::{Args as ClapArgs, ValueEnum};
use eyre::{Context as _, Result, bail};
use futures_util::StreamExt;
#[cfg(target_os = "macos")]
use jiff::Timestamp;
use super::detect_sccache_path;
use crate::shell::Shell;
use crate::{error, header, line, note, success, warn};
use waterui_cli::toolchain_checks;
use waterui_cli::{
android::{
device::{AndroidDevice, AndroidEmulator},
platform::AndroidPlatform,
},
apple::{
device::AppleSimulator,
physical::ApplePhysicalDevice,
platform::{build_rust_lib, package_apple},
toolchain::AppleSdk,
},
backend::reinit_backend,
build::{BuildOptions, BuildProfile},
device::{Artifact, Device, DeviceEvent, Local, LogLevel, RunOptions, Running},
esp32::{backend::Esp32Backend, platform::run_esp32},
gtk4::{
backend::Gtk4Backend,
platform::{build_gtk4, package_gtk4},
},
hydrolysis::{
backend::HydrolysisBackend,
platform::{
HydrolysisWebDevServer, build_hydrolysis, package_hydrolysis,
prepare_hydrolysis_web_dev_site,
},
},
platform::{PackageOptions, TargetPlatform as LibTargetPlatform},
project::Project,
web,
};
#[cfg(target_os = "macos")]
use waterui_cli::debug;
#[cfg(target_os = "macos")]
struct CrashReportContext {
started_at: Timestamp,
device_identifier: String,
bundle_id: String,
process_name: String,
}
#[cfg(target_os = "macos")]
impl CrashReportContext {
fn try_new(
project: &Project,
platform: TargetPlatform,
backend: TargetBackend,
) -> Result<Option<Self>> {
if platform != TargetPlatform::Macos || backend != TargetBackend::Apple {
return Ok(None);
}
let device_identifier =
whoami::hostname().map_err(|e| eyre::eyre!("Failed to determine hostname: {e}"))?;
let process_name = waterui_cli::apple::backend::apple_product_name(project)?.to_string();
Ok(Some(Self {
started_at: Timestamp::now(),
device_identifier,
bundle_id: project.bundle_identifier().to_string(),
process_name,
}))
}
fn refresh_start(&mut self) {
self.started_at = Timestamp::now();
}
}
#[cfg(target_os = "macos")]
async fn find_latest_ips_report(
host: &waterui_cli::toolchain::Host,
ctx: &CrashReportContext,
) -> Option<debug::CrashReport> {
debug::find_macos_ips_crash_report_since(
host,
"macOS",
&ctx.device_identifier,
&ctx.bundle_id,
&ctx.process_name,
None,
ctx.started_at,
)
.await
}
#[derive(Debug, Clone, Copy)]
struct BackendAvailability {
available: [bool; 5],
}
impl BackendAvailability {
const fn has(self, backend: TargetBackend) -> bool {
self.available[match backend {
TargetBackend::Apple => 0,
TargetBackend::Android => 1,
TargetBackend::Gtk4 => 2,
TargetBackend::Hydrolysis => 3,
TargetBackend::Dew => 4,
}]
}
}
struct RunContext {
project: Project,
platform: TargetPlatform,
backend: TargetBackend,
}
struct DeviceSelection {
device: SelectedDevice,
needs_launch: bool,
}
struct BuildPlan {
lib_platform: LibTargetPlatform,
android_abi: Option<waterui_cli::android::platform::AndroidAbi>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
pub enum TargetPlatform {
Ios,
Android,
Macos,
Linux,
Windows,
Web,
Esp32s3,
Esp32c3,
Esp32p4,
}
impl TargetPlatform {
const fn esp32_chip(self) -> Option<waterui_cli::esp32::chip::Esp32Chip> {
use waterui_cli::esp32::chip::Esp32Chip;
match self {
Self::Esp32s3 => Some(Esp32Chip::Esp32S3),
Self::Esp32c3 => Some(Esp32Chip::Esp32C3),
Self::Esp32p4 => Some(Esp32Chip::Esp32P4),
_ => None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
pub enum TargetBackend {
Apple,
Android,
Gtk4,
Hydrolysis,
Dew,
}
#[derive(ClapArgs, Debug)]
#[allow(clippy::struct_excessive_bools)]
pub struct Args {
#[arg(short, long, value_enum)]
platform: Option<TargetPlatform>,
#[arg(short, long, value_enum)]
backend: Option<TargetBackend>,
#[arg(short, long)]
device: Option<String>,
#[arg(long, default_value = ".")]
path: PathBuf,
#[arg(long, value_enum)]
logs: Option<CliLogLevel>,
#[arg(long)]
native_logs: bool,
#[arg(long = "env", value_name = "KEY=VALUE", value_parser = parse_env_assignment)]
env: Vec<(String, String)>,
#[arg(
long,
conflicts_with_all = ["platform", "backend", "device", "logs", "native_logs"]
)]
tui: bool,
#[arg(long)]
release: bool,
#[arg(long, conflicts_with = "release")]
profiling: bool,
#[arg(long, conflicts_with_all = ["release", "profiling"])]
debug: bool,
#[arg(long)]
no_dev_server: bool,
}
fn parse_env_assignment(raw: &str) -> Result<(String, String), String> {
let (key, value) = raw
.split_once('=')
.ok_or_else(|| String::from("expected KEY=VALUE"))?;
if key.is_empty() {
return Err(String::from("expected KEY=VALUE with a non-empty key"));
}
Ok((key.to_string(), value.to_string()))
}
#[derive(Debug, Clone, Copy, ValueEnum, Default)]
pub enum CliLogLevel {
Error,
Warn,
#[default]
Info,
Debug,
Verbose,
}
impl From<CliLogLevel> for LogLevel {
fn from(level: CliLogLevel) -> Self {
match level {
CliLogLevel::Error => Self::Error,
CliLogLevel::Warn => Self::Warn,
CliLogLevel::Info => Self::Info,
CliLogLevel::Debug => Self::Debug,
CliLogLevel::Verbose => Self::Verbose,
}
}
}
fn resolve_backend(
platform: TargetPlatform,
backend_override: Option<TargetBackend>,
) -> Result<TargetBackend> {
let default_backend = match platform {
TargetPlatform::Ios | TargetPlatform::Macos => TargetBackend::Apple,
TargetPlatform::Android => TargetBackend::Android,
TargetPlatform::Linux | TargetPlatform::Windows | TargetPlatform::Web => {
TargetBackend::Hydrolysis
}
TargetPlatform::Esp32s3 | TargetPlatform::Esp32c3 | TargetPlatform::Esp32p4 => {
TargetBackend::Dew
}
};
let backend = backend_override.unwrap_or(default_backend);
let supported = matches!(
(platform, backend),
(TargetPlatform::Ios, TargetBackend::Apple)
| (
TargetPlatform::Macos,
TargetBackend::Apple | TargetBackend::Hydrolysis
)
| (TargetPlatform::Android, TargetBackend::Android)
| (
TargetPlatform::Linux,
TargetBackend::Gtk4 | TargetBackend::Hydrolysis
)
| (
TargetPlatform::Windows | TargetPlatform::Web,
TargetBackend::Hydrolysis
)
| (
TargetPlatform::Esp32s3 | TargetPlatform::Esp32c3 | TargetPlatform::Esp32p4,
TargetBackend::Dew
)
);
if !supported {
bail!(
"Backend {:?} does not support platform {:?}.\n\
Valid combinations:\n \
- iOS: apple\n \
- macOS: apple, hydrolysis\n \
- Android: android\n \
- Linux: gtk4, hydrolysis\n \
- Windows: hydrolysis\n \
- Web: hydrolysis\n \
- ESP32-S3: dew\n \
- ESP32-C3: dew\n \
- ESP32-P4: dew",
backend,
platform
);
}
Ok(backend)
}
const fn default_backend_priority(platform: TargetPlatform) -> &'static [TargetBackend] {
match platform {
TargetPlatform::Ios => &[TargetBackend::Apple],
TargetPlatform::Android => &[TargetBackend::Android],
TargetPlatform::Macos => &[TargetBackend::Apple, TargetBackend::Hydrolysis],
TargetPlatform::Linux => &[TargetBackend::Hydrolysis, TargetBackend::Gtk4],
TargetPlatform::Windows | TargetPlatform::Web => &[TargetBackend::Hydrolysis],
TargetPlatform::Esp32s3 | TargetPlatform::Esp32c3 | TargetPlatform::Esp32p4 => {
&[TargetBackend::Dew]
}
}
}
fn resolve_default_backend_for_project(
platform: TargetPlatform,
project_is_playground: bool,
availability: BackendAvailability,
) -> TargetBackend {
let backends = default_backend_priority(platform);
if project_is_playground {
return backends[0];
}
for backend in backends {
if availability.has(*backend) {
return *backend;
}
}
backends[0]
}
const fn resolve_platform(platform_override: Option<TargetPlatform>) -> TargetPlatform {
if let Some(platform) = platform_override {
return platform;
}
#[cfg(target_os = "macos")]
{
TargetPlatform::Macos
}
#[cfg(target_os = "linux")]
{
TargetPlatform::Linux
}
#[cfg(target_os = "windows")]
{
TargetPlatform::Windows
}
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
{
panic!(
"`water run` could not determine a default platform for this host. Please pass --platform explicitly."
);
}
}
pub async fn run(shell: &Shell, args: Args) -> Result<()> {
if args.tui {
return run_tui_app(shell, args).await;
}
let host = waterui_cli::toolchain::Host::current();
let context = Box::pin(prepare_run_context(shell, &args)).await?;
print_run_header(shell, &context);
check_run_toolchain(shell, &host, context.platform, context.backend).await?;
if context.platform == TargetPlatform::Web {
return run_web_app(shell, &context.project).await;
}
if context.platform.esp32_chip().is_some() {
return run_esp32_app(shell, &context.project, args.device.as_deref()).await;
}
let selection = select_run_device(
shell,
&host,
context.platform,
context.backend,
&context.project,
args.device.as_deref(),
)
.await?;
let config = build_run_config(shell, &host, &args, &context.project, context.backend).await;
#[cfg(target_os = "macos")]
let mut crash_ctx =
match CrashReportContext::try_new(&context.project, context.platform, context.backend) {
Ok(ctx) => ctx,
Err(e) => {
warn!(shell, "Crash report augmentation disabled: {e}");
None
}
};
let (running, _dev_server) = shell
.display_output(build_and_run(
shell,
&host,
&context.project,
context.platform,
context.backend,
selection,
config,
))
.await?;
line!(shell);
note!(shell, "Press Ctrl+C to stop the application");
line!(shell);
#[cfg(target_os = "macos")]
stream_running_events(shell, &host, running, context.backend, &mut crash_ctx).await?;
#[cfg(not(target_os = "macos"))]
stream_running_events(shell, running, context.backend).await?;
Ok(())
}
async fn run_tui_app(shell: &Shell, args: Args) -> Result<()> {
use std::io::IsTerminal as _;
warn!(
shell,
"The TUI backend is experimental — unsupported views panic instead of degrading"
);
if !std::io::stdin().is_terminal() || !std::io::stdout().is_terminal() {
bail!("the TUI backend requires an interactive terminal");
}
let project_path = crate::project_path::canonicalize(&args.path)?;
let project = Project::open(&project_path).await?;
let launcher_dir = waterui_cli::tui::ensure_launcher(&project).await?;
let sccache_path = detect_sccache_path(shell, &waterui_cli::toolchain::Host::current()).await;
let binary = shell
.display_output(waterui_cli::tui::build(
&project,
&launcher_dir,
sccache_path,
))
.await?;
note!(
shell,
"The TUI backend replaces this terminal until the app exits"
);
shell.clear();
waterui_cli::tui::exec(&binary)
}
async fn prepare_run_context(shell: &Shell, args: &Args) -> Result<RunContext> {
let project_path = crate::project_path::canonicalize(&args.path)?;
let mut project = Project::open(&project_path).await?;
let platform = resolve_platform(args.platform);
let backend = resolve_run_backend(&project, platform, args.backend)?;
if backend == TargetBackend::Gtk4 {
warn!(
shell,
"The GTK4 backend is experimental — hydrolysis is the default"
);
}
validate_desktop_backend_platform_on_host(platform, backend)?;
validate_device_arg(platform, backend, args.device.as_deref())?;
validate_log_pipeline_args(platform, args.logs, args.native_logs)?;
ensure_run_backend_ready(&project, backend)?;
if let Some(chip) = platform.esp32_chip() {
project.set_esp32_chip(chip).await?;
}
let project = ensure_generated_run_backend(shell, &project_path, project, backend).await?;
Ok(RunContext {
project,
platform,
backend,
})
}
fn resolve_run_backend(
project: &Project,
platform: TargetPlatform,
backend_override: Option<TargetBackend>,
) -> Result<TargetBackend> {
resolve_backend(
platform,
backend_override.or_else(|| {
Some(resolve_default_backend_for_project(
platform,
project.is_playground(),
backend_availability(project),
))
}),
)
}
const fn backend_availability(project: &Project) -> BackendAvailability {
BackendAvailability {
available: [
project.apple_backend().is_some(),
project.android_backend().is_some(),
project.gtk4_backend().is_some(),
project.hydrolysis_backend().is_some(),
project.esp32_backend().is_some(),
],
}
}
fn ensure_run_backend_ready(project: &Project, backend: TargetBackend) -> Result<()> {
if project.is_playground() {
return Ok(());
}
match backend {
TargetBackend::Apple if project.apple_backend().is_none() => {
bail!("Apple backend is not configured. Run `water backend add apple`.");
}
TargetBackend::Android if project.android_backend().is_none() => {
bail!("Android backend is not configured. Run `water backend add android`.");
}
TargetBackend::Gtk4 if project.gtk4_backend().is_none() => {
bail!("GTK4 backend is not configured. Run `water backend add gtk4`.");
}
TargetBackend::Hydrolysis if project.hydrolysis_backend().is_none() => {
bail!("Hydrolysis backend is not configured. Run `water backend add hydrolysis`.");
}
TargetBackend::Dew if project.esp32_backend().is_none() => {
bail!("ESP32 backend is not configured. Run `water backend add esp32`.");
}
_ => Ok(()),
}
}
async fn ensure_generated_run_backend(
shell: &Shell,
project_path: &PathBuf,
project: Project,
backend: TargetBackend,
) -> Result<Project> {
match backend {
TargetBackend::Gtk4 if project.is_playground() => {
let needs_reinit = Gtk4Backend::requires_regeneration(&project).await?;
ensure_generated_run_backend_impl::<Gtk4Backend>(
shell,
project_path,
project,
needs_reinit,
"Initializing GTK4 backend...",
"GTK4 backend initialized",
)
.await
}
TargetBackend::Hydrolysis if project.is_playground() => {
let needs_reinit = HydrolysisBackend::requires_regeneration(&project).await?;
ensure_generated_run_backend_impl::<HydrolysisBackend>(
shell,
project_path,
project,
needs_reinit,
"Initializing hydrolysis backend...",
"Hydrolysis backend initialized",
)
.await
}
TargetBackend::Dew if project.is_playground() => {
let needs_reinit =
project.esp32_backend().is_none() || Esp32Backend::requires_regeneration(&project)?;
ensure_generated_run_backend_impl::<Esp32Backend>(
shell,
project_path,
project,
needs_reinit,
"Initializing ESP32 backend...",
"ESP32 backend initialized",
)
.await
}
_ => Ok(project),
}
}
async fn ensure_generated_run_backend_impl<T>(
shell: &Shell,
project_path: &PathBuf,
project: Project,
needs_reinit: bool,
spinner_message: &str,
success_message: &str,
) -> Result<Project>
where
T: waterui_cli::backend::Backend,
{
if !needs_reinit {
return Ok(project);
}
let spinner = shell.spinner(spinner_message);
reinit_backend::<T>(&project).await?;
let project = Project::open(project_path).await?;
if let Some(pb) = spinner {
pb.finish_and_clear();
}
success!(shell, "{success_message}");
Ok(project)
}
fn print_run_header(shell: &Shell, context: &RunContext) {
header!(
shell,
"Running {} on {} ({})",
context.project.crate_name(),
platform_name(context.platform),
backend_name(context.backend)
);
}
async fn check_run_toolchain(
shell: &Shell,
host: &waterui_cli::toolchain::Host,
platform: TargetPlatform,
backend: TargetBackend,
) -> Result<()> {
let spinner = shell.spinner("Checking toolchain...");
check_toolchain_for_backend(host, platform, backend).await?;
if let Some(pb) = spinner {
pb.finish_and_clear();
}
success!(shell, "Toolchain ready");
Ok(())
}
async fn run_web_app(shell: &Shell, project: &Project) -> Result<()> {
let spinner = shell.spinner("Building Hydrolysis web app...");
let site_root = prepare_hydrolysis_web_dev_site(project).await?;
if let Some(pb) = spinner {
pb.finish_and_clear();
}
success!(shell, "Built Hydrolysis web app at {}", site_root.display());
let server = HydrolysisWebDevServer::start(site_root).await?;
line!(shell);
note!(shell, "Serving at http://{}/", server.address());
note!(shell, "Press Ctrl+C to stop the web server");
let _server = server;
futures_util::future::pending::<()>().await;
unreachable!("web dev server future should be cancelled by Ctrl+C")
}
async fn run_esp32_app(shell: &Shell, project: &Project, device: Option<&str>) -> Result<()> {
let sccache_path = detect_sccache_path(shell, &waterui_cli::toolchain::Host::current()).await;
let build_options = sccache_path.map_or_else(
|| BuildOptions::development(BuildProfile::Debug),
|sccache| BuildOptions::development(BuildProfile::Debug).with_sccache(sccache),
);
let _ = shell.status(">", "Building ESP32 firmware...");
shell
.display_output(run_esp32(project, build_options, device))
.await
}
async fn select_run_device(
shell: &Shell,
host: &waterui_cli::toolchain::Host,
platform: TargetPlatform,
backend: TargetBackend,
project: &Project,
device_id: Option<&str>,
) -> Result<DeviceSelection> {
let device = find_device(shell, host, platform, backend, project, device_id).await?;
let needs_launch = device.needs_launch();
if needs_launch {
note!(shell, "Will launch: {}", device_name(&device));
} else {
success!(shell, "Found device: {}", device_name(&device));
}
Ok(DeviceSelection {
device,
needs_launch,
})
}
async fn build_run_config(
shell: &Shell,
host: &waterui_cli::toolchain::Host,
args: &Args,
project: &Project,
backend: TargetBackend,
) -> BuildRunConfig {
let sccache_path = detect_sccache_path(shell, host).await;
let mut run_options = RunOptions::new();
if let Some(level) = args.logs.map(LogLevel::from) {
run_options.set_log_level(level);
}
run_options.set_native_logs(args.native_logs);
run_options.describe_project(project);
for (key, value) in &args.env {
run_options.insert_env_var(key.clone(), value.clone());
}
let profile = run_profile(args, backend);
BuildRunConfig {
run_options,
sccache_path,
profile,
dev_server: !profile.is_release() && !args.no_dev_server,
}
}
const fn run_profile(args: &Args, backend: TargetBackend) -> BuildProfile {
if args.release {
BuildProfile::Release
} else if args.profiling {
BuildProfile::Profiling
} else if args.debug {
BuildProfile::Debug
} else if matches!(backend, TargetBackend::Hydrolysis) {
BuildProfile::Optimized
} else {
BuildProfile::Debug
}
}
#[cfg(target_os = "macos")]
async fn stream_running_events(
shell: &Shell,
host: &waterui_cli::toolchain::Host,
running: Running,
backend: TargetBackend,
crash_ctx: &mut Option<CrashReportContext>,
) -> Result<()> {
let mut running = std::pin::pin!(running);
let backend_log_name = backend_name(backend);
loop {
let event = running.next().await;
#[cfg(target_os = "macos")]
let mut event = event;
#[cfg(target_os = "macos")]
if matches!(event.as_ref(), Some(DeviceEvent::Started))
&& let Some(ctx) = crash_ctx.as_mut()
{
ctx.refresh_start();
}
#[cfg(target_os = "macos")]
if let Some(ctx) = crash_ctx.as_ref() {
event = augment_event_with_crash_report(host, event, ctx).await;
}
if handle_device_event(shell, event, backend_log_name)? {
break;
}
}
Ok(())
}
#[cfg(not(target_os = "macos"))]
async fn stream_running_events(
shell: &Shell,
running: Running,
backend: TargetBackend,
) -> Result<()> {
let mut running = std::pin::pin!(running);
let backend_log_name = backend_name(backend);
loop {
if handle_device_event(shell, running.next().await, backend_log_name)? {
break;
}
}
Ok(())
}
#[cfg(target_os = "macos")]
async fn augment_event_with_crash_report(
host: &waterui_cli::toolchain::Host,
event: Option<DeviceEvent>,
ctx: &CrashReportContext,
) -> Option<DeviceEvent> {
use std::fmt::Write as _;
match event {
Some(DeviceEvent::Exited(exit)) => {
if let Some(report) = find_latest_ips_report(host, ctx).await {
return Some(DeviceEvent::Crashed(report.to_string()));
}
Some(DeviceEvent::Exited(exit))
}
Some(DeviceEvent::Crashed(mut msg)) => {
if !msg.contains("Crash report:")
&& let Some(report) = find_latest_ips_report(host, ctx).await
{
write!(msg, "\n\nCrash report: {}", report.log_path().display())
.expect("write to String");
}
Some(DeviceEvent::Crashed(msg))
}
other => other,
}
}
async fn build_and_run(
shell: &Shell,
host: &waterui_cli::toolchain::Host,
project: &Project,
cli_platform: TargetPlatform,
backend: TargetBackend,
selection: DeviceSelection,
config: BuildRunConfig,
) -> Result<(Running, Option<web::WebDevServer>)> {
let build_plan = resolve_build_plan(cli_platform, backend, &selection.device)?;
let physical_ios = is_physical_ios(&selection.device);
let launch_task =
spawn_device_launch_task(host.clone(), selection.device, selection.needs_launch);
let _ = shell.status(">", "Building...");
build_for_backend(project, backend, &build_plan, build_options(&config)).await?;
let dev_server = if config.dev_server {
start_web_dev_server(shell, project, config.sccache_path.as_deref(), physical_ios).await?
} else {
None
};
let _ = shell.status(">", "Packaging...");
let artifact = package_for_backend(
project,
backend,
&build_plan,
config.profile.is_release(),
dev_server.is_some(),
)
.await?;
if selection.needs_launch {
let _ = shell.status(">", "Waiting for device...");
}
let device = launch_task.await?;
let mut run_options = config.run_options;
if let Some(server) = &dev_server {
apply_dev_url_handoff(&device, server.url(), &mut run_options)?;
}
let _ = shell.status(">", "Running...");
let running = run_with_options(host, device, artifact, run_options).await?;
Ok((running, dev_server))
}
async fn start_web_dev_server(
shell: &Shell,
project: &Project,
sccache_path: Option<&std::path::Path>,
expose_on_lan: bool,
) -> Result<Option<web::WebDevServer>> {
let Some(meta) = web::web_mount(project, sccache_path).await? else {
return Ok(None);
};
let root = meta
.project
.as_ref()
.expect("web_mount only returns a mount that declares a project");
let package_manager = project
.manifest()
.web
.as_ref()
.map_or_else(web::PackageManager::default, |web| web.package_manager);
if !package_manager.is_installed().await {
bail!(
"`{}` is not installed; run `water doctor`",
package_manager.binary()
);
}
let script = web::dev_script(root)?;
let _ = shell.status(
">",
format!("Starting `{} run {script}`", package_manager.binary()),
);
let server = web::WebDevServer::spawn(package_manager, root, &script, expose_on_lan).await?;
let _ = shell.status(">", format!("Dev server ready at {}", server.url()));
Ok(Some(server))
}
fn apply_dev_url_handoff(
device: &SelectedDevice,
url: &url::Url,
run_options: &mut RunOptions,
) -> Result<()> {
let target = match device {
SelectedDevice::Local(_) => web::DevTarget::Desktop,
SelectedDevice::AppleSimulator(_) => web::DevTarget::IosSimulator,
SelectedDevice::ApplePhysical(_) => web::DevTarget::IosDevice,
SelectedDevice::AndroidDevice(_) | SelectedDevice::AndroidEmulator(_) => {
web::DevTarget::Android
}
};
let url = web::device_facing_url(target, url)?;
run_options.insert_env_var(web::DEV_URL_ENV.to_string(), url.to_string());
Ok(())
}
fn resolve_build_plan(
cli_platform: TargetPlatform,
backend: TargetBackend,
device: &SelectedDevice,
) -> Result<BuildPlan> {
let lib_platform = match cli_platform {
TargetPlatform::Ios => {
if is_physical_ios(device) {
LibTargetPlatform::IOS
} else {
LibTargetPlatform::IOSSimulator
}
}
TargetPlatform::Macos => LibTargetPlatform::MacOS,
TargetPlatform::Android => LibTargetPlatform::Android,
TargetPlatform::Linux => LibTargetPlatform::Linux,
TargetPlatform::Windows => LibTargetPlatform::Windows,
TargetPlatform::Web => panic!("web run should not enter build_and_run"),
TargetPlatform::Esp32s3 | TargetPlatform::Esp32c3 | TargetPlatform::Esp32p4 => {
panic!("esp32 run should not enter build_and_run")
}
};
let android_abi = resolve_android_abi(backend, device)?;
Ok(BuildPlan {
lib_platform,
android_abi,
})
}
fn resolve_android_abi(
backend: TargetBackend,
device: &SelectedDevice,
) -> Result<Option<waterui_cli::android::platform::AndroidAbi>> {
match (backend, device) {
(TargetBackend::Android, SelectedDevice::AndroidDevice(dev)) => Ok(Some(dev.abi())),
(TargetBackend::Android, SelectedDevice::AndroidEmulator(emu)) => {
Ok(Some(emu.expected_abi()))
}
(TargetBackend::Android, _) => {
bail!("Internal error: Android backend requires an Android device");
}
_ => Ok(None),
}
}
fn spawn_device_launch_task(
host: waterui_cli::toolchain::Host,
device: SelectedDevice,
needs_launch: bool,
) -> smol::Task<Result<SelectedDevice>> {
smol::spawn(async move {
if needs_launch {
match &device {
SelectedDevice::AppleSimulator(sim) => sim.launch(&host).await?,
SelectedDevice::ApplePhysical(dev) => dev.launch(&host).await?,
SelectedDevice::Local(local) => local.launch(&host).await?,
SelectedDevice::AndroidDevice(dev) => dev.launch(&host).await?,
SelectedDevice::AndroidEmulator(emu) => emu.launch(&host).await?,
}
}
Ok(device)
})
}
fn build_options(config: &BuildRunConfig) -> BuildOptions {
config
.sccache_path
.as_ref()
.map_or_else(
|| BuildOptions::development(config.profile),
|sccache| BuildOptions::development(config.profile).with_sccache(sccache.clone()),
)
.with_dev_server(config.dev_server)
}
async fn build_for_backend(
project: &Project,
backend: TargetBackend,
plan: &BuildPlan,
build_options: BuildOptions,
) -> Result<()> {
match backend {
TargetBackend::Apple => {
build_rust_lib(project, plan.lib_platform, build_options).await?;
}
TargetBackend::Android => {
let abi = plan
.android_abi
.ok_or_else(|| eyre::eyre!("Internal error: missing Android ABI for build"))?;
AndroidPlatform::clean_jni_libs(project).await?;
AndroidPlatform::new(abi)
.build(project, build_options)
.await?;
}
TargetBackend::Gtk4 => {
build_gtk4(project, build_options).await?;
}
TargetBackend::Hydrolysis => {
build_hydrolysis(project, plan.lib_platform, build_options).await?;
}
TargetBackend::Dew => {
panic!("esp32 run should not enter build_and_run")
}
}
Ok(())
}
async fn package_for_backend(
project: &Project,
backend: TargetBackend,
plan: &BuildPlan,
release: bool,
dev_server: bool,
) -> Result<Artifact> {
let package_options = PackageOptions::development()
.with_debug(!release)
.with_dev_server(dev_server);
match backend {
TargetBackend::Apple => package_apple(project, plan.lib_platform, package_options).await,
TargetBackend::Android => {
let abi = plan
.android_abi
.ok_or_else(|| eyre::eyre!("Internal error: missing Android ABI for packaging"))?;
AndroidPlatform::package_with_abis(project, package_options, &[abi]).await
}
TargetBackend::Gtk4 => package_gtk4(project, package_options).await,
TargetBackend::Hydrolysis => {
package_hydrolysis(project, plan.lib_platform, package_options).await
}
TargetBackend::Dew => panic!("esp32 run should not enter build_and_run"),
}
}
struct BuildRunConfig {
run_options: RunOptions,
sccache_path: Option<PathBuf>,
profile: BuildProfile,
dev_server: bool,
}
async fn run_with_options(
host: &waterui_cli::toolchain::Host,
device: SelectedDevice,
artifact: Artifact,
run_options: RunOptions,
) -> Result<Running> {
let running = match device {
SelectedDevice::AppleSimulator(sim) => sim.run(host, artifact, run_options).await?,
SelectedDevice::ApplePhysical(dev) => dev.run(host, artifact, run_options).await?,
SelectedDevice::Local(local) => local.run(host, artifact, run_options).await?,
SelectedDevice::AndroidDevice(dev) => dev.run(host, artifact, run_options).await?,
SelectedDevice::AndroidEmulator(emu) => emu.run(host, artifact, run_options).await?,
};
Ok(running)
}
enum SelectedDevice {
AppleSimulator(AppleSimulator),
ApplePhysical(ApplePhysicalDevice),
Local(Local),
AndroidDevice(AndroidDevice),
AndroidEmulator(AndroidEmulator),
}
impl SelectedDevice {
fn needs_launch(&self) -> bool {
match self {
Self::AppleSimulator(sim) => sim.state != "Booted",
Self::ApplePhysical(_) | Self::Local(_) | Self::AndroidDevice(_) => false,
Self::AndroidEmulator(_) => true,
}
}
}
const fn is_physical_ios(device: &SelectedDevice) -> bool {
matches!(device, SelectedDevice::ApplePhysical(_))
}
struct DeviceCandidate {
device: SelectedDevice,
id: String,
label: String,
}
const fn device_memory_key(backend: TargetBackend, platform: TargetPlatform) -> &'static str {
match (backend, platform) {
(TargetBackend::Apple, TargetPlatform::Ios) => "apple/ios",
(TargetBackend::Android, TargetPlatform::Android) => "android/android",
_ => unreachable!(),
}
}
async fn remembered_device(key: &str) -> Option<String> {
match waterui_cli::water_dir::ensure_global_config().await {
Ok(config) => config.last_used_device.get(key).cloned(),
Err(error) => {
tracing::warn!("could not read the Water config for device memory: {error:#}");
None
}
}
}
async fn persist_device_choice(key: &str, id: &str) {
match waterui_cli::water_dir::ensure_global_config().await {
Ok(mut config) => {
if config.last_used_device.get(key).map(String::as_str) == Some(id) {
return;
}
config
.last_used_device
.insert(key.to_owned(), id.to_owned());
if let Err(error) = waterui_cli::water_dir::write_global_config(&config).await {
tracing::warn!("could not persist the last-used device: {error:#}");
}
}
Err(error) => {
tracing::warn!("could not read the Water config for device memory: {error:#}");
}
}
}
#[derive(Debug)]
enum DeviceChoice {
Use(usize, bool),
Stale(String),
Prompt,
}
fn device_choice(candidates: &[DeviceCandidate], remembered: Option<&str>) -> DeviceChoice {
if let Some(id) = remembered {
return candidates.iter().position(|c| c.id == id).map_or_else(
|| DeviceChoice::Stale(id.to_owned()),
|index| DeviceChoice::Use(index, false),
);
}
if candidates.len() == 1 {
DeviceChoice::Use(0, false)
} else {
DeviceChoice::Prompt
}
}
fn prompt_for_device(
shell: &Shell,
prompt: &str,
candidates: &[DeviceCandidate],
spinner: Option<&indicatif::ProgressBar>,
) -> Result<usize> {
use std::fmt::Write as _;
if !shell.is_terminal() {
let list = candidates.iter().fold(String::new(), |mut out, candidate| {
write!(out, "\n {} — {}", candidate.label, candidate.id)
.expect("writing to a String cannot fail");
out
});
bail!("Several devices are available; pass --device to choose one:{list}");
}
let labels: Vec<&str> = candidates.iter().map(|c| c.label.as_str()).collect();
let pick = || {
dialoguer::Select::with_theme(&dialoguer::theme::ColorfulTheme::default())
.with_prompt(prompt)
.items(&labels)
.default(0)
.interact()
};
Ok(spinner.map_or_else(&pick, |pb| pb.suspend(pick))?)
}
async fn choose_device_candidate(
shell: &Shell,
memory_key: &str,
prompt: &str,
candidates: Vec<DeviceCandidate>,
spinner: Option<&indicatif::ProgressBar>,
) -> Result<SelectedDevice> {
debug_assert!(!candidates.is_empty());
let mut remembered = remembered_device(memory_key).await;
let mut stale_memory = false;
let (index, persist) = loop {
match device_choice(&candidates, remembered.as_deref()) {
DeviceChoice::Use(index, persist) => break (index, persist || stale_memory),
DeviceChoice::Stale(id) => {
warn!(shell, "Last-used device \"{id}\" is not available");
remembered = None;
stale_memory = true;
}
DeviceChoice::Prompt => {
break (
prompt_for_device(shell, prompt, &candidates, spinner)?,
true,
);
}
}
};
let candidate = candidates
.into_iter()
.nth(index)
.expect("the index came from the candidate list");
if persist {
persist_device_choice(memory_key, &candidate.id).await;
}
Ok(candidate.device)
}
async fn select_ios_device(
shell: &Shell,
host: &waterui_cli::toolchain::Host,
project: &Project,
device_id: Option<&str>,
spinner: Option<&indicatif::ProgressBar>,
) -> Result<SelectedDevice> {
let physical = ApplePhysicalDevice::scan(host)
.await
.unwrap_or_else(|error| {
tracing::warn!("devicectl device scan failed: {error:#}");
Vec::new()
});
if let Some(query) = device_id {
if let Some(device) = select_physical_ios(host, &physical, project, query).await? {
persist_device_choice(
device_memory_key(TargetBackend::Apple, TargetPlatform::Ios),
&device.identifier,
)
.await;
return Ok(SelectedDevice::ApplePhysical(device));
}
let sim = AppleSimulator::select_ios(host, project, Some(query)).await?;
persist_device_choice(
device_memory_key(TargetBackend::Apple, TargetPlatform::Ios),
&sim.udid,
)
.await;
return Ok(SelectedDevice::AppleSimulator(sim));
}
let candidates = ios_device_candidates(host, project, &physical).await?;
if candidates.is_empty() {
return Ok(SelectedDevice::AppleSimulator(
AppleSimulator::select_ios(host, project, None).await?,
));
}
choose_device_candidate(
shell,
device_memory_key(TargetBackend::Apple, TargetPlatform::Ios),
"Select an iOS device",
candidates,
spinner,
)
.await
}
async fn ios_device_candidates(
host: &waterui_cli::toolchain::Host,
project: &Project,
physical: &[ApplePhysicalDevice],
) -> Result<Vec<DeviceCandidate>> {
let ios_version = |version: Option<&semver::Version>| {
version.map_or_else(|| String::from("an unknown iOS"), |v| format!("iOS {v}"))
};
let deployment_target = |target_platform: LibTargetPlatform| async move {
let (_, target) =
waterui_cli::apple::platform::apple_deployment_target(project, target_platform).await?;
waterui_cli::utils::parse_semver_version(&target).wrap_err_with(|| {
format!("Failed to parse the project's IPHONEOS_DEPLOYMENT_TARGET `{target}`")
})
};
let sim_target = deployment_target(LibTargetPlatform::IOSSimulator).await?;
let device_target = deployment_target(LibTargetPlatform::IOS).await?;
let mut simulators = AppleSimulator::scan_ios(host).await?;
simulators.retain(|sim| sim.supports_deployment_target(&sim_target));
simulators.sort_by_key(|sim| usize::from(sim.state != "Booted"));
let mut candidates: Vec<DeviceCandidate> = simulators
.into_iter()
.map(|sim| DeviceCandidate {
label: format!(
"{} — {} ({})",
sim.name,
ios_version(sim.runtime_version.as_ref()),
sim.state
),
id: sim.udid.clone(),
device: SelectedDevice::AppleSimulator(sim),
})
.collect();
candidates.extend(
physical
.iter()
.filter(|device| {
device.usability().is_ok() && device.supports_deployment_target(&device_target)
})
.map(|device| DeviceCandidate {
label: format!(
"{} — {} ({})",
device.name,
ios_version(device.os_version.as_ref()),
match device.transport {
waterui_cli::apple::physical::Transport::Wired => "USB",
waterui_cli::apple::physical::Transport::LocalNetwork => "Wi-Fi",
waterui_cli::apple::physical::Transport::Other => "paired",
}
),
id: device.identifier.clone(),
device: SelectedDevice::ApplePhysical(device.clone()),
}),
);
Ok(candidates)
}
async fn select_physical_ios(
host: &waterui_cli::toolchain::Host,
devices: &[ApplePhysicalDevice],
project: &Project,
query: &str,
) -> Result<Option<ApplePhysicalDevice>> {
let matches: Vec<&ApplePhysicalDevice> = devices
.iter()
.filter(|device| device.identifier == query || device.udid == query || device.name == query)
.collect();
let device = match matches.as_slice() {
[] => return Ok(None),
[device] => *device,
candidates => {
use std::fmt::Write as _;
let list = candidates.iter().fold(String::new(), |mut out, device| {
write!(out, "\n {} ({})", device.name, device.identifier)
.expect("writing to a String cannot fail");
out
});
bail!(
"Device \"{query}\" matches {} devices; select one by identifier:{list}",
candidates.len()
);
}
};
if let Err(reason) = device.usability() {
bail!("{}", reason.remedy(device));
}
let (_, target) =
waterui_cli::apple::platform::apple_deployment_target(project, LibTargetPlatform::IOS)
.await?;
let deployment_target =
waterui_cli::utils::parse_semver_version(&target).wrap_err_with(|| {
format!("Failed to parse the project's IPHONEOS_DEPLOYMENT_TARGET `{target}`")
})?;
if !device.supports_deployment_target(&deployment_target) {
bail!(
"{} runs {}, but this app requires iOS {deployment_target} (IPHONEOS_DEPLOYMENT_TARGET)",
device.name,
device.os_version.as_ref().map_or_else(
|| String::from("an unknown iOS version"),
|v| format!("iOS {v}")
),
);
}
waterui_cli::apple::toolchain::development_team_id(host).await?;
Ok(Some(device.clone()))
}
async fn check_toolchain_for_backend(
host: &waterui_cli::toolchain::Host,
platform: TargetPlatform,
backend: TargetBackend,
) -> Result<()> {
match backend {
TargetBackend::Apple => {
let sdk = match platform {
TargetPlatform::Ios => AppleSdk::IosSimulator,
TargetPlatform::Macos => AppleSdk::Macos,
TargetPlatform::Android
| TargetPlatform::Linux
| TargetPlatform::Windows
| TargetPlatform::Web
| TargetPlatform::Esp32s3
| TargetPlatform::Esp32c3
| TargetPlatform::Esp32p4 => {
bail!("Internal error: Apple backend is not supported on {platform:?}");
}
};
toolchain_checks::check_apple(host, sdk).await?;
}
TargetBackend::Android => {
if platform != TargetPlatform::Android {
bail!("Internal error: Android backend is not supported on {platform:?}");
}
toolchain_checks::check_android_run(host).await?;
}
TargetBackend::Gtk4 => {
if platform != TargetPlatform::Linux {
bail!("Internal error: GTK4 backend is not supported on {platform:?}");
}
toolchain_checks::check_gtk4(host).await?;
}
TargetBackend::Hydrolysis => {
if platform != TargetPlatform::Macos
&& platform != TargetPlatform::Linux
&& platform != TargetPlatform::Windows
&& platform != TargetPlatform::Web
{
bail!("Internal error: hydrolysis backend is not supported on {platform:?}");
}
if platform == TargetPlatform::Web {
toolchain_checks::check_web(host).await?;
} else {
toolchain_checks::check_hydrolysis(host).await?;
}
}
TargetBackend::Dew => {
if platform.esp32_chip().is_none() {
bail!("Internal error: dew backend is not supported on {platform:?}");
}
}
}
Ok(())
}
async fn find_device(
shell: &Shell,
host: &waterui_cli::toolchain::Host,
platform: TargetPlatform,
backend: TargetBackend,
project: &Project,
device_id: Option<&str>,
) -> Result<SelectedDevice> {
if backend == TargetBackend::Gtk4 || backend == TargetBackend::Hydrolysis {
return Ok(SelectedDevice::Local(Local));
}
let spinner = shell.spinner("Scanning for devices...");
let device = match platform {
TargetPlatform::Ios => {
select_ios_device(shell, host, project, device_id, spinner.as_ref()).await
}
TargetPlatform::Macos => {
Ok(SelectedDevice::Local(Local))
}
TargetPlatform::Android => {
select_android_device(shell, host, device_id, spinner.as_ref()).await
}
TargetPlatform::Linux | TargetPlatform::Windows => {
Ok(SelectedDevice::Local(Local))
}
TargetPlatform::Web => {
bail!("web platform does not use the device pipeline");
}
TargetPlatform::Esp32s3 | TargetPlatform::Esp32c3 | TargetPlatform::Esp32p4 => {
bail!("esp32 platform does not use the device pipeline");
}
};
if let Some(pb) = spinner {
pb.finish_and_clear();
}
device
}
async fn select_android_device(
shell: &Shell,
host: &waterui_cli::toolchain::Host,
device_id: Option<&str>,
spinner: Option<&indicatif::ProgressBar>,
) -> Result<SelectedDevice> {
const KEY: &str = device_memory_key(TargetBackend::Android, TargetPlatform::Android);
let devices = AndroidDevice::scan(host).await?;
let avds = AndroidPlatform::list_avds(host).await?;
if let Some(query) = device_id {
for dev in devices {
if dev.identifier() == query {
persist_device_choice(KEY, dev.identifier()).await;
return Ok(SelectedDevice::AndroidDevice(dev));
}
}
if avds.iter().any(|avd| avd == query) {
persist_device_choice(KEY, query).await;
return Ok(SelectedDevice::AndroidEmulator(
AndroidEmulator::open(host, query.to_string()).await?,
));
}
bail!("Device not found: {query}");
}
let mut candidates: Vec<DeviceCandidate> = devices
.into_iter()
.map(|dev| DeviceCandidate {
label: format!("{} (connected)", dev.identifier()),
id: dev.identifier().to_owned(),
device: SelectedDevice::AndroidDevice(dev),
})
.collect();
for avd in avds {
candidates.push(DeviceCandidate {
label: format!("{avd} (emulator)"),
id: avd.clone(),
device: SelectedDevice::AndroidEmulator(AndroidEmulator::open(host, avd).await?),
});
}
if candidates.is_empty() {
bail!(
"No Android devices connected and no emulators available. Create an emulator with Android Studio or `avdmanager`, or connect a device."
);
}
choose_device_candidate(shell, KEY, "Select an Android device", candidates, spinner).await
}
fn device_name(device: &SelectedDevice) -> String {
match device {
SelectedDevice::AppleSimulator(sim) => sim.name.clone(),
SelectedDevice::ApplePhysical(dev) => dev.name.clone(),
SelectedDevice::Local(local) => local.name().to_string(),
SelectedDevice::AndroidDevice(dev) => dev.identifier().to_string(),
SelectedDevice::AndroidEmulator(emu) => format!("{} (emulator)", emu.avd_name()),
}
}
const fn platform_name(platform: TargetPlatform) -> &'static str {
match platform {
TargetPlatform::Ios => "iOS",
TargetPlatform::Android => "Android",
TargetPlatform::Macos => "macOS",
TargetPlatform::Linux => "Linux",
TargetPlatform::Windows => "Windows",
TargetPlatform::Web => "Web",
TargetPlatform::Esp32s3 => "ESP32-S3",
TargetPlatform::Esp32c3 => "ESP32-C3",
TargetPlatform::Esp32p4 => "ESP32-P4",
}
}
const fn backend_name(backend: TargetBackend) -> &'static str {
match backend {
TargetBackend::Apple => "Apple",
TargetBackend::Android => "Android",
TargetBackend::Gtk4 => "GTK4",
TargetBackend::Hydrolysis => "Hydrolysis",
TargetBackend::Dew => "Dew",
}
}
fn validate_device_arg(
platform: TargetPlatform,
backend: TargetBackend,
device: Option<&str>,
) -> Result<()> {
if device.is_none() {
return Ok(());
}
if platform == TargetPlatform::Web {
bail!("--device is not supported with the web platform");
}
let local_only = matches!(backend, TargetBackend::Gtk4 | TargetBackend::Hydrolysis)
|| (platform, backend) == (TargetPlatform::Macos, TargetBackend::Apple);
if local_only {
bail!("--device is not supported: this target always runs on the local machine");
}
Ok(())
}
fn validate_log_pipeline_args(
platform: TargetPlatform,
logs: Option<CliLogLevel>,
native_logs: bool,
) -> Result<()> {
let log_pipeline_unsupported = match platform {
TargetPlatform::Web => Some("web"),
TargetPlatform::Esp32s3 => Some("esp32s3"),
TargetPlatform::Esp32c3 => Some("esp32c3"),
TargetPlatform::Esp32p4 => Some("esp32p4"),
_ => None,
};
let Some(platform_label) = log_pipeline_unsupported else {
return Ok(());
};
if logs.is_some() {
bail!("--logs is not supported with the {platform_label} platform");
}
if native_logs {
bail!("--native-logs is not supported with the {platform_label} platform");
}
Ok(())
}
fn validate_desktop_backend_platform_on_host(
platform: TargetPlatform,
backend: TargetBackend,
) -> Result<()> {
if platform == TargetPlatform::Web {
return Ok(());
}
match backend {
TargetBackend::Gtk4 => {
#[cfg(target_os = "linux")]
{
if platform != TargetPlatform::Linux {
bail!("GTK4 backend on Linux host requires --platform linux");
}
}
#[cfg(not(target_os = "linux"))]
{
bail!("GTK4 backend is only supported on Linux hosts");
}
}
TargetBackend::Hydrolysis => {
#[cfg(target_os = "macos")]
if platform != TargetPlatform::Macos {
bail!("Hydrolysis backend on macOS host requires --platform macos");
}
#[cfg(target_os = "linux")]
if platform != TargetPlatform::Linux {
bail!("Hydrolysis backend on Linux host requires --platform linux");
}
#[cfg(target_os = "windows")]
if platform != TargetPlatform::Windows {
bail!("Hydrolysis backend on Windows host requires --platform windows");
}
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
bail!("Hydrolysis backend is only supported on macOS, Linux, or Windows hosts");
}
TargetBackend::Apple => {
#[cfg(not(target_os = "macos"))]
bail!("Apple backend requires a macOS host");
}
TargetBackend::Android | TargetBackend::Dew => {}
}
Ok(())
}
fn handle_device_event(
shell: &Shell,
event: Option<DeviceEvent>,
platform_name: &str,
) -> Result<bool> {
match event {
Some(DeviceEvent::Started) => {
let _ = shell.status("*", "Application started");
Ok(false)
}
Some(DeviceEvent::Stopped) => {
let _ = shell.status("o", "Application stopped");
Ok(true)
}
Some(DeviceEvent::Stdout { message }) => {
line!(shell, "[stdout] {message}");
Ok(false)
}
Some(DeviceEvent::Stderr { message }) => {
warn!(shell, "[stderr] {message}");
Ok(false)
}
Some(DeviceEvent::Log { level, message }) => {
let _ = shell.device_log(platform_name, level, message);
Ok(false)
}
Some(DeviceEvent::Exited(exit)) => {
let _ = shell.status("o", exit.terminal_message());
Ok(true)
}
Some(DeviceEvent::Crashed(msg)) => {
if msg.starts_with("Panic:") {
shell.panic_message(&msg);
} else {
error!(shell, "Application crashed: {msg}");
}
bail!("application crashed");
}
None => Ok(true),
}
}
#[cfg(test)]
mod tests {
use super::{
BackendAvailability, DeviceCandidate, DeviceChoice, SelectedDevice, TargetBackend,
TargetPlatform, device_choice, handle_device_event, parse_env_assignment,
prompt_for_device, resolve_backend, resolve_default_backend_for_project, resolve_platform,
validate_desktop_backend_platform_on_host, validate_device_arg,
};
use waterui_cli::device::{ApplicationExit, DeviceEvent, Local};
#[test]
fn env_assignment_splits_on_first_equals() {
assert_eq!(
parse_env_assignment("A=b=c").expect("value may hold '='"),
(String::from("A"), String::from("b=c"))
);
assert_eq!(
parse_env_assignment("EMPTY=").expect("empty value is fine"),
(String::from("EMPTY"), String::new())
);
}
#[test]
fn env_assignment_rejects_malformed_pairs() {
assert!(parse_env_assignment("NOEQUALS").is_err());
assert!(parse_env_assignment("=novalue").is_err());
}
#[test]
fn rejects_device_with_desktop_backend() {
let err = validate_device_arg(TargetPlatform::Linux, TargetBackend::Gtk4, Some("foo"))
.expect_err("gtk4 with --device should fail");
assert!(err.to_string().contains("--device is not supported"));
let err = validate_device_arg(
TargetPlatform::Linux,
TargetBackend::Hydrolysis,
Some("foo"),
)
.expect_err("hydrolysis with --device should fail");
assert!(err.to_string().contains("--device is not supported"));
}
#[test]
fn accepts_device_with_non_gtk4_backend() {
assert!(
validate_device_arg(TargetPlatform::Ios, TargetBackend::Apple, Some("sim-1")).is_ok()
);
}
#[test]
fn rejects_device_on_local_only_targets() {
let err = validate_device_arg(TargetPlatform::Macos, TargetBackend::Apple, Some("foo"))
.expect_err("apple/macos with --device should fail");
assert!(err.to_string().contains("--device is not supported"));
let err = validate_device_arg(TargetPlatform::Web, TargetBackend::Hydrolysis, Some("foo"))
.expect_err("web with --device should fail");
assert!(err.to_string().contains("--device is not supported"));
}
#[test]
fn device_choice_prefers_remembered_device() {
let candidates = vec![
device_candidate("sim-1"),
device_candidate("phys-1"),
device_candidate("sim-2"),
];
match device_choice(&candidates, Some("phys-1")) {
DeviceChoice::Use(1, false) => {}
other => panic!("expected Use(1, false), got {other:?}"),
}
}
#[test]
fn device_choice_reports_stale_memory() {
let candidates = vec![device_candidate("sim-1")];
match device_choice(&candidates, Some("gone")) {
DeviceChoice::Stale(id) => assert_eq!(id, "gone"),
other => panic!("expected Stale, got {other:?}"),
}
}
#[test]
fn device_choice_single_candidate_is_unambiguous() {
let candidates = vec![device_candidate("sim-1")];
match device_choice(&candidates, None) {
DeviceChoice::Use(0, false) => {}
other => panic!("expected Use(0, false), got {other:?}"),
}
}
#[test]
fn device_choice_multiple_candidates_prompts() {
let candidates = vec![device_candidate("a"), device_candidate("b")];
assert!(matches!(
device_choice(&candidates, None),
DeviceChoice::Prompt
));
}
#[test]
fn non_interactive_multi_device_error_lists_candidates() {
let shell = crate::shell::Shell::new(true);
let candidates = vec![device_candidate("serial-a"), device_candidate("avd-b")];
let err = prompt_for_device(&shell, "Pick", &candidates, None)
.expect_err("non-interactive runs cannot pick");
let message = err.to_string();
assert!(message.contains("--device"));
assert!(message.contains("serial-a") && message.contains("avd-b"));
}
fn device_candidate(id: &str) -> DeviceCandidate {
DeviceCandidate {
device: SelectedDevice::Local(Local),
id: id.to_owned(),
label: id.to_owned(),
}
}
#[test]
fn clean_device_exit_stops_without_error() {
let shell = crate::shell::Shell::new(false);
let should_stop = handle_device_event(
&shell,
Some(DeviceEvent::Exited(ApplicationExit::completed())),
"test",
)
.expect("clean device exit should not fail water run");
assert!(should_stop);
}
#[test]
fn resolve_backend_defaults_include_web() {
assert_eq!(
resolve_backend(TargetPlatform::Web, None).expect("web backend"),
TargetBackend::Hydrolysis
);
}
#[test]
fn resolve_backend_defaults_match_platforms() {
assert_eq!(
resolve_backend(TargetPlatform::Ios, None).expect("ios backend"),
TargetBackend::Apple
);
assert_eq!(
resolve_backend(TargetPlatform::Android, None).expect("android backend"),
TargetBackend::Android
);
assert_eq!(
resolve_backend(TargetPlatform::Linux, None).expect("linux backend"),
TargetBackend::Hydrolysis
);
assert_eq!(
resolve_backend(TargetPlatform::Windows, None).expect("windows backend"),
TargetBackend::Hydrolysis
);
}
#[test]
fn default_backend_prefers_native_then_hydrolysis_for_app_projects() {
assert_eq!(
resolve_default_backend_for_project(
TargetPlatform::Linux,
false,
BackendAvailability {
available: [false, false, false, true, false],
}
),
TargetBackend::Hydrolysis
);
assert_eq!(
resolve_default_backend_for_project(
TargetPlatform::Macos,
false,
BackendAvailability {
available: [false, false, false, true, false],
}
),
TargetBackend::Hydrolysis
);
assert_eq!(
resolve_default_backend_for_project(
TargetPlatform::Linux,
false,
BackendAvailability {
available: [false, false, false, false, false],
}
),
TargetBackend::Hydrolysis
);
assert_eq!(
resolve_default_backend_for_project(
TargetPlatform::Linux,
false,
BackendAvailability {
available: [false, false, true, false, false],
}
),
TargetBackend::Gtk4
);
}
#[test]
fn playground_defaults_use_platform_native_backend() {
assert_eq!(
resolve_default_backend_for_project(
TargetPlatform::Macos,
true,
BackendAvailability {
available: [false, false, false, true, false],
}
),
TargetBackend::Apple
);
assert_eq!(
resolve_default_backend_for_project(
TargetPlatform::Linux,
true,
BackendAvailability {
available: [false, false, false, true, false],
}
),
TargetBackend::Hydrolysis
);
}
#[cfg(target_os = "macos")]
#[test]
fn resolve_platform_defaults_to_host_on_macos() {
assert_eq!(resolve_platform(None), TargetPlatform::Macos);
}
#[cfg(target_os = "linux")]
#[test]
fn resolve_platform_defaults_to_host_on_linux() {
assert_eq!(resolve_platform(None), TargetPlatform::Linux);
}
#[cfg(target_os = "windows")]
#[test]
fn resolve_platform_defaults_to_host_on_windows() {
assert_eq!(resolve_platform(None), TargetPlatform::Windows);
}
#[cfg(target_os = "macos")]
#[test]
fn desktop_backend_platform_must_match_macos_host() {
assert!(
validate_desktop_backend_platform_on_host(TargetPlatform::Macos, TargetBackend::Gtk4)
.is_err()
);
assert!(
validate_desktop_backend_platform_on_host(
TargetPlatform::Macos,
TargetBackend::Hydrolysis
)
.is_ok()
);
assert!(
validate_desktop_backend_platform_on_host(TargetPlatform::Linux, TargetBackend::Gtk4)
.is_err()
);
assert!(
validate_desktop_backend_platform_on_host(
TargetPlatform::Linux,
TargetBackend::Hydrolysis
)
.is_err()
);
}
#[cfg(target_os = "linux")]
#[test]
fn desktop_backend_platform_must_match_linux_host() {
assert!(
validate_desktop_backend_platform_on_host(TargetPlatform::Linux, TargetBackend::Gtk4)
.is_ok()
);
assert!(
validate_desktop_backend_platform_on_host(
TargetPlatform::Linux,
TargetBackend::Hydrolysis
)
.is_ok()
);
assert!(
validate_desktop_backend_platform_on_host(TargetPlatform::Macos, TargetBackend::Gtk4)
.is_err()
);
assert!(
validate_desktop_backend_platform_on_host(
TargetPlatform::Macos,
TargetBackend::Hydrolysis
)
.is_err()
);
}
#[cfg(target_os = "windows")]
#[test]
fn desktop_backend_platform_must_match_windows_host() {
assert!(
validate_desktop_backend_platform_on_host(
TargetPlatform::Windows,
TargetBackend::Hydrolysis
)
.is_ok()
);
assert!(
validate_desktop_backend_platform_on_host(TargetPlatform::Windows, TargetBackend::Gtk4)
.is_err()
);
assert!(
validate_desktop_backend_platform_on_host(
TargetPlatform::Macos,
TargetBackend::Hydrolysis
)
.is_err()
);
}
}