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:
+104
-27
@@ -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<VecModel<MessageItem>>;
|
||||
let status_text: String;
|
||||
let is_connected: bool;
|
||||
let ws_credential: Option<(String, String, Vec<gotify::GotifyApplication>)>;
|
||||
|
||||
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::<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 ───────────────────────────────────────
|
||||
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() {
|
||||
|
||||
+29
-9
@@ -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<MessageItem>, String)> {
|
||||
) -> Option<(Vec<crate::gotify::GotifyApplication>, Vec<crate::gotify::GotifyMessage>, 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<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)))
|
||||
}
|
||||
Reference in New Issue
Block a user