Chores customize install #23
@@ -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"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user