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:
+112
-38
@@ -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"
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user