- 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(托盘初始化快照用)
696 lines
27 KiB
Rust
696 lines
27 KiB
Rust
//! 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;
|
||
}
|
||
} |