From 0b766f496ed4fdd4d1dc935f0ccc940e446a608f Mon Sep 17 00:00:00 2001 From: kennethcheng Date: Sat, 19 Sep 2026 06:41:20 +0800 Subject: [PATCH] =?UTF-8?q?feat(gotify):=20Phase=204=20C2=20=E2=80=94=20WS?= =?UTF-8?q?=20worker=20+=20channel=20+=20UI=20timer=20drain?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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>::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) --- src/lib.rs | 131 +++++++++++++++++++++++++++++++++++++++----------- src/models.rs | 38 +++++++++++---- 2 files changed, 133 insertions(+), 36 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index d9c3179..34176ed 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,13 +1,23 @@ //! Windows Gotify Client (Rust) — core library. //! -//! Phase 3 surface: +//! Phase 4 surface: //! - [`config`]: placeholder AppConfig (Phase 5 swaps for persistence) //! - [`error`]: AppError / AppResult -//! - [`gotify`]: blocking REST client (health / applications / messages) +//! - [`gotify`]: blocking REST + WebSocket clients (Phase 3/4) //! - [`logging`]: tracing-subscriber init -//! - [`models`]: mock feed + Gotify→Slint adapter -//! - [`storage`]: `%APPDATA%/WindowsGotify/state.json` (Phase 2 C3) +//! - [`models`]: mock feed + Gotify→Slint adapter + model builders +//! - [`storage`]: `%APPDATA%/WindowsGotify/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. pub mod config; pub mod error; @@ -20,13 +30,13 @@ slint::include_modules!(); use std::cell::RefCell; use std::rc::Rc; +use std::sync::mpsc; + +use slint::{ModelRc, VecModel}; use storage::AppState; /// 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<()> { tracing::info!( "starting windows-gotify-client-rust v{}", @@ -42,35 +52,104 @@ pub fn run() -> error::AppResult<()> { height = initial_state.last_window_height, "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( initial_state.last_window_width.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 (messages_model, status_text, is_connected) = match cfg.server.as_ref() { - Some(server) => match models::try_fetch_real_messages(&server.base_url, &server.token) { - Some((model, text)) => { - tracing::info!(server = %server.base_url, "connected to real Gotify"); - (model, text, true) + let shared_model: Rc>; + let status_text: String; + let is_connected: bool; + let ws_credential: Option<(String, String, Vec)>; + + 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 => { - tracing::warn!(server = %server.base_url, "real Gotify unreachable; using mock data"); - let text = format!("Disconnected · mock feed · {}", server.base_url); - (models::mock_messages_model(), text, false) + tracing::warn!( + server = %server.base_url, + "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 => { - tracing::info!("no server configured; using mock data"); - (models::mock_messages_model(), "No server configured · mock feed".to_string(), false) + tracing::info!("no server configured; mock feed only"); + 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_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::(); + let apps_by_id: std::collections::HashMap = + 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 ─────────────────────────────────────── ui.on_test_notification_clicked(|| { tracing::info!("empty-state: Test Notification clicked (stub)"); @@ -83,20 +162,18 @@ pub fn run() -> error::AppResult<()> { ui.on_test_connection_requested({ let ui_handle = ui.as_weak(); 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"); let Some(server) = config::AppConfig::placeholder().server else { return; }; - match models::try_fetch_real_messages(&server.base_url, &server.token) { - Some((model, text)) => { + match models::try_fetch_initial_data(&server.base_url, &server.token) { + Some((apps, msgs, text)) => { 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_is_connected(true); } - tracing::info!("Test Connection: ok"); + tracing::info!(msgs = msgs.len(), "Test Connection: ok"); } None => { if let Some(ui) = ui_handle.upgrade() { diff --git a/src/models.rs b/src/models.rs index e0872aa..76f9b4e 100644 --- a/src/models.rs +++ b/src/models.rs @@ -211,12 +211,15 @@ fn map_slint(p: i32) -> crate::Priority { } } -/// Attempt to load real Gotify data and convert it to a Slint model. -/// Returns `None` on any error — caller falls back to mock data. -pub fn try_fetch_real_messages( +/// Fetch the raw applications + messages DTOs from a Gotify server. +/// Returns `None` on any error; caller falls back to mock data. This +/// 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, token: &str, -) -> Option<(ModelRc, String)> { +) -> Option<(Vec, Vec, String)> { use crate::gotify::GotifyApi; let api = GotifyApi::new(crate::gotify::ServerConfig::new( @@ -230,9 +233,26 @@ pub fn try_fetch_real_messages( } let apps = api.applications().ok()?; let msgs = api.messages(50).ok()?; - let items = gotify_messages_to_items(&msgs, &apps); - Some(( - ModelRc::from(std::rc::Rc::new(VecModel::from(items))), - format!("{} · {} messages", api.base_url(), msgs.len()), - )) + let text = format!("{} · {} messages", api.base_url(), msgs.len()); + Some((apps, msgs, text)) +} + +/// 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, 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`. +pub fn build_message_model( + msgs: &[crate::gotify::GotifyMessage], + apps: &[crate::gotify::GotifyApplication], +) -> ModelRc { + let items = gotify_messages_to_items(msgs, apps); + ModelRc::from(std::rc::Rc::new(VecModel::from(items))) } \ No newline at end of file