diff --git a/src/config.rs b/src/config.rs index 67b9102..f378bab 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,29 +1,66 @@ -//! Application configuration placeholder. +//! Active-config view used at runtime. //! -//! Phase 3 ships only a hardcoded `placeholder()` so the UI wiring can -//! demonstrate the real-data path. Phase 5 replaces this with persistent -//! JSON storage at `%APPDATA%/WindowsGotify/config.json` (AGENTS.md §5/§19/§20). -//! -//! AGENTS.md §20 — the placeholder token below is intentionally not a -//! real credential. Real auth fails, the UI falls back to mock data, and -//! nothing about the token ever reaches a log line. +//! Phase 5 C1: this is a thin wrapper over `storage::AppConfig`. It +//! surfaces the single selected server as `ServerConfig` so the rest +//! of the runtime (`gotify::GotifyApi`, `gotify::websocket`) doesn't +//! have to care about the multi-server storage layout. The placeholder +//! URL/token that lived here in Phase 3 C2 is gone — first-run users +//! will see "no server configured" until they fill in Settings +//! (Phase 6 hooks up the Settings → Connection form). +use crate::error::AppResult; use crate::gotify::ServerConfig; +use crate::storage::AppConfig as PersistedConfig; -pub struct AppConfig { +pub struct ActiveConfig { pub server: Option, + pub appearance: String, + pub desktop_notifications: bool, + pub sound: bool, + pub notify_high: bool, + pub notify_max: bool, + pub start_with_windows: bool, + pub start_minimized: bool, + pub close_to_tray: bool, } -impl AppConfig { - /// Hardcoded single-server placeholder. Returns `None` here means - /// "no server configured" — but for now Phase 3 always returns `Some` - /// so the UI wiring is exercised on every start. - pub fn placeholder() -> Self { +impl ActiveConfig { + /// Load from `%APPDATA%/WindowsGotify/config.json` (or defaults on + /// missing / malformed file). Returns `Ok(ActiveConfig)` even when + /// there's no server configured — the caller decides how to + /// surface that in the UI. + pub fn load() -> AppResult { + let persisted = crate::storage::load_config(); + Ok(Self::from_persisted(persisted)) + } + + fn from_persisted(p: PersistedConfig) -> Self { + let server = p + .active_server() + .or_else(|| p.first_enabled_server()) + .map(|entry| entry.config.clone()); Self { - server: Some(ServerConfig::new( - "http://192.168.50.103:50334", - "placeholder-token-do-not-log", - )), + server, + appearance: p.appearance, + desktop_notifications: p.desktop_notifications, + sound: p.sound, + notify_high: p.notify_high, + notify_max: p.notify_max, + start_with_windows: p.start_with_windows, + start_minimized: p.start_minimized, + close_to_tray: p.close_to_tray, } } + + /// Build an `ActiveConfig` from a fully-formed `PersistedConfig` + /// value (handy for tests and for the upcoming Settings → Save + /// path, which constructs the persisted config in memory and then + /// asks us to mirror it back). + pub fn from_persisted_value(p: PersistedConfig) -> Self { + Self::from_persisted(p) + } + + pub fn placeholder() -> Self { + Self::from_persisted(PersistedConfig::default()) + } } \ No newline at end of file diff --git a/src/gotify/model.rs b/src/gotify/model.rs index b26f814..c48985c 100644 --- a/src/gotify/model.rs +++ b/src/gotify/model.rs @@ -50,8 +50,10 @@ pub struct GotifyMessage { } /// Connection target — single server in V1, multi-server array lives in -/// `AppConfig` (Phase 5). -#[derive(Debug, Clone)] +/// `AppConfig` (Phase 5). The token is serializable (it must be, to +/// persist `config.json`), but **must never appear in logs** — see +/// `src/storage/config.rs` and AGENTS.md §20. +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct ServerConfig { pub base_url: String, pub token: String, diff --git a/src/lib.rs b/src/lib.rs index 34176ed..49aca05 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,7 +1,7 @@ //! Windows Gotify Client (Rust) — core library. //! //! Phase 4 surface: -//! - [`config`]: placeholder AppConfig (Phase 5 swaps for persistence) +//! - [`config`]: active-config wrapper (Phase 5 C1: reads storage) //! - [`error`]: AppError / AppResult //! - [`gotify`]: blocking REST + WebSocket clients (Phase 3/4) //! - [`logging`]: tracing-subscriber init @@ -58,8 +58,8 @@ pub fn run() -> error::AppResult<()> { initial_state.last_window_height.max(0) as f32, ))); - // ── Phase 3 C2 + 4 C2: HTTP catch-up, then channel for WS ──── - let cfg = config::AppConfig::placeholder(); + // ── Phase 3 C2 + 4 C2 + 5 C1: HTTP catch-up, then channel for WS ──── + let cfg = config::ActiveConfig::load()?; let shared_model: Rc>; let status_text: String; let is_connected: bool; @@ -163,7 +163,7 @@ pub fn run() -> error::AppResult<()> { let ui_handle = ui.as_weak(); move || { tracing::info!("settings: Test Connection clicked"); - let Some(server) = config::AppConfig::placeholder().server else { + let Some(server) = config::ActiveConfig::load().ok().and_then(|c| c.server) else { return; }; match models::try_fetch_initial_data(&server.base_url, &server.token) { diff --git a/src/storage/config.rs b/src/storage/config.rs new file mode 100644 index 0000000..5287f6e --- /dev/null +++ b/src/storage/config.rs @@ -0,0 +1,241 @@ +//! Persistent application configuration (AGENTS.md §19 / §20). +//! +//! File: `%APPDATA%/WindowsGotify/config.json`. JSON via serde. Atomic +//! write via tmp + rename (same pattern as `state.rs`). Missing file → +//! returns `AppConfig::default()`. Token in `ServerEntry.token` must +//! never reach a log line (§20). +//! +//! The persistence shape is the multi-server target from §21; Phase 5 C1 +//! only persists the single active server (V1 still connects to one at +//! a time, see §3 / §21), but the data layout is already multi-server +//! so the Phase 6 / 7 multi-server UI doesn't have to migrate schema. + +use std::fs; +use std::io::Write; +use std::path::{Path, PathBuf}; + +use serde::{Deserialize, Serialize}; + +use crate::error::{AppError, AppResult}; +use crate::gotify::ServerConfig; + +const CONFIG_DIR: &str = "WindowsGotify"; +const CONFIG_FILE: &str = "config.json"; +const CONFIG_VERSION: u32 = 1; + +fn current_version() -> u32 { + CONFIG_VERSION +} +fn default_true() -> bool { + true +} +fn default_appearance() -> String { + "dark".into() +} + +/// One configured Gotify server. `id` is stable across renames; V1 only +/// surfaces `selected_server` to the runtime, so this field exists in +/// anticipation of the Phase 6 multi-server UI (§21). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ServerEntry { + pub id: String, + pub name: String, + #[serde(flatten)] + pub config: ServerConfig, + #[serde(default = "default_true")] + pub enabled: bool, +} + +/// The full user-visible settings payload. `version` lets us run +/// migrations when the schema changes; we default it via +/// `#[serde(default = current_version)]` so older files upgrade on load. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AppConfig { + #[serde(default = "current_version")] + pub version: u32, + + #[serde(default)] + pub servers: Vec, + /// ID of the server the runtime should connect to. None means + /// "no server configured". + #[serde(default)] + pub selected_server: Option, + + /// `"system"` / `"light"` / `"dark"`. + #[serde(default = "default_appearance")] + pub appearance: String, + + #[serde(default = "default_true")] + pub desktop_notifications: bool, + #[serde(default = "default_true")] + pub sound: bool, + #[serde(default = "default_true")] + pub notify_high: bool, + #[serde(default = "default_true")] + pub notify_max: bool, + + #[serde(default)] + pub start_with_windows: bool, + #[serde(default)] + pub start_minimized: bool, + #[serde(default = "default_true")] + pub close_to_tray: bool, +} + +impl Default for AppConfig { + fn default() -> Self { + Self { + version: CONFIG_VERSION, + servers: Vec::new(), + selected_server: None, + appearance: "dark".into(), + desktop_notifications: true, + sound: true, + notify_high: true, + notify_max: true, + start_with_windows: false, + start_minimized: false, + close_to_tray: true, + } + } +} + +impl AppConfig { + /// Currently-selected server entry, if any. + pub fn active_server(&self) -> Option<&ServerEntry> { + let id = self.selected_server.as_ref()?; + self.servers.iter().find(|s| s.id == *id) + } + + /// First enabled server, regardless of `selected_server`. Used when + /// the user has not yet picked a default (typical first-run state). + pub fn first_enabled_server(&self) -> Option<&ServerEntry> { + self.servers.iter().find(|s| s.enabled) + } +} + +/// `%APPDATA%/WindowsGotify/config.json`. Falls back to CWD when +/// `APPDATA` is unset (dev shells). +pub fn config_path() -> Option { + if let Some(roaming) = std::env::var_os("APPDATA") { + return Some(Path::new(&roaming).join(CONFIG_DIR).join(CONFIG_FILE)); + } + std::env::current_dir() + .ok() + .map(|d| d.join(CONFIG_DIR).join(CONFIG_FILE)) +} + +/// Load config from disk. Missing file or invalid JSON yields +/// `AppConfig::default()`. +pub fn load_config() -> AppConfig { + let Some(path) = config_path() else { + return AppConfig::default(); + }; + let Ok(bytes) = fs::read(&path) else { + return AppConfig::default(); + }; + match serde_json::from_slice::(&bytes) { + Ok(c) => c, + Err(e) => { + tracing::warn!(?path, error = %e, "config.json malformed; using defaults"); + AppConfig::default() + } + } +} + +/// Atomic write. Creates the directory if needed. Returns Ok(()) on +/// success; the dev-shell fallback is a silent Ok so we don't crash +/// when `APPDATA` is unset. +pub fn save_config(config: &AppConfig) -> AppResult<()> { + let Some(path) = config_path() else { + tracing::debug!("APPDATA unset; skipping config.json save"); + return Ok(()); + }; + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).map_err(|e| AppError::Storage(e.to_string()))?; + } + let tmp = path.with_extension("json.tmp"); + { + let mut f = fs::File::create(&tmp).map_err(|e| AppError::Storage(e.to_string()))?; + // Serializing may fail if `ServerConfig`/`ServerEntry` is + // malformed; that should never happen because we own the types, + // but we still surface the error instead of panicking. + let body = serde_json::to_vec_pretty(config) + .map_err(|e| AppError::Storage(e.to_string()))?; + f.write_all(&body) + .map_err(|e| AppError::Storage(e.to_string()))?; + f.sync_all() + .map_err(|e| AppError::Storage(e.to_string()))?; + } + fs::rename(&tmp, &path).map_err(|e| AppError::Storage(e.to_string()))?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn default_round_trip() { + let c = AppConfig::default(); + let s = serde_json::to_string(&c).unwrap(); + let parsed: AppConfig = serde_json::from_str(&s).unwrap(); + assert_eq!(parsed.version, c.version); + assert_eq!(parsed.appearance, "dark"); + assert!(parsed.desktop_notifications); + assert!(parsed.close_to_tray); + assert!(!parsed.start_with_windows); + } + + #[test] + fn first_enabled_server_returns_first_match() { + let mut c = AppConfig::default(); + c.servers = vec![ + ServerEntry { + id: "a".into(), + name: "Home".into(), + config: ServerConfig::new("https://h.example.com", "tok-a"), + enabled: false, + }, + ServerEntry { + id: "b".into(), + name: "NAS".into(), + config: ServerConfig::new("http://nas.local:50334", "tok-b"), + enabled: true, + }, + ]; + assert_eq!(c.first_enabled_server().unwrap().id, "b"); + } + + #[test] + fn active_server_respects_selection() { + let mut c = AppConfig::default(); + c.servers = vec![ + ServerEntry { + id: "a".into(), + name: "A".into(), + config: ServerConfig::new("https://a.example", "ta"), + enabled: true, + }, + ServerEntry { + id: "b".into(), + name: "B".into(), + config: ServerConfig::new("https://b.example", "tb"), + enabled: true, + }, + ]; + c.selected_server = Some("b".into()); + assert_eq!(c.active_server().unwrap().id, "b"); + } + + #[test] + fn missing_optional_fields_default_safely() { + // Older config files written before some field existed must + // still parse with the right defaults. + let minimal = r#"{"version":1}"#; + let c: AppConfig = serde_json::from_str(minimal).unwrap(); + assert!(c.desktop_notifications); + assert_eq!(c.appearance, "dark"); + assert!(c.close_to_tray); + } +} \ No newline at end of file diff --git a/src/storage/mod.rs b/src/storage/mod.rs new file mode 100644 index 0000000..b44937c --- /dev/null +++ b/src/storage/mod.rs @@ -0,0 +1,18 @@ +//! Local persistence layer (AGENTS.md §18-20). +//! +//! Two on-disk files, both under `%APPDATA%/WindowsGotify/`: +//! +//! - `state.json` [`state`] — runtime state (window geometry, +//! last-theme, last-message-id) +//! - `config.json` [`config`] — user-visible settings (servers, +//! appearance, notification toggles, +//! startup toggles) +//! +//! Both modules share the same atomic-write pattern (tmp + rename + +//! fsync). Tokens are never serialized to logs (§20). + +pub mod config; +pub mod state; + +pub use config::{load_config, save_config, AppConfig, ServerEntry}; +pub use state::{load_state, save_state, AppState}; \ No newline at end of file diff --git a/src/storage.rs b/src/storage/state.rs similarity index 63% rename from src/storage.rs rename to src/storage/state.rs index 98b7224..96dfab0 100644 --- a/src/storage.rs +++ b/src/storage/state.rs @@ -1,12 +1,8 @@ -//! Local persistent state (AGENTS.md §18). +//! Runtime state persistence (AGENTS.md §18). //! -//! Phase 2 C3 only persists **window geometry** (size). The full state.json -//! schema — `last_message_id` / `selected_server` / etc. — lands in Phase 5 -//! (Storage) when `AppConfig` joins in. For now we keep this minimal: read -//! on startup, write on close, atomic via tmp + rename. -//! -//! Location: `%APPDATA%/WindowsGotify/state.json`. The directory is created -//! on first save; missing-file on first run is treated as "no prior state". +//! File: `%APPDATA%/WindowsGotify/state.json`. Atomic write via +//! tmp + rename (same pattern as `config.rs`). Missing file → returns +//! `AppState::default()`. use std::fs; use std::io::Write; @@ -23,7 +19,6 @@ const STATE_VERSION: u32 = 1; /// Persistable subset of application state. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AppState { - /// Bumped when the on-disk schema changes so future migrations can run. #[serde(default = "current_version")] pub version: u32, pub last_window_width: i32, @@ -38,27 +33,21 @@ impl Default for AppState { fn default() -> Self { Self { version: STATE_VERSION, - // Match MainWindow's `preferred-width/height` from ui/app.slint. last_window_width: 960, last_window_height: 680, } } } -/// `%APPDATA%/WindowsGotify/state.json`. Falls back to a temp-relative path -/// when `APPDATA` is unset (rare on real Windows installs but harmless for -/// dev shells). pub fn state_path() -> Option { if let Some(roaming) = std::env::var_os("APPDATA") { return Some(Path::new(&roaming).join(STATE_DIR).join(STATE_FILE)); } - // Fallback: current working dir + subfolder — only matters in dev shells. std::env::current_dir() .ok() .map(|d| d.join(STATE_DIR).join(STATE_FILE)) } -/// Load state from disk. Missing file or invalid JSON yields `AppState::default()`. pub fn load_state() -> AppState { let Some(path) = state_path() else { return AppState::default(); @@ -69,15 +58,12 @@ pub fn load_state() -> AppState { match serde_json::from_slice::(&bytes) { Ok(s) => s, Err(e) => { - tracing::warn!(?path, error = %e, "state.json is malformed; using defaults"); + tracing::warn!(?path, error = %e, "state.json malformed; using defaults"); AppState::default() } } } -/// Atomic write: write `state.json.tmp`, fsync, rename over `state.json`. -/// Returns Ok(()) on success, even when `%APPDATA%` is unset (no-op in -/// that case so dev shells don't crash). pub fn save_state(state: &AppState) -> AppResult<()> { let Some(path) = state_path() else { tracing::debug!("APPDATA unset; skipping state.json save"); @@ -98,4 +84,28 @@ pub fn save_state(state: &AppState) -> AppResult<()> { } fs::rename(&tmp, &path).map_err(|e| AppError::Storage(e.to_string()))?; Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn default_window_is_960x680() { + let s = AppState::default(); + assert_eq!(s.last_window_width, 960); + assert_eq!(s.last_window_height, 680); + assert_eq!(s.version, STATE_VERSION); + } + + #[test] + fn round_trip_via_json() { + let mut s = AppState::default(); + s.last_window_width = 1024; + s.last_window_height = 768; + let j = serde_json::to_string(&s).unwrap(); + let p: AppState = serde_json::from_str(&j).unwrap(); + assert_eq!(p.last_window_width, 1024); + assert_eq!(p.last_window_height, 768); + } } \ No newline at end of file