amphetamine 0.1.2

Reclaim memory and win scheduler contention on Apple Silicon, safely.
//! Enumerating and gracefully closing GUI applications.
//!
//! Closing goes through `NSRunningApplication::terminate`, which posts the same
//! quit request as Cmd-Q: the app runs its normal shutdown, flushes state, and
//! is free to put up an unsaved-work sheet. That is the difference between
//! reclaiming memory and losing work, so a signal is never the first move —
//! and `SIGKILL` is never sent at all.

use crate::{config::Config, guard, proc};
use objc2_app_kit::{NSApplicationActivationPolicy, NSRunningApplication, NSWorkspace};
use std::time::{Duration, Instant};

#[derive(Debug, Clone)]
pub struct App {
    pub pid: i32,
    pub uid: u32,
    pub bundle_id: Option<String>,
    pub name: String,
    /// Resident memory of the app's whole process tree, not just its main pid.
    pub rss: u64,
    /// True for apps with a Dock presence, as opposed to background agents.
    pub foreground: bool,
    /// True when this app is itself a helper inside another app's tree.
    ///
    /// Electron apps register their renderer and extension-host helpers with
    /// LaunchServices, so they appear as applications in their own right. Their
    /// memory is already counted in the parent's tree, and reporting both would
    /// double-count it.
    pub nested: bool,
}

impl App {
    /// Every name this app answers to, for matching against any list.
    pub fn identities(&self) -> Vec<&str> {
        let mut v = vec![self.name.as_str()];
        v.extend(self.bundle_id.as_deref());
        v
    }
}

/// Every running application, with whole-tree memory attributed to each.
pub fn list(table: &proc::Table) -> Vec<App> {
    let running = NSWorkspace::sharedWorkspace().runningApplications();
    let mut apps: Vec<App> = running
        .iter()
        .filter_map(|a| {
            let pid = a.processIdentifier();
            if pid <= 0 {
                return None;
            }
            let bundle_id = a.bundleIdentifier().map(|s| s.to_string());
            let name = a
                .localizedName()
                .map(|s| s.to_string())
                .or_else(|| table.by_pid.get(&pid).map(|p| p.name.clone()))?;
            Some(App {
                pid,
                uid: table.by_pid.get(&pid).map_or(u32::MAX, |p| p.uid),
                bundle_id,
                name,
                rss: table.tree_rss(pid),
                foreground: a.activationPolicy() == NSApplicationActivationPolicy::Regular,
                nested: false,
            })
        })
        .collect();

    let pids: std::collections::HashSet<i32> = apps.iter().map(|a| a.pid).collect();
    for app in &mut apps {
        app.nested = table.ancestors(app.pid).any(|pid| pids.contains(&pid));
    }
    apps.sort_by_key(|a| std::cmp::Reverse(a.rss));
    apps
}

fn running_app(pid: i32) -> Option<objc2::rc::Retained<NSRunningApplication>> {
    NSRunningApplication::runningApplicationWithProcessIdentifier(pid)
}

/// Whether an application with this bundle identifier is installed.
///
/// Asks LaunchServices, which is the same lookup Finder uses. Lets the cache
/// sweeper tell an application's cache from a build tool's scratch directory
/// without maintaining a list of every tool in existence.
pub fn is_installed_app(bundle_id: &str) -> bool {
    if !crate::guard::looks_like_bundle_id(bundle_id) {
        return false;
    }
    NSWorkspace::sharedWorkspace()
        .URLForApplicationWithBundleIdentifier(&objc2_foundation::NSString::from_str(bundle_id))
        .is_some()
}

/// A vetted decision about one app. Refusals are kept rather than filtered so
/// the report can explain every app it declined to touch.
#[derive(Debug, Clone)]
pub enum Decision {
    Close(App),
    Refuse(App, String),
}

/// Matches the configured close list against reality and vets every hit.
pub fn plan(cfg: &Config, apps: &[App]) -> Vec<Decision> {
    apps.iter()
        .filter(|a| guard::any_matches(&a.identities(), &cfg.apps.close))
        .map(|a| {
            let ids = a.identities();
            if guard::any_matches(&ids, &cfg.apps.protect) {
                return Decision::Refuse(a.clone(), "protected by your config".into());
            }
            match guard::vet_process(a.pid, a.uid, &ids) {
                Ok(()) => Decision::Close(a.clone()),
                Err(r) => Decision::Refuse(a.clone(), r.to_string()),
            }
        })
        .collect()
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Outcome {
    Closed,
    /// Still running after the timeout — usually an unsaved-work dialog is up,
    /// which is exactly the case where forcing would lose data.
    StillRunning,
    Refused(String),
    Failed(String),
}

#[derive(Debug, Clone)]
pub struct Result_ {
    pub app: App,
    pub outcome: Outcome,
    pub freed: u64,
}

/// Asks each planned app to quit, waiting for it to actually exit.
///
/// `force` escalates a timeout to `forceTerminate` (an unsaveable kill), and is
/// only ever reachable through an explicit `--force` flag.
pub fn execute(plan: &[Decision], cfg: &Config, dry_run: bool, force: bool) -> Vec<Result_> {
    let timeout = Duration::from_secs(cfg.apps.quit_timeout_secs);
    let mut results = Vec::new();

    for decision in plan {
        let app = match decision {
            Decision::Refuse(app, why) => {
                results.push(Result_ {
                    app: app.clone(),
                    outcome: Outcome::Refused(why.clone()),
                    freed: 0,
                });
                continue;
            }
            Decision::Close(app) => app,
        };

        if dry_run {
            results.push(Result_ {
                app: app.clone(),
                outcome: Outcome::Closed,
                freed: app.rss,
            });
            continue;
        }

        let Some(handle) = running_app(app.pid) else {
            // Exited on its own between planning and now.
            results.push(Result_ {
                app: app.clone(),
                outcome: Outcome::Closed,
                freed: app.rss,
            });
            continue;
        };

        if !handle.terminate() {
            results.push(Result_ {
                app: app.clone(),
                outcome: Outcome::Failed("app refused the quit request".into()),
                freed: 0,
            });
            continue;
        }

        let outcome = match wait_for_exit(app.pid, timeout) {
            true => Outcome::Closed,
            false if force && handle.forceTerminate() => {
                match wait_for_exit(app.pid, Duration::from_secs(5)) {
                    true => Outcome::Closed,
                    false => Outcome::StillRunning,
                }
            }
            false => Outcome::StillRunning,
        };

        let freed = if outcome == Outcome::Closed {
            app.rss
        } else {
            0
        };
        results.push(Result_ {
            app: app.clone(),
            outcome,
            freed,
        });
    }
    results
}

fn wait_for_exit(pid: i32, timeout: Duration) -> bool {
    let deadline = Instant::now() + timeout;
    while Instant::now() < deadline {
        if !proc::is_alive(pid) {
            return true;
        }
        std::thread::sleep(Duration::from_millis(100));
    }
    !proc::is_alive(pid)
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::config::Config;

    fn app(name: &str, bundle: Option<&str>) -> App {
        App {
            pid: 90_000,
            uid: unsafe { libc::getuid() },
            bundle_id: bundle.map(str::to_owned),
            name: name.into(),
            rss: 1 << 30,
            foreground: true,
            nested: false,
        }
    }

    #[test]
    fn empty_close_list_plans_nothing() {
        let cfg = Config::default();
        let apps = vec![app("Slack", Some("com.tinyspeck.slackmacgap"))];
        assert!(plan(&cfg, &apps).is_empty());
    }

    #[test]
    fn listed_app_is_planned_by_name_or_bundle_id() {
        let mut cfg = Config::default();
        cfg.apps.close = vec!["Slack".into()];
        let apps = vec![app("Slack", Some("com.tinyspeck.slackmacgap"))];
        assert!(matches!(plan(&cfg, &apps)[0], Decision::Close(_)));

        cfg.apps.close = vec!["com.tinyspeck.slackmacgap".into()];
        assert!(matches!(plan(&cfg, &apps)[0], Decision::Close(_)));
    }

    #[test]
    fn builtin_denylist_overrides_an_explicit_close_request() {
        let mut cfg = Config::default();
        // Even named outright, these must never be planned for closing.
        cfg.apps.close = vec!["Cursor".into(), "Finder".into(), "Docker".into()];
        let apps = vec![
            app("Cursor", Some("com.todesktop.230313mzl4w4u92")),
            app("Finder", Some("com.apple.finder")),
            app("Docker", Some("com.docker.docker")),
        ];
        let plan = plan(&cfg, &apps);
        assert_eq!(plan.len(), 3);
        assert!(plan.iter().all(|d| matches!(d, Decision::Refuse(..))));
    }

    #[test]
    fn user_protect_list_beats_user_close_list() {
        let mut cfg = Config::default();
        cfg.apps.close = vec!["Spotify".into()];
        cfg.apps.protect = vec!["Spotify".into()];
        let apps = vec![app("Spotify", Some("com.spotify.client"))];
        assert!(matches!(plan(&cfg, &apps)[0], Decision::Refuse(..)));
    }

    #[test]
    fn dry_run_reports_reclaim_without_touching_anything() {
        let mut cfg = Config::default();
        cfg.apps.close = vec!["Spotify".into()];
        let apps = vec![app("Spotify", Some("com.spotify.client"))];
        let results = execute(&plan(&cfg, &apps), &cfg, true, false);
        assert_eq!(results[0].outcome, Outcome::Closed);
        assert_eq!(results[0].freed, 1 << 30);
        // The pid is fictional; a real attempt would have errored.
        assert!(!proc::is_alive(90_000) || true);
    }

    #[test]
    fn refusals_are_reported_and_never_executed() {
        let mut cfg = Config::default();
        cfg.apps.close = vec!["Finder".into()];
        let apps = vec![app("Finder", Some("com.apple.finder"))];
        let results = execute(&plan(&cfg, &apps), &cfg, false, true);
        assert!(matches!(results[0].outcome, Outcome::Refused(_)));
        assert_eq!(results[0].freed, 0);
    }

    #[test]
    fn real_app_enumeration_finds_finder_and_attributes_memory() {
        let table = proc::Table::load().unwrap();
        let apps = list(&table);
        assert!(!apps.is_empty(), "no running applications found");
        let finder = apps
            .iter()
            .find(|a| a.bundle_id.as_deref() == Some("com.apple.finder"))
            .expect("Finder is always running");
        assert!(finder.rss > 0);
        assert!(finder.foreground);
        assert!(
            !finder.nested,
            "Finder is launched by launchd, not by an app"
        );
        // Sorted by footprint, descending.
        assert!(apps.windows(2).all(|w| w[0].rss >= w[1].rss));
    }

    #[test]
    fn helper_apps_are_marked_nested_and_do_not_double_count() {
        let table = proc::Table::load().unwrap();
        let apps = list(&table);
        let pids: std::collections::HashSet<i32> = apps.iter().map(|a| a.pid).collect();

        // Whatever the machine happens to be running, the invariant holds: an
        // app is nested exactly when one of its ancestors is also an app.
        for a in &apps {
            let has_app_ancestor = table.ancestors(a.pid).any(|p| pids.contains(&p));
            assert_eq!(a.nested, has_app_ancestor, "{} misclassified", a.name);
        }

        // Top-level footprints must not exceed physical memory, which is what
        // double-counting a helper tree would cause.
        let top: u64 = apps.iter().filter(|a| !a.nested).map(|a| a.rss).sum();
        let total = crate::sysinfo::memory().unwrap().total;
        assert!(
            top <= total,
            "top-level apps sum to {top} on a {total}-byte machine"
        );
    }
}