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
+159 -9
View File
@@ -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<VecModel<…>>` 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<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
// 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<()>) {}
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;
}
}