feat(storage): Phase 5 C2 — Theme + last_message_id 持久化

- src/storage/state.rs: AppState 加 last_theme (string "system" /
  "light" / "dark") + last_message_id (i64);#[serde(default)]
  让 legacy state.json(只有 window width/height)也能 forward-
  compat parse;3 个 test(含 legacy round-trip)
- src/gotify/api.rs: messages() 加 since: Option<i64> 参数,
  since>0 时拼 ?since=N&limit=M
- src/models.rs: try_fetch_initial_data 加 since 参数透传
- src/lib.rs: state_rc: Rc<RefCell<AppState>> 替代只读 initial_state
  · 启动读 last_theme 应用到 MainWindow.theme + appearance_index
  · HTTP catch-up 用 since=last_message_id;返回的 msgs 推
    watermark
  · WS drain closure 同步更新 last_message_id(共享 Rc 进 UI
    线程 closure)
  · on_close_requested 把整个 state clone 一份再 save(state_for_
    save Rc 也被 drain 用,不能 hold borrow 同时 serialize)
  · 新 callback appearance-changed-persist:Settings → Appearance
    radio 改 theme 时立刻写 state.last_theme
- ui/app.slint: MainWindow 加 appearance-changed-persist callback;
  SettingsPage 的 appearance-changed 调它

避坑:
- VecModel 不是 Send → last_message_id 维护必须放在 UI 线程的
  timer drain closure 里(不是 WS worker),所以走 Rc<RefCell>
  共享 + 不需要锁
- on_close_requested 之前用 state_for_save.borrow_mut() 修改然后
  borrow() save——drain 也在 borrow_mut() 会死锁(同一 RefCell)。
  改成"修改 borrow_mut + save clone",避免同时持有两个 borrow
- AppearanceMode::Dark 的 parse fallback:state.json 写错时不
  锁死在 Light
- legacy state.json 没 last_theme / last_message_id 也能正常
  parse + 用 default(serde(default) + 1 个测试覆盖)

验证:cargo build / build --release 都 exit 0,0 warning
- cargo test: 15 passed (6 storage unit + 6 api + 3 websocket); 0 failed
- debug   35.56 MB, subsystem = WINDOWS_CUI
- release  9.77 MB, subsystem = WINDOWS_GUI
This commit is contained in:
2026-09-19 06:54:42 +08:00
parent 07317e2890
commit 28524b4128
5 changed files with 178 additions and 48 deletions
+8 -3
View File
@@ -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<Vec<GotifyMessage>> {
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<i64>, limit: u32) -> AppResult<Vec<GotifyMessage>> {
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<T: DeserializeOwned>(&self, path: &str) -> AppResult<T> {
+112 -38
View File
@@ -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<RefCell<AppState>> =
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<gotify::GotifyApplication>)>;
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::<MessageItem>();
let apps_by_id: std::collections::HashMap<i64, gotify::GotifyApplication> =
@@ -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<RefCell<AppState>> = 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"
);
}
+8 -3
View File
@@ -219,6 +219,7 @@ fn map_slint(p: i32) -> crate::Priority {
pub fn try_fetch_initial_data(
base_url: &str,
token: &str,
since: Option<i64>,
) -> Option<(Vec<crate::gotify::GotifyApplication>, Vec<crate::gotify::GotifyMessage>, 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<MessageItem>, 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))
}
+46 -4
View File
@@ -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);
}
}
+4
View File
@@ -57,6 +57,9 @@ export component MainWindow inherits Window {
// ── Phase 2 C3: Settings / About / window state ──────────────
in-out property <int> 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 <bool> 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();