use std::collections::{HashMap, HashSet};
use crate::{
cli::atomic::should_dry_run,
commands::{BrewInstallCmd, Runnable, RunnableInvokeRules},
config::remote::RemoteConfigManager,
context::AppContext,
domains::{
collect,
convert::{prefvalue_to_serializable, toml_to_prefvalue},
core::{self, get_sys_domain_strings},
},
exec::{ExecMode, run_all},
log_cute, log_dry, log_err, log_info, log_warn,
snapshot::core::SettingState,
util::{
io::{confirm, restart_services},
sha::get_digest,
},
};
use anyhow::{Context, Result, bail};
use async_trait::async_trait;
use clap::Args;
use defaults_rs::{Domain, PrefValue, Preferences};
use crate::domains::convert::SerializablePrefValue;
#[derive(Args, Debug)]
pub struct ApplyCmd {
#[arg(short, long)]
url: Option<String>,
#[arg(short, long, conflicts_with_all = &["all_cmd", "flagged_cmd"])]
no_cmd: bool,
#[arg(short, long, conflicts_with_all = &["no_cmd", "flagged_cmd"])]
all_cmd: bool,
#[arg(short, long, conflicts_with_all = &["all_cmd", "no_cmd"])]
flagged_cmd: bool,
#[arg(long)]
no_dom_check: bool,
#[arg(short, long)]
brew: bool,
#[arg(long)]
brew_force: bool,
#[arg(long)]
brew_skip_cask: bool,
#[arg(long)]
brew_skip_formula: bool,
}
#[derive(Debug)]
struct PreferenceJob {
domain: String,
key: String,
original: Option<SerializablePrefValue>,
new_value: PrefValue,
}
#[async_trait]
impl Runnable for ApplyCmd {
fn get_invoke_rules(&self) -> RunnableInvokeRules {
RunnableInvokeRules {
do_config_autosync: true,
require_sudo: false,
respect_lock: true,
}
}
async fn run(&self, ctx: &AppContext) -> Result<()> {
let dry_run = should_dry_run();
if let Some(url) = &self.url {
if ctx.config.is_loadable()
&& !confirm("Local config exists but a URL was still passed. Proceed?")
{
bail!("Aborted apply: --url is passed despite local config.")
}
let remote_mgr = RemoteConfigManager::new(url.to_owned());
remote_mgr.fetch().await?;
remote_mgr.save().await?;
log_info!("Remote config downloaded at path: {:?}", ctx.config.path());
}
let digest = get_digest(ctx.config.path())?;
let doc = ctx.config.load_as_mut().await?;
let config_system_domains = collect(&doc).await?;
let mut is_bad_snap: bool = false;
let snap = if ctx.snapshot.is_loadable() {
match ctx.snapshot.load().await {
Ok(snap) => snap,
Err(e) => {
log_warn!("Bad snapshot: {e}; starting new.");
log_warn!("When unapplying, all your settings will reset to factory defaults.");
is_bad_snap = true;
ctx.snapshot.new_empty()
}
}
} else {
ctx.snapshot.new_empty()
};
let mut jobs: Vec<PreferenceJob> = Vec::new();
let system_domains: HashSet<String> = get_sys_domain_strings()?;
let mut existing: HashMap<_, _> = snap
.settings
.iter()
.map(|s| ((s.domain.clone(), s.key.clone()), s))
.collect();
for (dom, keyval_table) in config_system_domains {
for (key, toml_value) in keyval_table {
let (eff_dom, eff_key) = core::get_effective_sys_domain_key(&dom, &key);
if !self.no_dom_check
&& eff_dom != "NSGlobalDomain"
&& !system_domains.contains(&eff_dom)
{
bail!(
"Domain \"{eff_dom}\" was not found; cannot write to it. Disable this behavior by passing: --no-dom-check"
)
}
let current_pref = core::read_current(&eff_dom, &eff_key).await;
let new_pref = toml_to_prefvalue(&toml_value)?;
let changed = match ¤t_pref {
Some(current) => current != &new_pref,
None => true, };
let old_entry = existing.get(&(eff_dom.clone(), eff_key.clone())).cloned();
if changed {
existing.remove(&(eff_dom.clone(), eff_key.clone()));
let original = if let Some(e) = &old_entry {
e.original_value.clone()
} else if let Some(pref) = current_pref {
Some(prefvalue_to_serializable(&pref).with_context(|| {
format!(
"Failed to serialize current preference value for key '{eff_key}'."
)
})?)
} else {
None
};
jobs.push(PreferenceJob {
domain: eff_dom,
key: eff_key,
new_value: new_pref,
original: if is_bad_snap { None } else { original },
});
} else {
log_info!("Skipping unchanged {eff_dom} | {eff_key}",);
}
}
}
if dry_run {
for job in &jobs {
log_dry!(
"Would apply: {} {} -> {}",
job.domain,
job.key,
job.new_value
);
}
} else {
let mut applyable_settings_count = 0;
for job in &jobs {
let domain_obj = if job.domain == "NSGlobalDomain" {
Domain::Global
} else {
Domain::User(job.domain.clone())
};
log_info!(
"Applying {} | {} -> {} {}",
job.domain,
job.key,
job.new_value.to_string(),
if let Some(orig) = &job.original {
format!(
"[Restorable to {}]",
serde_json::to_string(orig).unwrap_or_else(|_| "?".to_string())
)
} else {
String::new()
}
);
if let Err(e) = Preferences::write(domain_obj, &job.key, job.new_value.clone()) {
log_err!(
"Failed to apply preference ({} | {}). Error: {}",
job.domain,
job.key,
e
);
} else {
applyable_settings_count += 1;
}
}
if applyable_settings_count > 0 {
log_info!(
"Applied {} settings, will restart services.",
applyable_settings_count
);
restart_services().await;
}
}
let mut new_snap = ctx.snapshot.new_empty();
for ((_, _), old_entry) in existing {
new_snap.settings.push(old_entry.clone());
}
for job in jobs {
new_snap.settings.push(SettingState {
domain: job.domain,
key: job.key,
original_value: job.original.clone(),
});
}
new_snap.digest = digest;
if dry_run {
log_dry!("Would save snapshot with system preferences.");
} else {
new_snap.save().await?;
log_info!("Logged system preferences change in snapshot.");
}
if self.brew {
BrewInstallCmd {
force: self.brew_force,
skip_cask: self.brew_skip_cask,
skip_formula: self.brew_skip_formula,
}
.run(ctx)
.await?;
}
if !self.no_cmd {
let mode = if self.all_cmd {
ExecMode::All
} else if self.flagged_cmd {
ExecMode::Flagged
} else {
ExecMode::Regular
};
let loaded_config = ctx.config.load().await?;
let exec_run_count = run_all(loaded_config, mode).await?;
if dry_run {
log_dry!("Would save snapshot with external command execution.");
} else if exec_run_count > 0 {
new_snap.exec_run_count = exec_run_count;
new_snap.save().await?;
log_info!("Logged command execution in snapshot.");
}
}
log_cute!("Applying complete!");
Ok(())
}
}