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,
pub rss: u64,
pub foreground: bool,
pub nested: bool,
}
impl App {
pub fn identities(&self) -> Vec<&str> {
let mut v = vec![self.name.as_str()];
v.extend(self.bundle_id.as_deref());
v
}
}
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)
}
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()
}
#[derive(Debug, Clone)]
pub enum Decision {
Close(App),
Refuse(App, String),
}
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,
StillRunning,
Refused(String),
Failed(String),
}
#[derive(Debug, Clone)]
pub struct Result_ {
pub app: App,
pub outcome: Outcome,
pub freed: u64,
}
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 {
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();
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);
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"
);
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();
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);
}
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"
);
}
}