use std::path::PathBuf;
use std::process::Command;
use anyhow::{Context, Result, bail};
#[derive(clap::Args)]
pub struct InstallArgs {
#[arg(long)]
pub no_daemon: bool,
#[arg(long)]
pub no_shell: bool,
#[arg(long)]
pub helper_path: Option<PathBuf>,
}
pub async fn execute(args: InstallArgs) -> Result<()> {
println!("ArcBox Install");
println!("==============");
println!();
print_step(1, 3, "Installing arcbox-helper...");
install_helper(args.helper_path.as_deref())?;
print_done();
print_step(2, 3, "Registering daemon service...");
if args.no_daemon {
print_skipped();
} else {
register_daemon_service()?;
print_done();
}
print_step(3, 3, "Setting up shell integration...");
if args.no_shell {
print_skipped();
} else if std::env::var("SUDO_USER").is_ok() {
println!("skipped (sudo)");
println!();
println!(" Run as your normal user: abctl setup install");
} else {
super::setup::execute(
super::setup::SetupCommands::Install,
super::OutputFormat::Quiet,
)
.await?;
print_done();
}
println!();
println!("ArcBox installed. The daemon will start automatically.");
println!("DNS, Docker socket, and boot assets are configured on first daemon start.");
Ok(())
}
fn print_step(n: u32, total: u32, msg: &str) {
print!("[{n}/{total}] {msg:<40}");
}
fn print_done() {
println!("done");
}
fn print_skipped() {
println!("skipped");
}
use arcbox_constants::paths::privileged;
fn install_helper(custom_path: Option<&std::path::Path>) -> Result<()> {
let dest = PathBuf::from(privileged::HELPER_BINARY);
let helper_src = if let Some(path) = custom_path {
path.to_path_buf()
} else {
let exe = std::env::current_exe().context("could not determine current executable")?;
let exe_dir = exe.parent().context("executable has no parent directory")?;
exe_dir.join("arcbox-helper")
};
if !helper_src.exists() {
bail!(
"arcbox-helper not found at {}. Build it first with: cargo build -p arcbox-helper",
helper_src.display()
);
}
std::fs::create_dir_all("/usr/local/libexec").context("failed to create /usr/local/libexec")?;
std::fs::copy(&helper_src, &dest).with_context(|| {
format!(
"failed to copy {} -> {}",
helper_src.display(),
dest.display()
)
})?;
let status = Command::new("chown")
.args(["root:wheel", privileged::HELPER_BINARY])
.status()
.context("failed to chown helper binary")?;
if !status.success() {
bail!("chown root:wheel failed (are you running with sudo?)");
}
std::fs::write(
privileged::HELPER_PLIST,
include_bytes!("../../../../bundle/com.arcboxlabs.desktop.helper.plist"),
)
.with_context(|| format!("failed to write {}", privileged::HELPER_PLIST))?;
let _ = Command::new("launchctl")
.args(["bootout", "system", privileged::HELPER_PLIST])
.output();
let status = Command::new("launchctl")
.args(["bootstrap", "system", privileged::HELPER_PLIST])
.status()
.context("failed to run launchctl bootstrap")?;
if !status.success() {
bail!("launchctl bootstrap failed for helper service");
}
Ok(())
}
const DAEMON_LABEL: &str = "com.arcboxlabs.desktop.daemon";
fn register_daemon_service() -> Result<()> {
let (home, uid) = resolve_real_user()?;
let plist_dir = home.join("Library/LaunchAgents");
std::fs::create_dir_all(&plist_dir).context("failed to create LaunchAgents directory")?;
let log_dir = home.join(".arcbox/log");
std::fs::create_dir_all(&log_dir).context("failed to create log directory")?;
let _ = Command::new("chown")
.args([
"-R",
&format!("{uid}:staff"),
&home.join(".arcbox").to_string_lossy(),
])
.status();
let plist_path = plist_dir.join(format!("{DAEMON_LABEL}.plist"));
let exe = std::env::current_exe().context("could not determine current executable")?;
let exe_dir = exe.parent().context("executable has no parent directory")?;
let daemon_bin = exe_dir.join("arcbox-daemon");
let daemon_path = if daemon_bin.exists() {
daemon_bin.to_string_lossy().to_string()
} else {
let alt = home.join(".arcbox/bin/arcbox-daemon");
if alt.exists() {
alt.to_string_lossy().to_string()
} else {
"arcbox-daemon".to_string()
}
};
let plist_content = 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>Label</key>
<string>{DAEMON_LABEL}</string>
<key>ProgramArguments</key>
<array>
<string>{daemon_path}</string>
</array>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
<!--
ExitTimeOut: seconds launchd waits after SIGTERM before sending SIGKILL.
Default is 20s, but daemon shutdown needs ~35s (5s service drain + 30s VM
graceful stop). Set to 45s to avoid SIGKILL mid-shutdown.
-->
<key>ExitTimeOut</key>
<integer>45</integer>
</dict>
</plist>
"#
);
std::fs::write(&plist_path, plist_content)
.with_context(|| format!("failed to write {}", plist_path.display()))?;
let domain_target = format!("gui/{uid}");
let _ = Command::new("launchctl")
.args(["bootout", &domain_target, &plist_path.to_string_lossy()])
.output();
let status = Command::new("launchctl")
.args(["bootstrap", &domain_target, &plist_path.to_string_lossy()])
.status()
.context("failed to run launchctl bootstrap")?;
if !status.success() {
bail!("launchctl bootstrap failed");
}
Ok(())
}
fn resolve_real_user() -> Result<(PathBuf, u32)> {
if let Ok(sudo_user) = std::env::var("SUDO_USER") {
let uid: u32 = std::env::var("SUDO_UID")
.ok()
.and_then(|s| s.parse().ok())
.context("SUDO_USER is set but SUDO_UID is missing or invalid")?;
let home = home_for_user(&sudo_user)
.unwrap_or_else(|| PathBuf::from(format!("/Users/{sudo_user}")));
return Ok((home, uid));
}
let home = dirs::home_dir().context("could not determine home directory")?;
let uid = unsafe { libc::getuid() };
Ok((home, uid))
}
fn home_for_user(username: &str) -> Option<PathBuf> {
let c_name = std::ffi::CString::new(username).ok()?;
let pw = unsafe { libc::getpwnam(c_name.as_ptr()) };
if pw.is_null() {
return None;
}
let dir = unsafe { std::ffi::CStr::from_ptr((*pw).pw_dir) };
Some(PathBuf::from(dir.to_string_lossy().into_owned()))
}