use std::fs;
use std::path::Path;
use std::process::Command;
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum Target {
MacOs,
Windows,
Linux,
Web,
Ios,
Android,
}
pub struct RunOptions {
pub target: Target,
pub port: u16,
pub device: String,
}
impl RunOptions {
pub fn from_args(args: &[String]) -> Result<Self, String> {
if args.iter().any(|a| a == "--help" || a == "-h") {
print_help();
std::process::exit(0);
}
let mut target = None;
let mut port = 8080u16;
let mut device = String::new();
let mut i = 0;
while i < args.len() {
match args[i].as_str() {
"--target" | "-t" => {
i += 1;
target = Some(parse_target(args.get(i).map(String::as_str))?);
}
"--mac" => target = Some(Target::MacOs),
"--win" => target = Some(Target::Windows),
"--lnx" => target = Some(Target::Linux),
"--port" => {
i += 1;
port = args.get(i).and_then(|s| s.parse().ok())
.ok_or_else(|| "--port requires a number".to_string())?;
}
"--device" => {
i += 1;
device = args.get(i).cloned()
.ok_or_else(|| "--device requires a value".to_string())?;
}
other if other.starts_with("--target=") => {
target = Some(parse_target(Some(other.trim_start_matches("--target=")))?);
}
other if other.starts_with("--port=") => {
port = other.trim_start_matches("--port=").parse()
.map_err(|_| "invalid --port".to_string())?;
}
_ => {}
}
i += 1;
}
let target = target.unwrap_or_else(host_target);
Ok(Self { target, port, device })
}
}
fn host_target() -> Target {
if cfg!(target_os = "macos") { Target::MacOs }
else if cfg!(target_os = "windows") { Target::Windows }
else { Target::Linux }
}
fn parse_target(s: Option<&str>) -> Result<Target, String> {
match s {
Some("macos") => Ok(Target::MacOs),
Some("windows") => Ok(Target::Windows),
Some("linux") => Ok(Target::Linux),
Some("web") => Ok(Target::Web),
Some("ios") => Ok(Target::Ios),
Some("android") => Ok(Target::Android),
Some(other) => Err(format!("unknown target '{}'. Use: macos, windows, linux, web, ios, android", other)),
None => Err("--target requires a value (macos, windows, linux, web, ios, android)".to_string()),
}
}
pub fn print_help() {
println!("rsc run — build + run the app on a platform");
println!();
println!("USAGE:");
println!(" rsc run [OPTIONS]");
println!();
println!("OPTIONS:");
println!(" --target <t> macos | windows | linux | web | ios | android (default: host OS)");
println!(" --mac / --win / --lnx shorthand for --target macos|windows|linux");
println!(" --port <n> Web dev server port (default: 8080)");
println!(" --device <id> iOS simulator (name or UDID, default: \"iPhone 15 Pro\") or");
println!(" Android device/emulator (adb serial) — run `rsc devices` to list");
println!(" -h, --help Print this message");
println!();
println!("Before building, a preflight check confirms the tools each target needs");
println!("are actually installed (codesign for macOS; a rustup cross target for");
println!("Windows/Linux) and fails fast with install instructions if not — cross-");
println!("building Windows/Linux from macOS only produces a binary, it can't run it.");
println!();
println!("EXAMPLES:");
println!(" rsc run");
println!(" rsc run --mac");
println!(" rsc run --target web --port 3000");
println!(" rsc run --target ios --device \"iPhone 15\"");
}
pub fn run(opts: RunOptions) -> Result<(), String> {
preflight(opts.target)?;
let app = App::read()?;
match opts.target {
Target::MacOs => run_macos(&app),
Target::Windows => run_windows_cross_build(&app),
Target::Linux => run_linux_cross_build(&app),
Target::Web => run_web(&app, opts.port),
Target::Ios => run_ios(&app, &opts.device),
Target::Android => run_android(&app, &opts.device),
}
}
fn preflight(target: Target) -> Result<(), String> {
match target {
Target::MacOs => preflight_macos(),
Target::Windows => preflight_cross_target("x86_64-pc-windows-gnu", "Windows", Some("mingw-w64")),
Target::Linux => preflight_cross_target("x86_64-unknown-linux-gnu", "Linux", None),
Target::Web => Ok(()), Target::Ios => preflight_ios(),
Target::Android => preflight_android(),
}
}
fn preflight_ios() -> Result<(), String> {
if !Path::new("ios/App.xcodeproj").exists() {
return Ok(()); }
let ok = Command::new("xcodebuild")
.arg("-version")
.output()
.map(|o| o.status.success())
.unwrap_or(false);
if ok {
Ok(())
} else {
Err(
"xcodebuild not found or not runnable. Install Xcode from the App Store, then run:\n \
sudo xcode-select --switch /Applications/Xcode.app/Contents/Developer"
.to_string(),
)
}
}
fn preflight_android() -> Result<(), String> {
if !Path::new("android/gradlew").exists() {
return Err(
"android/gradlew not found. Either this project predates Android support \
(recreate with `rsc new --platforms android`), or `gradle wrapper` failed \
when it was created — run `gradle wrapper` inside android/ yourself \
(requires Gradle installed: https://gradle.org/install)."
.to_string(),
);
}
let adb_ok = Command::new("adb").arg("version").output().is_ok();
if !adb_ok {
println!(" Warning: adb not found — will build the APK but can't install/launch it.");
println!(" Install Android platform-tools (via Android Studio or `brew install android-platform-tools`).");
}
Ok(())
}
fn preflight_macos() -> Result<(), String> {
let ok = Command::new("xcrun")
.args(["-f", "codesign"])
.output()
.map(|o| o.status.success())
.unwrap_or(false);
if ok {
Ok(())
} else {
Err("codesign not found. Install Xcode Command Line Tools: xcode-select --install".to_string())
}
}
fn preflight_cross_target(triple: &str, label: &str, linker_hint: Option<&str>) -> Result<(), String> {
let output = Command::new("rustup")
.args(["target", "list", "--installed"])
.output()
.map_err(|e| format!("failed to run rustup: {}", e))?;
let installed = String::from_utf8_lossy(&output.stdout);
if !installed.contains(triple) {
let mut msg = format!(
"{} target not installed. Run:\n rustup target add {}\n",
label, triple
);
if let Some(hint) = linker_hint {
msg.push_str(&format!(
" You'll also need a cross-linker. On macOS:\n brew install {}\n",
hint
));
}
msg.push_str(&format!(
" Note: this only lets you BUILD a {} binary from this host, not run it.",
label
));
return Err(msg);
}
Ok(())
}
struct App {
name: String,
crate_name: String,
bundle_id: String,
}
impl App {
fn read() -> Result<Self, String> {
if !Path::new("Cargo.toml").exists() {
return Err("no Cargo.toml here — run `rsc run` from an app directory".to_string());
}
let mut name = None;
let mut bundle = None;
if let Ok(s) = fs::read_to_string("rsc.toml") {
for line in s.lines() {
if let Some((k, v)) = line.split_once('=') {
let val = v.trim().trim_matches('"').to_string();
match k.trim() {
"name" => name = Some(val),
"bundle_id" => bundle = Some(val),
_ => {}
}
}
}
}
let name = name.or_else(cargo_pkg_name).ok_or_else(|| {
"could not determine app name (no rsc.toml name / Cargo.toml package)".to_string()
})?;
let crate_name = name.replace('-', "_");
let bundle_id = bundle.unwrap_or_else(|| format!("dev.rosace.{}", crate_name));
Ok(Self { name, crate_name, bundle_id })
}
}
fn cargo_pkg_name() -> Option<String> {
let s = fs::read_to_string("Cargo.toml").ok()?;
let mut in_pkg = false;
for line in s.lines() {
let t = line.trim();
if t == "[package]" { in_pkg = true; continue; }
if in_pkg && t.starts_with('[') { break; }
if in_pkg {
if let Some((k, v)) = t.split_once('=') {
if k.trim() == "name" {
return Some(v.trim().trim_matches('"').to_string());
}
}
}
}
None
}
fn run_macos(app: &App) -> Result<(), String> {
if !cfg!(target_os = "macos") {
return Err(
"rsc run --mac requires running rsc on macOS itself — cross-running \
(build on one OS, execute on another) isn't supported."
.to_string(),
);
}
println!("Running '{}' on macOS...", app.name);
let status = Command::new("cargo")
.args(["build", "--bin", &app.crate_name])
.status()
.map_err(|e| format!("failed to invoke cargo: {}", e))?;
if !status.success() {
return Err("cargo build failed".to_string());
}
if Path::new("macos/Info.plist").exists() {
let bin_src = format!("target/debug/{}", app.crate_name);
if let Some(result) = assemble_and_launch_mac_bundle(app, &bin_src) {
return result;
}
}
let status = Command::new("cargo")
.args(["run", "--bin", &app.crate_name])
.status()
.map_err(|e| format!("failed to invoke cargo: {}", e))?;
if status.success() { Ok(()) } else { Err("app exited with an error".to_string()) }
}
#[cfg(target_os = "macos")]
fn assemble_and_launch_mac_bundle(app: &App, bin_src: &str) -> Option<Result<(), String>> {
match crate::commands::package::assemble_macos_app(
&app.name, &app.crate_name, "target/rsc-run", Path::new(bin_src), None,
) {
Ok(app_dir) => {
let exe = format!("{}/Contents/MacOS/{}", app_dir, app.crate_name);
Some(match Command::new(&exe).status() {
Ok(status) if status.success() => Ok(()),
Ok(_) => Err("app exited with an error".to_string()),
Err(e) => Err(format!("failed to launch {}: {}", exe, e)),
})
}
Err(e) => {
println!(" Note: couldn't assemble a .app bundle ({e}) — running the bare binary instead");
None
}
}
}
#[cfg(not(target_os = "macos"))]
fn assemble_and_launch_mac_bundle(_app: &App, _bin_src: &str) -> Option<Result<(), String>> {
None
}
fn run_windows_cross_build(app: &App) -> Result<(), String> {
const TRIPLE: &str = "x86_64-pc-windows-gnu";
println!("Building '{}' for Windows ({})...", app.name, TRIPLE);
let ok = Command::new("cargo")
.args(["build", "--bin", &app.name, "--target", TRIPLE])
.status()
.map_err(|e| format!("cargo: {}", e))?
.success();
if !ok {
return Err(format!("Windows cross-build failed (target/{}/debug/{}.exe)", TRIPLE, app.crate_name));
}
println!(" Built target/{}/debug/{}.exe", TRIPLE, app.crate_name);
if !cfg!(target_os = "windows") {
println!(" This host can't run a Windows binary — copy it to a Windows machine to launch it.");
}
Ok(())
}
fn run_linux_cross_build(app: &App) -> Result<(), String> {
const TRIPLE: &str = "x86_64-unknown-linux-gnu";
println!("Building '{}' for Linux ({})...", app.name, TRIPLE);
let ok = Command::new("cargo")
.args(["build", "--bin", &app.name, "--target", TRIPLE])
.status()
.map_err(|e| format!("cargo: {}", e))?
.success();
if !ok {
return Err(format!("Linux cross-build failed (target/{}/debug/{})", TRIPLE, app.crate_name));
}
println!(" Built target/{}/debug/{}", TRIPLE, app.crate_name);
if !cfg!(target_os = "linux") {
println!(" This host can't run a Linux binary — copy it to a Linux machine to launch it.");
}
Ok(())
}
fn run_web(app: &App, port: u16) -> Result<(), String> {
println!("Building '{}' for web (wasm)...", app.name);
let ok = Command::new("cargo")
.args(["build", "--lib", "--target", "wasm32-unknown-unknown"])
.status()
.map_err(|e| format!("cargo: {}", e))?
.success();
if !ok {
return Err("wasm build failed (run: rustup target add wasm32-unknown-unknown)".into());
}
let wasm = format!("target/wasm32-unknown-unknown/debug/{}.wasm", app.crate_name);
if !Path::new(&wasm).exists() {
return Err(format!("expected wasm artifact not found: {}", wasm));
}
fs::create_dir_all("dist").map_err(|e| format!("cannot create dist/: {}", e))?;
let bindgen = wasm_bindgen_bin()?;
println!(" Generating JS glue (wasm-bindgen)...");
let ok = Command::new(&bindgen)
.args([&wasm, "--out-dir", "dist", "--target", "web", "--out-name", &app.crate_name])
.status()
.map_err(|e| format!("wasm-bindgen: {}", e))?
.success();
if !ok {
return Err("wasm-bindgen failed".into());
}
crate::commands::package::copy_assets_into(
Path::new("assets"),
std::path::PathBuf::from("dist").join("assets"),
)?;
let index_src = Path::new("web/index.html");
if index_src.exists() {
fs::copy(index_src, "dist/index.html").map_err(|e| format!("copy index.html: {}", e))?;
} else {
fs::write("dist/index.html", default_index_html(&app.crate_name))
.map_err(|e| format!("write index.html: {}", e))?;
}
println!(" Open http://localhost:{}/", port);
crate::commands::dev::serve_dist(port)
}
fn wasm_bindgen_bin() -> Result<String, String> {
if Command::new("wasm-bindgen").arg("--version").output().map(|o| o.status.success()).unwrap_or(false) {
return Ok("wasm-bindgen".to_string());
}
if let Ok(home) = std::env::var("HOME") {
let p = format!("{}/.cargo/bin/wasm-bindgen", home);
if Path::new(&p).exists() {
return Ok(p);
}
}
Err("wasm-bindgen not found. Install it: cargo install wasm-bindgen-cli".into())
}
fn default_index_html(crate_name: &str) -> String {
format!(
"<!doctype html><html><head><meta charset=\"utf-8\">\
<style>html,body{{margin:0;background:#14141a}}</style></head><body>\
<script type=\"module\">import init from './{crate_name}.js'; init();</script>\
</body></html>\n"
)
}
fn run_ios(app: &App, device: &str) -> Result<(), String> {
let device = if device.is_empty() { "iPhone 15 Pro" } else { device };
if Path::new("ios/App.xcodeproj").exists() {
run_ios_xcodeproj(app, device)
} else {
run_ios_legacy(app, device)
}
}
fn run_ios_xcodeproj(app: &App, device: &str) -> Result<(), String> {
println!("Building '{}' for the iOS simulator (xcodebuild)...", app.name);
let udid = resolve_simulator_udid(device)?;
let derived_data = "target/ios-build";
let ok = Command::new("xcodebuild")
.args([
"-project", "ios/App.xcodeproj",
"-scheme", "App",
"-destination", &format!("id={}", udid),
"-derivedDataPath", derived_data,
"build",
])
.status()
.map_err(|e| format!("xcodebuild: {}", e))?
.success();
if !ok {
return Err(
"xcodebuild failed. Common cause: Xcode Command Line Tools not selected \
(xcode-select --install).".to_string(),
);
}
let bundle = format!("{}/Build/Products/Debug-iphonesimulator/App.app", derived_data);
if !Path::new(&bundle).exists() {
return Err(format!("xcodebuild reported success but {} wasn't produced — unexpected", bundle));
}
crate::commands::package::copy_assets_into(Path::new("assets"), Path::new(&bundle).join("assets"))?;
let _ = Command::new("xcrun").args(["simctl", "boot", &udid]).status();
let _ = Command::new("open").args(["-a", "Simulator"]).status();
println!(" Installing on '{}'...", device);
run_checked("xcrun", &["simctl", "install", &udid, &bundle], "simctl install")?;
println!(" Launching {}...", app.bundle_id);
run_checked("xcrun", &["simctl", "launch", "--console", &udid, &app.bundle_id], "simctl launch")
}
fn resolve_simulator_udid(device: &str) -> Result<String, String> {
let device = if device.is_empty() { "iPhone 15 Pro" } else { device };
if crate::commands::devices::find_uuid(device).map(|(u, s)| s == 0 && u.len() == device.len()).unwrap_or(false) {
return Ok(device.to_string());
}
crate::commands::devices::list_devices()
.into_iter()
.find(|d| d.platform == "ios" && d.name == device)
.map(|d| d.id)
.ok_or_else(|| format!(
"no simulator named '{}' found. Run `rsc devices` to see real names/ids \
(pass either via --device).",
device
))
}
fn run_ios_legacy(app: &App, device: &str) -> Result<(), String> {
println!("Building '{}' for the iOS simulator (legacy harness — no ios/App.xcodeproj found)...", app.name);
let mut build = Command::new("cargo");
build.args(["build", "--bin", &app.name, "--target", "aarch64-apple-ios-sim"]);
if std::env::var("RSC_HOT").as_deref() == Ok("1") {
build.args(["--features", "rosace/rsc-hot"]);
println!(" (hot reload: building with rosace/rsc-hot)");
}
let ok = build.status().map_err(|e| format!("cargo: {}", e))?.success();
if !ok {
return Err("iOS build failed (run: rustup target add aarch64-apple-ios-sim)".into());
}
let bin = format!("target/aarch64-apple-ios-sim/debug/{}", app.name);
let bundle = format!("target/{}.app", app.name);
let _ = fs::remove_dir_all(&bundle);
fs::create_dir_all(&bundle).map_err(|e| format!("mkdir bundle: {}", e))?;
fs::copy(&bin, format!("{}/{}", bundle, app.crate_name))
.map_err(|e| format!("copy executable: {}", e))?;
let plist_src = Path::new("ios/Info.plist");
if !plist_src.exists() {
return Err("ios/Info.plist not found — scaffold with `rsc new --platforms ios`".into());
}
fs::copy(plist_src, format!("{}/Info.plist", bundle))
.map_err(|e| format!("copy Info.plist: {}", e))?;
run_checked("codesign", &["--force", "--sign", "-", &bundle], "codesign")?;
let _ = Command::new("xcrun").args(["simctl", "boot", device]).status();
let _ = Command::new("open").args(["-a", "Simulator"]).status();
println!(" Installing on '{}'...", device);
run_checked("xcrun", &["simctl", "install", "booted", &bundle], "simctl install")?;
println!(" Launching {}...", app.bundle_id);
run_checked("xcrun", &["simctl", "launch", "--console", "booted", &app.bundle_id], "simctl launch")
}
fn run_android(app: &App, device: &str) -> Result<(), String> {
if !Path::new("android").exists() {
return Err("android/ not found — scaffold with `rsc new --platforms android`".into());
}
ensure_android_local_properties()?;
crate::commands::package::copy_assets_into(
Path::new("assets"),
Path::new("android/app/src/main/assets").to_path_buf(),
)?;
println!("Building '{}' for Android (Gradle assembleDebug)...", app.name);
let ok = Command::new("./gradlew")
.args(["assembleDebug"])
.current_dir("android")
.status()
.map_err(|e| format!("gradlew: {}", e))?
.success();
if !ok {
return Err("Gradle build failed".into());
}
let apk = "android/app/build/outputs/apk/debug/app-debug.apk".to_string();
println!(" Built {}", apk);
let connected = crate::commands::devices::list_devices()
.into_iter()
.filter(|d| d.platform == "android" && d.status == "device")
.count();
if connected == 0 {
println!(" No device/emulator connected (adb devices) — built the APK, not installed.");
println!(" Install manually: adb install {}", apk);
return Ok(());
}
if !device.is_empty() && connected > 1 {
let known = crate::commands::devices::list_devices()
.into_iter()
.any(|d| d.platform == "android" && d.id == device);
if !known {
return Err(format!("no Android device with id '{}' connected. Run `rsc devices` to see real ids.", device));
}
}
let adb_target: Vec<&str> = if device.is_empty() { vec![] } else { vec!["-s", device] };
println!(" Installing on device...");
let mut install_args = adb_target.clone();
install_args.extend(["install", "-r", &apk]);
run_checked("adb", &install_args, "adb install")?;
let activity = format!("{}/.MainActivity", app.bundle_id);
println!(" Launching {}...", app.bundle_id);
let mut launch_args = adb_target;
launch_args.extend(["shell", "am", "start", "-n", &activity]);
run_checked("adb", &launch_args, "adb shell am start")
}
fn ensure_android_local_properties() -> Result<(), String> {
let lp = Path::new("android/local.properties");
if lp.exists() {
return Ok(());
}
let sdk = std::env::var("ANDROID_HOME")
.ok()
.filter(|s| !s.is_empty())
.or_else(|| std::env::var("ANDROID_SDK_ROOT").ok().filter(|s| !s.is_empty()))
.or_else(|| {
let home = std::env::var("HOME").ok()?;
[
format!("{home}/Library/Android/sdk"), format!("{home}/Android/Sdk"), ]
.into_iter()
.find(|cand| Path::new(cand).exists())
})
.ok_or_else(|| {
"Android SDK not found — set ANDROID_HOME (or ANDROID_SDK_ROOT) to your SDK path, \
or create android/local.properties with `sdk.dir=/path/to/sdk`."
.to_string()
})?;
std::fs::write(lp, format!("sdk.dir={sdk}\n"))
.map_err(|e| format!("writing android/local.properties: {e}"))?;
println!(" Wrote android/local.properties (sdk.dir={sdk})");
Ok(())
}
fn run_checked(cmd: &str, args: &[&str], what: &str) -> Result<(), String> {
let ok = Command::new(cmd)
.args(args)
.status()
.map_err(|e| format!("{}: {}", what, e))?
.success();
if ok { Ok(()) } else { Err(format!("{} failed", what)) }
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_target_accepts_explicit_os_names() {
assert_eq!(parse_target(Some("macos")).unwrap(), Target::MacOs);
assert_eq!(parse_target(Some("windows")).unwrap(), Target::Windows);
assert_eq!(parse_target(Some("linux")).unwrap(), Target::Linux);
assert_eq!(parse_target(Some("web")).unwrap(), Target::Web);
assert_eq!(parse_target(Some("ios")).unwrap(), Target::Ios);
assert_eq!(parse_target(Some("android")).unwrap(), Target::Android);
}
#[test]
fn parse_target_rejects_old_desktop_keyword() {
let err = parse_target(Some("desktop")).unwrap_err();
assert!(err.contains("macos"), "error should list the real options: {err}");
}
#[test]
fn mac_win_lnx_flags_set_the_right_target() {
let opts = RunOptions::from_args(&["--mac".to_string()]).unwrap();
assert_eq!(opts.target, Target::MacOs);
let opts = RunOptions::from_args(&["--win".to_string()]).unwrap();
assert_eq!(opts.target, Target::Windows);
let opts = RunOptions::from_args(&["--lnx".to_string()]).unwrap();
assert_eq!(opts.target, Target::Linux);
}
#[test]
fn no_target_flag_defaults_to_host_os() {
let opts = RunOptions::from_args(&[]).unwrap();
assert_eq!(opts.target, host_target());
}
#[test]
fn target_flag_still_works() {
let opts = RunOptions::from_args(&["--target".to_string(), "web".to_string()]).unwrap();
assert_eq!(opts.target, Target::Web);
let opts = RunOptions::from_args(&["--target=ios".to_string()]).unwrap();
assert_eq!(opts.target, Target::Ios);
}
#[test]
fn resolve_simulator_udid_rejects_prefix_collision() {
let err = resolve_simulator_udid("definitely not a real simulator name").unwrap_err();
assert!(err.contains("no simulator named"), "{err}");
}
#[test]
fn resolve_simulator_udid_trusts_an_already_uuid_shaped_device() {
let udid = resolve_simulator_udid("DA884712-56EF-4605-A4FD-C00865FCC084").unwrap();
assert_eq!(udid, "DA884712-56EF-4605-A4FD-C00865FCC084");
}
#[test]
fn preflight_cross_target_reports_missing_target_actionably() {
let err = preflight_cross_target("bogus-target-triple", "Bogus", Some("bogus-linker")).unwrap_err();
assert!(err.contains("rustup target add bogus-target-triple"), "{err}");
assert!(err.contains("bogus-linker"), "{err}");
assert!(err.contains("BUILD"), "should clarify build-only: {err}");
}
}