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
+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"
);
}