feat(gotify): Phase 4 C2 — WS worker + channel + UI timer drain

- src/lib.rs: HTTP catch-up 拿 apps+msgs → 保留 apps 给 WS worker →
  spawn std::thread 跑 websocket::run_stream_loop;WS 帧 → MessageItem
  → std::sync::mpsc::channel → UI 线程 slint::Timer 100ms tick drain
  → shared Rc<VecModel<MessageItem>>::push
- src/models.rs: 拆 try_fetch_initial_data 返回 (apps, msgs, text)
  原始 DTO,让 lib.rs 自己决定 model 包装时机;保留 try_fetch_real_messages
  + 新增 build_message_model 给 Settings → Test Connection 复用

避坑:
- VecModel 不是 Send,不能跨线程直接 push——必须 mpsc channel +
  UI 线程 timer 中转(之前想用 invoke_from_event_loop 包装 Rc 失败:
  Rc 不是 Send,closure 不满足 FnOnce + Send + 'static)
- mpsc::Receiver 在 std Rust 是 !Sync 但 Send;Timer::start 只要求
  'static 不要求 Send(Timer callback 总在 UI 线程跑)—— 所以
  closure 直接 capture rx 不需要 wrap
- channel 选 unbounded:WS 重连风暴期间 message 风暴,UI 100ms
  drain 完全跟得上,不需要 backpressure
- drain_timer 必须持有到 run() 末尾否则被 drop(slint Timer drop
  自动停止)

验证:cargo build / build --release 都 exit 0,0 warning
- cargo test: 12 passed (3 reconnect unit + 6 api + 3 websocket); 0 failed
- debug   35.46 MB, subsystem = WINDOWS_CUI
- release  9.75 MB, subsystem = WINDOWS_GUI(channel + timer drain +0.3 MB)
This commit is contained in:
2026-09-19 06:41:20 +08:00
parent 319eeb44ae
commit 0b766f496e
2 changed files with 133 additions and 36 deletions
+104 -27
View File
@@ -1,13 +1,23 @@
//! Windows Gotify Client (Rust) — core library. //! Windows Gotify Client (Rust) — core library.
//! //!
//! Phase 3 surface: //! Phase 4 surface:
//! - [`config`]: placeholder AppConfig (Phase 5 swaps for persistence) //! - [`config`]: placeholder AppConfig (Phase 5 swaps for persistence)
//! - [`error`]: AppError / AppResult //! - [`error`]: AppError / AppResult
//! - [`gotify`]: blocking REST client (health / applications / messages) //! - [`gotify`]: blocking REST + WebSocket clients (Phase 3/4)
//! - [`logging`]: tracing-subscriber init //! - [`logging`]: tracing-subscriber init
//! - [`models`]: mock feed + Gotify→Slint adapter //! - [`models`]: mock feed + Gotify→Slint adapter + model builders
//! - [`storage`]: `%APPDATA%/WindowsGotify/state.json` (Phase 2 C3) //! - [`storage`]: `%APPDATA%/WindowsGotify/state.json`
//! - [`run`]: builds the MainWindow and runs its event loop //! - [`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.
pub mod config; pub mod config;
pub mod error; pub mod error;
@@ -20,13 +30,13 @@ slint::include_modules!();
use std::cell::RefCell; use std::cell::RefCell;
use std::rc::Rc; use std::rc::Rc;
use std::sync::mpsc;
use slint::{ModelRc, VecModel};
use storage::AppState; use storage::AppState;
/// Build the MainWindow and run its event loop until the window is closed. /// Build the MainWindow and run its event loop until the window is closed.
///
/// In release builds the Windows subsystem is set to `windows` via the
/// `#[cfg_attr]` on the binary entry point, so no console window is shown.
pub fn run() -> error::AppResult<()> { pub fn run() -> error::AppResult<()> {
tracing::info!( tracing::info!(
"starting windows-gotify-client-rust v{}", "starting windows-gotify-client-rust v{}",
@@ -42,35 +52,104 @@ pub fn run() -> error::AppResult<()> {
height = initial_state.last_window_height, height = initial_state.last_window_height,
"loaded state.json" "loaded state.json"
); );
ui.set_theme(AppearanceMode::Dark); // C4 reads theme from state.json. ui.set_theme(AppearanceMode::Dark); // C5 reads theme from state.json.
ui.window().set_size(slint::WindowSize::Logical(slint::LogicalSize::new( ui.window().set_size(slint::WindowSize::Logical(slint::LogicalSize::new(
initial_state.last_window_width.max(0) as f32, initial_state.last_window_width.max(0) as f32,
initial_state.last_window_height.max(0) as f32, initial_state.last_window_height.max(0) as f32,
))); )));
// ── Phase 3 C2: try the real Gotify server, fall back to mock ── // ── Phase 3 C2 + 4 C2: HTTP catch-up, then channel for WS ────
let cfg = config::AppConfig::placeholder(); let cfg = config::AppConfig::placeholder();
let (messages_model, status_text, is_connected) = match cfg.server.as_ref() { let shared_model: Rc<VecModel<MessageItem>>;
Some(server) => match models::try_fetch_real_messages(&server.base_url, &server.token) { let status_text: String;
Some((model, text)) => { let is_connected: bool;
tracing::info!(server = %server.base_url, "connected to real Gotify"); let ws_credential: Option<(String, String, Vec<gotify::GotifyApplication>)>;
(model, text, true)
match cfg.server.as_ref() {
Some(server) => match models::try_fetch_initial_data(&server.base_url, &server.token) {
Some((apps, msgs, text)) => {
tracing::info!(
server = %server.base_url,
msgs = msgs.len(),
apps = apps.len(),
"gotify: HTTP catch-up ok"
);
shared_model = Rc::new(VecModel::from(
models::gotify_messages_to_items(&msgs, &apps),
));
status_text = text;
is_connected = true;
ws_credential = Some((server.base_url.clone(), server.token.clone(), apps));
} }
None => { None => {
tracing::warn!(server = %server.base_url, "real Gotify unreachable; using mock data"); tracing::warn!(
let text = format!("Disconnected · mock feed · {}", server.base_url); server = %server.base_url,
(models::mock_messages_model(), text, false) "gotify: HTTP fetch failed; using mock data, WS worker skipped"
);
shared_model = Rc::new(VecModel::from(models::mock_messages()));
status_text = format!("Disconnected · mock feed · {}", server.base_url);
is_connected = false;
ws_credential = None;
} }
}, },
None => { None => {
tracing::info!("no server configured; using mock data"); tracing::info!("no server configured; mock feed only");
(models::mock_messages_model(), "No server configured · mock feed".to_string(), false) shared_model = Rc::new(VecModel::from(models::mock_messages()));
status_text = "No server configured · mock feed".to_string();
is_connected = false;
ws_credential = None;
} }
}; }
ui.set_messages(messages_model);
ui.set_messages(ModelRc::from(shared_model.clone()));
ui.set_status_text(status_text.into()); ui.set_status_text(status_text.into());
ui.set_is_connected(is_connected); 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.
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> =
apps.iter().map(|a| (a.id, a.clone())).collect();
std::thread::Builder::new()
.name("gotify-ws".into())
.spawn({
move || {
let server = gotify::ServerConfig::new(base_url, token);
gotify::websocket::run_stream_loop(server, move |msg| {
let apps = apps_by_id.clone();
let item = models::gotify_message_to_item(&msg, &apps);
if tx.send(item).is_err() {
// UI closed; nothing to do.
}
});
}
})
.map_err(|e| error::AppError::Gotify(format!("ws thread spawn: {e}")))?;
let timer = slint::Timer::default();
let model_for_drain = shared_model.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),
Err(_) => break,
}
}
},
);
Some(timer)
} else {
None
};
// Keep `drain_timer` alive for the rest of `run()`.
let _drain_timer = drain_timer;
// ── Phase 2 C2 callbacks ─────────────────────────────────────── // ── Phase 2 C2 callbacks ───────────────────────────────────────
ui.on_test_notification_clicked(|| { ui.on_test_notification_clicked(|| {
tracing::info!("empty-state: Test Notification clicked (stub)"); tracing::info!("empty-state: Test Notification clicked (stub)");
@@ -83,20 +162,18 @@ pub fn run() -> error::AppResult<()> {
ui.on_test_connection_requested({ ui.on_test_connection_requested({
let ui_handle = ui.as_weak(); let ui_handle = ui.as_weak();
move || { move || {
// Re-run the same fetch path; UI already shows "Disconnected"
// so this is the only way the user can poke the server.
tracing::info!("settings: Test Connection clicked"); tracing::info!("settings: Test Connection clicked");
let Some(server) = config::AppConfig::placeholder().server else { let Some(server) = config::AppConfig::placeholder().server else {
return; return;
}; };
match models::try_fetch_real_messages(&server.base_url, &server.token) { match models::try_fetch_initial_data(&server.base_url, &server.token) {
Some((model, text)) => { Some((apps, msgs, text)) => {
if let Some(ui) = ui_handle.upgrade() { if let Some(ui) = ui_handle.upgrade() {
ui.set_messages(model); ui.set_messages(models::build_message_model(&msgs, &apps));
ui.set_status_text(text.into()); ui.set_status_text(text.into());
ui.set_is_connected(true); ui.set_is_connected(true);
} }
tracing::info!("Test Connection: ok"); tracing::info!(msgs = msgs.len(), "Test Connection: ok");
} }
None => { None => {
if let Some(ui) = ui_handle.upgrade() { if let Some(ui) = ui_handle.upgrade() {
+29 -9
View File
@@ -211,12 +211,15 @@ fn map_slint(p: i32) -> crate::Priority {
} }
} }
/// Attempt to load real Gotify data and convert it to a Slint model. /// Fetch the raw applications + messages DTOs from a Gotify server.
/// Returns `None` on any error — caller falls back to mock data. /// Returns `None` on any error; caller falls back to mock data. This
pub fn try_fetch_real_messages( /// separates the network call from the Slint model construction so the
/// WebSocket path (Phase 4 C2) can reuse the apps-by-id resolver
/// without re-querying `/application`.
pub fn try_fetch_initial_data(
base_url: &str, base_url: &str,
token: &str, token: &str,
) -> Option<(ModelRc<MessageItem>, String)> { ) -> Option<(Vec<crate::gotify::GotifyApplication>, Vec<crate::gotify::GotifyMessage>, String)> {
use crate::gotify::GotifyApi; use crate::gotify::GotifyApi;
let api = GotifyApi::new(crate::gotify::ServerConfig::new( let api = GotifyApi::new(crate::gotify::ServerConfig::new(
@@ -230,9 +233,26 @@ pub fn try_fetch_real_messages(
} }
let apps = api.applications().ok()?; let apps = api.applications().ok()?;
let msgs = api.messages(50).ok()?; let msgs = api.messages(50).ok()?;
let items = gotify_messages_to_items(&msgs, &apps); let text = format!("{} · {} messages", api.base_url(), msgs.len());
Some(( Some((apps, msgs, text))
ModelRc::from(std::rc::Rc::new(VecModel::from(items))), }
format!("{} · {} messages", api.base_url(), msgs.len()),
)) /// Convenience wrapper: fetch + build a Slint `ModelRc` in one call.
/// Used by the startup path (Phase 3 C2) and the Settings → Test
/// Connection button.
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)?;
Some((build_message_model(&msgs, &apps), text))
}
/// Wrap a list of DTOs in a fresh `ModelRc<MessageItem>`.
pub fn build_message_model(
msgs: &[crate::gotify::GotifyMessage],
apps: &[crate::gotify::GotifyApplication],
) -> ModelRc<MessageItem> {
let items = gotify_messages_to_items(msgs, apps);
ModelRc::from(std::rc::Rc::new(VecModel::from(items)))
} }