use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct Config {
pub apps: Apps,
pub caches: Caches,
pub focus: Focus,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct Apps {
pub close: Vec<String>,
pub protect: Vec<String>,
pub quit_timeout_secs: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct Caches {
pub enabled: bool,
pub min_age_days: u64,
pub skip_running_apps: bool,
pub skip: Vec<String>,
pub allow: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct Focus {
pub demote: Vec<String>,
pub nice_level: i32,
pub prevent_sleep: bool,
}
impl Default for Apps {
fn default() -> Self {
Self {
close: Vec::new(),
protect: Vec::new(),
quit_timeout_secs: 10,
}
}
}
impl Default for Caches {
fn default() -> Self {
Self {
enabled: true,
min_age_days: 7,
skip_running_apps: true,
skip: Vec::new(),
allow: Vec::new(),
}
}
}
impl Default for Focus {
fn default() -> Self {
Self {
demote: Vec::new(),
nice_level: 10,
prevent_sleep: true,
}
}
}
impl Caches {
pub fn effective_skips(&self) -> Vec<String> {
crate::guard::CACHE_SKIP_DEFAULT
.iter()
.map(|s| (*s).to_owned())
.filter(|s| !self.allow.iter().any(|a| a.eq_ignore_ascii_case(s)))
.chain(self.skip.iter().cloned())
.collect()
}
}
pub fn path() -> PathBuf {
std::env::var_os("AMPHETAMINE_CONFIG")
.map(PathBuf::from)
.or_else(|| {
std::env::var_os("XDG_CONFIG_HOME")
.map(PathBuf::from)
.or_else(|| dirs::home_dir().map(|h| h.join(".config")))
.map(|c| c.join("amphetamine").join("config.toml"))
})
.unwrap_or_else(|| PathBuf::from("amphetamine.toml"))
}
pub fn load() -> Result<Option<Config>> {
let p = path();
if !p.exists() {
return Ok(None);
}
let text = std::fs::read_to_string(&p).with_context(|| format!("reading {}", p.display()))?;
toml::from_str(&text)
.map(Some)
.with_context(|| format!("parsing {}", p.display()))
}
pub fn init(at: &Path) -> Result<()> {
if at.exists() {
anyhow::bail!("{} already exists", at.display());
}
if let Some(parent) = at.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("creating {}", parent.display()))?;
}
std::fs::write(at, TEMPLATE).with_context(|| format!("writing {}", at.display()))?;
Ok(())
}
pub const TEMPLATE: &str = r#"# Amphetamine
#
# Nothing is closed or reprioritised until you list it below. Both lists start
# empty on purpose. Entries match an app's bundle ID, its name, or the last
# component of its bundle ID, case-insensitively.
#
# Run `amph status` to see what is actually eating memory before filling these in.
[apps]
# Apps to quit on `amph boost`. They are asked to quit the normal way, so
# unsaved-work prompts still appear and nothing is killed out from under you.
close = [
# "Slack",
# "Spotify",
# "Discord",
# "com.tinyspeck.slackmacgap",
]
# Extra protections on top of the built-in denylist. The built-ins already
# cover the OS, your editor and terminal, and all VMs and container runtimes.
protect = []
# Seconds to let an app save and exit before Amphetamine gives up and leaves
# it running. It is never escalated to a kill unless you pass --force.
quit_timeout_secs = 10
[caches]
enabled = true
# Only files untouched for at least this many days are eligible. Raising this
# is the single easiest way to make cache clearing more conservative.
min_age_days = 7
# Never clear a cache belonging to a running app. Leave this on.
skip_running_apps = true
# Extra cache buckets to leave alone, on top of the built-in list.
skip = []
# Buckets to clear even though they are skipped by default — developer
# toolchains and browsers. These are safe to delete but cost you a rebuild or
# sign you out of websites, which is why they are opt-in.
# allow = ["go-build", "Homebrew", "pip"]
allow = []
[focus]
# Processes deprioritised during `amph focus`, leaving more CPU for your
# editor. They keep running, just with a weaker claim on the scheduler.
#
# This requires `amph setup` first. Raising a nice value needs no privileges,
# but lowering it back needs root, and Amphetamine refuses to demote anything
# it cannot prove it can undo.
demote = [
# "Spotify",
# "Dropbox",
]
# How far to deprioritise, 1-20. 10 is a firm nudge; 20 means "only run when
# nothing else wants the CPU".
nice_level = 10
# Hold off idle sleep for the duration of a focus session. Your display still
# sleeps normally.
prevent_sleep = true
"#;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn template_parses_and_is_empty_by_default() {
let c: Config = toml::from_str(TEMPLATE).expect("shipped template must parse");
assert!(c.apps.close.is_empty());
assert!(c.focus.demote.is_empty());
assert!(c.caches.skip_running_apps);
assert!(c.caches.min_age_days >= 1);
}
#[test]
fn unknown_keys_are_rejected_rather_than_ignored() {
let bad = "[apps]\nclose = []\nclosee = [\"Slack\"]\n";
assert!(toml::from_str::<Config>(bad).is_err());
}
#[test]
fn empty_file_yields_safe_defaults() {
let c: Config = toml::from_str("").unwrap();
assert!(c.apps.close.is_empty());
assert_eq!(c.apps.quit_timeout_secs, 10);
}
#[test]
fn allow_reopens_a_default_skip_but_skip_wins_when_both() {
let c = Caches {
allow: vec!["Arc".into()],
..Caches::default()
};
let skips = c.effective_skips();
assert!(!skips.iter().any(|s| s == "Arc"));
assert!(skips.iter().any(|s| s == "com.apple.Safari"));
let both = Caches {
allow: vec!["Arc".into()],
skip: vec!["Arc".into()],
..Caches::default()
};
assert!(both.effective_skips().iter().any(|s| s == "Arc"));
}
}