// Getting the person's attention when a bot needs them (or finished) while they're away. // // From the original: // - sand_notification_settings.node `getNotificationSettings` (0x35b4): needs macOS 10.14+ and a real // .app bundle, asks UNUserNotificationCenter and waits at most 3 s (dispatch_time 0xb2d05e00 ns). // authorizationStatus 0..4 = notDetermined/denied/authorized/provisional/ephemeral (else "unknown"); // alertSetting 0..2 = notSupported/disabled/enabled (else "unknown"). // - main-app.cjs `hBt`/`mur` (@3183391): authorized/provisional/ephemeral → granted, denied → denied, // notDetermined → undetermined. `fBt`: banners only when authorized and alerts enabled. // - `K4`/`lBt` (@3180700): the desktop counts as present only when not locked, not idle for // `Z_e = 9e4` ms (90 s) and the window is alive; checked every `uBt = 1e4` ms (10 s). use std::process::{Command, Stdio}; /// Away after this much keyboard/mouse silence (main-app.cjs `Z_e=9e4`). pub const IDLE_THRESHOLD_SECS: f64 = 90.0; /// How often presence is re-read (main-app.cjs `uBt=1e4`). pub const PRESENCE_POLL_SECS: f64 = 10.0; /// Seconds since the last keyboard or mouse input (macOS `HIDIdleTime`, in nanoseconds). pub fn parse_hid_idle(ioreg: &str) -> Option { let line = ioreg.lines().find(|l| l.contains("\"HIDIdleTime\""))?; let n: f64 = line.rsplit('=').next()?.trim().parse().ok()?; Some(n / 1e9) } pub fn system_idle_secs() -> Option { #[cfg(target_os = "linux")] return crate::desk::idle_secs(); #[allow(unreachable_code)] if !cfg!(target_os = "macos") { return None; } let out = Command::new("ioreg").args(["-c", "IOHIDSystem", "-d", "4"]).stderr(Stdio::null()).output().ok()?; parse_hid_idle(&String::from_utf8_lossy(&out.stdout)) } /// Notify unless the person is looking at AnyBot right now: the window is in front and they /// were active within the last 90 s. pub fn should_notify(window_focused: bool, idle_secs: Option) -> bool { !window_focused || idle_secs.map(|s| s >= IDLE_THRESHOLD_SECS).unwrap_or(false) } // ---------- applying a new version while you're away ---------- // main-app.cjs @3365216: `uQt` = autoUpdateWhenIdle && updateStaged && `phr` && `uhr`, where // `phr` = (screenLocked || screensaverActive) && systemIdleSeconds >= `pQt` (600), and `uhr` = // host idle confirmed (no agent work). `getAutoUpdateWhenIdle() ?? true` (on by default). // Checks: first `bhr = 3e4` ms after launch, then every `khr = 3600*1e3` ms plus up to // `Ihr = 300*1e3` ms of random jitter (@3372407, `nextPeriodicDelayMs`). pub const AUTO_UPDATE_IDLE_SECS: f64 = 600.0; pub const UPDATE_FIRST_CHECK_SECS: u64 = 30; pub const UPDATE_CHECK_INTERVAL_SECS: u64 = 3600; pub const UPDATE_CHECK_JITTER_SECS: u64 = 300; /// Every input of the original's rule, in one place. pub fn should_apply_update_now(enabled: bool, update_ready: bool, screen_locked: bool, screensaver: bool, idle_secs: Option, bots_idle: bool) -> bool { enabled && update_ready && (screen_locked || screensaver) && idle_secs.map(|s| s >= AUTO_UPDATE_IDLE_SECS).unwrap_or(false) && bots_idle } /// The screen is locked (Electron's `getSystemIdleState` "locked"; here the session flag). pub fn screen_locked() -> bool { if !cfg!(target_os = "macos") { // systemd-logind knows when the session is locked. let id = std::env::var("XDG_SESSION_ID").unwrap_or_default(); let mut c = Command::new("loginctl"); c.arg("show-session"); if !id.is_empty() { c.arg(&id); } return c.args(["-p", "LockedHint"]).stderr(Stdio::null()).output().map(|o| String::from_utf8_lossy(&o.stdout).trim() == "LockedHint=yes").unwrap_or(false); } Command::new("ioreg").args(["-n", "Root", "-d1"]).stderr(Stdio::null()).output().map(|o| String::from_utf8_lossy(&o.stdout).contains("\"CGSSessionScreenIsLocked\" = Yes")).unwrap_or(false) } /// The screensaver is running: the original's `NRn` runs `pgrep -x ScreenSaverEngine` (2 s timeout). pub fn screensaver_active() -> bool { #[cfg(target_os = "linux")] return crate::desk::screensaver_on(); #[allow(unreachable_code)] if !cfg!(target_os = "macos") { return false; } let Ok(mut c) = Command::new("pgrep").args(["-x", "ScreenSaverEngine"]).stdout(Stdio::null()).stderr(Stdio::null()).spawn() else { return false }; let start = std::time::Instant::now(); while start.elapsed() < std::time::Duration::from_secs(2) { if let Ok(Some(st)) = c.try_wait() { return st.success(); } std::thread::sleep(std::time::Duration::from_millis(20)); } let _ = c.kill(); let _ = c.wait(); false } /// Next periodic check: one hour plus a random part of five minutes. pub fn next_update_check_secs(random_0_1: f64) -> u64 { UPDATE_CHECK_INTERVAL_SECS + (random_0_1.clamp(0.0, 1.0) * UPDATE_CHECK_JITTER_SECS as f64).floor() as u64 } fn applescript_quote(s: &str) -> String { let clean: String = s.chars().filter(|c| !c.is_control() || *c == ' ').collect(); format!("\"{}\"", clean.replace('\\', "\\\\").replace('"', "\\\"")) } /// The AppleScript line for a banner (title + message), with text safely quoted. pub fn banner_script(title: &str, body: &str) -> String { let body: String = body.chars().take(180).collect(); format!("display notification {} with title {} sound name \"Glass\"", applescript_quote(&body), applescript_quote(title)) } /// Show a macOS banner. AnyBot isn't a signed .app bundle when run from a build folder, so it can't /// ask the notification center itself (the original's module needs a bundle); macOS's own /// `display notification` is used instead. Doesn't wait. pub fn banner(title: &str, body: &str) { if !cfg!(target_os = "macos") { // The desktop's notification service (GNOME, KDE, XFCE…) through notify-send. let body: String = body.chars().take(180).collect(); let _ = crate::computer::spawn_reaped(&mut Command::new("notify-send").args(["-a", "AnyBot", title, &body]).stdin(Stdio::null()).stdout(Stdio::null()).stderr(Stdio::null())); return; } let _ = crate::computer::spawn_reaped(&mut Command::new("osascript").arg("-e").arg(banner_script(title, body)).stdin(Stdio::null()).stdout(Stdio::null()).stderr(Stdio::null())); } #[cfg(test)] mod tests { use super::*; #[test] fn presence_rule() { assert!(!should_notify(true, Some(5.0))); assert!(should_notify(true, Some(90.0))); assert!(should_notify(false, Some(1.0))); assert!(!should_notify(true, None)); let sample = " | | \"HIDIdleTime\" = 123000000000\n"; assert_eq!(parse_hid_idle(sample), Some(123.0)); } #[test] fn update_applies_only_when_truly_away() { assert!(should_apply_update_now(true, true, true, false, Some(600.0), true)); assert!(should_apply_update_now(true, true, false, true, Some(900.0), true)); assert!(!should_apply_update_now(true, true, false, false, Some(900.0), true), "screen in use"); assert!(!should_apply_update_now(true, true, true, false, Some(599.0), true), "not idle long enough"); assert!(!should_apply_update_now(true, true, true, false, Some(900.0), false), "a bot is working"); assert!(!should_apply_update_now(false, true, true, false, Some(900.0), true), "turned off"); assert!(!should_apply_update_now(true, false, true, false, Some(900.0), true), "nothing to apply"); assert_eq!(next_update_check_secs(0.0), 3600); assert_eq!(next_update_check_secs(0.999), 3899); } #[test] fn banner_text_is_quoted() { let s = banner_script("電腦助手 需要你允許", "執行 \"rm\" \\ 指令\n下一行"); assert_eq!(s, "display notification \"執行 \\\"rm\\\" \\\\ 指令下一行\" with title \"電腦助手 需要你允許\" sound name \"Glass\""); } }