From 301052698db8d4fa9cbab6853f384614150e139c Mon Sep 17 00:00:00 2001 From: kennethcheng Date: Sat, 19 Sep 2026 08:20:03 +0800 Subject: [PATCH] =?UTF-8?q?feat(gotify):=20Phase=207=20C2=20=E2=80=94=20Wi?= =?UTF-8?q?n32=20Toast=20=E9=80=9A=E7=9F=A5=20+=20URL=20=E6=89=93=E5=BC=80?= =?UTF-8?q?=E6=8E=A5=E7=BA=BF=20+=20=E5=8D=95=20VecModel?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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,统一改用 set_vec - on_restart 改用 Arc>> 占位 + 设置,避免 tray 线程闭包捕获 Rc(!Send) - ActiveConfig 派生 Clone(托盘初始化快照用) --- src/config.rs | 1 + src/lib.rs | 518 ++++++++++++++++++++++++++++---------------- src/windows/tray.rs | 168 +++++++++++++- 3 files changed, 493 insertions(+), 194 deletions(-) diff --git a/src/config.rs b/src/config.rs index ab53741..c3f04d5 100644 --- a/src/config.rs +++ b/src/config.rs @@ -12,6 +12,7 @@ use crate::error::AppResult; use crate::gotify::ServerConfig; use crate::storage::AppConfig as PersistedConfig; +#[derive(Clone)] pub struct ActiveConfig { pub server: Option, pub appearance: String, diff --git a/src/lib.rs b/src/lib.rs index 83c9ead..778df93 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,23 +1,30 @@ //! Windows Gotify Client (Rust) — core library. //! -//! 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/{config,state}.json` -//! - [`run`]: builds the MainWindow and runs its event loop +//! 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 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. +//! 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>` 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; @@ -34,15 +41,14 @@ slint::include_modules!(); use std::cell::RefCell; use std::rc::Rc; use std::sync::mpsc; +use std::sync::Arc; -use slint::{ModelRc, VecModel}; +use slint::{Model, ModelRc, VecModel}; use storage::AppState; #[cfg(windows)] -use crate::windows::tray::{TrayCallbacks, TrayIcon}; -#[cfg(windows)] -use crate::windows::url as url_opener; +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 @@ -101,26 +107,143 @@ pub fn run() -> error::AppResult<()> { state_rc.borrow().last_window_height.max(0) as f32, ))); - // ── Phase 6 C2: load live AppConfig toggles into MainWindow ─── - { - 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 3 C2 + 4 C2 + 5 C1: HTTP catch-up, then channel for WS ──── + // ── 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()?; - let shared_model: Rc>; + 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> = 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` 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> = { + 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` once the tray is constructed. + // Using `OnceCell>` keeps the closure Send + // + Sync without ever capturing the UI-bound + // `Rc>`. + let tray_state_for_restart_holder: Arc>> = + 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 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> = None; + #[cfg(windows)] + let tray_state_for_ws: Option> = + 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)>; let status_text: String; let is_connected: bool; - let ws_credential: Option<(String, String, Vec)>; - let since = state_rc.borrow().last_message_id; match cfg.server.as_ref() { Some(server) => match models::try_fetch_initial_data( &server.base_url, @@ -134,9 +257,8 @@ pub fn run() -> error::AppResult<()> { apps = apps.len(), "gotify: HTTP catch-up ok" ); - shared_model = Rc::new(VecModel::from( - models::gotify_messages_to_items(&msgs, &apps), - )); + 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)); @@ -155,7 +277,7 @@ pub fn run() -> error::AppResult<()> { server = %server.base_url, "gotify: HTTP fetch failed; using mock data, WS worker skipped" ); - shared_model = Rc::new(VecModel::from(models::mock_messages())); + shared_model.set_vec(models::mock_messages()); status_text = format!("Disconnected · mock feed · {}", server.base_url); is_connected = false; ws_credential = None; @@ -163,27 +285,22 @@ pub fn run() -> error::AppResult<()> { }, None => { tracing::info!("no server configured; mock feed only"); - shared_model = Rc::new(VecModel::from(models::mock_messages())); + shared_model.set_vec(models::mock_messages()); status_text = "No server configured · mock feed".to_string(); is_connected = false; ws_credential = None; } } - ui.set_messages(ModelRc::from(shared_model.clone())); ui.set_status_text(status_text.into()); ui.set_is_connected(is_connected); - // ── Phase 7 C1: tray state cell ───────────────────────────────────── - // The tray is created further down (after the WS drain is wired so - // we have a clean order). The drain closure needs to bump the tray - // state on each new message, so we share a cell that the tray - // init later populates. `RefCell` is fine here — both the drain - // callback and the post-tray-init assignment run on the UI thread. - let tray_state_cell: Rc>>> = - Rc::new(RefCell::new(None)); - - // ── Phase 4 C2 + 5 C2: WebSocket worker + UI-thread drain ─────────── + // ── 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::(); let apps_by_id: std::collections::HashMap = @@ -208,15 +325,14 @@ pub fn run() -> error::AppResult<()> { let timer = slint::Timer::default(); let model_for_drain = shared_model.clone(); let state_for_drain = state_rc.clone(); - // Phase 7 C1: tray "New messages: N" counter. The tray owns - // the Arc; we capture the cell here so the UI - // drain can bump the atomic once the tray has populated the - // cell (post-init, below). - let tray_state_for_drain = tray_state_cell.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) => { @@ -227,17 +343,95 @@ pub fn run() -> error::AppResult<()> { s.last_message_id = id; } } - // Bump the tray counter so the menu - // "New messages: N" row refreshes. - if let Some(ref ts) = *tray_state_for_drain.borrow() { + // 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) @@ -250,123 +444,63 @@ pub fn run() -> error::AppResult<()> { 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"); + tracing::debug!(idx, "message card clicked (no-op on non-Windows)"); }); - // ── Phase 7 C1: tray icon (Windows only) with full menu ─────── - // The tray owns a hidden helper window and a dedicated message- - // pump thread. On top of the Phase 6 C3 Open/Quit menu we now - // also expose Pause notifications / Settings / Restart - // connection, plus a grayed-out "New messages: N" info row. - // Single-instance activation: when a second copy of the binary - // launches, it sends a registered window message to this helper - // window (see `single_instance::activate_existing_instance`); - // our WndProc treats that as an extra `on_open` invocation. + // ── 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 _tray = { - let ui_handle_for_open = ui.as_weak(); - let ui_handle_for_settings = ui.as_weak(); - let ui_handle_for_restart = ui.as_weak(); - let tray_state_for_pause_init = tray_state_cell.clone(); - let cfg_for_pause_init = config::ActiveConfig::load().ok(); - 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 (it does the same on the - // registered activate-message path). This closure - // only needs to surface the window. - 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"); - } - }), - on_restart: Box::new(move || { - tracing::info!("tray: Restart connection requested"); - // Phase 7 C1: "restart" is just a refetch using the - // existing Test Connection path; we don't (yet) - // cycle the WS worker thread, which would require - // an additional shutdown signal. The catch-up + - // status refresh is enough for V1. - let Some(server) = - config::ActiveConfig::load().ok().and_then(|c| c.server) - else { - tracing::warn!("tray: Restart → no server configured"); - return; - }; - let Some((apps, msgs, text)) = - models::try_fetch_initial_data(&server.base_url, &server.token, None) - else { - tracing::warn!("tray: Restart → fetch failed"); - return; - }; - if let Some(ui) = ui_handle_for_restart.upgrade() { - ui.set_messages(models::build_message_model(&msgs, &apps)); - ui.set_status_text(text.into()); - ui.set_is_connected(true); - } - }), - on_quit: Box::new(|| { - tracing::info!("tray: Quit → exit"); - std::process::exit(0); - }), - }) - .map(|tray| { - // Populate the shared state cell so the WS drain can - // bump the "New messages" counter, and prime the pause - // toggle from the persisted config so the menu's - // checkmark reflects the saved value on first show. - let ts = tray.state(); - if let Some(cfg) = cfg_for_pause_init.as_ref() { - ts.paused.store(cfg.notifications_paused, std::sync::atomic::Ordering::Relaxed); - } - *tray_state_for_pause_init.borrow_mut() = Some(ts); - tracing::info!("tray: installed (Phase 7 C1 menu)"); - tray - }) - .map_err(|e| { - tracing::warn!(error = %e, "tray: failed to install, continuing without it"); - }) - .ok() - }; - #[cfg(windows)] - let _tray = _tray; // hold for the rest of run() + let _ = crate::windows::url::open; - // ── Phase 6 C3: URL opener (Windows only) — already wired; the - // Gotify `click_url` field is reserved in `models.rs` (Phase 3) - // and is opened via this helper when Phase 7 polish wires a - // message-card click on `has_click_url`. ── - #[cfg(windows)] - let _ = url_opener::open; // keep the import alive for Phase 7 - - // ── Phase 3 C2: Settings → Connection → Test button ─────────── + // ── 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 { + 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_messages(models::build_message_model(&msgs, &apps)); ui.set_status_text(text.into()); ui.set_is_connected(true); } @@ -399,34 +533,44 @@ pub fn run() -> error::AppResult<()> { } }); - // ── Phase 6 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). - ui.on_setting_toggled(|label, value| { - // Phase 6 C2 also: AppConfig is mutated through a fresh load + - // save because the toggle handler is a `Fn` (no captured - // mutex); this keeps the persistence path side-effect-only and - // doesn't risk racing with the close-requested save below. - let mut cfg = match storage::load_config() { - c => c, - }; + // ── 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, + "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, - "notify_max" => cfg.notify_max = 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; - // Runtime-only: also flip the MainWindow property so - // the close-requested handler picks up the new value - // immediately without a restart. The Slint callback - // already set root.close-to-tray = value on the UI - // side; we just mirror it in `cfg` so save_config - // captures the same value. } "start_with_windows" => { cfg.start_with_windows = value; @@ -509,6 +653,10 @@ pub fn run() -> error::AppResult<()> { } }); + // 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())) } diff --git a/src/windows/tray.rs b/src/windows/tray.rs index b58b611..e3ab546 100644 --- a/src/windows/tray.rs +++ b/src/windows/tray.rs @@ -27,17 +27,17 @@ use windows_sys::Win32::Foundation::{ }; use windows_sys::Win32::System::Threading::GetCurrentThreadId; use windows_sys::Win32::UI::Shell::{ - Shell_NotifyIconW, NIF_ICON, NIF_MESSAGE, NIF_TIP, NIM_ADD, NIM_DELETE, - NOTIFYICONDATAW, + Shell_NotifyIconW, NIF_ICON, NIF_INFO, NIF_MESSAGE, NIF_TIP, NIM_ADD, NIM_DELETE, + NIM_MODIFY, NOTIFYICONDATAW, }; use windows_sys::Win32::UI::WindowsAndMessaging::{ AppendMenuW, CreatePopupMenu, CreateWindowExW, DefWindowProcW, DestroyWindow, - DispatchMessageW, GetCursorPos, GetMessageW, GetWindowLongPtrW, RegisterClassExW, - RegisterWindowMessageW, SendMessageW, SetForegroundWindow, SetWindowLongPtrW, - TrackPopupMenuEx, TranslateMessage, CS_HREDRAW, CW_USEDEFAULT, GWLP_USERDATA, - MF_CHECKED, MF_GRAYED, MF_SEPARATOR, MF_STRING, MSG, TPM_BOTTOMALIGN, TPM_LEFTALIGN, - TPM_RETURNCMD, WM_COMMAND, WM_DESTROY, WM_LBUTTONDBLCLK, WM_RBUTTONUP, WM_USER, - WNDCLASSEXW, WS_EX_TOOLWINDOW, + DispatchMessageW, GetCursorPos, GetMessageW, GetWindowLongPtrW, PostMessageW, + RegisterClassExW, RegisterWindowMessageW, SendMessageW, SetForegroundWindow, + SetWindowLongPtrW, TrackPopupMenuEx, TranslateMessage, CS_HREDRAW, CW_USEDEFAULT, + GWLP_USERDATA, MF_CHECKED, MF_GRAYED, MF_SEPARATOR, MF_STRING, MSG, TPM_BOTTOMALIGN, + TPM_LEFTALIGN, TPM_RETURNCMD, WM_COMMAND, WM_DESTROY, WM_LBUTTONDBLCLK, WM_RBUTTONUP, + WM_USER, WNDCLASSEXW, WS_EX_TOOLWINDOW, }; use windows_sys::core::GUID; @@ -48,6 +48,19 @@ const IDM_SETTINGS: u16 = 4; const IDM_RESTART: u16 = 5; const IDM_QUIT: u16 = 6; const WM_TRAYICON: u32 = WM_USER + 1; +// Phase 7 C2: posted from any thread to ask the tray's WndProc to +// raise a toast via NIM_MODIFY. Payload is a heap-allocated +// `ToastPayload`; the WndProc reconstructs the Box and drops it. +const WM_SHOW_TOAST: u32 = WM_USER + 2; +// Windows 10+ toast severity flags (Win32 NIIF_* constants — +// windows-sys 0.59 doesn't re-export the names, so we hard-code). +// `pub` so `lib.rs` can pick a severity based on Gotify priority +// without re-hard-coding the bit patterns. +pub const NIIF_NONE: u32 = 0x0000_0000; +pub const NIIF_INFO: u32 = 0x0000_0001; +pub const NIIF_WARNING: u32 = 0x0000_0002; +pub const NIIF_ERROR: u32 = 0x0000_0003; +pub const NIIF_NOSOUND: u32 = 0x0000_0010; const TRAY_CLASS_NAME: &str = "WindowsGotifyClientRust.TrayHidden\0"; const TRAY_TIP: &str = "Windows Gotify Client\0"; // Wide-char constant for RegisterWindowMessageW. `RegisterWindowMessageW` @@ -61,6 +74,22 @@ const ACTIVATE_INSTANCE_MSG_NAME: &str = "WindowsGotifyClientRust.ActivateInstan pub struct TrayState { pub paused: AtomicBool, pub new_messages: AtomicU32, + /// Phase 7 C2: cached "show desktop notifications" master toggle + /// so the WS drain's toast gating can read it lock-free without + /// a config reload per message. `lib.rs` writes here from the + /// SettingsPage toggle callback. + pub desktop_notifications: AtomicBool, + /// Phase 7 C2: cached "notify on High priority" toggle. + pub notify_high: AtomicBool, + /// Phase 7 C2: cached "notify on Max priority" toggle. + pub notify_max: AtomicBool, + /// Phase 7 C2: "Restart connection" request flag. The tray's + /// `on_restart` callback sets it from the tray thread; the UI + /// drain timer (which runs on the UI thread) swaps it back to + /// `false` and triggers the actual refetch. Decouples the tray + /// callback from the UI-bound `Rc>` so the closure + /// can stay Send + Sync. + pub restart_requested: AtomicBool, } impl TrayState { @@ -68,6 +97,10 @@ impl TrayState { Self { paused: AtomicBool::new(false), new_messages: AtomicU32::new(0), + desktop_notifications: AtomicBool::new(true), + notify_high: AtomicBool::new(true), + notify_max: AtomicBool::new(true), + restart_requested: AtomicBool::new(false), } } } @@ -264,6 +297,34 @@ impl TrayIcon { } } + /// Phase 7 C2: show a desktop toast for an incoming Gotify message. + /// `title` becomes `szInfoTitle`, `body` becomes `szInfo`, + /// `icon_flags` is one of the NIIF_* constants. Safe to call from + /// any thread — we heap-allocate the payload and PostMessageW it + /// to the tray's helper window so the actual `Shell_NotifyIconW` + /// call (which must happen on the registration thread) runs in the + /// WndProc. + pub fn show_toast(&self, title: &str, body: &str, icon_flags: u32) { + if self.hwnd.is_null() { + return; + } + let payload = Box::new(ToastPayload { + title: title.to_string(), + body: body.to_string(), + icon_flags, + }); + let ptr = Box::into_raw(payload) as isize; + // SAFETY: PostMessageW queues the message; the WndProc owns + // the only reference to the heap-allocated payload and will + // reconstruct + drop it. If the WndProc never runs (e.g. the + // tray was just dropped and the helper HWND is destroyed), + // we leak the Box — that's a hard-to-trigger edge case we + // accept for V1. + unsafe { + PostMessageW(self.hwnd, WM_SHOW_TOAST, 0, ptr as LPARAM); + } + } + fn activate_msg_id_for_send(&self) -> u32 { // Re-resolve the registered message id in the calling // process. The system-wide table returns the same u32 that @@ -322,6 +383,14 @@ struct TrayShared { state: Arc, } +/// Heap-allocated payload for the WM_SHOW_TOAST posted message. Lives +/// only long enough to be reconstructed by the WndProc and dropped. +struct ToastPayload { + title: String, + body: String, + icon_flags: u32, +} + // Helper: convert a Rust `&str` into a UTF-16 buffer for menu item // text. The caller appends the null terminator implicitly via // `chain(once(0))`. @@ -383,6 +452,21 @@ unsafe extern "system" fn wnd_proc( return 0; } + if msg == WM_SHOW_TOAST { + // Phase 7 C2: show a desktop toast via NIM_MODIFY. Reconstruct + // the heap payload from LPARAM and drop it before returning. + let ptr = lparam as isize; + if ptr != 0 { + // SAFETY: `ptr` came from `Box::into_raw` in `show_toast` + // and we own the only reference (the WndProc is + // single-threaded for this message). Reconstructing the + // Box drops the String buffers when this scope ends. + let payload = unsafe { Box::from_raw(ptr as *mut ToastPayload) }; + show_toast(hwnd, &payload); + } + return 0; + } + match msg { WM_TRAYICON => { let event = lparam as u32; @@ -543,4 +627,70 @@ fn show_context_menu(hwnd: HWND, shared: &TrayShared) { // state-change" pass (so callbacks can re-show the menu). Phase 7 C1 // doesn't need it but the import documents the intended surface. #[allow(dead_code)] -fn _mutex_keepalive(_m: &Mutex<()>) {} \ No newline at end of file +fn _mutex_keepalive(_m: &Mutex<()>) {} + +/// Phase 7 C2: render a single Gotify message as a desktop toast +/// via `Shell_NotifyIconW` with `NIF_INFO`. On Windows 10+ this is +/// routed through Action Center and shows up exactly like a modern +/// toast; on older Windows it falls back to a classic balloon. The +/// WndProc owns the call so it runs on the same thread that called +/// `NIM_ADD` — a hard Win32 requirement that the heap-allocated +/// payload dance in `show_toast` is structured around. +fn show_toast(hwnd: HWND, payload: &ToastPayload) { + let mut nid = NOTIFYICONDATAW { + cbSize: std::mem::size_of::() as u32, + hWnd: hwnd, + uID: 1, + // Re-declare every flag every time — NIM_MODIFY only honors + // the fields whose bits are set in `uFlags`. We set + // NIF_TIP|NIF_INFO so the OS picks up the existing tip AND + // the new info text. NIF_ICON is left alone: we don't ship + // a custom icon yet (TODO Phase 7 C5) so reusing whatever + // was registered at NIM_ADD keeps the visual consistent. + uFlags: NIF_INFO | NIF_TIP, + uCallbackMessage: 0, + hIcon: std::ptr::null_mut(), + szTip: [0u16; 128], + szInfo: [0u16; 256], + Anonymous: unsafe { std::mem::zeroed() }, + szInfoTitle: [0u16; 64], + dwState: 0, + dwStateMask: 0, + dwInfoFlags: payload.icon_flags, + guidItem: GUID { data1: 0, data2: 0, data3: 0, data4: [0; 8] }, + hBalloonIcon: std::ptr::null_mut(), + }; + + // Truncate aggressively: szInfoTitle is 64 wide chars (incl. the + // null terminator), szInfo is 256. + copy_wide_trunc(&payload.title, &mut nid.szInfoTitle); + copy_wide_trunc(&payload.body, &mut nid.szInfo); + + unsafe { + let ok = Shell_NotifyIconW(NIM_MODIFY, &nid); + if ok == 0 { + tracing::warn!( + title = %payload.title, + "tray: Shell_NotifyIconW NIM_MODIFY returned 0 (toast failed)" + ); + } + } +} + +/// UTF-16 encode `s` into `dst`, truncating to `dst.len() - 1` +/// characters and null-terminating so Win32 treats it as a C-style +/// wide string. +fn copy_wide_trunc(s: &str, dst: &mut [u16]) { + debug_assert!(!dst.is_empty()); + let max = dst.len() - 1; // leave room for the null terminator + for (i, ch) in s.encode_utf16().take(max).enumerate() { + dst[i] = ch; + } + // Fill the rest with zeros so the buffer is "null-terminated" + // for Win32 readers that look at the trailing bytes (some shell + // versions do, surprisingly). + let written = s.encode_utf16().count().min(max); + for slot in &mut dst[written..] { + *slot = 0; + } +} \ No newline at end of file