swiftui-native 0.0.1

SwiftUI from pure Rust — no Swift bridge, no shim, only swift-runtime calls
Documentation
//! Building an iOS bundle and putting it on a simulator or a device.
//!
//! Shared by the examples that run on iOS, because none of it is about the
//! interface: it writes an `Info.plist`, lays out a `.app`, signs it when a
//! device is the target, and hands the result to `simctl` or `devicectl`.
//!
//! Not an example itself — a file in `examples/` is only built as one when it
//! is `examples/<name>.rs` or `examples/<name>/main.rs`.
#![allow(dead_code)]

/// What is being built and where it goes.
pub struct Bundle {
    /// The cargo example to build, e.g. `"ios_app"`.
    pub example: &'static str,
    /// `CFBundleIdentifier`, and what `simctl` launches.
    pub bundle_id: &'static str,
    /// The executable's name inside the bundle.
    pub executable: &'static str,
    /// `CFBundleDisplayName`.
    pub display_name: &'static str,
    /// Arguments handed to the application on launch.
    pub launch_args: &'static [&'static str],
}

/// What the driver was asked to do.
pub enum Mode {
    /// Build, boot a simulator if needed, install, launch, photograph.
    Simulator,
    /// Build for hardware, sign, package, and install if a device is attached.
    Device,
    /// As `Device`, but stop once the `.ipa` exists.
    Ipa,
}

pub fn mode() -> Mode {
    if std::env::args().any(|a| a == "device") {
        Mode::Device
    } else if std::env::args().any(|a| a == "ipa") {
        Mode::Ipa
    } else {
        Mode::Simulator
    }
}

/// `cargo build` for one of the iOS targets, so the caller needs no prior step.
fn build_for(b: &Bundle, triple: &str) -> std::path::PathBuf {
    use std::process::Command;
    eprintln!("building for {triple}...");
    let ok = Command::new("cargo")
        .args(["build", "-p", "swiftui-native", "--example", b.example, "--target", triple])
        .status()
        .map(|s| s.success())
        .unwrap_or(false);
    assert!(ok, "cargo build for {triple} failed");
    std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
        .join("../../target")
        .join(triple)
        .join(format!("debug/examples/{}", b.example))
}

/// Lay out `RustUI.app`: the executable, and the `Info.plist` describing it.
fn write_bundle(b: &Bundle, dir: &std::path::Path, binary: &std::path::Path, for_device: bool) {
    let _ = std::fs::remove_dir_all(dir);
    std::fs::create_dir_all(dir).expect("bundle directory");
    std::fs::copy(binary, dir.join(b.executable)).expect("copying the executable");
    let platform = if for_device { "iPhoneOS" } else { "iPhoneSimulator" };
    std::fs::write(dir.join("Info.plist"), info_plist(b, platform)).expect("writing Info.plist");
}

/// Everything iOS insists on before it will launch a bundle.
///
/// `CFBundleSupportedPlatforms` has to match what the binary was built for —
/// a simulator build declaring `iPhoneOS` is rejected at install time, and the
/// reverse is rejected too.
fn info_plist(b: &Bundle, platform: &str) -> String {
    format!(
        r#"<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"><dict>
  <key>CFBundleExecutable</key><string>{exe}</string>
  <key>CFBundleIdentifier</key><string>{id}</string>
  <key>CFBundleName</key><string>{name}</string>
  <key>CFBundleDisplayName</key><string>{name}</string>
  <key>CFBundlePackageType</key><string>APPL</string>
  <key>CFBundleShortVersionString</key><string>1.0</string>
  <key>CFBundleVersion</key><string>1</string>
  <key>MinimumOSVersion</key><string>17.0</string>
  <key>UIDeviceFamily</key><array><integer>1</integer><integer>2</integer></array>
  <key>CFBundleSupportedPlatforms</key><array><string>{platform}</string></array>
  <key>UILaunchScreen</key><dict/>
  <key>UIRequiredDeviceCapabilities</key><array><string>arm64</string></array>
</dict></plist>
"#,
        exe = b.executable,
        id = b.bundle_id,
        name = b.display_name,
    )
}

pub fn run_on_simulator(b: &Bundle, target_dir: &std::path::Path) {
    use std::process::Command;
    let binary = build_for(b, "aarch64-apple-ios-sim");
    let bundle = target_dir.join(format!("ios-app/{}.app", b.executable));
    write_bundle(b, &bundle, &binary, false);

    // Boot one if the caller has not, rather than telling them to.
    let device = booted_device().unwrap_or_else(|| {
        let name = first_available_iphone().expect("no iPhone simulator is available");
        eprintln!("booting {name}...");
        let _ = Command::new("xcrun").args(["simctl", "boot", &name]).status();
        std::thread::sleep(std::time::Duration::from_secs(8));
        booted_device().expect("simulator failed to boot")
    });
    // Without this the device is booted headlessly and there is nothing to look
    // at — which is the whole point of running it here.
    let _ = Command::new("open").args(["-a", "Simulator"]).status();
    eprintln!("simulator: {device}");

    let simctl = |args: &[&str]| {
        Command::new("xcrun").arg("simctl").args(args).status().map(|s| s.success())
    };
    let _ = simctl(&["terminate", &device, b.bundle_id]);
    let _ = simctl(&["uninstall", &device, b.bundle_id]);
    assert!(
        simctl(&["install", &device, &bundle.to_string_lossy()]).unwrap_or(false),
        "installing the bundle"
    );
    let mut launch = vec!["launch", &device, b.bundle_id];
    launch.extend_from_slice(b.launch_args);
    assert!(simctl(&launch).unwrap_or(false), "launching the application");

    std::thread::sleep(std::time::Duration::from_secs(6));
    let shot = target_dir.join(format!("ios-app/{}.png", b.executable));
    let ok = simctl(&["io", &device, "screenshot", &shot.to_string_lossy()]).unwrap_or(false);
    eprintln!("screenshot: {} ({})", shot.display(), if ok { "written" } else { "failed" });
}

/// Build for hardware, sign, and wrap in an `.ipa`.
///
/// An `.ipa` is a zip with the bundle under `Payload/`. The signature is what
/// makes it installable: an unsigned bundle is refused by the device, so this
/// signs with the first available identity unless `RSWIFT_CODESIGN_IDENTITY`
/// names one.
pub fn run_on_device(b: &Bundle, target_dir: &std::path::Path, install: bool) {
    use std::process::Command;
    let binary = build_for(b, "aarch64-apple-ios");
    let stage = target_dir.join("ios-app/device/Payload");
    let bundle = stage.join(format!("{}.app", b.executable));
    write_bundle(b, &bundle, &binary, true);

    // A provisioning profile, if one was supplied, goes inside the bundle.
    if let Ok(profile) = std::env::var("RSWIFT_PROVISIONING_PROFILE") {
        std::fs::copy(&profile, bundle.join("embedded.mobileprovision"))
            .expect("copying the provisioning profile");
        eprintln!("profile: {profile}");
    }

    match codesign_identity() {
        Some(id) => {
            eprintln!("signing as {id}");
            let mut cmd = Command::new("codesign");
            cmd.args(["--force", "--sign", &id]);
            if let Ok(ents) = std::env::var("RSWIFT_ENTITLEMENTS") {
                cmd.args(["--entitlements", &ents]);
            }
            let ok = cmd.arg(&bundle).status().map(|s| s.success()).unwrap_or(false);
            assert!(ok, "codesign failed");
        }
        None => eprintln!(
            "no code-signing identity found — the .ipa will be unsigned and a device will \
             refuse it. Set RSWIFT_CODESIGN_IDENTITY to choose one."
        ),
    }

    let ipa = target_dir.join(format!("ios-app/{}.ipa", b.executable));
    let _ = std::fs::remove_file(&ipa);
    let ok = Command::new("zip")
        .args(["-qr", &ipa.to_string_lossy(), "Payload"])
        .current_dir(stage.parent().expect("device dir"))
        .status()
        .map(|s| s.success())
        .unwrap_or(false);
    assert!(ok, "packaging the .ipa");
    eprintln!("ipa: {}", ipa.display());

    if !install {
        return;
    }
    match connected_device() {
        Some(id) => {
            eprintln!("installing on {id}");
            let ok = Command::new("xcrun")
                .args(["devicectl", "device", "install", "app", "--device", &id])
                .arg(&bundle)
                .status()
                .map(|s| s.success())
                .unwrap_or(false);
            if ok {
                let _ = Command::new("xcrun")
                    .args(["devicectl", "device", "process", "launch", "--device", &id, b.bundle_id])
                    .status();
            } else {
                eprintln!("install failed — check the profile covers {}", b.bundle_id);
            }
        }
        None => eprintln!(
            "no device connected. Pair one and re-run, or install the .ipa with \
             `xcrun devicectl device install app --device <id> {}`",
            bundle.display()
        ),
    }
}

/// The identity to sign with: `RSWIFT_CODESIGN_IDENTITY`, else the first valid one.
fn codesign_identity() -> Option<String> {
    if let Ok(id) = std::env::var("RSWIFT_CODESIGN_IDENTITY") {
        return Some(id);
    }
    let out = std::process::Command::new("security")
        .args(["find-identity", "-v", "-p", "codesigning"])
        .output()
        .ok()?;
    let text = String::from_utf8_lossy(&out.stdout);
    text.lines()
        .find(|l| l.contains('"') && !l.contains("CSSMERR"))
        .and_then(|l| {
            let start = l.find('"')? + 1;
            let end = l[start..].find('"')? + start;
            Some(l[start..end].to_string())
        })
}

/// The first device `devicectl` reports as connected.
fn connected_device() -> Option<String> {
    let out = std::process::Command::new("xcrun")
        .args(["devicectl", "list", "devices"])
        .output()
        .ok()?;
    let text = String::from_utf8_lossy(&out.stdout);
    text.lines()
        .find(|l| l.contains("connected") || l.contains("available"))
        .and_then(|l| l.split_whitespace().find(|w| w.len() == 36 && w.contains('-')))
        .map(str::to_string)
}

/// Any available iPhone simulator, for when none is booted.
fn first_available_iphone() -> Option<String> {
    let out = std::process::Command::new("xcrun")
        .args(["simctl", "list", "devices", "available"])
        .output()
        .ok()?;
    let text = String::from_utf8_lossy(&out.stdout);
    let line = text.lines().find(|l| l.trim_start().starts_with("iPhone"))?;
    Some(line.trim().split(" (").next()?.to_string())
}


fn booted_device() -> Option<String> {
    let out = std::process::Command::new("xcrun")
        .args(["simctl", "list", "devices", "booted"])
        .output()
        .ok()?;
    let text = String::from_utf8_lossy(&out.stdout);
    let line = text.lines().find(|l| l.contains("(Booted)"))?;
    let start = line.find('(')? + 1;
    let end = line[start..].find(')')? + start;
    Some(line[start..end].to_string())
}


/// Build and run, choosing what to do from the command line.
pub fn main(b: &Bundle) {
    let target_dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../target");
    match mode() {
        Mode::Simulator => run_on_simulator(b, &target_dir),
        Mode::Device => run_on_device(b, &target_dir, true),
        Mode::Ipa => run_on_device(b, &target_dir, false),
    }
}