Files
Windows-Gotify-Client-Rust/src/windows/tray.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

696 lines
27 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! System tray icon (AGENTS.md §12, §13).
//!
//! V1 surface: a single hidden helper window + dedicated message-pump
//! thread; the helper owns a Shell_NotifyIconW registration.
//!
//! Phase 7 C1 additions on top of the Phase 6 C3 scaffolding:
//! - Richer context menu: `Open`, grayed-out "New messages: N" info
//! row, `Pause notifications` (checked when paused), `Settings`,
//! `Restart connection`, separator, `Quit`. (AGENTS.md §12.)
//! - `TrayState` with `Arc<AtomicBool>` paused + `Arc<AtomicU32>`
//! new_messages. `lib.rs` mutates them; `show_context_menu` reads
//! them when building the menu.
//! - New callbacks: `on_pause_toggle`, `on_settings`, `on_restart`.
//! - Registered window message `WM_ACTIVATE_INSTANCE`. The WndProc
//! treats it as "another instance launched, please surface the main
//! window". Used by `single_instance::activate_existing_instance`
//! when a second launch detects the singleton mutex is taken.
#![cfg(windows)]
use std::sync::atomic::{AtomicBool, AtomicU32, AtomicUsize, Ordering};
use std::sync::{Arc, Mutex};
use std::thread::JoinHandle;
use windows_sys::Win32::Foundation::{
GetLastError, HWND, LPARAM, LRESULT, POINT, WPARAM,
};
use windows_sys::Win32::System::Threading::GetCurrentThreadId;
use windows_sys::Win32::UI::Shell::{
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, 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;
const IDM_OPEN: u16 = 1;
const IDM_NEW_MESSAGES: u16 = 2;
const IDM_PAUSE: u16 = 3;
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`
// is idempotent across processes — both the live and the launching
// instance get the same u32 ID back.
const ACTIVATE_INSTANCE_MSG_NAME: &str = "WindowsGotifyClientRust.ActivateInstance\0";
/// Shared, runtime-mutable state surfaced through the tray menu. Lives
/// behind `Arc` so callers (`lib.rs` WS drain + the menu builder) can
/// share without a lock for the small atomic reads/writes here.
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 {
fn new() -> Self {
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),
}
}
}
/// Callbacks the tray dispatches to the main runtime.
pub struct TrayCallbacks {
pub on_open: Box<dyn Fn() + Send + Sync + 'static>,
pub on_pause_toggle: Box<dyn Fn(bool) + Send + Sync + 'static>,
pub on_settings: Box<dyn Fn() + Send + Sync + 'static>,
pub on_restart: Box<dyn Fn() + Send + Sync + 'static>,
pub on_quit: Box<dyn Fn() + Send + Sync + 'static>,
}
pub struct TrayIcon {
hwnd: HWND,
thread: Option<JoinHandle<()>>,
quit_flag: std::sync::Arc<AtomicBool>,
/// Incremented each time the user picks a menu item or a registered
/// message arrives so the caller can correlate traces across
/// threads. Not load-bearing.
tick: std::sync::Arc<AtomicUsize>,
state: Arc<TrayState>,
}
impl TrayIcon {
/// Build the helper window, register the tray icon, and spawn the
/// message-pump thread. Fails fast on any setup error.
pub fn new(callbacks: TrayCallbacks) -> Result<Self, String> {
let hinstance = unsafe { GetCurrentThreadId() } as *mut _;
// RegisterClassExW — registration is process-wide, so the
// class name is namespaced ("...TrayHidden") to avoid colliding
// with anything else. CS_HREDRAW is the smallest constant that
// satisfies the win32 docs requirement of "any class style".
let class_name_w: Vec<u16> = TRAY_CLASS_NAME.encode_utf16().collect();
let wc = WNDCLASSEXW {
cbSize: std::mem::size_of::<WNDCLASSEXW>() as u32,
style: CS_HREDRAW,
lpfnWndProc: Some(wnd_proc),
cbClsExtra: 0,
cbWndExtra: 0,
hInstance: hinstance,
hIcon: std::ptr::null_mut(),
hCursor: std::ptr::null_mut(),
hbrBackground: std::ptr::null_mut(),
lpszMenuName: std::ptr::null(),
lpszClassName: class_name_w.as_ptr(),
hIconSm: std::ptr::null_mut(),
};
let atom = unsafe { RegisterClassExW(&wc) };
if atom == 0 {
return Err(format!("RegisterClassExW failed: err={}", unsafe { GetLastError() }));
}
// CreateWindowExW with HWND_MESSAGE-style (we leave the hWndParent
// argument null and pick an explicit WS_EX_TOOLWINDOW + zero
// size to keep the helper out of the taskbar / Alt-Tab list).
// Window size 0×0 + CW_USEDEFAULT for the position.
let hwnd = unsafe {
CreateWindowExW(
WS_EX_TOOLWINDOW,
class_name_w.as_ptr(),
std::ptr::null(),
0, // no WS_OVERLAPPEDWINDOW, etc.
CW_USEDEFAULT,
CW_USEDEFAULT,
0,
0,
std::ptr::null_mut(),
std::ptr::null_mut(),
hinstance,
std::ptr::null(),
)
};
if hwnd.is_null() {
return Err(format!("CreateWindowExW failed: err={}", unsafe { GetLastError() }));
}
// Resolve the registered window message id. Both this process
// (for the WndProc switch) and a fresh second-launching process
// (to send the message via single_instance::activate_existing)
// call RegisterWindowMessageW with the same string; the OS
// returns the same u32 either way.
let activate_msg_w: Vec<u16> = ACTIVATE_INSTANCE_MSG_NAME.encode_utf16().collect();
let activate_msg_id =
unsafe { RegisterWindowMessageW(activate_msg_w.as_ptr()) };
// Attach a `TrayShared` pointer to the window so the WndProc
// can find the callbacks + state without going through TLS or
// statics.
let tick = std::sync::Arc::new(AtomicUsize::new(0));
let state = Arc::new(TrayState::new());
let shared = Box::new(TrayShared {
callbacks,
activate_msg_id,
tick: tick.clone(),
state: state.clone(),
});
let shared_ptr = Box::into_raw(shared) as isize;
unsafe {
SetWindowLongPtrW(hwnd, GWLP_USERDATA, shared_ptr);
}
// Spawn the message-pump thread. It owns a clone of
// `shared_ptr` and a quit flag.
let quit_flag = std::sync::Arc::new(AtomicBool::new(false));
let quit_for_thread = quit_flag.clone();
// HWND is a raw pointer (not Send) — coerce to a usize for
// the cross-thread handoff. The thread ignores it anyway
// (WndProc looks up the shared pointer from the window's
// own user-data slot).
let hwnd_for_thread = hwnd as usize;
let thread = std::thread::Builder::new()
.name("gotify-tray".into())
.spawn(move || {
run_message_loop(hwnd_for_thread, shared_ptr, quit_for_thread);
})
.map_err(|e| format!("tray thread spawn: {e}"))?;
// Register the icon. NULL hIcon → Windows draws a default
// 16×16 placeholder; the C2 polish pass will swap in a real
// `.ico`.
let tip_w: Vec<u16> = TRAY_TIP.encode_utf16().collect();
let mut nid = NOTIFYICONDATAW {
cbSize: std::mem::size_of::<NOTIFYICONDATAW>() as u32,
hWnd: hwnd,
uID: 1,
uFlags: NIF_ICON | NIF_TIP | NIF_MESSAGE,
uCallbackMessage: WM_TRAYICON,
hIcon: std::ptr::null_mut(),
szTip: [0u16; 128],
szInfo: [0u16; 256],
Anonymous: unsafe { std::mem::zeroed() },
szInfoTitle: [0u16; 64],
dwState: 0,
dwStateMask: 0,
dwInfoFlags: 0,
guidItem: GUID { data1: 0, data2: 0, data3: 0, data4: [0; 8] },
hBalloonIcon: std::ptr::null_mut(),
};
// Copy the wide tip into the fixed-size buffer.
for (i, ch) in tip_w.iter().take(127).enumerate() {
nid.szTip[i] = *ch;
}
unsafe {
let ok = Shell_NotifyIconW(NIM_ADD, &nid);
if ok == 0 {
tracing::warn!("tray: Shell_NotifyIconW NIM_ADD returned 0");
}
}
// We also have to set the version explicitly so the union
// field doesn't keep the old layout. Some Windows builds
// require this *before* NIM_ADD in older docs, but NIM_ADD
// + NIM_VERSION-then-MODIFY is the modern order.
// (NIM_VERSION = 4 in the Win32 header; windows-sys 0.59
// didn't re-export the name, so we hard-code the constant.)
let mut version_nid = nid;
version_nid.Anonymous.uVersion = 4u32;
unsafe { Shell_NotifyIconW(windows_sys::Win32::UI::Shell::NIM_SETVERSION, &version_nid); }
Ok(Self {
hwnd,
thread: Some(thread),
quit_flag,
tick: std::sync::Arc::new(AtomicUsize::new(0)),
state,
})
}
/// Shared state for the menu (paused toggle + new-messages
/// counter). The WS drain closure in `lib.rs` bumps the counter;
/// the menu builder reads both.
pub fn state(&self) -> Arc<TrayState> {
self.state.clone()
}
pub fn tick(&self) -> usize {
self.tick.load(Ordering::Relaxed)
}
/// Public hook used by `single_instance::activate_existing_instance`
/// to forward the registered message to this tray's WndProc without
/// re-importing the Win32 / message-id internals here.
pub fn request_activate(&self) {
if self.hwnd.is_null() {
return;
}
// SAFETY: SendMessageW with our registered message id (a
// value that exists because the tray was constructed with the
// same id). 0/0 are unused wparam/lparam slots for the
// activation message.
unsafe {
SendMessageW(self.hwnd, self.activate_msg_id_for_send(), 0, 0);
}
}
/// 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
// TrayIcon::new() registered on the live instance. We avoid
// stashing the id inside TrayIcon because single_instance
// lives in a different module; the indirection keeps the
// send path testable in isolation.
let wide: Vec<u16> = ACTIVATE_INSTANCE_MSG_NAME.encode_utf16().collect();
unsafe { RegisterWindowMessageW(wide.as_ptr()) }
}
}
impl Drop for TrayIcon {
fn drop(&mut self) {
// Remove the icon first so the user doesn't see a stale tray
// entry pointing at a window that's about to be destroyed.
let nid = NOTIFYICONDATAW {
cbSize: std::mem::size_of::<NOTIFYICONDATAW>() as u32,
hWnd: self.hwnd,
uID: 1,
uFlags: 0,
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: 0,
guidItem: GUID { data1: 0, data2: 0, data3: 0, data4: [0; 8] },
hBalloonIcon: std::ptr::null_mut(),
};
unsafe { let _ = Shell_NotifyIconW(NIM_DELETE, &nid); }
// Tell the message loop to exit, then post WM_DESTROY so the
// window's HWND is gone before the thread joins.
self.quit_flag.store(true, Ordering::SeqCst);
// Posting via the helper window's DestroyWindow is enough: it
// queues WM_DESTROY on the same thread, which causes the
// message loop to fall through.
unsafe { DestroyWindow(self.hwnd); }
if let Some(handle) = self.thread.take() {
let _ = handle.join();
}
}
}
/// State shared between the WndProc (UI thread) and the message-pump
/// thread. The pointer lives in the helper window's GWLP_USERDATA slot.
struct TrayShared {
callbacks: TrayCallbacks,
activate_msg_id: u32,
tick: std::sync::Arc<AtomicUsize>,
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))`.
fn wide(s: &str) -> Vec<u16> {
s.encode_utf16().chain(std::iter::once(0)).collect()
}
fn run_message_loop(_hwnd: usize, shared_ptr: isize, quit_flag: std::sync::Arc<AtomicBool>) {
// SAFETY: shared_ptr came from `Box::into_raw` in `new()`. We
// reconstruct the box at the end of the loop, regardless of how
// we exit (clean shutdown, DestroyWindow, etc.).
let _shared = unsafe { Box::from_raw(shared_ptr as *mut TrayShared) };
let mut msg: MSG = unsafe { std::mem::zeroed() };
loop {
// SAFETY: GetMessageW blocks until a message arrives. It
// returns 0 on WM_QUIT and -1 on error; we treat both as
// exit.
let rc = unsafe { GetMessageW(&mut msg, std::ptr::null_mut(), 0, 0) };
if rc == 0 || rc == -1 {
break;
}
// SAFETY: standard message-pump pair; both are no-ops for
// windowless message dispatch.
unsafe {
TranslateMessage(&msg);
DispatchMessageW(&msg);
}
if quit_flag.load(Ordering::SeqCst) {
break;
}
}
}
unsafe extern "system" fn wnd_proc(
hwnd: HWND,
msg: u32,
wparam: WPARAM,
lparam: LPARAM,
) -> LRESULT {
// Recover the shared pointer (cast back to the same `isize` width
// that SetWindowLongPtrW stored).
let shared_ptr = unsafe { GetWindowLongPtrW(hwnd, GWLP_USERDATA) } as isize;
if shared_ptr == 0 {
return unsafe { DefWindowProcW(hwnd, msg, wparam, lparam) };
}
let shared = unsafe { &*(shared_ptr as *const TrayShared) };
// Registered message has to be checked first because it is the
// `RegisterWindowMessageW` return value, not a compile-time
// constant.
if msg == shared.activate_msg_id {
// Phase 7 C1: a second instance just asked us to surface the
// main window. Reset the "New messages" counter before
// dispatching on_open, mirroring the tray double-click path.
shared.state.new_messages.store(0, Ordering::Relaxed);
shared.tick.fetch_add(1, Ordering::Relaxed);
(shared.callbacks.on_open)();
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;
match event {
WM_LBUTTONDBLCLK => {
shared.state.new_messages.store(0, Ordering::Relaxed);
shared.tick.fetch_add(1, Ordering::Relaxed);
(shared.callbacks.on_open)();
}
WM_RBUTTONUP => {
show_context_menu(hwnd, shared);
}
_ => {}
}
0
}
WM_COMMAND => {
let id = (wparam & 0xFFFF) as u16;
match id {
IDM_OPEN => {
shared.state.new_messages.store(0, Ordering::Relaxed);
shared.tick.fetch_add(1, Ordering::Relaxed);
(shared.callbacks.on_open)();
}
IDM_PAUSE => {
shared.tick.fetch_add(1, Ordering::Relaxed);
let now = !shared.state.paused.load(Ordering::Relaxed);
shared.state.paused.store(now, Ordering::Relaxed);
(shared.callbacks.on_pause_toggle)(now);
}
IDM_SETTINGS => {
shared.tick.fetch_add(1, Ordering::Relaxed);
(shared.callbacks.on_settings)();
}
IDM_RESTART => {
shared.tick.fetch_add(1, Ordering::Relaxed);
(shared.callbacks.on_restart)();
}
IDM_QUIT => {
shared.tick.fetch_add(1, Ordering::Relaxed);
(shared.callbacks.on_quit)();
}
_ => {}
}
0
}
WM_DESTROY => 0,
_ => unsafe { DefWindowProcW(hwnd, msg, wparam, lparam) },
}
}
fn show_context_menu(hwnd: HWND, shared: &TrayShared) {
// SAFETY: CreatePopupMenu / AppendMenuW / TrackPopupMenuEx are all
// standard user-side APIs; the menu is destroyed via the implicit
// lifetime of the TrackPopupMenuEx call (it auto-cleans on return).
unsafe {
let menu = CreatePopupMenu();
if menu.is_null() {
tracing::warn!("tray: CreatePopupMenu failed");
return;
}
AppendMenuW(
menu,
MF_STRING,
IDM_OPEN as usize,
wide("Open").as_ptr(),
);
// "New messages: N" — info row, grayed so it can't be picked.
// Menu strings are UTF-16 null-terminated; we build them per
// show so the count is live.
let n = shared.state.new_messages.load(Ordering::Relaxed);
let info_label = if n == 0 {
"New messages: 0".to_string()
} else {
format!("New messages: {n}")
};
AppendMenuW(
menu,
MF_STRING | MF_GRAYED,
IDM_NEW_MESSAGES as usize,
wide(&info_label).as_ptr(),
);
AppendMenuW(menu, MF_SEPARATOR, 0, std::ptr::null());
let pause_flag = if shared.state.paused.load(Ordering::Relaxed) {
MF_STRING | MF_CHECKED
} else {
MF_STRING
};
AppendMenuW(
menu,
pause_flag,
IDM_PAUSE as usize,
wide("Pause notifications").as_ptr(),
);
AppendMenuW(
menu,
MF_STRING,
IDM_SETTINGS as usize,
wide("Settings...").as_ptr(),
);
AppendMenuW(
menu,
MF_STRING,
IDM_RESTART as usize,
wide("Restart connection").as_ptr(),
);
AppendMenuW(menu, MF_SEPARATOR, 0, std::ptr::null());
AppendMenuW(
menu,
MF_STRING,
IDM_QUIT as usize,
wide("Quit").as_ptr(),
);
// TrackPopupMenuEx requires the window to be the foreground
// window or the menu dismisses immediately.
SetForegroundWindow(hwnd);
let mut pt: POINT = std::mem::zeroed();
GetCursorPos(&mut pt);
let cmd = TrackPopupMenuEx(
menu,
TPM_BOTTOMALIGN | TPM_LEFTALIGN | TPM_RETURNCMD,
pt.x,
pt.y,
hwnd,
std::ptr::null(),
);
// `cmd == 0` means the user dismissed without picking
// anything; fall through cleanly.
if cmd > 0 {
shared.tick.fetch_add(1, Ordering::Relaxed);
match cmd as u16 {
IDM_OPEN => {
shared.state.new_messages.store(0, Ordering::Relaxed);
(shared.callbacks.on_open)()
}
IDM_PAUSE => {
let now = !shared.state.paused.load(Ordering::Relaxed);
shared.state.paused.store(now, Ordering::Relaxed);
(shared.callbacks.on_pause_toggle)(now);
}
IDM_SETTINGS => (shared.callbacks.on_settings)(),
IDM_RESTART => (shared.callbacks.on_restart)(),
IDM_QUIT => (shared.callbacks.on_quit)(),
_ => {}
}
}
}
}
// Keep the Mutex import alive for the future "menu rebuild on
// 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<()>) {}
/// 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;
}
}