Chores customize install #23

Merged
iamdoubz merged 19 commits from chores_customize_install into main 2026-07-14 21:40:02 -05:00
Showing only changes of commit 55c5143de0 - Show all commits
+178
View File
@@ -0,0 +1,178 @@
//! Enterprise deployment: seed default settings from an admin-supplied `.ini`
//! on **first run only** (before any `settings.json` exists).
//!
//! An admin mass-deploying WhispAssist (GPO / SCCM / Intune) drops a
//! `wa-defaults.ini` and every fresh install picks it up once, seeding
//! `settings.json` with their chosen defaults (record-by-default, preferred
//! backend, retention, model to auto-download, …) — all via native Windows file
//! deployment, no WiX custom actions. See `docs/enterprise-deployment.md`.
//!
//! **Guardrail (CLAUDE.md):** the file must never carry secrets. Keys that look
//! like credentials are ignored here as defense in depth — API keys / OAuth
//! tokens live only in the OS credential store.
use crate::models::Settings;
use serde_json::{Map, Value};
use std::path::PathBuf;
/// Special (non-`Settings`) INI key: when truthy, the first-run seed also fetches
/// the configured `whisper_model` in the background so the machine is ready
/// offline. Stripped before the settings merge.
const AUTO_DOWNLOAD_KEY: &str = "auto_download_model";
/// Candidate locations, first found wins:
/// 1. `%PROGRAMDATA%\WhispAssist\wa-defaults.ini` — machine-wide enterprise path.
/// 2. `<exe dir>\wa-defaults.ini` — the bundled template / per-install override.
fn candidate_paths() -> Vec<PathBuf> {
let mut paths = Vec::new();
if let Ok(program_data) = std::env::var("ProgramData") {
paths.push(PathBuf::from(program_data).join("WhispAssist").join("wa-defaults.ini"));
}
if let Ok(exe) = std::env::current_exe() {
if let Some(dir) = exe.parent() {
paths.push(dir.join("wa-defaults.ini"));
}
}
paths
}
/// Reads the first existing defaults file and produces the seeded settings plus
/// the whisper model id to auto-download (if `auto_download_model` was set).
/// `None` when no file exists or it contains no overrides (the shipped template
/// is fully commented, so normal installs get exactly today's behavior).
pub fn seed_settings_from_defaults() -> Option<(Settings, Option<String>)> {
let text = candidate_paths()
.into_iter()
.find_map(|p| std::fs::read_to_string(p).ok())?;
seed_from_ini(&text)
}
/// Testable core: parse INI text → merge onto the built-in defaults.
fn seed_from_ini(text: &str) -> Option<(Settings, Option<String>)> {
let mut overrides = parse_ini(text);
if overrides.is_empty() {
return None;
}
// Pull the non-Settings auto-download flag out before the merge.
let auto_download = overrides
.remove(AUTO_DOWNLOAD_KEY)
.map(|v| truthy(&v))
.unwrap_or(false);
// Merge overrides onto the default settings' JSON form, then deserialize.
// Unknown keys (typos) are ignored — `Settings` has no deny_unknown_fields.
let mut base = match serde_json::to_value(crate::commands::default_settings()) {
Ok(Value::Object(map)) => map,
_ => return None,
};
for (k, v) in overrides {
base.insert(k, v);
}
let settings: Settings = serde_json::from_value(Value::Object(base)).ok()?;
let model = if auto_download {
Some(settings.whisper_model.clone())
} else {
None
};
Some((settings, model))
}
/// Minimal INI reader: skips blanks, `;`/`#` comments and `[section]` headers;
/// splits each `key = value` on the first `=`; coerces values to bool / integer /
/// string so serde lands them on the typed `Settings` fields. Silently drops any
/// key that looks like a secret (guardrail — no credentials in the deploy file).
fn parse_ini(text: &str) -> Map<String, Value> {
let mut map = Map::new();
for line in text.lines() {
let line = line.trim();
if line.is_empty()
|| line.starts_with(';')
|| line.starts_with('#')
|| line.starts_with('[')
{
continue;
}
let Some((key, value)) = line.split_once('=') else {
continue;
};
let key = key.trim().to_string();
let value = value.trim();
if key.is_empty() || looks_like_secret(&key) {
continue;
}
map.insert(key, coerce(value));
}
map
}
/// `true`/`false` → bool, all-integer → number, everything else → string.
fn coerce(value: &str) -> Value {
match value.to_ascii_lowercase().as_str() {
"true" => return Value::Bool(true),
"false" => return Value::Bool(false),
_ => {}
}
if let Ok(n) = value.parse::<i64>() {
return Value::Number(n.into());
}
Value::String(value.to_string())
}
fn truthy(v: &Value) -> bool {
matches!(v, Value::Bool(true)) || matches!(v, Value::String(s) if s.eq_ignore_ascii_case("true"))
}
/// Defense in depth: never seed anything that smells like a credential.
fn looks_like_secret(key: &str) -> bool {
let k = key.to_ascii_lowercase();
["key", "token", "secret", "credential", "password"]
.iter()
.any(|needle| k.contains(needle))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn fully_commented_file_is_a_noop() {
let ini = "; default_record = true\n# preferred_backend = cpu\n[general]\n\n";
assert!(seed_from_ini(ini).is_none());
}
#[test]
fn coerces_bool_int_and_string_fields() {
let ini = "default_record = true\nretention_max_age_days = 90\npreferred_backend = cpu\n";
let (settings, model) = seed_from_ini(ini).expect("overrides present");
assert!(settings.default_record);
assert_eq!(settings.retention_max_age_days, Some(90));
assert_eq!(settings.preferred_backend, "cpu");
assert!(model.is_none());
}
#[test]
fn auto_download_returns_the_configured_model() {
let ini = "whisper_model = base.en-q5_1\nauto_download_model = true\n";
let (_settings, model) = seed_from_ini(ini).expect("overrides present");
assert_eq!(model.as_deref(), Some("base.en-q5_1"));
}
#[test]
fn unset_fields_keep_their_defaults() {
let ini = "default_record = true\n";
let (settings, _) = seed_from_ini(ini).unwrap();
// microphone stays on, auto_start stays off — only the named key changed.
assert!(settings.microphone_enabled);
assert!(!settings.auto_start);
}
#[test]
fn secret_keys_are_ignored() {
let ini = "anthropic_api_key = sk-should-be-dropped\ndefault_record = true\n";
let map = parse_ini(ini);
assert!(!map.contains_key("anthropic_api_key"));
assert!(map.contains_key("default_record"));
}
}