AnyBot/src/notify.rs

159 lines
7.8 KiB
Rust
Raw Normal View History

// 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<f64> {
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<f64> {
#[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<f64>) -> 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<f64>, 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\"");
}
}