Files
Windows-Gotify-Client-Rust/src/lib.rs
T
KennethCheng 301052698d feat(gotify): Phase 7 C2 — Win32 Toast 通知 + URL 打开接线 + 单 VecModel
- tray.rs: 增加 show_toast 方法(PostMessageW + 堆分配 payload + WndProc 内 Shell_NotifyIconW NIM_MODIFY),遵守 NIM_MODIFY 必须 NIM_ADD 同线程的硬性要求
- WS drain 每条新消息根据 priority + cached desktop_notifications/notify_high/notify_max/paused atomics 决定是否弹 toast,icon_flags 按 High/Max 分别用 NIIF_WARNING/NIIF_ERROR
- on_message_card_clicked:点击含 has_click_url 的卡片时调 windows::url::open(已有 http/https scheme 严格校验)
- TrayState 增加 desktop_notifications/notify_high/notify_max/restart_requested 原子,Settings 切换实时同步,tray on_restart 只翻 restart_requested flag
- 修复 Phase 6 C3 隐藏 bug:Test Connection / tray Restart 用 set_messages(new_model) 会让 WS drain 推入旧 model。新增单例 Rc<VecModel>,统一改用 set_vec
- on_restart 改用 Arc<OnceLock<Arc<TrayState>>> 占位 + 设置,避免 tray 线程闭包捕获 Rc<VecModel>(!Send)
- ActiveConfig 派生 Clone(托盘初始化快照用)
2026-09-19 08:20:03 +08:00

664 lines
28 KiB
Rust

//! Windows Gotify Client (Rust) — core library.
//!
//! Phase 7 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/{config,state}.json`
//! - [`run`]: builds the MainWindow and runs its event loop
//!
//! Phase 7 additions on top of Phase 6:
//! - Tray helper now exposes a full menu (Open / New messages / Pause
//! / Settings / Restart / Quit) and the registered
//! `WM_ACTIVATE_INSTANCE` path so a second launch surfaces the
//! existing instance. (AGENTS.md §12, §26.)
//! - Win32 toast (NIF_INFO via `Shell_NotifyIconW`) on every new
//! Gotify message. Respects `desktop_notifications`,
//! `notify_high`, `notify_max`, and the tray-menu pause toggle.
//! (AGENTS.md §13.)
//! - Message-card click opens the `click_url` via the strict
//! http/https URL opener. (AGENTS.md §13 / §38.)
//! - Single, long-lived `Rc<VecModel<MessageItem>>` so the WS
//! drain, the catch-up path, the Test Connection button, and the
//! tray Restart item all update the same model — fixing a Phase 6
//! C3 foot-gun where `set_messages(new_model)` detached the WS
//! drain.
pub mod config;
pub mod error;
pub mod gotify;
pub mod logging;
pub mod models;
pub mod storage;
#[cfg(windows)]
pub mod windows;
slint::include_modules!();
use std::cell::RefCell;
use std::rc::Rc;
use std::sync::mpsc;
use std::sync::Arc;
use slint::{Model, ModelRc, VecModel};
use storage::AppState;
#[cfg(windows)]
use crate::windows::tray::{TrayCallbacks, TrayIcon, TrayState, NIIF_ERROR, NIIF_INFO, NIIF_WARNING};
/// 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!(
"starting windows-gotify-client-rust v{}",
env!("CARGO_PKG_VERSION")
);
let ui = MainWindow::new().map_err(|e| error::AppError::Ui(e.to_string()))?;
// ── 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(
state_rc.borrow().last_window_width.max(0) as f32,
state_rc.borrow().last_window_height.max(0) as f32,
)));
// ── Phase 6 C2 + 7 C2: load live AppConfig toggles into MainWindow
// (SettingsPage is just a thin View onto these properties). ──
let cfg = config::ActiveConfig::load()?;
ui.set_desktop_notify(cfg.desktop_notifications);
ui.set_sound(cfg.sound);
ui.set_notify_high(cfg.notify_high);
ui.set_notify_max(cfg.notify_max);
ui.set_start_with_windows(cfg.start_with_windows);
ui.set_start_minimized(cfg.start_minimized);
ui.set_close_to_tray(cfg.close_to_tray);
// ── Phase 7 C2: long-lived message model. We bind `ui.messages`
// to this single VecModel once and update it via `set_vec` on
// every fetch so the WS drain keeps pushing into the same model
// even after a refetch. ──
let shared_model: Rc<VecModel<MessageItem>> = Rc::new(VecModel::default());
ui.set_messages(ModelRc::from(shared_model.clone()));
// ── Phase 7 C1+C2: tray icon (Windows only). Created up-front so
// the WS drain closure can capture an `Arc<TrayIcon>` and call
// `show_toast` for every new message. We initialize the cached
// notification preferences (desktop_notifications / notify_high /
// notify_max / paused) from the loaded AppConfig so the toast
// gating matches what the user picked in Settings. ──
#[cfg(windows)]
let tray_arc: Option<Arc<TrayIcon>> = {
let ui_handle_for_open = ui.as_weak();
let ui_handle_for_settings = ui.as_weak();
let cfg_snapshot = cfg.clone();
// Phase 7 C2: indirection so the `on_restart` closure
// below (which runs on the tray thread and must stay Send
// + Sync) can capture an `Arc<…>` placeholder, then reach
// the live `Arc<TrayState>` once the tray is constructed.
// Using `OnceCell<Arc<TrayState>>` keeps the closure Send
// + Sync without ever capturing the UI-bound
// `Rc<VecModel<…>>`.
let tray_state_for_restart_holder: Arc<std::sync::OnceLock<Arc<TrayState>>> =
Arc::new(std::sync::OnceLock::new());
match TrayIcon::new(TrayCallbacks {
on_open: Box::new(move || {
// Phase 7 C1: the tray's own WndProc already reset
// the "New messages" counter when it decided to
// dispatch this callback. We only surface the
// window here.
if let Some(ui) = ui_handle_for_open.upgrade() {
let _ = ui.window().show();
tracing::info!("tray: Open → window shown");
}
}),
on_pause_toggle: Box::new(move |now: bool| {
tracing::info!(paused = now, "tray: Pause notifications toggled");
let mut cfg = storage::load_config();
cfg.notifications_paused = now;
if let Err(e) = storage::save_config(&cfg) {
tracing::warn!(error = %e, "tray: save_config (pause) failed");
}
}),
on_settings: Box::new(move || {
if let Some(ui) = ui_handle_for_settings.upgrade() {
ui.set_selection(SidebarSelection::Settings);
let _ = ui.window().show();
tracing::info!("tray: Settings → sidebar switched");
}
}),
// Phase 7 C2: Restart connection just sets the
// `restart_requested` flag in TrayState. The UI drain
// timer (which runs on the UI thread) swaps it back to
// false and triggers the actual refetch — keeping the
// tray callback Send+Sync (it only captures an
// `Arc<…>`) and the HTTP fetch + VecModel mutation on
// the UI thread where they belong.
on_restart: {
let holder = tray_state_for_restart_holder.clone();
Box::new(move || {
tracing::info!("tray: Restart connection requested");
if let Some(ts) = holder.get() {
ts.restart_requested
.store(true, std::sync::atomic::Ordering::Relaxed);
}
})
},
on_quit: Box::new(|| {
tracing::info!("tray: Quit → exit");
std::process::exit(0);
}),
}) {
Ok(tray) => {
// Seed the cached notification preferences from the
// loaded config so the WS drain's toast gating
// matches what the user picked in Settings.
let ts = tray.state();
ts.paused.store(
cfg_snapshot.notifications_paused,
std::sync::atomic::Ordering::Relaxed,
);
ts.desktop_notifications.store(
cfg_snapshot.desktop_notifications,
std::sync::atomic::Ordering::Relaxed,
);
ts.notify_high.store(
cfg_snapshot.notify_high,
std::sync::atomic::Ordering::Relaxed,
);
ts.notify_max.store(
cfg_snapshot.notify_max,
std::sync::atomic::Ordering::Relaxed,
);
// Hand the live Arc<TrayState> to the on_restart
// closure via the OnceCell. From here on,
// restart_requested toggles will reach the drain
// timer.
let _ = tray_state_for_restart_holder.set(ts.clone());
tracing::info!("tray: installed (Phase 7 C1 menu + C2 toast)");
Some(Arc::new(tray))
}
Err(e) => {
tracing::warn!(error = %e, "tray: failed to install, continuing without it");
None
}
}
};
#[cfg(not(windows))]
let tray_arc: Option<Arc<TrayIcon>> = None;
#[cfg(windows)]
let tray_state_for_ws: Option<Arc<TrayState>> =
tray_arc.as_ref().map(|t| t.state());
// ── Phase 3 C2 + 4 C2 + 5 C1 + 7 C2: HTTP catch-up. ──
// Note: Phase 7 C2 mutates the *single* `shared_model` via
// `set_vec` instead of replacing it with a new VecModel — that's
// the change that lets the WS drain (which captured the Rc)
// keep pushing into the same model after a refetch.
let since = state_rc.borrow().last_message_id;
let ws_credential: Option<(String, String, Vec<gotify::GotifyApplication>)>;
let status_text: String;
let is_connected: bool;
match cfg.server.as_ref() {
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,
msgs = msgs.len(),
apps = apps.len(),
"gotify: HTTP catch-up ok"
);
shared_model
.set_vec(models::gotify_messages_to_items(&msgs, &apps));
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!(
server = %server.base_url,
"gotify: HTTP fetch failed; using mock data, WS worker skipped"
);
shared_model.set_vec(models::mock_messages());
status_text = format!("Disconnected · mock feed · {}", server.base_url);
is_connected = false;
ws_credential = None;
}
},
None => {
tracing::info!("no server configured; mock feed only");
shared_model.set_vec(models::mock_messages());
status_text = "No server configured · mock feed".to_string();
is_connected = false;
ws_credential = None;
}
}
ui.set_status_text(status_text.into());
ui.set_is_connected(is_connected);
// ── Phase 4 C2 + 5 C2 + 7 C1/C2: WebSocket worker + UI-thread
// drain. The drain closure also fires a desktop toast for each
// new message via the tray's `show_toast` (any thread → the tray
// helper window's WndProc, which is the only thread allowed to
// call Shell_NotifyIconW), and now also services the
// `restart_requested` flag set by the tray's on_restart. ──
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();
let state_for_drain = state_rc.clone();
let tray_for_drain = tray_arc.clone();
let tray_state_for_drain = tray_state_for_ws.clone();
let ui_handle_for_drain = ui.as_weak();
timer.start(
slint::TimerMode::Repeated,
std::time::Duration::from_millis(100),
move || {
// Drain the WS channel.
loop {
match rx.try_recv() {
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;
}
}
// Phase 7 C1: bump the tray counter so
// the menu "New messages: N" row
// refreshes.
if let Some(ref ts) = tray_state_for_drain {
ts.new_messages
.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
}
// Phase 7 C2: surface a desktop toast
// for the new message when the user's
// preferences allow it. The check fires
// *before* pushing into the model so a
// suppressed toast still updates the
// in-app list.
if let (Some(ts), Some(tray)) = (
tray_state_for_drain.as_ref(),
tray_for_drain.as_ref(),
) {
let priority = item.priority;
let desktop_ok = ts
.desktop_notifications
.load(std::sync::atomic::Ordering::Relaxed);
let paused = ts
.paused
.load(std::sync::atomic::Ordering::Relaxed);
let priority_ok = match priority {
crate::Priority::High => ts
.notify_high
.load(std::sync::atomic::Ordering::Relaxed),
crate::Priority::Max => ts
.notify_max
.load(std::sync::atomic::Ordering::Relaxed),
_ => false, // Low / Normal don't toast.
};
if desktop_ok && !paused && priority_ok {
let title: String = if !item.title.is_empty() {
item.title.to_string()
} else {
item.app_name.to_string()
};
let body = item.body.to_string();
let icon_flags = match priority {
crate::Priority::High => NIIF_WARNING,
crate::Priority::Max => NIIF_ERROR,
_ => NIIF_INFO,
};
tray.show_toast(&title, &body, icon_flags);
}
}
model_for_drain.push(item);
}
Err(_) => break,
}
}
// Phase 7 C2: handle "Restart connection" requests
// from the tray menu. We swap the flag back to
// false so a second click also works.
if let Some(ref ts) = tray_state_for_drain {
if ts
.restart_requested
.swap(false, std::sync::atomic::Ordering::Relaxed)
{
tracing::info!("drain: restart_requested set → refetching");
let Some(server) = config::ActiveConfig::load()
.ok()
.and_then(|c| c.server)
else {
tracing::warn!("drain: restart → no server configured");
return;
};
match models::try_fetch_initial_data(
&server.base_url,
&server.token,
None,
) {
Some((apps, msgs, text)) => {
model_for_drain.set_vec(
models::gotify_messages_to_items(&msgs, &apps),
);
if let Some(ui) = ui_handle_for_drain.upgrade() {
ui.set_status_text(text.into());
ui.set_is_connected(true);
}
}
None => {
tracing::warn!("drain: restart → fetch failed");
}
}
}
}
},
);
Some(timer)
} else {
None
};
let _drain_timer = drain_timer;
// ── Phase 2 C2 callbacks ───────────────────────────────────────
ui.on_test_notification_clicked(|| {
tracing::info!("empty-state: Test Notification clicked (stub)");
});
// Phase 7 C2: clicking a message card selects it AND opens the
// Gotify `click_url` via the strict http/https opener if the card
// carries one. We read the item from `shared_model` rather than
// re-parse the index — keeps the wiring independent of the Slint
// message-card internals.
#[cfg(windows)]
{
let ui_handle_for_click = ui.as_weak();
let model_for_click = shared_model.clone();
ui.on_message_card_clicked(move |idx: i32| {
let Some(item) = model_for_click.row_data(idx as usize) else {
tracing::debug!(idx, "message card clicked but index out of range");
return;
};
tracing::debug!(idx, title = %item.title, "message card clicked");
if item.has_click_url && !item.click_url.is_empty() {
if !crate::windows::url::open(&item.click_url) {
tracing::warn!(
idx,
url = %item.click_url,
"message card click: url::open refused the URL"
);
}
}
if let Some(ui) = ui_handle_for_click.upgrade() {
ui.set_selected_message_index(idx);
}
});
}
#[cfg(not(windows))]
ui.on_message_card_clicked(|idx| {
tracing::debug!(idx, "message card clicked (no-op on non-Windows)");
});
// ── Phase 6 C3: URL opener import kept alive even when not on
// Windows — the cfg(windows) gate above handles the actual use.
// (Kept here so future cross-platform polish knows where to hook.) ──
#[cfg(windows)]
let _ = crate::windows::url::open;
// ── Phase 3 C2 + 7 C2: Settings → Connection → Test button ───
ui.on_test_connection_requested({
let ui_handle = ui.as_weak();
let model_for_test = shared_model.clone();
move || {
tracing::info!("settings: Test Connection clicked");
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, None) {
Some((apps, msgs, text)) => {
model_for_test.set_vec(
models::gotify_messages_to_items(&msgs, &apps),
);
if let Some(ui) = ui_handle.upgrade() {
ui.set_status_text(text.into());
ui.set_is_connected(true);
}
tracing::info!(msgs = msgs.len(), "Test Connection: ok");
}
None => {
if let Some(ui) = ui_handle.upgrade() {
ui.set_status_text(
format!("Disconnected · mock feed · {}", server.base_url).into(),
);
ui.set_is_connected(false);
}
tracing::warn!("Test Connection: failed");
}
}
}
});
// ── 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 6 C2 + 7 C1/C2: every other SettingsPage toggle
// (notifications, startup) routes through one callback. We
// dispatch on the `label` string the UI hands us, persist into
// `config.json`, and for `start_with_windows` we also write HKCU
// Run (Phase 6 C1). Phase 7 C1/C2 also mirrors the relevant
// notification toggles into the tray state's cached atomics so
// the WS drain's toast gating stays in sync without a restart. ──
let tray_state_for_settings = tray_state_for_ws.clone();
ui.on_setting_toggled(move |label, value| {
let mut cfg = storage::load_config();
let label_str = label.to_string();
let label_ref: &str = &label_str;
match label_ref {
"desktop_notifications" => {
cfg.desktop_notifications = value;
if let Some(ref ts) = tray_state_for_settings {
ts.desktop_notifications
.store(value, std::sync::atomic::Ordering::Relaxed);
}
}
"sound" => cfg.sound = value,
"notify_high" => {
cfg.notify_high = value;
if let Some(ref ts) = tray_state_for_settings {
ts.notify_high
.store(value, std::sync::atomic::Ordering::Relaxed);
}
}
"notify_max" => {
cfg.notify_max = value;
if let Some(ref ts) = tray_state_for_settings {
ts.notify_max
.store(value, std::sync::atomic::Ordering::Relaxed);
}
}
"start_minimized" => cfg.start_minimized = value,
"close_to_tray" => {
cfg.close_to_tray = value;
}
"start_with_windows" => {
cfg.start_with_windows = value;
#[cfg(windows)]
{
use crate::windows::startup;
if !startup::set_enabled(value) {
tracing::warn!("startup: HKCU Run write failed");
}
}
#[cfg(not(windows))]
{
tracing::debug!("startup: not on Windows, skipping HKCU");
}
}
_ => {
tracing::warn!(label = %label_str, "settings: unknown toggle label");
return;
}
}
if let Err(e) = storage::save_config(&cfg) {
tracing::warn!(error = %e, label = %label_str, "settings: save_config failed");
} else {
tracing::info!(label = %label_str, value, "settings: toggle persisted");
}
});
// ── Phase 2 C3 callbacks ───────────────────────────────────────
ui.on_about_clicked({
let ui_handle = ui.as_weak();
move || {
tracing::info!("about-clicked");
if let Some(ui) = ui_handle.upgrade() {
ui.set_selection(SidebarSelection::About);
}
}
});
// ── Phase 2 C3 / 5 C2 / 6 C2: save state on OS-close ──────────
// Phase 6 C2: when `close_to_tray` is true, hide the window and
// pretend to honor the close (return `KeepWindowShown`); otherwise
// let Slint proceed with its default behavior (which hides the
// window on Windows so the process keeps running until Quit).
ui.window().on_close_requested({
let state_for_save = state_rc.clone();
let ui_handle = ui.as_weak();
move || {
let close_to_tray = ui_handle
.upgrade()
.map(|u| u.get_close_to_tray())
.unwrap_or(false);
if let Some(ui) = ui_handle.upgrade() {
let size = ui.window().size();
let mut s = state_for_save.borrow_mut();
s.last_window_width = size.width as i32;
s.last_window_height = size.height as i32;
}
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 = 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"
);
}
if close_to_tray {
if let Some(ui) = ui_handle.upgrade() {
let _ = ui.window().hide();
tracing::info!("close: window hidden (close-to-tray)");
return slint::CloseRequestResponse::KeepWindowShown;
}
}
slint::CloseRequestResponse::HideWindow
}
});
// Keep tray_arc alive for the lifetime of run(); the Drop impl
// removes the icon and joins the helper thread.
let _tray_arc = tray_arc;
ui.run()
.map_err(|e| error::AppError::Ui(e.to_string()))
}
pub use error::{AppError, AppResult};