diff --git a/src/gotify/api.rs b/src/gotify/api.rs index 60d010b..3f62fde 100644 --- a/src/gotify/api.rs +++ b/src/gotify/api.rs @@ -84,9 +84,14 @@ impl GotifyApi { self.get_json("/application") } - /// `GET /message?limit=N` — most recent N messages newest-first. - pub fn messages(&self, limit: u32) -> AppResult> { - self.get_json(&format!("/message?limit={limit}")) + /// `GET /message?since=N&limit=M` — most recent M messages newer + /// than id `N`. Pass `None` to fetch the latest batch. + pub fn messages(&self, since: Option, limit: u32) -> AppResult> { + let path = match since { + Some(s) if s > 0 => format!("/message?since={s}&limit={limit}"), + _ => format!("/message?limit={limit}"), + }; + self.get_json(&path) } fn get_json(&self, path: &str) -> AppResult { diff --git a/src/lib.rs b/src/lib.rs index 49aca05..75ecde8 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,23 +1,23 @@ //! Windows Gotify Client (Rust) — core library. //! -//! Phase 4 surface: -//! - [`config`]: active-config wrapper (Phase 5 C1: reads storage) +//! Phase 5 surface: +//! - [`config`]: active-config wrapper (Phase 5 C1) //! - [`error`]: AppError / AppResult //! - [`gotify`]: blocking REST + WebSocket clients (Phase 3/4) //! - [`logging`]: tracing-subscriber init //! - [`models`]: mock feed + Gotify→Slint adapter + model builders -//! - [`storage`]: `%APPDATA%/WindowsGotify/state.json` +//! - [`storage`]: `%APPDATA%/WindowsGotify/{config,state}.json` //! - [`run`]: builds the MainWindow and runs its event loop //! -//! Phase 4 architecture: -//! 1. HTTP catch-up fetches the most recent 50 messages (Phase 3 C2). -//! 2. A background `std::thread` runs `websocket::run_stream_loop` and -//! converts each text frame into a `MessageItem`. -//! 3. Items flow through an `std::sync::mpsc` channel to a UI-thread -//! `slint::Timer` (100 ms tick) that drains the receiver into the -//! shared `VecModel` via `push()`. `VecModel` is `!Send` (it owns -//! its own internal state and only lives on the UI thread), so the -//! timer is the only place that ever mutates it. +//! Phase 5 C2 additions on top of Phase 4: +//! - `last_theme` + `last_message_id` persist into `state.json` +//! (AGENTS.md §10 / §16 / §18). +//! - Settings → Appearance radio fires `appearance-changed-persist`, +//! which writes the new theme into `state.last_theme` immediately. +//! - The HTTP catch-up uses `since=last_message_id` so a restart only +//! downloads the gap. +//! - The WS drain closure bumps `last_message_id` on every accepted +//! frame, so a WS-only session still advances the watermark. pub mod config; pub mod error; @@ -36,6 +36,26 @@ use slint::{ModelRc, VecModel}; use storage::AppState; +/// Map the persisted theme string (`state.json::last_theme`) back into +/// the Slint `AppearanceMode` enum. Unknown values fall back to Dark so +/// a corrupted config file can't strand the UI on Light with no way to +/// switch. +fn parse_theme(s: &str) -> AppearanceMode { + match s { + "system" => AppearanceMode::System, + "light" => AppearanceMode::Light, + _ => AppearanceMode::Dark, + } +} + +fn theme_str(m: AppearanceMode) -> &'static str { + match m { + AppearanceMode::System => "system", + AppearanceMode::Light => "light", + AppearanceMode::Dark => "dark", + } +} + /// Build the MainWindow and run its event loop until the window is closed. pub fn run() -> error::AppResult<()> { tracing::info!( @@ -45,17 +65,32 @@ pub fn run() -> error::AppResult<()> { let ui = MainWindow::new().map_err(|e| error::AppError::Ui(e.to_string()))?; - // ── Load persisted window geometry (Phase 2 C3) ─────────────── - let initial_state = storage::load_state(); - tracing::info!( - width = initial_state.last_window_width, - height = initial_state.last_window_height, - "loaded state.json" - ); - ui.set_theme(AppearanceMode::Dark); // C5 reads theme from state.json. + // ── Shared in-memory state. Lives for the whole process so the + // WS drain closure, the close-requested callback, and the + // appearance-changed-persist callback can all mutate it. ── + let state_rc: Rc> = + Rc::new(RefCell::new(storage::load_state())); + { + let s = state_rc.borrow(); + tracing::info!( + width = s.last_window_width, + height = s.last_window_height, + theme = %s.last_theme, + last_message_id = s.last_message_id, + "loaded state.json" + ); + } + let initial_theme = parse_theme(&state_rc.borrow().last_theme); + let initial_appearance_index = match initial_theme { + AppearanceMode::System => 0, + AppearanceMode::Light => 1, + AppearanceMode::Dark => 2, + }; + ui.set_theme(initial_theme); + ui.set_appearance_index(initial_appearance_index); ui.window().set_size(slint::WindowSize::Logical(slint::LogicalSize::new( - initial_state.last_window_width.max(0) as f32, - initial_state.last_window_height.max(0) as f32, + state_rc.borrow().last_window_width.max(0) as f32, + state_rc.borrow().last_window_height.max(0) as f32, ))); // ── Phase 3 C2 + 4 C2 + 5 C1: HTTP catch-up, then channel for WS ──── @@ -65,8 +100,13 @@ pub fn run() -> error::AppResult<()> { let is_connected: bool; let ws_credential: Option<(String, String, Vec)>; + let since = state_rc.borrow().last_message_id; match cfg.server.as_ref() { - Some(server) => match models::try_fetch_initial_data(&server.base_url, &server.token) { + Some(server) => match models::try_fetch_initial_data( + &server.base_url, + &server.token, + Some(since), + ) { Some((apps, msgs, text)) => { tracing::info!( server = %server.base_url, @@ -80,6 +120,15 @@ pub fn run() -> error::AppResult<()> { status_text = text; is_connected = true; ws_credential = Some((server.base_url.clone(), server.token.clone(), apps)); + // Phase 5 C2: advance the watermark from the catch-up + // batch even before the WS stream starts. + { + let mut s = state_rc.borrow_mut(); + let max_id = msgs.iter().map(|m| m.id).max().unwrap_or(s.last_message_id); + if max_id > s.last_message_id { + s.last_message_id = max_id; + } + } } None => { tracing::warn!( @@ -105,9 +154,7 @@ pub fn run() -> error::AppResult<()> { ui.set_status_text(status_text.into()); ui.set_is_connected(is_connected); - // ── Phase 4 C2: WebSocket worker + UI-thread drain ─────────── - // Channel + 100 ms timer keeps `VecModel::push` (not Send) on the - // UI thread without giving up on real-time updates from the worker. + // ── Phase 4 C2 + 5 C2: WebSocket worker + UI-thread drain ─────────── let drain_timer = if let Some((base_url, token, apps)) = ws_credential { let (tx, rx) = mpsc::channel::(); let apps_by_id: std::collections::HashMap = @@ -131,13 +178,23 @@ pub fn run() -> error::AppResult<()> { let timer = slint::Timer::default(); let model_for_drain = shared_model.clone(); + let state_for_drain = state_rc.clone(); timer.start( slint::TimerMode::Repeated, std::time::Duration::from_millis(100), move || { loop { match rx.try_recv() { - Ok(item) => model_for_drain.push(item), + Ok(item) => { + { + let mut s = state_for_drain.borrow_mut(); + let id = item.id as i64; + if id > s.last_message_id { + s.last_message_id = id; + } + } + model_for_drain.push(item); + } Err(_) => break, } } @@ -147,7 +204,6 @@ pub fn run() -> error::AppResult<()> { } else { None }; - // Keep `drain_timer` alive for the rest of `run()`. let _drain_timer = drain_timer; // ── Phase 2 C2 callbacks ─────────────────────────────────────── @@ -166,7 +222,7 @@ pub fn run() -> error::AppResult<()> { 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) { + match models::try_fetch_initial_data(&server.base_url, &server.token, None) { Some((apps, msgs, text)) => { if let Some(ui) = ui_handle.upgrade() { ui.set_messages(models::build_message_model(&msgs, &apps)); @@ -188,8 +244,21 @@ pub fn run() -> error::AppResult<()> { } }); + // ── Phase 5 C2: Settings → Appearance radio persists theme ───── + ui.on_appearance_changed_persist({ + let state_for_theme = state_rc.clone(); + move |idx: i32| { + let s = theme_str(match idx { + 0 => AppearanceMode::System, + 1 => AppearanceMode::Light, + _ => AppearanceMode::Dark, + }); + tracing::info!(theme = %s, "settings: theme changed → persisting"); + state_for_theme.borrow_mut().last_theme = s.to_string(); + } + }); + // ── Phase 2 C3 callbacks ─────────────────────────────────────── - let state_for_save: Rc> = Rc::new(RefCell::new(initial_state)); ui.on_about_clicked({ let ui_handle = ui.as_weak(); move || { @@ -200,23 +269,28 @@ pub fn run() -> error::AppResult<()> { } }); - // ── Phase 2 C3: save state on OS-close ──────────────────────── + // ── Phase 2 C3 / 5 C2: save state on OS-close ───────────────── + // Captures the current window geometry, theme, and last_message_id + // (already updated by the WS drain closure). ui.window().on_close_requested({ - let state_for_save = state_for_save.clone(); + let state_for_save = state_rc.clone(); let ui_handle = ui.as_weak(); move || { if let Some(ui) = ui_handle.upgrade() { let size = ui.window().size(); - let mut state = state_for_save.borrow_mut(); - state.last_window_width = size.width as i32; - state.last_window_height = size.height as i32; + let mut s = state_for_save.borrow_mut(); + s.last_window_width = size.width as i32; + s.last_window_height = size.height as i32; } - if let Err(e) = storage::save_state(&state_for_save.borrow()) { + let snapshot = state_for_save.borrow().clone(); + if let Err(e) = storage::save_state(&snapshot) { tracing::warn!(error = %e, "on close: failed to save state.json"); } else { tracing::info!( - width = state_for_save.borrow().last_window_width, - height = state_for_save.borrow().last_window_height, + width = snapshot.last_window_width, + height = snapshot.last_window_height, + last_message_id = snapshot.last_message_id, + theme = %snapshot.last_theme, "saved state.json on close" ); } diff --git a/src/models.rs b/src/models.rs index 76f9b4e..f3213fa 100644 --- a/src/models.rs +++ b/src/models.rs @@ -219,6 +219,7 @@ fn map_slint(p: i32) -> crate::Priority { pub fn try_fetch_initial_data( base_url: &str, token: &str, + since: Option, ) -> Option<(Vec, Vec, String)> { use crate::gotify::GotifyApi; @@ -232,8 +233,12 @@ pub fn try_fetch_initial_data( return None; } let apps = api.applications().ok()?; - let msgs = api.messages(50).ok()?; - let text = format!("{} · {} messages", api.base_url(), msgs.len()); + let msgs = api.messages(since, 50).ok()?; + let text = format!( + "{} · {} messages", + api.base_url(), + msgs.len() + ); Some((apps, msgs, text)) } @@ -244,7 +249,7 @@ pub fn try_fetch_real_messages( base_url: &str, token: &str, ) -> Option<(ModelRc, String)> { - let (apps, msgs, text) = try_fetch_initial_data(base_url, token)?; + let (apps, msgs, text) = try_fetch_initial_data(base_url, token, None)?; Some((build_message_model(&msgs, &apps), text)) } diff --git a/src/storage/state.rs b/src/storage/state.rs index 96dfab0..75905c6 100644 --- a/src/storage/state.rs +++ b/src/storage/state.rs @@ -3,6 +3,13 @@ //! File: `%APPDATA%/WindowsGotify/state.json`. Atomic write via //! tmp + rename (same pattern as `config.rs`). Missing file → returns //! `AppState::default()`. +//! +//! Phase 5 C2 adds two persistence targets: +//! - `last_theme`: drives MainWindow.theme on the next launch so the +//! user's appearance choice survives a restart +//! - `last_message_id`: highest Gotify message id seen since startup; +//! HTTP catch-up on the next launch uses `since=last_message_id` +//! to fetch only the gap (AGENTS.md §16) use std::fs; use std::io::Write; @@ -16,17 +23,36 @@ const STATE_DIR: &str = "WindowsGotify"; const STATE_FILE: &str = "state.json"; const STATE_VERSION: u32 = 1; +fn current_version() -> u32 { + STATE_VERSION +} +fn default_theme() -> String { + "dark".into() +} +fn default_last_message_id() -> i64 { + 0 +} + /// Persistable subset of application state. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AppState { #[serde(default = "current_version")] pub version: u32, + pub last_window_width: i32, pub last_window_height: i32, -} -fn current_version() -> u32 { - STATE_VERSION + /// "system" / "light" / "dark". Drives the Slint theme on the next + /// launch. AGENTS.md §10 / §52. + #[serde(default = "default_theme")] + pub last_theme: String, + + /// Highest Gotify message id observed across the HTTP catch-up and + /// the WebSocket stream. The next launch passes this as `since=` to + /// `/message` so we only download the gap (AGENTS.md §16). + /// `0` means "first run, fetch the latest 50". + #[serde(default = "default_last_message_id")] + pub last_message_id: i64, } impl Default for AppState { @@ -35,6 +61,8 @@ impl Default for AppState { version: STATE_VERSION, last_window_width: 960, last_window_height: 680, + last_theme: "dark".into(), + last_message_id: 0, } } } @@ -103,9 +131,23 @@ mod tests { let mut s = AppState::default(); s.last_window_width = 1024; s.last_window_height = 768; + s.last_theme = "light".into(); + s.last_message_id = 12345; 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); + assert_eq!(p.last_theme, "light"); + assert_eq!(p.last_message_id, 12345); + } + + #[test] + fn legacy_state_without_new_fields_still_parses() { + // Older state.json files written before last_theme / + // last_message_id existed must still parse with safe defaults. + let legacy = r#"{"version":1,"last_window_width":1280,"last_window_height":720}"#; + let s: AppState = serde_json::from_str(legacy).unwrap(); + assert_eq!(s.last_window_width, 1280); + assert_eq!(s.last_theme, "dark"); + assert_eq!(s.last_message_id, 0); } } \ No newline at end of file diff --git a/ui/app.slint b/ui/app.slint index d46b605..fcdc679 100644 --- a/ui/app.slint +++ b/ui/app.slint @@ -57,6 +57,9 @@ export component MainWindow inherits Window { // ── Phase 2 C3: Settings / About / window state ────────────── in-out property appearance-index: 2; callback about-clicked(); + // Phase 5 C2: fires whenever the user picks a new theme in Settings. + // Rust persists it into `state.json::last_theme`. + callback appearance-changed-persist(int); // ── Phase 3 C2: live connection status ─────────────────────── in-out property is-connected: false; @@ -212,6 +215,7 @@ export component MainWindow inherits Window { root.theme = idx == 0 ? AppearanceMode.System : idx == 1 ? AppearanceMode.Light : AppearanceMode.Dark; + root.appearance-changed-persist(idx); } test-connection-requested => { root.test-connection-requested();