feat(storage): Phase 5 C1 — AppConfig 持久化到 config.json
- src/storage/config.rs: AppConfig + ServerEntry + load_config +
save_config,atomic write(tmp + rename + fsync);多 server
数据结构按 §21 落地但 V1 只 active 一个;3 个 #[cfg(test)]
round-trip + default + missing-field 测试
- src/storage/state.rs: 从 src/storage.rs 拆过来;atomic write + 2
个测试(default 960×680 + JSON round-trip)
- src/storage/mod.rs: pub use re-export 两组 API
- src/config.rs: 重构为 ActiveConfig thin wrapper,调
storage::load_config() 拿到第一个 selected_server;placeholder
hardcoded URL/token 在 §19/§20 意义上不再合适,删掉
- src/gotify/model.rs: ServerConfig 加 Serialize/Deserialize(要
写到 config.json 必须能 serialize;token 仍走 §20 不进日志)
- src/lib.rs: run() 启动用 ActiveConfig::load() 取代 placeholder;
on_test_connection_requested 同样改用 ActiveConfig::load()
- 删除 src/storage.rs(被 src/storage/{config,state}.rs 替代;
同样内容已迁过去)
避坑:
- ServerConfig 之前没 derive Serialize,所以 #[serde(flatten)]
嵌套到 ServerEntry 失败——加 derive 是必要的
- src/storage.rs 和 src/storage/mod.rs 不能并存,否则 Rust 报
ambiguous module;删旧文件就行
- AppConfig 多 vec + serde default 让旧 config 文件(字段缺失)
也能 forward-compat parse(3 个测试覆盖)
验证:cargo build / build --release 都 exit 0,0 warning
- cargo test: 14 passed (5 storage unit + 6 api + 3 websocket); 0 failed
- debug 35.54 MB, subsystem = WINDOWS_CUI
- release 9.76 MB, subsystem = WINDOWS_GUI
This commit is contained in:
+55
-18
@@ -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<ServerConfig>,
|
||||
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<Self> {
|
||||
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())
|
||||
}
|
||||
}
|
||||
+4
-2
@@ -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,
|
||||
|
||||
+4
-4
@@ -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<VecModel<MessageItem>>;
|
||||
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) {
|
||||
|
||||
@@ -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<ServerEntry>,
|
||||
/// ID of the server the runtime should connect to. None means
|
||||
/// "no server configured".
|
||||
#[serde(default)]
|
||||
pub selected_server: Option<String>,
|
||||
|
||||
/// `"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<PathBuf> {
|
||||
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::<AppConfig>(&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);
|
||||
}
|
||||
}
|
||||
@@ -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};
|
||||
@@ -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<PathBuf> {
|
||||
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::<AppState>(&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");
|
||||
@@ -99,3 +85,27 @@ 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user