use std::time::{Duration, Instant};
use argonauts_core::NoteLevel;
use argonauts_core::app::{AppEvent, AwakeHold};
use argonauts_core::settings::Span;
use argonauts_core::settings::awake::AwakeSettings;
use time::OffsetDateTime;
use crate::controller::{Ctx, Effect};
pub(crate) trait Hold: Send + Sync {}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Keeps {
Computer,
ComputerAndScreen,
}
impl Keeps {
fn while_working(settings: &AwakeSettings) -> Self {
if settings.screen_while_working {
Keeps::ComputerAndScreen
} else {
Keeps::Computer
}
}
}
pub(crate) type StartHold = Box<dyn Fn(Keeps, Option<u64>) -> Option<Box<dyn Hold>> + Send + Sync>;
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct Said {
pub text: String,
pub undo: Option<String>,
pub caveat: Option<String>,
}
impl Said {
fn held(text: String) -> Self {
Self {
text,
undo: Some("/awake off lets them sleep again.".to_owned()),
caveat: Some(LID.to_owned()),
}
}
fn cant() -> Self {
Self {
text: CANT.to_owned(),
undo: None,
caveat: None,
}
}
}
const LID: &str = "On battery, closing the lid still puts the Mac to sleep.";
enum Asked {
Nothing,
UntilQuit,
Until {
ends: Instant,
at: OffsetDateTime,
},
Off,
}
pub(crate) struct KeepAwake {
start: StartHold,
while_working: bool,
work_keeps: Keeps,
working: bool,
asked: Asked,
for_work: Option<(Keeps, Box<dyn Hold>)>,
for_you: Option<Box<dyn Hold>>,
}
impl KeepAwake {
pub(crate) fn never() -> Self {
Self::with(Box::new(|_, _| None))
}
pub(crate) fn system() -> Self {
Self::with(Box::new(system_hold))
}
pub(crate) fn with(start: StartHold) -> Self {
Self {
start,
while_working: true,
work_keeps: Keeps::Computer,
working: false,
asked: Asked::Nothing,
for_work: None,
for_you: None,
}
}
pub(crate) fn working(&mut self, working: bool, settings: &AwakeSettings) {
self.working = working;
self.while_working = settings.while_working;
self.work_keeps = Keeps::while_working(settings);
self.settle();
}
pub(crate) fn until_quit(&mut self) -> Said {
self.for_you = (self.start)(Keeps::ComputerAndScreen, None);
if self.for_you.is_none() {
return Said::cant();
}
self.asked = Asked::UntilQuit;
self.settle();
Said::held("Keeping the computer and its screen awake until you quit.".to_owned())
}
pub(crate) fn for_a_time(&mut self, span: Duration, time: &str, now: OffsetDateTime) -> Said {
self.for_you = (self.start)(Keeps::ComputerAndScreen, Some(span.as_secs().max(1)));
if self.for_you.is_none() {
return Said::cant();
}
self.asked = Asked::Until {
ends: Instant::now() + span,
at: now + span,
};
self.settle();
Said::held(format!(
"Keeping the computer and its screen awake for {time}."
))
}
pub(crate) fn off(&mut self) -> Said {
self.asked = Asked::Off;
self.for_you = None;
self.settle();
Said {
text: "The computer and its screen may sleep again, also while agents work.".to_owned(),
undo: Some("/awake keeps them awake.".to_owned()),
caveat: None,
}
}
pub(crate) fn held(&self) -> Option<AwakeHold> {
match &self.asked {
Asked::UntilQuit => Some(AwakeHold::UntilQuit),
Asked::Until { ends, at } if Instant::now() < *ends => {
Some(AwakeHold::Until { at: *at })
}
Asked::Until { .. } | Asked::Nothing | Asked::Off => None,
}
}
fn settle(&mut self) {
if matches!(&self.asked, Asked::Until { ends, .. } if Instant::now() >= *ends) {
self.asked = Asked::Nothing;
self.for_you = None;
}
let wanted = self.working && self.while_working && !matches!(self.asked, Asked::Off);
let held = self.for_work.as_ref().map(|(keeps, _)| *keeps);
match (wanted, held) {
(false, _) => self.for_work = None,
(true, held) if held != Some(self.work_keeps) => {
self.for_work = None;
self.for_work =
(self.start)(self.work_keeps, None).map(|hold| (self.work_keeps, hold));
}
(true, _) => {}
}
}
}
const CANT: &str = "Argonauts Code can keep only a Mac awake so far, so nothing changed.";
pub(crate) fn command(ctx: &mut Ctx, time: Option<String>) -> anyhow::Result<Vec<Effect>> {
let said = match time.as_deref() {
None => ctx.awake.until_quit(),
Some("off") => ctx.awake.off(),
Some(time) => match Span::new(time).parse() {
Ok(Some(span)) if !span.is_zero() => {
ctx.awake.for_a_time(span, time, OffsetDateTime::now_utc())
}
_ => anyhow::bail!("/awake takes a time such as 2h or 45m, or off"),
},
};
match said {
Said {
text,
undo: None,
caveat: None,
} => ctx.notice(NoteLevel::Info, text),
Said { text, undo, caveat } => ctx.send(AppEvent::Switched { text, undo, caveat }),
}
Ok(vec![Effect::Status])
}
pub(crate) fn while_hosting(settings: &AwakeSettings) -> Option<Box<dyn Hold>> {
system_hold(Keeps::while_working(settings), None)
}
#[cfg(target_os = "macos")]
fn system_hold(keeps: Keeps, secs: Option<u64>) -> Option<Box<dyn Hold>> {
mac::Caffeinate::start(keeps, secs).map(|hold| Box::new(hold) as Box<dyn Hold>)
}
#[cfg(not(target_os = "macos"))]
fn system_hold(_keeps: Keeps, _secs: Option<u64>) -> Option<Box<dyn Hold>> {
None
}
#[cfg(target_os = "macos")]
mod mac {
use std::process::{Child, Command, Stdio};
use super::Keeps;
const CAFFEINATE: &str = "/usr/bin/caffeinate";
pub(super) struct Caffeinate(Child);
impl Caffeinate {
pub(super) fn start(keeps: Keeps, secs: Option<u64>) -> Option<Self> {
let mut command = Command::new(CAFFEINATE);
command
.arg(flags(keeps))
.args(["-w", &std::process::id().to_string()]);
if let Some(secs) = secs {
command.args(["-t", &secs.to_string()]);
}
let child = command
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn();
match child {
Ok(child) => Some(Self(child)),
Err(err) => {
tracing::warn!("{CAFFEINATE} didn't start, so the computer may sleep: {err}");
None
}
}
}
}
pub(super) fn flags(keeps: Keeps) -> &'static str {
match keeps {
Keeps::Computer => "-is",
Keeps::ComputerAndScreen => "-dis",
}
}
impl super::Hold for Caffeinate {}
#[cfg(test)]
impl Caffeinate {
pub(super) fn pid(&self) -> u32 {
self.0.id()
}
}
impl Drop for Caffeinate {
fn drop(&mut self) {
let _ = self.0.kill();
let _ = self.0.wait();
}
}
}
#[cfg(test)]
mod tests {
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::{Arc, Mutex};
use super::*;
struct Counted(Arc<AtomicUsize>);
impl Hold for Counted {}
impl Drop for Counted {
fn drop(&mut self) {
self.0.fetch_sub(1, Ordering::SeqCst);
}
}
type Started = Arc<Mutex<Vec<(Keeps, Option<u64>)>>>;
fn counted() -> (KeepAwake, Arc<AtomicUsize>, Started) {
let held = Arc::new(AtomicUsize::new(0));
let started = Started::default();
let (count, log) = (held.clone(), started.clone());
let start: StartHold = Box::new(move |keeps, secs| {
log.lock().unwrap().push((keeps, secs));
count.fetch_add(1, Ordering::SeqCst);
Some(Box::new(Counted(count.clone())) as Box<dyn Hold>)
});
(KeepAwake::with(start), held, started)
}
fn settings(while_working: bool, screen_while_working: bool) -> AwakeSettings {
AwakeSettings {
while_working,
screen_while_working,
}
}
#[test]
fn it_holds_sleep_off_only_while_something_works() {
let (mut awake, held, started) = counted();
let on = settings(true, false);
assert_eq!(held.load(Ordering::SeqCst), 0);
awake.working(true, &on);
awake.working(true, &on);
assert_eq!(held.load(Ordering::SeqCst), 1, "one hold, however often");
assert_eq!(awake.held(), None, "the bottom line names only your own");
awake.working(false, &on);
assert_eq!(held.load(Ordering::SeqCst), 0);
awake.working(true, &settings(false, false));
assert_eq!(held.load(Ordering::SeqCst), 0);
awake.working(true, &on);
assert_eq!(held.load(Ordering::SeqCst), 1);
assert_eq!(
*started.lock().unwrap(),
[(Keeps::Computer, None), (Keeps::Computer, None)]
);
}
#[test]
fn work_under_way_keeps_the_screen_on_only_when_the_setting_adds_it() {
let (mut awake, held, started) = counted();
awake.working(true, &settings(true, true));
assert_eq!(*started.lock().unwrap(), [(Keeps::ComputerAndScreen, None)]);
awake.working(true, &settings(true, false));
assert_eq!(held.load(Ordering::SeqCst), 1);
assert_eq!(
started.lock().unwrap().last(),
Some(&(Keeps::Computer, None))
);
assert_eq!(started.lock().unwrap().len(), 2);
}
#[test]
fn awake_holds_the_screen_too_until_you_quit_and_off_ends_every_hold() {
let (mut awake, held, started) = counted();
let on = settings(true, false);
let said = awake.until_quit();
assert_eq!(
said,
Said {
text: "Keeping the computer and its screen awake until you quit.".into(),
undo: Some("/awake off lets them sleep again.".into()),
caveat: Some("On battery, closing the lid still puts the Mac to sleep.".into()),
}
);
assert_eq!(held.load(Ordering::SeqCst), 1);
assert_eq!(awake.held(), Some(AwakeHold::UntilQuit));
assert_eq!(*started.lock().unwrap(), [(Keeps::ComputerAndScreen, None)]);
awake.working(true, &on);
awake.working(false, &on);
assert_eq!(held.load(Ordering::SeqCst), 1, "yours stays when work ends");
awake.working(true, &on);
let said = awake.off();
assert_eq!(
said.text,
"The computer and its screen may sleep again, also while agents work."
);
assert_eq!(said.undo.as_deref(), Some("/awake keeps them awake."));
assert_eq!(said.caveat, None);
assert_eq!(held.load(Ordering::SeqCst), 0, "not even while agents work");
assert_eq!(awake.held(), None);
awake.until_quit();
assert_eq!(held.load(Ordering::SeqCst), 2);
assert!(
started
.lock()
.unwrap()
.iter()
.all(|(_, secs)| secs.is_none())
);
}
#[test]
fn a_time_is_given_to_the_hold_and_reaches_the_bottom_line() {
let (mut awake, held, started) = counted();
let now = time::macros::datetime!(2026-10-07 13:45 UTC);
let said = awake.for_a_time(Duration::from_secs(2 * 3600), "2h", now);
assert_eq!(
said.text,
"Keeping the computer and its screen awake for 2h."
);
assert_eq!(
said.undo.as_deref(),
Some("/awake off lets them sleep again.")
);
assert_eq!(
*started.lock().unwrap(),
[(Keeps::ComputerAndScreen, Some(7200))]
);
assert_eq!(held.load(Ordering::SeqCst), 1);
let until = time::macros::datetime!(2026-10-07 15:45 UTC);
assert_eq!(awake.held(), Some(AwakeHold::Until { at: until }));
}
#[test]
fn the_command_says_what_it_did_and_work_under_way_holds_sleep_off_too() {
use argonauts_core::app::AppMode;
use argonauts_core::names::TurnId;
use crate::controller::test_support::{CliOptions, TestCtx};
let (mut parts, _) = TestCtx::new(CliOptions::default());
let (awake, held, _) = counted();
parts.ctx.awake = awake;
let ctx = &mut parts.ctx;
let effects = command(ctx, None).unwrap();
assert!(
matches!(effects.as_slice(), [Effect::Status]),
"{effects:?}"
);
assert_eq!(held.load(Ordering::SeqCst), 1);
assert_eq!(ctx.awake.held(), Some(AwakeHold::UntilQuit));
let turn = TurnId::numbered(1);
ctx.set_mode(AppMode::Answering { turn });
assert_eq!(held.load(Ordering::SeqCst), 2);
ctx.set_mode(AppMode::Idle);
assert_eq!(held.load(Ordering::SeqCst), 1);
command(ctx, Some("off".to_owned())).unwrap();
assert_eq!(held.load(Ordering::SeqCst), 0);
command(ctx, Some("45m".to_owned())).unwrap();
assert_eq!(held.load(Ordering::SeqCst), 1);
assert!(matches!(ctx.awake.held(), Some(AwakeHold::Until { .. })));
assert!(command(ctx, Some("soon".to_owned())).is_err());
let mut said = Vec::new();
while let Ok(event) = parts.events.try_recv() {
if let AppEvent::Switched { text, undo, caveat } = event {
said.push((text, undo, caveat.is_some()));
}
}
let undo = |words: &str| Some(words.to_owned());
assert_eq!(
said,
[
(
"Keeping the computer and its screen awake until you quit.".to_owned(),
undo("/awake off lets them sleep again."),
true
),
(
"The computer and its screen may sleep again, also while agents work."
.to_owned(),
undo("/awake keeps them awake."),
false
),
(
"Keeping the computer and its screen awake for 45m.".to_owned(),
undo("/awake off lets them sleep again."),
true
),
]
);
}
#[cfg(target_os = "macos")]
#[test]
fn on_a_mac_the_hold_is_caffeinate_and_it_ends_when_dropped() {
let alive = |pid: u32| {
std::process::Command::new("/bin/kill")
.args(["-0", &pid.to_string()])
.stderr(std::process::Stdio::null())
.status()
.is_ok_and(|status| status.success())
};
assert_eq!(mac::flags(Keeps::Computer), "-is");
assert_eq!(mac::flags(Keeps::ComputerAndScreen), "-dis");
let hold = mac::Caffeinate::start(Keeps::Computer, Some(30)).expect("caffeinate starts");
let pid = hold.pid();
assert!(alive(pid));
drop(hold);
assert!(!alive(pid));
}
#[test]
fn a_computer_with_no_way_to_hold_sleep_off_says_so() {
let mut awake = KeepAwake::never();
assert_eq!(awake.until_quit(), Said::cant());
assert_eq!(awake.held(), None);
awake.working(true, &settings(true, false));
assert_eq!(awake.held(), None);
}
}