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(托盘初始化快照用)
This commit is contained in:
2026-09-19 08:20:03 +08:00
parent 2614909c4c
commit 301052698d
3 changed files with 493 additions and 194 deletions
+1
View File
@@ -12,6 +12,7 @@ use crate::error::AppResult;
use crate::gotify::ServerConfig; use crate::gotify::ServerConfig;
use crate::storage::AppConfig as PersistedConfig; use crate::storage::AppConfig as PersistedConfig;
#[derive(Clone)]
pub struct ActiveConfig { pub struct ActiveConfig {
pub server: Option<ServerConfig>, pub server: Option<ServerConfig>,
pub appearance: String, pub appearance: String,
+320 -172
View File
@@ -1,6 +1,6 @@
//! Windows Gotify Client (Rust) — core library. //! Windows Gotify Client (Rust) — core library.
//! //!
//! Phase 5 surface: //! Phase 7 surface:
//! - [`config`]: active-config wrapper (Phase 5 C1) //! - [`config`]: active-config wrapper (Phase 5 C1)
//! - [`error`]: AppError / AppResult //! - [`error`]: AppError / AppResult
//! - [`gotify`]: blocking REST + WebSocket clients (Phase 3/4) //! - [`gotify`]: blocking REST + WebSocket clients (Phase 3/4)
@@ -9,15 +9,22 @@
//! - [`storage`]: `%APPDATA%/WindowsGotify/{config,state}.json` //! - [`storage`]: `%APPDATA%/WindowsGotify/{config,state}.json`
//! - [`run`]: builds the MainWindow and runs its event loop //! - [`run`]: builds the MainWindow and runs its event loop
//! //!
//! Phase 5 C2 additions on top of Phase 4: //! Phase 7 additions on top of Phase 6:
//! - `last_theme` + `last_message_id` persist into `state.json` //! - Tray helper now exposes a full menu (Open / New messages / Pause
//! (AGENTS.md §10 / §16 / §18). //! / Settings / Restart / Quit) and the registered
//! - Settings → Appearance radio fires `appearance-changed-persist`, //! `WM_ACTIVATE_INSTANCE` path so a second launch surfaces the
//! which writes the new theme into `state.last_theme` immediately. //! existing instance. (AGENTS.md §12, §26.)
//! - The HTTP catch-up uses `since=last_message_id` so a restart only //! - Win32 toast (NIF_INFO via `Shell_NotifyIconW`) on every new
//! downloads the gap. //! Gotify message. Respects `desktop_notifications`,
//! - The WS drain closure bumps `last_message_id` on every accepted //! `notify_high`, `notify_max`, and the tray-menu pause toggle.
//! frame, so a WS-only session still advances the watermark. //! (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 config;
pub mod error; pub mod error;
@@ -34,15 +41,14 @@ slint::include_modules!();
use std::cell::RefCell; use std::cell::RefCell;
use std::rc::Rc; use std::rc::Rc;
use std::sync::mpsc; use std::sync::mpsc;
use std::sync::Arc;
use slint::{ModelRc, VecModel}; use slint::{Model, ModelRc, VecModel};
use storage::AppState; use storage::AppState;
#[cfg(windows)] #[cfg(windows)]
use crate::windows::tray::{TrayCallbacks, TrayIcon}; use crate::windows::tray::{TrayCallbacks, TrayIcon, TrayState, NIIF_ERROR, NIIF_INFO, NIIF_WARNING};
#[cfg(windows)]
use crate::windows::url as url_opener;
/// Map the persisted theme string (`state.json::last_theme`) back into /// Map the persisted theme string (`state.json::last_theme`) back into
/// the Slint `AppearanceMode` enum. Unknown values fall back to Dark so /// the Slint `AppearanceMode` enum. Unknown values fall back to Dark so
@@ -101,8 +107,8 @@ pub fn run() -> error::AppResult<()> {
state_rc.borrow().last_window_height.max(0) as f32, state_rc.borrow().last_window_height.max(0) as f32,
))); )));
// ── Phase 6 C2: load live AppConfig toggles into MainWindow ─── // ── 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 cfg = config::ActiveConfig::load()?;
ui.set_desktop_notify(cfg.desktop_notifications); ui.set_desktop_notify(cfg.desktop_notifications);
ui.set_sound(cfg.sound); ui.set_sound(cfg.sound);
@@ -111,16 +117,133 @@ pub fn run() -> error::AppResult<()> {
ui.set_start_with_windows(cfg.start_with_windows); ui.set_start_with_windows(cfg.start_with_windows);
ui.set_start_minimized(cfg.start_minimized); ui.set_start_minimized(cfg.start_minimized);
ui.set_close_to_tray(cfg.close_to_tray); ui.set_close_to_tray(cfg.close_to_tray);
}
// ── Phase 3 C2 + 4 C2 + 5 C1: HTTP catch-up, then channel for WS ──── // ── Phase 7 C2: long-lived message model. We bind `ui.messages`
let cfg = config::ActiveConfig::load()?; // to this single VecModel once and update it via `set_vec` on
let shared_model: Rc<VecModel<MessageItem>>; // 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 status_text: String;
let is_connected: bool; let is_connected: bool;
let ws_credential: Option<(String, String, Vec<gotify::GotifyApplication>)>;
let since = state_rc.borrow().last_message_id;
match cfg.server.as_ref() { match cfg.server.as_ref() {
Some(server) => match models::try_fetch_initial_data( Some(server) => match models::try_fetch_initial_data(
&server.base_url, &server.base_url,
@@ -134,9 +257,8 @@ pub fn run() -> error::AppResult<()> {
apps = apps.len(), apps = apps.len(),
"gotify: HTTP catch-up ok" "gotify: HTTP catch-up ok"
); );
shared_model = Rc::new(VecModel::from( shared_model
models::gotify_messages_to_items(&msgs, &apps), .set_vec(models::gotify_messages_to_items(&msgs, &apps));
));
status_text = text; status_text = text;
is_connected = true; is_connected = true;
ws_credential = Some((server.base_url.clone(), server.token.clone(), apps)); ws_credential = Some((server.base_url.clone(), server.token.clone(), apps));
@@ -155,7 +277,7 @@ pub fn run() -> error::AppResult<()> {
server = %server.base_url, server = %server.base_url,
"gotify: HTTP fetch failed; using mock data, WS worker skipped" "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); status_text = format!("Disconnected · mock feed · {}", server.base_url);
is_connected = false; is_connected = false;
ws_credential = None; ws_credential = None;
@@ -163,27 +285,22 @@ pub fn run() -> error::AppResult<()> {
}, },
None => { None => {
tracing::info!("no server configured; mock feed only"); 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(); status_text = "No server configured · mock feed".to_string();
is_connected = false; is_connected = false;
ws_credential = None; ws_credential = None;
} }
} }
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 7 C1: tray state cell ───────────────────────────────────── // ── Phase 4 C2 + 5 C2 + 7 C1/C2: WebSocket worker + UI-thread
// The tray is created further down (after the WS drain is wired so // drain. The drain closure also fires a desktop toast for each
// we have a clean order). The drain closure needs to bump the tray // new message via the tray's `show_toast` (any thread → the tray
// state on each new message, so we share a cell that the tray // helper window's WndProc, which is the only thread allowed to
// init later populates. `RefCell` is fine here — both the drain // call Shell_NotifyIconW), and now also services the
// callback and the post-tray-init assignment run on the UI thread. // `restart_requested` flag set by the tray's on_restart. ──
let tray_state_cell: Rc<RefCell<Option<std::sync::Arc<windows::tray::TrayState>>>> =
Rc::new(RefCell::new(None));
// ── Phase 4 C2 + 5 C2: WebSocket worker + UI-thread drain ───────────
let drain_timer = if let Some((base_url, token, apps)) = ws_credential { let drain_timer = if let Some((base_url, token, apps)) = ws_credential {
let (tx, rx) = mpsc::channel::<MessageItem>(); let (tx, rx) = mpsc::channel::<MessageItem>();
let apps_by_id: std::collections::HashMap<i64, gotify::GotifyApplication> = let apps_by_id: std::collections::HashMap<i64, gotify::GotifyApplication> =
@@ -208,15 +325,14 @@ pub fn run() -> error::AppResult<()> {
let timer = slint::Timer::default(); let timer = slint::Timer::default();
let model_for_drain = shared_model.clone(); let model_for_drain = shared_model.clone();
let state_for_drain = state_rc.clone(); let state_for_drain = state_rc.clone();
// Phase 7 C1: tray "New messages: N" counter. The tray owns let tray_for_drain = tray_arc.clone();
// the Arc<TrayState>; we capture the cell here so the UI let tray_state_for_drain = tray_state_for_ws.clone();
// drain can bump the atomic once the tray has populated the let ui_handle_for_drain = ui.as_weak();
// cell (post-init, below).
let tray_state_for_drain = tray_state_cell.clone();
timer.start( timer.start(
slint::TimerMode::Repeated, slint::TimerMode::Repeated,
std::time::Duration::from_millis(100), std::time::Duration::from_millis(100),
move || { move || {
// Drain the WS channel.
loop { loop {
match rx.try_recv() { match rx.try_recv() {
Ok(item) => { Ok(item) => {
@@ -227,17 +343,95 @@ pub fn run() -> error::AppResult<()> {
s.last_message_id = id; s.last_message_id = id;
} }
} }
// Bump the tray counter so the menu // Phase 7 C1: bump the tray counter so
// "New messages: N" row refreshes. // the menu "New messages: N" row
if let Some(ref ts) = *tray_state_for_drain.borrow() { // refreshes.
if let Some(ref ts) = tray_state_for_drain {
ts.new_messages ts.new_messages
.fetch_add(1, std::sync::atomic::Ordering::Relaxed); .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); model_for_drain.push(item);
} }
Err(_) => break, 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) Some(timer)
@@ -250,123 +444,63 @@ pub fn run() -> error::AppResult<()> {
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)");
}); });
// 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| { 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 ─────── // ── Phase 6 C3: URL opener import kept alive even when not on
// The tray owns a hidden helper window and a dedicated message- // Windows — the cfg(windows) gate above handles the actual use.
// pump thread. On top of the Phase 6 C3 Open/Quit menu we now // (Kept here so future cross-platform polish knows where to hook.) ──
// 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.
#[cfg(windows)] #[cfg(windows)]
let _tray = { let _ = crate::windows::url::open;
let ui_handle_for_open = ui.as_weak();
let ui_handle_for_settings = ui.as_weak(); // ── Phase 3 C2 + 7 C2: Settings → Connection → Test button ───
let ui_handle_for_restart = ui.as_weak(); ui.on_test_connection_requested({
let tray_state_for_pause_init = tray_state_cell.clone(); let ui_handle = ui.as_weak();
let cfg_for_pause_init = config::ActiveConfig::load().ok(); let model_for_test = shared_model.clone();
TrayIcon::new(TrayCallbacks { move || {
on_open: Box::new(move || { tracing::info!("settings: Test Connection clicked");
// 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) = let Some(server) =
config::ActiveConfig::load().ok().and_then(|c| c.server) config::ActiveConfig::load().ok().and_then(|c| c.server)
else { 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()
// ── 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 ───────────
ui.on_test_connection_requested({
let ui_handle = ui.as_weak();
move || {
tracing::info!("settings: Test Connection clicked");
let Some(server) = config::ActiveConfig::load().ok().and_then(|c| c.server) else {
return; return;
}; };
match models::try_fetch_initial_data(&server.base_url, &server.token, None) { match models::try_fetch_initial_data(&server.base_url, &server.token, None) {
Some((apps, msgs, text)) => { Some((apps, msgs, text)) => {
model_for_test.set_vec(
models::gotify_messages_to_items(&msgs, &apps),
);
if let Some(ui) = ui_handle.upgrade() { if let Some(ui) = ui_handle.upgrade() {
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);
} }
@@ -399,34 +533,44 @@ pub fn run() -> error::AppResult<()> {
} }
}); });
// ── Phase 6 C2: every other SettingsPage toggle (notifications, // ── Phase 6 C2 + 7 C1/C2: every other SettingsPage toggle
// startup) routes through one callback. We dispatch on the `label` // (notifications, startup) routes through one callback. We
// string the UI hands us, persist into `config.json`, and for // dispatch on the `label` string the UI hands us, persist into
// `start_with_windows` we also write HKCU Run (Phase 6 C1). // `config.json`, and for `start_with_windows` we also write HKCU
ui.on_setting_toggled(|label, value| { // Run (Phase 6 C1). Phase 7 C1/C2 also mirrors the relevant
// Phase 6 C2 also: AppConfig is mutated through a fresh load + // notification toggles into the tray state's cached atomics so
// save because the toggle handler is a `Fn` (no captured // the WS drain's toast gating stays in sync without a restart. ──
// mutex); this keeps the persistence path side-effect-only and let tray_state_for_settings = tray_state_for_ws.clone();
// doesn't risk racing with the close-requested save below. ui.on_setting_toggled(move |label, value| {
let mut cfg = match storage::load_config() { let mut cfg = storage::load_config();
c => c,
};
let label_str = label.to_string(); let label_str = label.to_string();
let label_ref: &str = &label_str; let label_ref: &str = &label_str;
match label_ref { 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, "sound" => cfg.sound = value,
"notify_high" => cfg.notify_high = value, "notify_high" => {
"notify_max" => cfg.notify_max = value, 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, "start_minimized" => cfg.start_minimized = value,
"close_to_tray" => { "close_to_tray" => {
cfg.close_to_tray = value; 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" => { "start_with_windows" => {
cfg.start_with_windows = value; 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() ui.run()
.map_err(|e| error::AppError::Ui(e.to_string())) .map_err(|e| error::AppError::Ui(e.to_string()))
} }
+158 -8
View File
@@ -27,17 +27,17 @@ use windows_sys::Win32::Foundation::{
}; };
use windows_sys::Win32::System::Threading::GetCurrentThreadId; use windows_sys::Win32::System::Threading::GetCurrentThreadId;
use windows_sys::Win32::UI::Shell::{ use windows_sys::Win32::UI::Shell::{
Shell_NotifyIconW, NIF_ICON, NIF_MESSAGE, NIF_TIP, NIM_ADD, NIM_DELETE, Shell_NotifyIconW, NIF_ICON, NIF_INFO, NIF_MESSAGE, NIF_TIP, NIM_ADD, NIM_DELETE,
NOTIFYICONDATAW, NIM_MODIFY, NOTIFYICONDATAW,
}; };
use windows_sys::Win32::UI::WindowsAndMessaging::{ use windows_sys::Win32::UI::WindowsAndMessaging::{
AppendMenuW, CreatePopupMenu, CreateWindowExW, DefWindowProcW, DestroyWindow, AppendMenuW, CreatePopupMenu, CreateWindowExW, DefWindowProcW, DestroyWindow,
DispatchMessageW, GetCursorPos, GetMessageW, GetWindowLongPtrW, RegisterClassExW, DispatchMessageW, GetCursorPos, GetMessageW, GetWindowLongPtrW, PostMessageW,
RegisterWindowMessageW, SendMessageW, SetForegroundWindow, SetWindowLongPtrW, RegisterClassExW, RegisterWindowMessageW, SendMessageW, SetForegroundWindow,
TrackPopupMenuEx, TranslateMessage, CS_HREDRAW, CW_USEDEFAULT, GWLP_USERDATA, SetWindowLongPtrW, TrackPopupMenuEx, TranslateMessage, CS_HREDRAW, CW_USEDEFAULT,
MF_CHECKED, MF_GRAYED, MF_SEPARATOR, MF_STRING, MSG, TPM_BOTTOMALIGN, TPM_LEFTALIGN, GWLP_USERDATA, MF_CHECKED, MF_GRAYED, MF_SEPARATOR, MF_STRING, MSG, TPM_BOTTOMALIGN,
TPM_RETURNCMD, WM_COMMAND, WM_DESTROY, WM_LBUTTONDBLCLK, WM_RBUTTONUP, WM_USER, TPM_LEFTALIGN, TPM_RETURNCMD, WM_COMMAND, WM_DESTROY, WM_LBUTTONDBLCLK, WM_RBUTTONUP,
WNDCLASSEXW, WS_EX_TOOLWINDOW, WM_USER, WNDCLASSEXW, WS_EX_TOOLWINDOW,
}; };
use windows_sys::core::GUID; use windows_sys::core::GUID;
@@ -48,6 +48,19 @@ const IDM_SETTINGS: u16 = 4;
const IDM_RESTART: u16 = 5; const IDM_RESTART: u16 = 5;
const IDM_QUIT: u16 = 6; const IDM_QUIT: u16 = 6;
const WM_TRAYICON: u32 = WM_USER + 1; 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_CLASS_NAME: &str = "WindowsGotifyClientRust.TrayHidden\0";
const TRAY_TIP: &str = "Windows Gotify Client\0"; const TRAY_TIP: &str = "Windows Gotify Client\0";
// Wide-char constant for RegisterWindowMessageW. `RegisterWindowMessageW` // Wide-char constant for RegisterWindowMessageW. `RegisterWindowMessageW`
@@ -61,6 +74,22 @@ const ACTIVATE_INSTANCE_MSG_NAME: &str = "WindowsGotifyClientRust.ActivateInstan
pub struct TrayState { pub struct TrayState {
pub paused: AtomicBool, pub paused: AtomicBool,
pub new_messages: AtomicU32, 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<VecModel<…>>` so the closure
/// can stay Send + Sync.
pub restart_requested: AtomicBool,
} }
impl TrayState { impl TrayState {
@@ -68,6 +97,10 @@ impl TrayState {
Self { Self {
paused: AtomicBool::new(false), paused: AtomicBool::new(false),
new_messages: AtomicU32::new(0), 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 { fn activate_msg_id_for_send(&self) -> u32 {
// Re-resolve the registered message id in the calling // Re-resolve the registered message id in the calling
// process. The system-wide table returns the same u32 that // process. The system-wide table returns the same u32 that
@@ -322,6 +383,14 @@ struct TrayShared {
state: Arc<TrayState>, state: Arc<TrayState>,
} }
/// 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 // Helper: convert a Rust `&str` into a UTF-16 buffer for menu item
// text. The caller appends the null terminator implicitly via // text. The caller appends the null terminator implicitly via
// `chain(once(0))`. // `chain(once(0))`.
@@ -383,6 +452,21 @@ unsafe extern "system" fn wnd_proc(
return 0; 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 { match msg {
WM_TRAYICON => { WM_TRAYICON => {
let event = lparam as u32; let event = lparam as u32;
@@ -544,3 +628,69 @@ fn show_context_menu(hwnd: HWND, shared: &TrayShared) {
// doesn't need it but the import documents the intended surface. // doesn't need it but the import documents the intended surface.
#[allow(dead_code)] #[allow(dead_code)]
fn _mutex_keepalive(_m: &Mutex<()>) {} 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::<NOTIFYICONDATAW>() 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;
}
}