use std::net::{IpAddr, Ipv4Addr, SocketAddr};
use std::path::{Path, PathBuf};
use std::pin::Pin;
use std::time::{Duration, Instant, UNIX_EPOCH};
use cargo_toml::Manifest as CargoManifest;
use eyre::{Context, Result, bail};
use futures_util::{FutureExt as _, pin_mut, select};
#[cfg(feature = "preview")]
use notify::{RecursiveMode, Watcher as _};
use sha2::Digest as _;
use smol::stream::StreamExt;
use tracing::{error, info};
use super::app_client::{PreviewAppClient, PreviewProbe};
use super::inputs::{ProjectInputsFingerprint, project_inputs_fingerprint};
use super::protocol::DylibId;
use super::protocol::PreviewPlatform;
use super::protocol::PreviewRuntimePlatform;
use super::protocol::PreviewTcpConfig;
use crate::build::BuildProgress;
use crate::apple::dynamic_runtime;
use crate::build::{BuildOptions, BuildProfile, RustBuild, RustLinkage};
use crate::device::{Device, DeviceEvent, Local, LogLevel, RunOptions, Running};
use crate::platform::TargetPlatform;
use crate::project::Project;
use crate::runtime_compat::{PREVIEW_RUNTIME_ENV_VARS, runtime_profile_tag};
use crate::runtime_fingerprint::{compute_runtime_fingerprint, runtime_package_identity};
use crate::support_app;
use waterui_preview_protocol::registry::preview_instance_registry_dir;
const PREVIEW_TEMPLATE_COMMIT: &str = env!("WATERUI_CLI_COMMIT");
const PREVIEW_METADATA_FILE: &str = ".waterui-preview-signature";
const PREVIEW_SCAFFOLD_GENERATION: u32 = 1;
const PREVIEW_DYLIB_METADATA_SUFFIX: &str = ".waterui-preview-dylib-signature";
#[derive(Debug, Clone)]
struct PreviewRequirements {
waterui_path: Option<PathBuf>,
runtime_fingerprint: String,
runtime_features: Vec<String>,
app_crate_name: crate::project_types::CrateName,
app_path: PathBuf,
}
#[derive(Debug)]
struct ResolvedPreviewMetadata {
metadata: cargo_metadata::Metadata,
app_crate_name: crate::project_types::CrateName,
app_path: PathBuf,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct PreviewLinkMode {
crate_type_override: Option<&'static str>,
prefer_dynamic: bool,
abi_feature: &'static str,
signature_tag: &'static str,
}
impl PreviewLinkMode {
const MACOS_DYNAMIC: Self = Self {
crate_type_override: None,
prefer_dynamic: true,
abi_feature: crate::templates::preview_ffi::APPLE_ABI_FEATURE,
signature_tag: "preview-dylib+shared-waterui-dylib+prefer-dynamic",
};
const PORTABLE_DYNAMIC: Self = Self {
crate_type_override: Some("cdylib"),
prefer_dynamic: true,
abi_feature: crate::templates::preview_ffi::APPLE_ABI_FEATURE,
signature_tag: "preview-cdylib+shared-waterui-dylib+prefer-dynamic",
};
const ANDROID_DYNAMIC: Self = Self {
crate_type_override: Some("cdylib"),
prefer_dynamic: true,
abi_feature: crate::templates::preview_ffi::ANDROID_ABI_FEATURE,
signature_tag: "preview-cdylib+shared-waterui-dylib+prefer-dynamic+build-std-16k",
};
const fn for_platform(platform: PreviewPlatform) -> Self {
match platform {
PreviewPlatform::Macos => Self::MACOS_DYNAMIC,
PreviewPlatform::Ios | PreviewPlatform::IosSimulator => Self::PORTABLE_DYNAMIC,
PreviewPlatform::Android => Self::ANDROID_DYNAMIC,
}
}
const fn signature_tag(self) -> &'static str {
self.signature_tag
}
fn configure_build(self, build: RustBuild) -> RustBuild {
let build = match self.crate_type_override {
Some(crate_type) => build.with_crate_type_override(crate_type),
None => build,
};
build.with_feature(self.abi_feature)
}
}
#[derive(Debug)]
pub struct PreviewSession {
pub client: PreviewAppClient,
pub platform: PreviewPlatform,
dylib_path: Option<PathBuf>,
running: Option<Pin<Box<Running>>>,
owns_app: bool,
sccache_path: Option<PathBuf>,
runtime_fingerprint: String,
}
#[derive(Debug, Clone)]
pub struct BuiltDylib {
pub id: DylibId,
pub path: PathBuf,
}
impl PreviewSession {
pub async fn build_dylib(&mut self, project_path: &std::path::Path) -> Result<BuiltDylib> {
Box::pin(build_preview_dylib(
project_path,
self.platform,
self.sccache_path.as_ref(),
&self.runtime_fingerprint,
&mut self.dylib_path,
))
.await
}
pub async fn render(
&mut self,
dylib: &BuiltDylib,
symbol: &str,
width: f32,
height: f32,
) -> Result<Vec<u8>> {
let prefer_local_path = self.platform == PreviewPlatform::Macos;
self.client
.render_with_dylib_file(
dylib.id,
&dylib.path,
symbol,
width,
height,
prefer_local_path,
)
.await
.map_err(|e| eyre::eyre!("Preview app error: {e}"))
}
pub async fn shutdown(&mut self) -> Result<()> {
if self.owns_app {
let result = self.client.shutdown().await;
self.running.take();
self.owns_app = false;
result?;
}
Ok(())
}
pub fn detach(&mut self) {
if let Some(mut running) = self.running.take() {
running.as_mut().detach();
self.owns_app = false;
}
}
}
async fn configure_preview_module_build(
preview_crate_path: &Path,
platform: PreviewPlatform,
target: TargetPlatform,
link_mode: PreviewLinkMode,
) -> Result<(RustBuild, Option<String>)> {
let support_project = Project::open(&preview_support_path()?)
.await
.wrap_err("Failed to open the preview support project")?;
let support_target_dir = support_project
.water_target_dir(RustLinkage::SharedRuntime)
.await?;
let rust_build = link_mode
.configure_build(
RustBuild::new(preview_crate_path, target.triple()).with_project(&support_project),
)
.with_target_dir(support_target_dir);
if matches!(platform, PreviewPlatform::Android) {
let host = crate::toolchain::Host::current();
let triple = target.triple();
let abi = crate::android::platform::AndroidAbi::from_triple(&triple).ok_or_else(|| {
eyre::eyre!("the Android preview module needs a supported ABI; `{triple}` has none")
})?;
let rust_envs = crate::android::platform::android_rust_build_envs(
&host,
&support_project,
abi,
&triple,
true,
)
.await?;
let nightly = crate::toolchain::rust::nightly_toolchain_with_rust_src(&host).await?;
let toolchain_identity =
crate::toolchain::rust::rustc_verbose_version(&host, &nightly).await?;
Ok((
rust_build
.with_envs(rust_envs)
.with_rustc_flag(crate::android::platform::ANDROID_MAX_PAGE_SIZE_LINK_ARG)
.with_build_std(nightly)
.with_features(
crate::android::platform::android_ffi_dependency_features(&support_project)
.await?,
),
Some(toolchain_identity),
))
} else {
let browser_runtime = support_project
.browser_runtime_plan(target, crate::platform::TargetBackend::Apple)
.await?;
let (key, value) =
crate::apple::platform::apple_deployment_target(&support_project, target)
.await
.wrap_err("Failed to resolve the preview support deployment target")?;
Ok((
rust_build.with_env(key, value).with_features(
crate::apple::platform::apple_ffi_dependency_features(
&support_project,
browser_runtime,
)
.await?,
),
None,
))
}
}
async fn build_preview_dylib(
project_path: &Path,
platform: PreviewPlatform,
sccache_path: Option<&PathBuf>,
runtime_fingerprint: &str,
dylib_path: &mut Option<PathBuf>,
) -> Result<BuiltDylib> {
let total_start = Instant::now();
let fingerprint_start = Instant::now();
let project_inputs = project_inputs_fingerprint(project_path).await?;
info!(
project_path = %project_path.display(),
fingerprint = %project_inputs,
elapsed_ms = fingerprint_start.elapsed().as_millis(),
"Preview fingerprinted project inputs"
);
let project_open_start = Instant::now();
let project = Project::open_for_preview_build(project_path).await?;
info!(
project_path = %project_path.display(),
elapsed_ms = project_open_start.elapsed().as_millis(),
"Preview opened project"
);
let scaffold_start = Instant::now();
let preview_crate_path = scaffold_preview_module(&project).await?;
info!(
path = %preview_crate_path.display(),
elapsed_ms = scaffold_start.elapsed().as_millis(),
"Preview module scaffold is up to date"
);
let preview_crate_name = project.preview_dylib_crate_name();
let target = match platform {
PreviewPlatform::Macos => TargetPlatform::MacOS,
PreviewPlatform::IosSimulator => TargetPlatform::IOSSimulator,
PreviewPlatform::Ios => TargetPlatform::IOS,
PreviewPlatform::Android => TargetPlatform::Android,
};
let target_triple = target.triple().to_string();
let link_mode = PreviewLinkMode::for_platform(platform);
ensure_project_dev_feature_for_preview(&project).await?;
let (mut rust_build, toolchain_identity) =
configure_preview_module_build(&preview_crate_path, platform, target, link_mode).await?;
let dylib_path_start = Instant::now();
let expected_path = rust_build
.dylib_path(preview_crate_name.as_str(), false)
.await?;
info!(
build_crate_path = %preview_crate_path.display(),
build_crate_name = %preview_crate_name,
path = %expected_path.display(),
elapsed_ms = dylib_path_start.elapsed().as_millis(),
"Preview resolved dylib path"
);
let candidate_path = dylib_path.clone().unwrap_or_else(|| expected_path.clone());
let dylib_signature = dylib_build_signature(
project_inputs,
runtime_fingerprint,
&target_triple,
preview_crate_name.as_str(),
link_mode,
toolchain_identity.as_deref(),
);
let built_path = if dylib_is_up_to_date(&candidate_path, &dylib_signature).await? {
candidate_path
} else {
info!("Building dylib...");
if let Some(sccache) = sccache_path {
rust_build = rust_build.with_sccache(sccache.clone());
}
if link_mode.prefer_dynamic {
rust_build = rust_build.with_preferred_dynamic_linking();
}
let build_start = Instant::now();
let built_path = rust_build
.build_dylib(false)
.await
.wrap_err("Failed to build dylib")?;
prepare_preview_module_linkage(&built_path, link_mode, platform).await?;
write_dylib_signature(&built_path, &dylib_signature).await?;
info!(
build_crate_path = %preview_crate_path.display(),
build_crate_name = %preview_crate_name,
path = %built_path.display(),
elapsed_ms = build_start.elapsed().as_millis(),
"Preview built dylib"
);
built_path
};
*dylib_path = Some(built_path.clone());
let dylib_id_start = Instant::now();
let id = compute_dylib_id(&built_path, &dylib_signature).await?;
info!(
path = %built_path.display(),
elapsed_ms = dylib_id_start.elapsed().as_millis(),
total_elapsed_ms = total_start.elapsed().as_millis(),
"Preview prepared dylib payload"
);
Ok(BuiltDylib {
id,
path: built_path,
})
}
async fn prepare_preview_module_linkage(
built_path: &Path,
link_mode: PreviewLinkMode,
platform: PreviewPlatform,
) -> Result<()> {
if platform == PreviewPlatform::Android {
return smol::unblock({
let built_path = built_path.to_path_buf();
move || crate::elf::require_aligned_load_segments(&built_path)
})
.await;
}
if !link_mode.prefer_dynamic {
return Ok(());
}
let build_lib_dir = built_path.parent().ok_or_else(|| {
eyre::eyre!(
"Preview dylib path has no output directory: {}",
built_path.display()
)
})?;
dynamic_runtime::retarget_module(built_path, build_lib_dir).await
}
async fn ensure_project_dev_feature_for_preview(project: &Project) -> Result<()> {
let manifest_path = project.root().join("Cargo.toml");
let manifest = smol::unblock(move || CargoManifest::from_path(&manifest_path)).await?;
let Some(dev_features) = manifest.features.get("dev") else {
bail!(
"Preview requires `{}/dev` feature. Add `[features] dev = [\"waterui/dynamic_linking\"]` to {}",
project.crate_name().as_str(),
project.root().join("Cargo.toml").display()
);
};
if !dev_features
.iter()
.any(|feature| feature == "waterui/dynamic_linking")
{
bail!(
"Preview requires `{}/dev` to include `waterui/dynamic_linking`. Update {}",
project.crate_name().as_str(),
project.root().join("Cargo.toml").display()
);
}
Ok(())
}
fn dylib_signature_path(path: &Path) -> PathBuf {
let mut raw = path.as_os_str().to_os_string();
raw.push(PREVIEW_DYLIB_METADATA_SUFFIX);
PathBuf::from(raw)
}
fn dylib_build_signature(
project_inputs: ProjectInputsFingerprint,
runtime_fingerprint: &str,
target_triple: &str,
crate_name: &str,
link_mode: PreviewLinkMode,
toolchain_identity: Option<&str>,
) -> String {
let link_mode = link_mode.signature_tag();
let toolchain = toolchain_identity.unwrap_or("ambient");
format!(
"inputs={project_inputs}\nruntime={runtime_fingerprint}\ntarget={target_triple}\ncrate={crate_name}\nlink_mode={link_mode}\ntoolchain={toolchain}"
)
}
fn preview_run_options(platform: PreviewPlatform) -> RunOptions {
let mut run_options = RunOptions::new();
run_options.set_replace_existing_macos_app_instances(false);
run_options.set_log_level(LogLevel::Info);
if platform != PreviewPlatform::Android {
let preview_cache_root = waterui_preview_protocol::registry::preview_cache_root_dir();
let water_cache_dir = preview_cache_root.parent().unwrap_or_else(|| {
panic!(
"preview cache root must have a parent directory: {}",
preview_cache_root.display()
)
});
run_options.insert_env_var(
"WATER_CACHE_DIR".to_string(),
water_cache_dir.display().to_string(),
);
}
for (key, value) in PREVIEW_RUNTIME_ENV_VARS {
run_options.insert_env_var(key.to_string(), value.to_string());
}
if let Some(rust_log) = std::env::var_os("RUST_LOG") {
run_options.insert_env_var(
"RUST_LOG".to_string(),
rust_log.to_string_lossy().into_owned(),
);
}
run_options
}
async fn write_dylib_signature(path: &Path, signature: &str) -> Result<()> {
let signature_path = dylib_signature_path(path);
smol::fs::write(signature_path, signature.as_bytes()).await?;
Ok(())
}
async fn dylib_is_up_to_date(path: &std::path::Path, expected_signature: &str) -> Result<bool> {
match smol::fs::metadata(path).await {
Ok(_) => {}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(false),
Err(e) => return Err(e.into()),
}
let signature_path = dylib_signature_path(path);
let stored_signature = match smol::fs::read_to_string(&signature_path).await {
Ok(text) => text,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(false),
Err(e) => return Err(e.into()),
};
Ok(stored_signature.trim() == expected_signature)
}
async fn compute_dylib_id(path: &Path, build_signature: &str) -> Result<DylibId> {
let path = path.to_path_buf();
let build_signature = build_signature.to_string();
smol::unblock(move || {
let metadata = std::fs::metadata(&path)?;
let modified = metadata.modified()?;
let mut hasher = sha2::Sha256::new();
hasher.update(build_signature.as_bytes());
hasher.update([0]);
hasher.update(path.to_string_lossy().as_bytes());
hasher.update([0]);
hasher.update(metadata.len().to_le_bytes());
match modified.duration_since(UNIX_EPOCH) {
Ok(duration) => {
hasher.update([0]);
hasher.update(duration.as_secs().to_le_bytes());
hasher.update(duration.subsec_nanos().to_le_bytes());
}
Err(err) => {
hasher.update([1]);
hasher.update(err.duration().as_secs().to_le_bytes());
hasher.update(err.duration().subsec_nanos().to_le_bytes());
}
}
let hash: [u8; 32] = hasher.finalize().into();
Ok(DylibId::from_bytes(hash))
})
.await
}
pub async fn launch_preview_session(
project_path: &Path,
platform: PreviewPlatform,
sccache_path: Option<PathBuf>,
progress: Option<BuildProgress>,
) -> Result<PreviewSession> {
let requirements_start = Instant::now();
let requirements = resolve_preview_requirements(project_path, platform).await?;
info!(
project_path = %project_path.display(),
elapsed_ms = requirements_start.elapsed().as_millis(),
"Preview resolved runtime requirements"
);
let expected_fingerprint = requirements.runtime_fingerprint.clone();
let tcp_config = PreviewTcpConfig::from_env()
.map_err(|e| eyre::eyre!(e))
.wrap_err("Invalid preview TCP config")?;
let connect_start = Instant::now();
if let Some(session) = try_connect_existing_preview_app(
tcp_config,
&expected_fingerprint,
platform,
sccache_path.clone(),
)
.await?
{
info!(
elapsed_ms = connect_start.elapsed().as_millis(),
"Preview reused existing support app"
);
return Ok(session);
}
let project = open_preview_support_project(&requirements).await?;
let running =
launch_preview_app_for_platform(&project, platform, tcp_config, progress.as_ref()).await?;
build_preview_session_from_launch(
running,
platform,
tcp_config,
expected_fingerprint,
sccache_path,
)
.await
}
async fn try_connect_existing_preview_app(
tcp_config: PreviewTcpConfig,
expected_fingerprint: &str,
platform: PreviewPlatform,
sccache_path: Option<PathBuf>,
) -> Result<Option<PreviewSession>> {
let probe = match platform {
PreviewPlatform::Macos => {
PreviewAppClient::probe_registered(expected_fingerprint, PreviewRuntimePlatform::Macos)
.await?
}
PreviewPlatform::IosSimulator | PreviewPlatform::Ios | PreviewPlatform::Android => {
PreviewAppClient::probe_ports(
tcp_config,
expected_fingerprint,
preview_runtime_platform(platform),
)
.await
}
};
let client = match probe {
PreviewProbe::Connected(client) => client,
PreviewProbe::Rejected(reason) => {
info!("Not reusing the running preview app: {reason}");
return Ok(None);
}
PreviewProbe::Silent => return Ok(None),
};
info!("Connected to existing preview app");
Ok(Some(PreviewSession {
client,
platform,
dylib_path: None,
running: None,
owns_app: false,
sccache_path,
runtime_fingerprint: expected_fingerprint.to_string(),
}))
}
const fn preview_runtime_platform(platform: PreviewPlatform) -> PreviewRuntimePlatform {
match platform {
PreviewPlatform::Macos => PreviewRuntimePlatform::Macos,
PreviewPlatform::IosSimulator => PreviewRuntimePlatform::IosSimulator,
PreviewPlatform::Ios => PreviewRuntimePlatform::Ios,
PreviewPlatform::Android => PreviewRuntimePlatform::Android,
}
}
async fn open_preview_support_project(requirements: &PreviewRequirements) -> Result<Project> {
info!("No preview app running, launching...");
let preview_app_path = preview_support_path()?;
let ensure_start = Instant::now();
ensure_preview_support_app(&preview_app_path, requirements).await?;
info!(
path = %preview_app_path.display(),
elapsed_ms = ensure_start.elapsed().as_millis(),
"Preview support app scaffold is up to date"
);
let open_start = Instant::now();
let project = Project::open(&preview_app_path)
.await
.wrap_err("Failed to open preview app project")?;
info!(
path = %preview_app_path.display(),
elapsed_ms = open_start.elapsed().as_millis(),
"Preview support project opened"
);
Ok(project)
}
async fn launch_preview_app_for_platform(
project: &Project,
platform: PreviewPlatform,
tcp_config: PreviewTcpConfig,
progress: Option<&BuildProgress>,
) -> Result<Running> {
match platform {
PreviewPlatform::Macos => launch_preview_on_macos(project, progress).await,
PreviewPlatform::IosSimulator => launch_preview_on_ios_simulator(project, progress).await,
PreviewPlatform::Ios => {
bail!("Physical iOS devices are not yet supported for preview");
}
PreviewPlatform::Android => launch_preview_on_android(project, tcp_config, progress).await,
}
}
async fn launch_preview_on_macos(
project: &Project,
progress: Option<&BuildProgress>,
) -> Result<Running> {
let backend = project
.apple_backend()
.ok_or_else(|| eyre::eyre!("Apple backend not configured"))?;
let host = crate::toolchain::Host::current();
let device = Local;
device.launch(&host).await?;
info!("Building and running preview app on macOS...");
project
.run_with_options(
backend,
TargetPlatform::MacOS,
device,
preview_run_options(PreviewPlatform::Macos),
progress.cloned(),
)
.await
.map_err(|e| eyre::eyre!("Failed to run preview app: {e}"))
}
async fn launch_preview_on_ios_simulator(
project: &Project,
progress: Option<&BuildProgress>,
) -> Result<Running> {
let backend = project
.apple_backend()
.ok_or_else(|| eyre::eyre!("Apple backend not configured"))?;
let host = crate::toolchain::Host::current();
let simulator = crate::apple::device::AppleSimulator::select_ios(&host, project, None).await?;
simulator.launch(&host).await?;
info!("Building and running preview app on iOS Simulator...");
project
.run_with_options(
backend,
TargetPlatform::IOSSimulator,
simulator,
preview_run_options(PreviewPlatform::IosSimulator),
progress.cloned(),
)
.await
.map_err(|e| eyre::eyre!("Failed to run preview app: {e}"))
}
async fn launch_preview_on_android(
project: &Project,
tcp_config: PreviewTcpConfig,
progress: Option<&BuildProgress>,
) -> Result<Running> {
let backend = project
.android_backend()
.ok_or_else(|| eyre::eyre!("Android backend not configured"))?;
let host = crate::toolchain::Host::current();
let mut run_options = preview_run_options(PreviewPlatform::Android);
run_options.set_forward_tcp_ports(tcp_config.ports());
if let Some(device) = crate::android::device::AndroidDevice::scan(&host)
.await?
.into_iter()
.next()
{
device.launch(&host).await?;
info!("Building and running preview app on Android device...");
return project
.run_android_with_options(
backend,
device,
run_options,
BuildOptions::development(BuildProfile::Debug).with_dynamic_module_loading(),
progress.cloned(),
)
.await
.map_err(|e| eyre::eyre!("Failed to run preview app: {e}"));
}
let avd_name = crate::android::platform::AndroidPlatform::list_avds(&host)
.await?
.into_iter()
.next()
.ok_or_else(|| eyre::eyre!("No Android devices or emulators available."))?;
let emulator = crate::android::device::AndroidEmulator::open(&host, avd_name).await?;
emulator.launch(&host).await?;
info!("Building and running preview app on Android emulator...");
project
.run_android_with_options(
backend,
emulator,
run_options,
BuildOptions::development(BuildProfile::Debug).with_dynamic_module_loading(),
progress.cloned(),
)
.await
.map_err(|e| eyre::eyre!("Failed to run preview app: {e}"))
}
async fn build_preview_session_from_launch(
running: Running,
platform: PreviewPlatform,
tcp_config: PreviewTcpConfig,
expected_fingerprint: String,
sccache_path: Option<PathBuf>,
) -> Result<PreviewSession> {
info!("Preview app launched, waiting for TCP connection...");
let mut running = Box::pin(running);
match wait_for_connection_or_crash(&mut running, platform, tcp_config, &expected_fingerprint)
.await
{
ConnectionWaitResult::Ready(client) => Ok(PreviewSession {
client,
platform,
dylib_path: None,
running: Some(running),
owns_app: true,
sccache_path,
runtime_fingerprint: expected_fingerprint,
}),
ConnectionWaitResult::Crashed(message) => {
bail!(
"Preview app crashed:
{message}"
);
}
ConnectionWaitResult::Exited => {
bail!(
"Preview app exited unexpectedly.
Check the app logs for more information."
);
}
ConnectionWaitResult::Timeout(Some(rejection)) => {
bail!(
"Preview app started but no compatible app ever answered within {} seconds.
{rejection}",
STARTUP_DEADLINE.as_secs()
);
}
ConnectionWaitResult::Timeout(None) => {
bail!(
"Preview app is still running after {} seconds but never accepted a connection.
Possible causes:
- The TCP server failed to start
- Port range {}..={} may be blocked
- The app is stuck during initialization
Try running with WATERUI_CRASH_DEBUG=1 for more details.",
STARTUP_DEADLINE.as_secs(),
tcp_config.port_start,
tcp_config.ports().end()
);
}
}
}
enum ConnectionWaitResult {
Ready(PreviewAppClient),
Crashed(String),
Exited,
Timeout(Option<String>),
}
const STARTUP_DEADLINE: Duration = Duration::from_mins(3);
async fn wait_for_connection_or_crash(
running: &mut Pin<Box<Running>>,
platform: PreviewPlatform,
tcp_config: PreviewTcpConfig,
expected_fingerprint: &str,
) -> ConnectionWaitResult {
const NON_MACOS_POLL_INTERVAL: Duration = Duration::from_millis(100);
let start = Instant::now();
let ready = match platform {
PreviewPlatform::Macos => {
wait_for_registered_preview_ready(
running,
expected_fingerprint,
start,
STARTUP_DEADLINE,
)
.await
}
PreviewPlatform::IosSimulator | PreviewPlatform::Ios | PreviewPlatform::Android => {
wait_for_polled_preview_ready(
running,
tcp_config,
expected_fingerprint,
preview_runtime_platform(platform),
start,
STARTUP_DEADLINE,
NON_MACOS_POLL_INTERVAL,
)
.await
}
};
match ready {
ConnectionWaitResult::Timeout(rejection) => {
drain_terminal_preview_event(running, rejection).await
}
other => other,
}
}
async fn wait_for_registered_preview_ready(
running: &mut Pin<Box<Running>>,
expected_fingerprint: &str,
start: Instant,
timeout: Duration,
) -> ConnectionWaitResult {
const POLL_INTERVAL: Duration = Duration::from_millis(100);
let mut rejection = None;
match probe_registered_preview(expected_fingerprint, PreviewRuntimePlatform::Macos, start).await
{
PreviewProbe::Connected(client) => return ConnectionWaitResult::Ready(client),
PreviewProbe::Rejected(reason) => rejection = Some(reason),
PreviewProbe::Silent => {}
}
let registry_dir = preview_instance_registry_dir();
if let Err(error) = smol::fs::create_dir_all(®istry_dir).await {
error!(path = %registry_dir.display(), "Failed to create preview registry dir: {error}");
return ConnectionWaitResult::Timeout(rejection);
}
#[cfg(feature = "preview")]
let (event_rx, _watcher) = {
let (event_tx, event_rx) = async_channel::unbounded();
let mut watcher = match notify::recommended_watcher(move |result| {
let _ = event_tx.try_send(result);
}) {
Ok(watcher) => watcher,
Err(error) => {
error!(path = %registry_dir.display(), "Failed to create preview registry watcher: {error}");
return ConnectionWaitResult::Timeout(rejection);
}
};
if let Err(error) = watcher.watch(®istry_dir, RecursiveMode::NonRecursive) {
error!(path = %registry_dir.display(), "Failed to watch preview registry dir: {error}");
return ConnectionWaitResult::Timeout(rejection);
}
(event_rx, watcher)
};
loop {
match probe_registered_preview(expected_fingerprint, PreviewRuntimePlatform::Macos, start)
.await
{
PreviewProbe::Connected(client) => return ConnectionWaitResult::Ready(client),
PreviewProbe::Rejected(reason) => rejection = Some(reason),
PreviewProbe::Silent => {}
}
let remaining = timeout.saturating_sub(start.elapsed());
if remaining.is_zero() {
return ConnectionWaitResult::Timeout(rejection);
}
let sleep = futures_util::FutureExt::fuse(smol::Timer::after(POLL_INTERVAL.min(remaining)));
let running_event = running.next().fuse();
#[cfg(feature = "preview")]
let registry_event = futures_util::FutureExt::fuse(event_rx.recv());
#[cfg(not(feature = "preview"))]
let registry_event = futures_util::FutureExt::fuse(futures_util::future::pending::<()>());
pin_mut!(sleep);
pin_mut!(running_event);
pin_mut!(registry_event);
select! {
event = running_event => {
if let Some(result) = preview_connection_result_from_device_event(
event,
expected_fingerprint,
PreviewRuntimePlatform::Macos,
start,
&mut rejection,
)
.await
{
return result;
}
},
event = registry_event => {
#[cfg(feature = "preview")]
match event {
Ok(Ok(_notification)) => {}
Ok(Err(error)) => {
error!(path = %registry_dir.display(), "Preview registry watcher error: {error}");
}
Err(_) => return ConnectionWaitResult::Timeout(rejection),
}
#[cfg(not(feature = "preview"))]
let () = event;
},
_ = sleep => {}
}
}
}
async fn wait_for_polled_preview_ready(
running: &mut Pin<Box<Running>>,
tcp_config: PreviewTcpConfig,
expected_fingerprint: &str,
expected_platform: PreviewRuntimePlatform,
start: Instant,
timeout: Duration,
poll_interval: Duration,
) -> ConnectionWaitResult {
let mut rejection = None;
loop {
match probe_polled_preview(tcp_config, expected_fingerprint, expected_platform, start).await
{
PreviewProbe::Connected(client) => return ConnectionWaitResult::Ready(client),
PreviewProbe::Rejected(reason) => rejection = Some(reason),
PreviewProbe::Silent => {}
}
let remaining = timeout.saturating_sub(start.elapsed());
if remaining.is_zero() {
return ConnectionWaitResult::Timeout(rejection);
}
let sleep = futures_util::FutureExt::fuse(smol::Timer::after(poll_interval.min(remaining)));
let running_event = running.next().fuse();
pin_mut!(sleep);
pin_mut!(running_event);
select! {
event = running_event => {
if let Some(result) = preview_connection_result_from_device_event(
event,
expected_fingerprint,
expected_platform,
start,
&mut rejection,
)
.await
{
return result;
}
},
_ = sleep => {}
}
}
}
async fn probe_registered_preview(
expected_fingerprint: &str,
expected_platform: PreviewRuntimePlatform,
start: Instant,
) -> PreviewProbe {
match PreviewAppClient::probe_registered(expected_fingerprint, expected_platform).await {
Ok(PreviewProbe::Connected(client)) => {
info!(
"Connected to preview app after {}ms",
start.elapsed().as_millis()
);
PreviewProbe::Connected(client)
}
Ok(other) => other,
Err(error) => {
error!("Failed to read the preview instance registry: {error}");
PreviewProbe::Silent
}
}
}
async fn probe_polled_preview(
tcp_config: PreviewTcpConfig,
expected_fingerprint: &str,
expected_platform: PreviewRuntimePlatform,
start: Instant,
) -> PreviewProbe {
let probe =
PreviewAppClient::probe_ports(tcp_config, expected_fingerprint, expected_platform).await;
if matches!(probe, PreviewProbe::Connected(_)) {
info!(
"Connected to preview app after {}ms",
start.elapsed().as_millis()
);
}
probe
}
async fn preview_connection_result_from_device_event(
event: Option<DeviceEvent>,
expected_fingerprint: &str,
expected_platform: PreviewRuntimePlatform,
start: Instant,
rejection: &mut Option<String>,
) -> Option<ConnectionWaitResult> {
match event? {
DeviceEvent::Crashed(message) => {
info!("App crashed after {}ms", start.elapsed().as_millis());
Some(ConnectionWaitResult::Crashed(message))
}
DeviceEvent::Exited(_) => {
info!("App exited after {}ms", start.elapsed().as_millis());
Some(ConnectionWaitResult::Exited)
}
DeviceEvent::Log { level, message } => {
info!("Preview app log event: {message}");
if level == tracing::Level::ERROR {
error!("{message}");
}
if let Some(addr) = parse_preview_listening_addr(&message) {
match PreviewAppClient::probe_addr(addr, expected_fingerprint, expected_platform)
.await
{
PreviewProbe::Connected(client) => {
info!(
"Connected to preview app after {}ms",
start.elapsed().as_millis()
);
return Some(ConnectionWaitResult::Ready(client));
}
PreviewProbe::Rejected(reason) => *rejection = Some(reason),
PreviewProbe::Silent => {}
}
}
None
}
_ => None,
}
}
fn parse_preview_listening_addr(message: &str) -> Option<SocketAddr> {
const PREFIX: &str = "Preview support app listening on ";
let suffix = message.split(PREFIX).nth(1)?;
let port = suffix.rsplit(':').next()?.trim().parse::<u16>().ok()?;
Some(SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), port))
}
async fn drain_terminal_preview_event(
running: &mut Pin<Box<Running>>,
rejection: Option<String>,
) -> ConnectionWaitResult {
while let Some(event) = futures_lite::future::poll_once(running.as_mut().next())
.await
.flatten()
{
match event {
DeviceEvent::Crashed(message) => return ConnectionWaitResult::Crashed(message),
DeviceEvent::Exited(_) => return ConnectionWaitResult::Exited,
_ => {}
}
}
ConnectionWaitResult::Timeout(rejection)
}
fn preview_support_path() -> Result<PathBuf> {
support_app::support_app_path("preview_support")
}
async fn preview_support_ffi_crate_path() -> Result<PathBuf> {
let support_path = preview_support_path()?;
smol::fs::create_dir_all(&support_path)
.await
.wrap_err("Failed to create the preview support application directory")?;
Ok(crate::water_dir::ensure_project_build_cache(&support_path)
.await?
.join("ffi"))
}
async fn scaffold_preview_module(project: &Project) -> Result<PathBuf> {
let support_path = preview_support_path()?;
let runtime_path = project
.manifest()
.waterui_path
.as_deref()
.map(|path| project.root().join(path));
support_app::discard_support_app_for_other_runtime(&support_path, runtime_path.as_deref())
.await?;
let workspace_root = preview_support_ffi_crate_path().await?;
let modules_root = workspace_root.join(crate::templates::PREVIEW_MODULES_DIR);
let crate_path = project.preview_dylib_crate_path(&workspace_root);
if let Ok(mut entries) = smol::fs::read_dir(&modules_root).await {
use smol::stream::StreamExt as _;
while let Some(entry) = entries.next().await {
let entry = entry.wrap_err("Failed to read preview modules directory")?;
if entry.path() != crate_path {
smol::fs::remove_dir_all(entry.path())
.await
.wrap_err("Failed to remove a stale preview module")?;
}
}
}
let crate_path = project
.scaffold_preview_ffi_companion(&workspace_root)
.await
.wrap_err("Failed to scaffold the preview module")?;
if support_path.join("Water.toml").is_file() {
Project::open(&support_path)
.await
.wrap_err("Failed to open the preview support project")?;
}
Ok(crate_path)
}
async fn ensure_preview_support_app(path: &Path, requirements: &PreviewRequirements) -> Result<()> {
let desired_signature = preview_signature(requirements);
let scaffold_path = path.to_path_buf();
let scaffold_requirements = requirements.clone();
support_app::ensure_support_app(
path,
PREVIEW_METADATA_FILE,
&desired_signature,
"preview support",
move || async move { scaffold_preview_app(&scaffold_path, &scaffold_requirements).await },
)
.await
}
async fn scaffold_preview_app(path: &Path, requirements: &PreviewRequirements) -> Result<()> {
use crate::project::{CreateOptions, Manifest as WaterManifest, PackageType};
use crate::templates::TemplateContext;
let waterui_path = requirements.waterui_path.clone();
let options = CreateOptions {
name: "WaterUI Preview".to_string(),
bundle_identifier: crate::project_types::BundleIdentifier::try_from("dev.waterui.preview")
.expect("preview support bundle identifier must be valid"),
package_type: PackageType::Playground,
waterui_path: waterui_path.clone(),
channel: None,
framework_manifest: None,
framework: None,
author: String::new(),
backends: Vec::new(),
web: None,
};
let project = Project::create(path, options)
.await
.map_err(|e| eyre::eyre!("Failed to create preview app: {e}"))?;
let mut manifest = WaterManifest::open(project.root().join("Water.toml")).await?;
manifest.package.accessory = true;
manifest.permissions.insert(
crate::project_types::PermissionKey::Internet,
crate::project::PermissionEntry::enabled(
"Hosts the preview TCP server that the CLI connects to",
),
);
manifest.save(project.root()).await?;
let ctx = TemplateContext::for_support_playground(
"WaterUI Preview",
project.crate_name().clone(),
crate::project_types::BundleIdentifier::try_from("dev.waterui.preview")
.expect("preview support bundle identifier must be valid"),
waterui_path,
&project.resolved_framework().await?,
true,
Some(requirements.runtime_fingerprint.clone()),
)
.with_preview_runtime_features(requirements.runtime_features.clone())
.with_preview_app_dependency(
requirements.app_crate_name.clone(),
requirements.app_path.clone(),
);
crate::templates::preview::scaffold(project.root(), &ctx)
.await
.wrap_err("Failed to scaffold embedded preview app template")?;
info!("Preview app scaffolded at {}", path.display());
Ok(())
}
fn preview_signature(requirements: &PreviewRequirements) -> String {
format!(
"template_commit={PREVIEW_TEMPLATE_COMMIT}\nscaffold_generation={PREVIEW_SCAFFOLD_GENERATION}\nwaterui_dependency={}\nruntime_fingerprint={}\ntemplate_fingerprint={}",
requirements.waterui_path.as_ref().map_or_else(
|| String::from("registry"),
|path| path.display().to_string()
),
requirements.runtime_fingerprint,
crate::templates::preview::template_fingerprint(),
)
}
async fn resolve_preview_requirements(
project_path: &Path,
platform: PreviewPlatform,
) -> Result<PreviewRequirements> {
let resolved = resolve_preview_metadata(project_path, platform).await?;
let metadata = &resolved.metadata;
let waterui = select_unique_package(metadata, "waterui")?;
let runtime_features = resolved_package_features(metadata, waterui)?;
let graph_fingerprint = resolved_graph_fingerprint(metadata)?;
if let Some(requirements) = resolve_preview_requirements_from_manifest(
project_path,
&runtime_features,
&graph_fingerprint,
&resolved.app_crate_name,
&resolved.app_path,
)
.await?
{
return Ok(requirements);
}
let waterui_core = select_unique_package(metadata, "waterui-core")?;
let runtime_identity = runtime_package_identity(waterui_core);
let runtime_fingerprint_start = Instant::now();
let runtime_fingerprint_base = if waterui.source.is_none() {
let waterui_root = waterui
.manifest_path
.as_std_path()
.parent()
.map(Path::to_path_buf)
.ok_or_else(|| eyre::eyre!("Failed to derive waterui package root path"))?;
let fingerprint = compute_runtime_fingerprint(&waterui_root, &runtime_identity).await?;
info!(
waterui_root = %waterui_root.display(),
elapsed_ms = runtime_fingerprint_start.elapsed().as_millis(),
"Preview computed dev-mode runtime fingerprint"
);
return Ok(PreviewRequirements {
waterui_path: Some(waterui_root),
runtime_fingerprint: runtime_fingerprint(
&fingerprint,
&runtime_features,
&graph_fingerprint,
),
runtime_features,
app_crate_name: resolved.app_crate_name,
app_path: resolved.app_path,
});
} else {
let source = waterui
.source
.as_ref()
.map(ToString::to_string)
.expect("registry dependency must have a source");
info!(
package = %runtime_identity,
source = %source,
elapsed_ms = runtime_fingerprint_start.elapsed().as_millis(),
"Preview resolved release-mode runtime fingerprint"
);
format!("{runtime_identity}:source:{source}")
};
Ok(PreviewRequirements {
waterui_path: None,
runtime_fingerprint: runtime_fingerprint(
&runtime_fingerprint_base,
&runtime_features,
&graph_fingerprint,
),
runtime_features,
app_crate_name: resolved.app_crate_name,
app_path: resolved.app_path,
})
}
async fn resolve_preview_requirements_from_manifest(
project_path: &Path,
runtime_features: &[String],
graph_fingerprint: &str,
app_crate_name: &crate::project_types::CrateName,
app_path: &Path,
) -> Result<Option<PreviewRequirements>> {
let manifest_open_start = Instant::now();
let manifest = crate::project::Manifest::open(project_path.join("Water.toml"))
.await
.map_err(|error| {
eyre::eyre!(
"Failed to read Water.toml for preview requirements at {}: {error}",
project_path.display()
)
})?;
info!(
project_path = %project_path.display(),
elapsed_ms = manifest_open_start.elapsed().as_millis(),
"Preview opened Water.toml for runtime requirements"
);
let Some(waterui_path) = manifest.waterui_path else {
return Ok(None);
};
let resolve_root_start = Instant::now();
let waterui_root = resolve_waterui_root_from_manifest(project_path, &waterui_path).await?;
info!(
project_path = %project_path.display(),
waterui_root = %waterui_root.display(),
elapsed_ms = resolve_root_start.elapsed().as_millis(),
"Preview resolved waterui root from manifest"
);
let runtime_identity_start = Instant::now();
let runtime_identity = runtime_identity_from_waterui_root(&waterui_root).await?;
info!(
waterui_root = %waterui_root.display(),
elapsed_ms = runtime_identity_start.elapsed().as_millis(),
"Preview resolved runtime identity"
);
let runtime_fingerprint_start = Instant::now();
let runtime_fingerprint = runtime_fingerprint(
&compute_runtime_fingerprint(&waterui_root, &runtime_identity).await?,
runtime_features,
graph_fingerprint,
);
info!(
project_path = %project_path.display(),
waterui_root = %waterui_root.display(),
elapsed_ms = runtime_fingerprint_start.elapsed().as_millis(),
"Preview resolved runtime requirements from Water.toml"
);
Ok(Some(PreviewRequirements {
waterui_path: Some(waterui_root),
runtime_fingerprint,
runtime_features: runtime_features.to_vec(),
app_crate_name: app_crate_name.clone(),
app_path: app_path.to_path_buf(),
}))
}
async fn resolve_preview_metadata(
project_path: &Path,
platform: PreviewPlatform,
) -> Result<ResolvedPreviewMetadata> {
let project = Project::open_for_preview_build(project_path).await?;
ensure_project_dev_feature_for_preview(&project).await?;
let manifest_path = scaffold_preview_module(&project).await?.join("Cargo.toml");
let app_crate_name = project.crate_name().clone();
let app_path = project.root().to_path_buf();
let metadata_start = Instant::now();
let metadata_manifest_path = manifest_path.clone();
let abi_feature = PreviewLinkMode::for_platform(platform)
.abi_feature
.to_string();
let metadata = smol::unblock(move || {
let mut command = cargo_metadata::MetadataCommand::new();
command
.manifest_path(metadata_manifest_path)
.features(cargo_metadata::CargoOpt::SomeFeatures(vec![abi_feature]));
command.exec()
})
.await
.wrap_err("Failed to resolve user project Cargo metadata with its dev feature")?;
info!(
project_path = %project_path.display(),
elapsed_ms = metadata_start.elapsed().as_millis(),
"Preview resolved user project cargo metadata"
);
Ok(ResolvedPreviewMetadata {
metadata,
app_crate_name,
app_path,
})
}
fn resolved_package_features(
metadata: &cargo_metadata::Metadata,
package: &cargo_metadata::Package,
) -> Result<Vec<String>> {
let resolve = metadata
.resolve
.as_ref()
.ok_or_else(|| eyre::eyre!("Cargo metadata omitted its dependency resolution graph"))?;
let node = resolve
.nodes
.iter()
.find(|node| node.id == package.id)
.ok_or_else(|| {
eyre::eyre!(
"Cargo metadata omitted the resolution node for package `{}`",
package.name
)
})?;
let mut features = node
.features
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>();
features.sort_unstable();
features.dedup();
if !features.iter().any(|feature| feature == "dynamic_linking") {
bail!("Preview requires the project dev feature to enable waterui/dynamic_linking");
}
Ok(features)
}
fn resolved_graph_fingerprint(metadata: &cargo_metadata::Metadata) -> Result<String> {
let resolve = metadata
.resolve
.as_ref()
.ok_or_else(|| eyre::eyre!("Cargo metadata omitted its dependency resolution graph"))?;
let mut units = resolve
.nodes
.iter()
.map(|node| {
let mut features = node
.features
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>();
features.sort_unstable();
format!("{}|{}", node.id, features.join(","))
})
.collect::<Vec<_>>();
units.sort_unstable();
let mut hasher = sha2::Sha256::new();
for unit in units {
hasher.update(unit.as_bytes());
hasher.update(b"\n");
}
Ok(hex::encode(hasher.finalize()))
}
fn runtime_fingerprint(base: &str, features: &[String], graph_fingerprint: &str) -> String {
format!(
"{base}|features={}|graph={}|profile={}",
features.join(","),
graph_fingerprint,
runtime_profile_tag()
)
}
async fn resolve_waterui_root_from_manifest(
project_path: &Path,
waterui_path: &str,
) -> Result<PathBuf> {
let candidate = PathBuf::from(waterui_path);
let resolved = if candidate.is_absolute() {
candidate
} else {
project_path.join(candidate)
};
smol::fs::canonicalize(&resolved).await.wrap_err_with(|| {
format!(
"Failed to resolve `waterui_path = {waterui_path}` from {}",
project_path.display()
)
})
}
async fn runtime_identity_from_waterui_root(waterui_root: &Path) -> Result<String> {
let core_manifest_path = waterui_root.join("core").join("Cargo.toml");
let manifest_text = smol::fs::read_to_string(&core_manifest_path)
.await
.wrap_err("Failed to read waterui-core Cargo.toml for preview requirements")?;
let manifest: toml::Table = manifest_text
.parse()
.wrap_err("Failed to parse waterui-core Cargo.toml for preview requirements")?;
let package = manifest
.get("package")
.and_then(toml::Value::as_table)
.ok_or_else(|| {
eyre::eyre!(
"Invalid waterui-core manifest at {}: missing package section",
core_manifest_path.display()
)
})?;
let package_name = package
.get("name")
.and_then(toml::Value::as_str)
.ok_or_else(|| {
eyre::eyre!(
"Invalid waterui-core manifest at {}: missing package.name",
core_manifest_path.display()
)
})?;
if package_name != "waterui-core" {
bail!(
"Invalid preview runtime root {}: expected core/Cargo.toml package `waterui-core`, found `{}`",
waterui_root.display(),
package_name
);
}
let package_version = package
.get("version")
.and_then(toml::Value::as_str)
.ok_or_else(|| {
eyre::eyre!(
"Invalid waterui-core manifest at {}: missing package.version",
core_manifest_path.display()
)
})?;
Ok(format!("{package_name}@{package_version}"))
}
fn select_unique_package<'a>(
metadata: &'a cargo_metadata::Metadata,
name: &str,
) -> Result<&'a cargo_metadata::Package> {
let mut matches = metadata.packages.iter().filter(|p| p.name == name);
let first = matches
.next()
.ok_or_else(|| eyre::eyre!("Could not resolve package `{name}` from metadata"))?;
if matches.next().is_some() {
bail!(
"Multiple `{name}` packages were resolved. Preview requires a single resolved `{name}` package to guarantee compatibility."
);
}
Ok(first)
}
#[cfg(test)]
mod tests {
use super::{PreviewLinkMode, PreviewPlatform};
#[test]
fn macos_preview_uses_shared_waterui_runtime() {
let link_mode = PreviewLinkMode::for_platform(PreviewPlatform::Macos);
assert_eq!(link_mode, PreviewLinkMode::MACOS_DYNAMIC);
assert_eq!(link_mode.crate_type_override, None);
assert!(link_mode.prefer_dynamic);
assert_eq!(
link_mode.abi_feature,
crate::templates::preview_ffi::APPLE_ABI_FEATURE
);
assert_eq!(
link_mode.signature_tag(),
"preview-dylib+shared-waterui-dylib+prefer-dynamic"
);
}
#[test]
fn remote_preview_platforms_use_shared_runtime_cdylibs() {
for platform in [PreviewPlatform::Ios, PreviewPlatform::IosSimulator] {
let link_mode = PreviewLinkMode::for_platform(platform);
assert_eq!(link_mode, PreviewLinkMode::PORTABLE_DYNAMIC);
assert_eq!(link_mode.crate_type_override, Some("cdylib"));
assert!(link_mode.prefer_dynamic);
assert_eq!(
link_mode.abi_feature,
crate::templates::preview_ffi::APPLE_ABI_FEATURE
);
assert_eq!(
link_mode.signature_tag(),
"preview-cdylib+shared-waterui-dylib+prefer-dynamic"
);
}
}
#[test]
fn android_preview_uses_the_jni_shared_runtime_abi() {
let link_mode = PreviewLinkMode::for_platform(PreviewPlatform::Android);
assert_eq!(link_mode, PreviewLinkMode::ANDROID_DYNAMIC);
assert_eq!(link_mode.crate_type_override, Some("cdylib"));
assert!(link_mode.prefer_dynamic);
assert_eq!(
link_mode.abi_feature,
crate::templates::preview_ffi::ANDROID_ABI_FEATURE
);
}
#[test]
fn dylib_signature_pins_the_build_std_toolchain() {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir_all(dir.path().join("src")).unwrap();
std::fs::write(dir.path().join("src/lib.rs"), "fn main() {}").unwrap();
let inputs = smol::block_on(super::project_inputs_fingerprint(dir.path())).unwrap();
let signature = |toolchain| {
super::dylib_build_signature(
inputs,
"runtime",
"aarch64-linux-android",
"preview_ffi",
PreviewLinkMode::ANDROID_DYNAMIC,
toolchain,
)
};
assert_ne!(
signature(Some("rustc 1.100.0-nightly (aaa 2026-08-30)")),
signature(Some("rustc 1.101.0-nightly (bbb 2026-10-04)")),
);
assert_ne!(signature(None), signature(Some("rustc 1.100.0-nightly")));
}
}