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:
+8
-3
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user