use std::fs;
use std::path::Path;
use std::process::Command;
use netsky_core::consts::{
ENV_NETSKY_DIR, ENV_OWNER_IMESSAGE, ENV_OWNER_NAME, LAUNCHD_BOOTSTRAP_ERR, LAUNCHD_INTERVAL_S,
LAUNCHD_JOB_PATH_TEMPLATE, LAUNCHD_LABEL, LAUNCHD_STDERR_LOG, LAUNCHD_STDOUT_LOG, NETSKY_BIN,
};
use netsky_core::paths::{home, launchd_plist_path};
use netsky_sh::which as find_in_path;
use crate::cli::LaunchdCommand;
const LAUNCHCTL: &str = "launchctl";
pub fn run(sub: LaunchdCommand) -> netsky_core::Result<()> {
match sub {
LaunchdCommand::Install => install(),
LaunchdCommand::Uninstall => uninstall(),
LaunchdCommand::Status => status(),
LaunchdCommand::Reinstall => {
uninstall()?;
install()
}
}
}
fn domain() -> String {
format!("gui/{}", unsafe { libc_getuid() })
}
fn install() -> netsky_core::Result<()> {
let plist = launchd_plist_path();
let plist_dir = plist
.parent()
.ok_or_else(|| netsky_core::anyhow!("plist has no parent dir"))?;
fs::create_dir_all(plist_dir)?;
let new_plist = render_plist()?;
let plist_changed = match fs::read_to_string(&plist) {
Ok(existing) => existing != new_plist,
Err(_) => true,
};
let loaded = launchctl_loaded();
if !plist_changed && loaded {
println!("[launchd install] {LAUNCHD_LABEL} already installed and loaded; no-op");
return Ok(());
}
if plist_changed {
fs::write(&plist, &new_plist)?;
println!("[launchd install] wrote {}", plist.display());
}
if loaded {
let _ = Command::new(LAUNCHCTL)
.arg("bootout")
.arg(domain())
.arg(&plist)
.status();
}
let bootstrap = Command::new(LAUNCHCTL)
.arg("bootstrap")
.arg(domain())
.arg(&plist)
.output()?;
if bootstrap.status.success() {
println!(
"[launchd install] bootstrapped {}/{LAUNCHD_LABEL}",
domain()
);
} else {
let err = String::from_utf8_lossy(&bootstrap.stderr);
if err.contains("already loaded") || err.contains("already bootstrapped") {
println!("[launchd install] {LAUNCHD_LABEL} already loaded; no-op");
} else {
let _ = fs::write(LAUNCHD_BOOTSTRAP_ERR, &*err);
netsky_core::bail!("bootstrap failed: {err}");
}
}
let _ = fs::remove_file(LAUNCHD_BOOTSTRAP_ERR);
println!("[launchd install] done — watchdog will tick every {LAUNCHD_INTERVAL_S}s (active).");
Ok(())
}
fn uninstall() -> netsky_core::Result<()> {
let plist = launchd_plist_path();
if launchctl_loaded() {
let by_label = Command::new(LAUNCHCTL)
.args(["bootout", &format!("{}/{LAUNCHD_LABEL}", domain())])
.status()?;
if by_label.success() {
println!(
"[launchd uninstall] booted out {}/{LAUNCHD_LABEL}",
domain()
);
} else if plist.exists() {
let _ = Command::new(LAUNCHCTL)
.arg("bootout")
.arg(domain())
.arg(&plist)
.status();
println!("[launchd uninstall] booted out via plist path");
}
} else {
println!("[launchd uninstall] {LAUNCHD_LABEL} not loaded; skipping bootout");
}
if plist.exists() {
fs::remove_file(&plist)?;
println!("[launchd uninstall] removed {}", plist.display());
} else {
println!("[launchd uninstall] {} already absent", plist.display());
}
Ok(())
}
fn status() -> netsky_core::Result<()> {
let plist = launchd_plist_path();
println!("label: {LAUNCHD_LABEL}");
println!("plist: {}", plist.display());
println!(
"plist state: {}",
if plist.exists() { "present" } else { "ABSENT" }
);
if launchctl_loaded() {
println!("load state: loaded ({}/{LAUNCHD_LABEL})", domain());
} else {
println!("load state: NOT LOADED");
}
if Path::new(LAUNCHD_STDOUT_LOG).exists() {
let mtime = fs::metadata(LAUNCHD_STDOUT_LOG)
.and_then(|m| m.modified())
.ok()
.map(format_utc)
.unwrap_or_else(|| "unknown".to_string());
let last = last_line(LAUNCHD_STDOUT_LOG).unwrap_or_else(|| "<empty>".to_string());
println!("last tick: {mtime}");
println!("last out: {last}");
} else {
println!("last tick: no log at {LAUNCHD_STDOUT_LOG} yet");
}
if fs::metadata(LAUNCHD_STDERR_LOG)
.map(|m| m.len() > 0)
.unwrap_or(false)
&& let Some(l) = last_line(LAUNCHD_STDERR_LOG)
{
println!("last err: {l}");
}
println!("interval: {LAUNCHD_INTERVAL_S}s (StartInterval; active wall-clock only)");
println!("logs: {LAUNCHD_STDOUT_LOG}, {LAUNCHD_STDERR_LOG}");
let extras = render_env_passthroughs();
if extras.is_empty() {
println!(
"baked env: none beyond HOME + PATH (set NETSKY_DIR / NETSKY_OWNER_NAME / NETSKY_OWNER_IMESSAGE / MACHINE_TYPE in your shell + reinstall to pin)"
);
} else {
let names: Vec<&str> = extras
.split("<key>")
.skip(1)
.filter_map(|s| s.split("</key>").next())
.collect();
println!("baked env: HOME, PATH, {}", names.join(", "));
}
Ok(())
}
fn launchctl_loaded() -> bool {
Command::new(LAUNCHCTL)
.args(["print", &format!("{}/{LAUNCHD_LABEL}", domain())])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false)
}
fn render_plist() -> netsky_core::Result<String> {
let netsky = find_in_path(NETSKY_BIN).ok_or_else(|| {
netsky_core::anyhow!(
"`{NETSKY_BIN}` not on PATH — `cargo install --path src/crates/netsky-cli` first"
)
})?;
let home_s = home().display().to_string();
let job_path = LAUNCHD_JOB_PATH_TEMPLATE.replace("<<HOME>>", &home_s);
let env_extras = render_env_passthroughs();
let plist = 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">
<!--
netsky watchdog driver. installed by `netsky launchd install`.
fires `netsky watchdog tick` every {interval}s (wall-clock active time;
launchd pauses StartInterval jobs during sleep). LegacyTimers=true
opts out of macOS background-timer coalescing — observed 20+ min
silent gaps otherwise. Acceptable energy tradeoff for a resilience
watchdog.
EnvironmentVariables baked at install time: HOME + PATH always, plus
any of NETSKY_OWNER_NAME / NETSKY_OWNER_IMESSAGE / MACHINE_TYPE that
were set in the installer's shell. launchd-spawned processes do NOT
inherit the user's interactive shell env, so these have to be pinned
here or escalate / tick-request use defaults. Re-run `netsky launchd
reinstall` after changing any of them in your shell profile.
-->
<plist version="1.0">
<dict>
<key>Label</key>
<string>{label}</string>
<key>ProgramArguments</key>
<array>
<string>{bin}</string>
<string>watchdog</string>
<string>tick</string>
</array>
<key>EnvironmentVariables</key>
<dict>
<key>HOME</key>
<string>{home}</string>
<key>PATH</key>
<string>{path}</string>{env_extras}
</dict>
<key>StartInterval</key>
<integer>{interval}</integer>
<key>RunAtLoad</key>
<false/>
<key>KeepAlive</key>
<false/>
<key>StandardOutPath</key>
<string>{stdout}</string>
<key>StandardErrorPath</key>
<string>{stderr}</string>
<key>LegacyTimers</key>
<true/>
</dict>
</plist>
"#,
label = xml_escape(LAUNCHD_LABEL),
bin = xml_escape(&netsky.display().to_string()),
home = xml_escape(&home_s),
path = xml_escape(&job_path),
env_extras = env_extras,
interval = LAUNCHD_INTERVAL_S,
stdout = xml_escape(LAUNCHD_STDOUT_LOG),
stderr = xml_escape(LAUNCHD_STDERR_LOG),
);
Ok(plist)
}
fn render_env_passthroughs() -> String {
const PASSTHROUGH: &[&str] = &[
ENV_NETSKY_DIR,
ENV_OWNER_NAME,
ENV_OWNER_IMESSAGE,
"MACHINE_TYPE",
];
let mut out = String::new();
for name in PASSTHROUGH {
if let Ok(value) = std::env::var(name)
&& !value.is_empty()
{
out.push_str(&format!(
"\n <key>{}</key>\n <string>{}</string>",
xml_escape(name),
xml_escape(&value)
));
}
}
out
}
fn xml_escape(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for c in s.chars() {
match c {
'&' => out.push_str("&"),
'<' => out.push_str("<"),
'>' => out.push_str(">"),
'"' => out.push_str("""),
'\'' => out.push_str("'"),
c => out.push(c),
}
}
out
}
fn last_line(path: &str) -> Option<String> {
let content = fs::read_to_string(path).ok()?;
content.lines().last().map(|s| s.to_string())
}
fn format_utc(t: std::time::SystemTime) -> String {
let dt: chrono::DateTime<chrono::Utc> = t.into();
dt.format("%Y-%m-%dT%H:%M:%SZ").to_string()
}
#[allow(non_snake_case)]
unsafe fn libc_getuid() -> u32 {
unsafe extern "C" {
fn getuid() -> u32;
}
unsafe { getuid() }
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Mutex;
static ENV_LOCK: Mutex<()> = Mutex::new(());
struct EnvSnapshot(Vec<(&'static str, Option<String>)>);
impl EnvSnapshot {
fn capture(names: &[&'static str]) -> Self {
Self(names.iter().map(|n| (*n, std::env::var(n).ok())).collect())
}
}
impl Drop for EnvSnapshot {
fn drop(&mut self) {
for (name, prior) in &self.0 {
unsafe {
match prior {
Some(v) => std::env::set_var(name, v),
None => std::env::remove_var(name),
}
}
}
}
}
#[test]
fn env_passthroughs_combined_behavior() {
let _guard = ENV_LOCK.lock().unwrap();
let _snap = EnvSnapshot::capture(&[
ENV_NETSKY_DIR,
ENV_OWNER_NAME,
ENV_OWNER_IMESSAGE,
"MACHINE_TYPE",
]);
unsafe {
std::env::remove_var(ENV_NETSKY_DIR);
std::env::remove_var(ENV_OWNER_NAME);
std::env::remove_var(ENV_OWNER_IMESSAGE);
std::env::remove_var("MACHINE_TYPE");
}
assert_eq!(render_env_passthroughs(), "");
unsafe {
std::env::set_var(ENV_OWNER_NAME, "Alice");
}
let with_name = render_env_passthroughs();
assert!(
with_name.contains("<key>NETSKY_OWNER_NAME</key>"),
"missing NETSKY_OWNER_NAME key: {with_name:?}"
);
assert!(
with_name.contains("<string>Alice</string>"),
"missing Alice value: {with_name:?}"
);
unsafe {
std::env::set_var(ENV_NETSKY_DIR, "/Users/alice/netsky");
std::env::set_var(ENV_OWNER_IMESSAGE, "+15551234567");
std::env::set_var("MACHINE_TYPE", "WORK");
}
let with_all = render_env_passthroughs();
assert!(with_all.contains("<key>NETSKY_DIR</key>"));
assert!(with_all.contains("<string>/Users/alice/netsky</string>"));
assert!(with_all.contains("<key>NETSKY_OWNER_IMESSAGE</key>"));
assert!(with_all.contains("<string>+15551234567</string>"));
assert!(with_all.contains("<key>MACHINE_TYPE</key>"));
assert!(with_all.contains("<string>WORK</string>"));
unsafe {
std::env::set_var(ENV_OWNER_NAME, r#"</string><key>injected</key><string>x"#);
}
let rendered = render_env_passthroughs();
for value in rendered.split("<string>").skip(1) {
let value_only = value.split("</string>").next().unwrap_or_default();
assert!(
!value_only.contains('<'),
"unescaped '<' in value: {value_only:?}"
);
assert!(
!value_only.contains('>'),
"unescaped '>' in value: {value_only:?}"
);
}
}
#[test]
fn xml_escape_handles_entities() {
assert_eq!(xml_escape("a&b"), "a&b");
assert_eq!(xml_escape("a<b>c"), "a<b>c");
assert_eq!(xml_escape(r#"a"b"#), "a"b");
assert_eq!(xml_escape(r"a'b"), "a'b");
assert_eq!(xml_escape("plain"), "plain");
}
#[test]
fn xml_escape_defangs_injection_payload() {
let payload = r#"/Users/a</string><key>ProgramArguments</key><array><string>/bin/sh"#;
let esc = xml_escape(payload);
assert!(!esc.contains('<'));
assert!(!esc.contains('>'));
}
}