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