use anyhow::{Context, Result};
use std::net::SocketAddr;
use std::path::{Path, PathBuf};
use std::sync::Arc;
pub mod builder;
pub mod hotpatch;
pub mod installer;
pub mod server;
pub mod watcher;
pub mod workspace;
pub use builder::Builder;
pub use installer::Installer;
pub use server::{Patch, PatchSender};
pub use watcher::{Change, ChangeKind};
pub use whisker_build::CaptureShims;
pub use workspace::{discover_path_deps, identify_crate_for_paths, PathDepCrate};
#[derive(Debug, Clone)]
pub struct Config {
pub workspace_root: PathBuf,
pub crate_dir: PathBuf,
pub package: String,
pub target: Target,
pub watch_paths: Vec<PathBuf>,
pub bind_addr: SocketAddr,
pub dev_token: Option<String>,
pub hot_patch_mode: HotPatchMode,
pub android: Option<AndroidParams>,
pub ios: Option<IosParams>,
}
impl Config {
pub fn defaults_for(workspace_root: PathBuf, package: String, target: Target) -> Self {
Self {
workspace_root: workspace_root.clone(),
crate_dir: workspace_root,
package,
target,
watch_paths: Vec::new(),
bind_addr: "127.0.0.1:9876".parse().expect("valid default addr"),
dev_token: None,
hot_patch_mode: HotPatchMode::Tier2ColdRebuild,
android: None,
ios: None,
}
}
}
#[derive(Debug, Clone)]
pub struct AndroidParams {
pub project_dir: PathBuf,
pub application_id: String,
pub launcher_activity: String,
pub abi: String,
}
#[derive(Debug, Clone)]
pub struct IosParams {
pub project_dir: PathBuf,
pub scheme: String,
pub bundle_id: String,
pub device_override: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Target {
Android,
IosSimulator,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum HotPatchMode {
Disabled,
Tier2ColdRebuild,
Tier1Subsecond,
}
#[derive(Debug, Clone)]
pub enum Event {
Started,
BuildingFull,
BuildSucceeded,
BuildFailed(String),
ClientConnected,
ClientDisconnected,
PatchBuilding,
PatchSent,
DeviceLog {
stream: String,
line: String,
ts_micros: u128,
},
}
pub struct DevServer {
config: Config,
on_event: Option<Arc<dyn Fn(Event) + Send + Sync>>,
}
impl DevServer {
pub fn new(config: Config) -> Result<Self> {
Ok(Self {
config,
on_event: None,
})
}
pub fn on_event(mut self, cb: impl Fn(Event) + Send + Sync + 'static) -> Self {
self.on_event = Some(Arc::new(cb));
self
}
pub async fn run(self) -> Result<()> {
if !whisker_build::ui::is_tui() {
whisker_build::ui::section("whisker run");
whisker_build::ui::info(format!(
"{} · {:?}",
self.config.package, self.config.target,
));
}
whisker_build::ui::debug(format!("mode={:?}", self.config.hot_patch_mode));
let mut builder = Builder::new(
self.config.workspace_root.clone(),
self.config.crate_dir.clone(),
self.config.package.clone(),
self.config.target,
)
.with_features(vec!["whisker/hot-reload".into()]);
let tier1_init = if self.config.hot_patch_mode == HotPatchMode::Tier1Subsecond {
match prepare_tier1_capture(&self.config) {
Ok(prep) => {
builder = builder.with_capture(prep.capture.clone());
Some(prep)
}
Err(e) => {
whisker_build::ui::warn(format!(
"Tier 1 capture setup failed ({e:#}); falling back to Tier 2 cold rebuilds",
));
None
}
}
} else {
None
};
let installer = Installer::new(
self.config.target,
self.config.android.clone(),
self.config.ios.clone(),
self.config.workspace_root.clone(),
self.config.package.clone(),
tier1_init.as_ref().map(|p| p.capture.clone()),
builder.features().to_vec(),
self.config.bind_addr.port(),
self.config.dev_token.clone(),
);
if !whisker_build::ui::is_tui() {
whisker_build::ui::section("Initial build");
}
emit(&self.on_event, Event::BuildingFull);
if let Err(e) = builder.build().await {
let msg = format!("{e:#}");
emit(&self.on_event, Event::BuildFailed(msg.clone()));
anyhow::bail!("initial build failed: {msg}");
}
emit(&self.on_event, Event::BuildSucceeded);
whisker_build::ui::ensure_status("dev-server");
let (sender, bound, _server_handle) = server::serve(
self.config.bind_addr,
self.on_event.clone(),
self.config.dev_token.clone(),
)
.await?;
whisker_build::ui::set_status(format!("ws://{bound} · 0 client(s)"));
whisker_build::ui::debug(format!("ws://{bound}/whisker-dev"));
let path_deps = workspace::discover_path_deps(
&self.config.crate_dir.join("Cargo.toml"),
&self.config.package,
)
.unwrap_or_else(|e| {
whisker_build::ui::warn(format!(
"cargo metadata failed ({e:#}); falling back to user crate only",
));
Vec::new()
});
let user_src = self.config.crate_dir.join("src");
let mut watch_roots: Vec<PathBuf> = path_deps
.iter()
.map(|c| c.src_dir.clone())
.filter(|p| p.is_dir())
.collect();
if !watch_roots.iter().any(|p| p == &user_src) && user_src.is_dir() {
watch_roots.push(user_src.clone());
}
if watch_roots.is_empty() {
watch_roots.push(user_src.clone());
}
let (tx, mut rx) = tokio::sync::mpsc::channel::<watcher::Change>(8);
let _watcher = watcher::spawn_watcher(
watch_roots.clone(),
std::time::Duration::from_millis(200),
tx,
)?;
for root in &watch_roots {
whisker_build::ui::debug(format!("watching {}", root.display()));
}
emit(&self.on_event, Event::Started);
if let Err(e) = installer.install_and_launch().await {
anyhow::bail!("initial install failed: {e:#}");
}
whisker_build::ui::info(format!(
"initial done · {} client(s) connected",
sender.client_count()
));
let patcher = match tier1_init {
Some(prep) => match init_patcher_for(&self.config, &prep) {
Ok(p) => {
whisker_build::ui::debug("Tier 1 patcher ready");
Some(p)
}
Err(e) => {
whisker_build::ui::warn(format!(
"Tier 1 patcher init failed ({e:#}); falling back to Tier 2 cold rebuilds",
));
None
}
},
None => None,
};
while let Some(change) = rx.recv().await {
if !whisker_build::ui::is_tui() {
whisker_build::ui::section("Change");
}
whisker_build::ui::debug(format!(
"{:?} — {} path(s)",
change.kind,
change.paths.len(),
));
let action = decide_action(change.kind, patcher.is_some());
match action {
LoopAction::Ignore => {
whisker_build::ui::debug(format!("ignored ({:?})", change.kind));
}
LoopAction::Tier1Patch => {
let p = patcher.as_ref().expect("decide_action guarantees Some");
let patch_step = whisker_build::ui::step("patch", "tier 1");
emit(&self.on_event, Event::PatchBuilding);
let crate_key = workspace::identify_crate_for_paths(&change.paths, &path_deps);
if !path_deps.is_empty() && crate_key.is_none() {
patch_step.fail("multi-crate change batch; using Tier 2");
run_build_cycle(
&builder,
&installer,
&self.on_event,
&sender,
"rebuild (tier2 fallback, multi-crate batch)",
)
.await;
continue;
}
let Some(aslr_reference) = sender.latest_aslr_reference() else {
patch_step.fail("no client aslr_reference yet; using Tier 2");
run_build_cycle(
&builder,
&installer,
&self.on_event,
&sender,
"rebuild (tier2 fallback, no aslr_reference)",
)
.await;
continue;
};
let started = std::time::Instant::now();
match p.build_patch(aslr_reference, crate_key.as_deref()).await {
Ok(plan) => {
let built_in = started.elapsed();
log_patch_diff(&plan.report);
let dylib_bytes = match read_lib_bytes(&plan.table.lib) {
Ok(b) => Arc::new(b),
Err(e) => {
patch_step.fail(format!(
"could not read dylib bytes ({}): {e:#}; using Tier 2",
plan.table.lib.display(),
));
run_build_cycle(
&builder,
&installer,
&self.on_event,
&sender,
"rebuild (tier2 fallback)",
)
.await;
continue;
}
};
let send_started = std::time::Instant::now();
let n = sender.send(Patch {
table: plan.table,
dylib_bytes,
});
whisker_build::ui::debug(format!(
"built {built_in:?} · queued {:?}",
send_started.elapsed()
));
patch_step.done(format!("{n} client(s)"));
emit(&self.on_event, Event::PatchSent);
}
Err(e) => {
patch_step.fail(format!("{e:#}; using Tier 2 cold rebuild"));
run_build_cycle(
&builder,
&installer,
&self.on_event,
&sender,
"rebuild (tier2 fallback)",
)
.await;
}
}
}
LoopAction::Tier2Rebuild => {
run_build_cycle(&builder, &installer, &self.on_event, &sender, "rebuild").await;
}
}
}
Ok(())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LoopAction {
Ignore,
Tier1Patch,
Tier2Rebuild,
}
pub fn decide_action(kind: ChangeKind, has_patcher: bool) -> LoopAction {
match kind {
ChangeKind::Other => LoopAction::Ignore,
ChangeKind::CargoToml => LoopAction::Tier2Rebuild,
ChangeKind::RustCode if has_patcher => LoopAction::Tier1Patch,
ChangeKind::RustCode => LoopAction::Tier2Rebuild,
}
}
fn log_patch_diff(report: &hotpatch::DiffReport) {
if report.added.is_empty() && report.removed.is_empty() {
return;
}
if !report.added.is_empty() {
whisker_build::ui::debug(format!(
"patch added {} symbol(s): {:?}",
report.added.len(),
report.added.iter().take(5).collect::<Vec<_>>(),
));
}
if !report.removed.is_empty() {
whisker_build::ui::debug(format!(
"patch removed {} symbol(s): {:?}",
report.removed.len(),
report.removed.iter().take(5).collect::<Vec<_>>(),
));
}
}
#[derive(Debug, Clone)]
struct Tier1Prep {
capture: CaptureShims,
real_linker: PathBuf,
}
fn prepare_tier1_capture(config: &Config) -> Result<Tier1Prep> {
let shims = hotpatch::resolve_shim_paths(&config.workspace_root)?;
let rustc_cache_dir = hotpatch::wrapper::default_cache_dir(&config.workspace_root);
let linker_cache_dir = hotpatch::wrapper::default_linker_cache_dir(&config.workspace_root);
let real_linker = resolve_linker_for(config)?;
let target_triple = target_triple_for(config);
Ok(Tier1Prep {
capture: CaptureShims {
rustc_shim: shims.rustc_shim,
linker_shim: shims.linker_shim,
rustc_cache_dir,
linker_cache_dir,
real_linker: real_linker.clone(),
target_triple,
},
real_linker,
})
}
fn target_triple_for(config: &Config) -> Option<String> {
match config.target {
Target::Android => {
let abi = config.android.as_ref().map(|a| a.abi.as_str())?;
let triple = match abi {
"arm64-v8a" => "aarch64-linux-android",
"armeabi-v7a" => "armv7-linux-androideabi",
"x86_64" => "x86_64-linux-android",
"x86" => "i686-linux-android",
_ => return None,
};
Some(triple.to_string())
}
Target::IosSimulator => {
let triple = match std::env::consts::ARCH {
"aarch64" => "aarch64-apple-ios-sim",
"x86_64" => "x86_64-apple-ios",
_ => return None,
};
Some(triple.to_string())
}
}
}
fn resolve_linker_for(config: &Config) -> Result<PathBuf> {
match config.target {
Target::Android => {
let abi = config
.android
.as_ref()
.map(|a| a.abi.as_str())
.unwrap_or("arm64-v8a");
let api = std::env::var("WHISKER_ANDROID_API")
.ok()
.and_then(|s| s.parse::<u32>().ok())
.unwrap_or(21);
hotpatch::android_ndk::android_clang_for(abi, api)
.with_context(|| format!("resolve NDK clang for ABI {abi} API {api}"))
}
Target::IosSimulator => Ok(hotpatch::wrapper::resolve_host_linker()),
}
}
fn init_patcher_for(config: &Config, prep: &Tier1Prep) -> Result<hotpatch::Patcher> {
let original_binary = original_binary_path(config)?;
hotpatch::Patcher::initialize(
&config.workspace_root,
config.package.clone(),
&prep.capture.rustc_cache_dir,
&prep.capture.linker_cache_dir,
&prep.real_linker,
&original_binary,
target_os_for(config.target),
prep.capture.target_triple.as_deref(),
)
}
fn original_binary_path(config: &Config) -> Result<PathBuf> {
let crate_underscored = config.package.replace('-', "_");
match config.target {
Target::Android => {
let android = config.android.as_ref().ok_or_else(|| {
anyhow::anyhow!(
"target=Android but Config.android is None — cli should have populated it from whisker.rs"
)
})?;
let so_name = format!("lib{crate_underscored}.so");
let abi_camel = android_abi_to_camel(&android.abi);
let candidate = config
.crate_dir
.join("gen/android/app/build/generated/jniLibs")
.join(format!("whiskerBuildDebug{abi_camel}"))
.join(&android.abi)
.join(&so_name);
if !candidate.is_file() {
anyhow::bail!(
"no Android cdylib at {} — gradle's whiskerBuildDebug{abi_camel} task didn't produce its output (run `whisker run android` first)",
candidate.display(),
);
}
Ok(candidate)
}
Target::IosSimulator => {
let _ios = config.ios.as_ref().ok_or_else(|| {
anyhow::anyhow!(
"target=IosSimulator but Config.ios is None — cli should have populated it from whisker.rs"
)
})?;
let dylib_name = format!("lib{crate_underscored}.dylib");
let triple = match std::env::consts::ARCH {
"aarch64" => "aarch64-apple-ios-sim",
"x86_64" => "x86_64-apple-ios",
arch => anyhow::bail!("unsupported host arch {arch} for iOS Simulator target"),
};
let dylib = config
.workspace_root
.join("target")
.join(triple)
.join("release")
.join(&dylib_name);
if !dylib.is_file() {
anyhow::bail!(
"no iOS Simulator dylib at {} — \
initial xcodebuild didn't drop the artifact where the dev loop expects it",
dylib.display(),
);
}
Ok(dylib)
}
}
}
fn android_abi_to_camel(abi: &str) -> String {
abi.split(['-', '_'])
.map(|seg| {
let mut chars = seg.chars();
match chars.next() {
Some(c) => c.to_uppercase().chain(chars).collect::<String>(),
None => String::new(),
}
})
.collect()
}
fn target_os_for(target: Target) -> hotpatch::LinkerOs {
match target {
Target::Android => hotpatch::LinkerOs::Linux,
Target::IosSimulator => hotpatch::LinkerOs::Macos,
}
}
fn read_lib_bytes(path: &Path) -> Result<Vec<u8>> {
std::fs::read(path).with_context(|| format!("read {}", path.display()))
}
async fn run_build_cycle(
builder: &Builder,
installer: &Installer,
on_event: &Option<Arc<dyn Fn(Event) + Send + Sync>>,
sender: &PatchSender,
label: &str,
) {
emit(on_event, Event::BuildingFull);
match builder.build().await {
Ok(()) => {
emit(on_event, Event::BuildSucceeded);
if let Err(e) = installer.install_and_launch().await {
whisker_build::ui::error(format!("{label} install failed: {e}"));
}
whisker_build::ui::info(format!(
"{label} done · {} client(s) connected",
sender.client_count()
));
}
Err(e) => {
let msg = format!("{e:#}");
whisker_build::ui::error(format!("{label} build failed: {msg}"));
emit(on_event, Event::BuildFailed(msg));
}
}
}
fn emit(on_event: &Option<Arc<dyn Fn(Event) + Send + Sync>>, ev: Event) {
if let Some(cb) = on_event {
cb(ev);
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::Path;
#[test]
fn config_defaults_pick_loopback_and_tier2() {
let cfg = Config::defaults_for(
PathBuf::from("/tmp/ws"),
"hello-world".to_string(),
Target::Android,
);
assert_eq!(cfg.workspace_root, Path::new("/tmp/ws"));
assert_eq!(cfg.package, "hello-world");
assert_eq!(cfg.target, Target::Android);
assert_eq!(cfg.bind_addr.port(), 9876);
assert!(cfg.bind_addr.ip().is_loopback());
assert_eq!(cfg.hot_patch_mode, HotPatchMode::Tier2ColdRebuild);
assert!(cfg.watch_paths.is_empty());
}
#[test]
fn target_variants_compare_by_value() {
assert_eq!(Target::Android, Target::Android);
assert_ne!(Target::Android, Target::IosSimulator);
}
#[test]
fn hot_patch_mode_variants_compare_by_value() {
assert_eq!(HotPatchMode::Disabled, HotPatchMode::Disabled);
assert_ne!(HotPatchMode::Tier1Subsecond, HotPatchMode::Tier2ColdRebuild,);
}
#[test]
fn dev_server_new_does_not_fail_for_a_well_formed_config() {
let cfg = Config::defaults_for(
PathBuf::from("/tmp/ws"),
"hello-world".to_string(),
Target::Android,
);
assert!(DevServer::new(cfg).is_ok());
}
fn mk_config(workspace_root: PathBuf, target: Target) -> Config {
let mut cfg = Config::defaults_for(workspace_root.clone(), "hello-world".into(), target);
cfg.crate_dir = workspace_root.clone();
match target {
Target::Android => {
cfg.android = Some(crate::AndroidParams {
project_dir: workspace_root.join("android"),
application_id: "rs.whisker.examples.helloworld".into(),
launcher_activity: ".MainActivity".into(),
abi: "arm64-v8a".into(),
});
}
Target::IosSimulator => {
cfg.ios = Some(crate::IosParams {
project_dir: workspace_root.join("ios"),
scheme: "HelloWorld".into(),
bundle_id: "rs.whisker.examples.helloWorld".into(),
device_override: None,
});
}
}
cfg
}
#[test]
fn original_binary_path_finds_ios_simulator_dylib_under_target() {
use std::sync::atomic::{AtomicU64, Ordering};
static SEQ: AtomicU64 = AtomicU64::new(0);
let n = SEQ.fetch_add(1, Ordering::Relaxed);
let pid = std::process::id();
let ws = std::env::temp_dir().join(format!("whisker-dev-test-ios-{pid}-{n}"));
let _ = std::fs::remove_dir_all(&ws);
let triple = match std::env::consts::ARCH {
"aarch64" => "aarch64-apple-ios-sim",
"x86_64" => "x86_64-apple-ios",
other => panic!("unsupported test host arch {other}"),
};
let release_dir = ws.join("target").join(triple).join("release");
std::fs::create_dir_all(&release_dir).unwrap();
let dylib = release_dir.join("libhello_world.dylib");
std::fs::write(&dylib, b"fake-macho").unwrap();
let cfg = mk_config(ws.clone(), Target::IosSimulator);
let resolved = original_binary_path(&cfg).unwrap();
assert_eq!(resolved, dylib);
let _ = std::fs::remove_dir_all(&ws);
}
#[test]
fn original_binary_path_errors_when_ios_simulator_dylib_missing() {
let cfg = mk_config(PathBuf::from("/nonexistent/ws"), Target::IosSimulator);
let res = original_binary_path(&cfg);
assert!(res.is_err());
}
#[test]
fn original_binary_path_finds_android_so_under_gradle_output() {
use std::sync::atomic::{AtomicU64, Ordering};
static SEQ: AtomicU64 = AtomicU64::new(0);
let n = SEQ.fetch_add(1, Ordering::Relaxed);
let pid = std::process::id();
let ws = std::env::temp_dir().join(format!("whisker-dev-test-orig-{pid}-{n}"));
let _ = std::fs::remove_dir_all(&ws);
let gradle_out_dir = ws
.join("gen/android/app/build/generated/jniLibs")
.join("whiskerBuildDebugArm64V8a")
.join("arm64-v8a");
std::fs::create_dir_all(&gradle_out_dir).unwrap();
let so = gradle_out_dir.join("libhello_world.so");
std::fs::write(&so, b"fake").unwrap();
let cfg = mk_config(ws.clone(), Target::Android);
let resolved = original_binary_path(&cfg).unwrap();
assert_eq!(resolved, so);
let _ = std::fs::remove_dir_all(&ws);
}
#[test]
fn android_abi_to_camel_matches_gradle_plugin_naming() {
assert_eq!(android_abi_to_camel("arm64-v8a"), "Arm64V8a");
assert_eq!(android_abi_to_camel("armeabi-v7a"), "ArmeabiV7a");
assert_eq!(android_abi_to_camel("x86_64"), "X8664");
assert_eq!(android_abi_to_camel("x86"), "X86");
}
#[test]
fn original_binary_path_errors_when_android_so_missing() {
let cfg = mk_config(PathBuf::from("/nonexistent/ws"), Target::Android);
let res = original_binary_path(&cfg);
assert!(res.is_err());
}
#[test]
fn target_os_for_maps_android_to_linux() {
assert_eq!(target_os_for(Target::Android), hotpatch::LinkerOs::Linux);
}
#[test]
fn target_os_for_maps_ios_to_macos() {
assert_eq!(
target_os_for(Target::IosSimulator),
hotpatch::LinkerOs::Macos,
);
}
#[test]
fn rust_code_with_patcher_chooses_tier1_patch() {
assert_eq!(
decide_action(ChangeKind::RustCode, true),
LoopAction::Tier1Patch,
);
}
#[test]
fn rust_code_without_patcher_falls_through_to_tier2_rebuild() {
assert_eq!(
decide_action(ChangeKind::RustCode, false),
LoopAction::Tier2Rebuild,
);
}
#[test]
fn cargo_toml_always_chooses_tier2_rebuild_even_with_patcher() {
assert_eq!(
decide_action(ChangeKind::CargoToml, true),
LoopAction::Tier2Rebuild,
);
assert_eq!(
decide_action(ChangeKind::CargoToml, false),
LoopAction::Tier2Rebuild,
);
}
#[test]
fn other_changes_are_ignored() {
assert_eq!(decide_action(ChangeKind::Other, true), LoopAction::Ignore);
assert_eq!(decide_action(ChangeKind::Other, false), LoopAction::Ignore);
}
#[test]
fn log_patch_diff_handles_empty_report_silently() {
let r = hotpatch::DiffReport {
added: vec![],
removed: vec![],
weak: vec![],
};
log_patch_diff(&r); }
#[test]
fn log_patch_diff_summarises_added_and_removed() {
let r = hotpatch::DiffReport {
added: vec!["new1".into(), "new2".into()],
removed: vec!["old1".into()],
weak: vec![],
};
log_patch_diff(&r); }
}