feat(windows): Phase 7 C1 — 托盘菜单增强 + 单实例激活现有窗口

- 托盘菜单加 4 项:New messages: N(灰)/ Pause notifications(勾选态)/ Settings.../ Restart connection
- 单实例:第二次启动通过 RegisterWindowMessageW 发激活请求,托盘 WndProc 收后调用 on_open(show+reset counter);main.rs AlreadyRunning 路径调用 activate_existing_instance
- AppConfig 加 notifications_paused 字段(与 desktop_notifications 区分,tray 暂停持久化)
- WS drain 通过 Rc<RefCell<Option<Arc<TrayState>>>> 与托盘共享 new_messages 计数器
- WndProc 在调用 on_open 前自己清零计数器,免去回调侧的 RefCell 捕获(保持 Send+Sync)
This commit is contained in:
2026-09-19 08:13:42 +08:00
parent 1df072356f
commit 2614909c4c
6 changed files with 420 additions and 63 deletions
+75 -4
View File
@@ -3,12 +3,19 @@
//! Uses a named `Mutex` (the kernel object, not Rust's `std::sync::Mutex`)
//! so the OS, not just our process, enforces "one instance per user".
//! When `acquire_or_signal_existing` returns `AlreadyRunning`, the
//! launcher should give up and the existing process should be brought
//! to the foreground (handled by the main entry point once we have
//! its hwnd; Phase 6 C2 wires the tray / hwnd side).
//! launcher should give up and ask the existing instance to surface
//! itself via `activate_existing_instance`.
use windows_sys::Win32::Foundation::{ERROR_ALREADY_EXISTS, GetLastError, CloseHandle, HANDLE};
#![cfg(windows)]
use windows_sys::Win32::Foundation::{
CloseHandle, ERROR_ALREADY_EXISTS, GetLastError, HANDLE, HWND,
};
use windows_sys::Win32::System::Threading::CreateMutexExW;
use windows_sys::Win32::UI::WindowsAndMessaging::{
FindWindowW, RegisterWindowMessageW, SendMessageW, SetForegroundWindow,
ShowWindow, SW_RESTORE, SW_SHOWNORMAL,
};
/// Stable mutex name. `Local\` would scope to the session only (good
/// for RDP-style multi-session boxes); `Global\` is single-instance
@@ -16,6 +23,9 @@ use windows_sys::Win32::System::Threading::CreateMutexExW;
/// not refuse to start a second instance when a different user is
/// logged in.
const MUTEX_NAME: &str = "Local\\WindowsGotifyClientRust.SingleInstance\0";
const TRAY_CLASS: &str = "WindowsGotifyClientRust.TrayHidden\0";
const MAIN_WINDOW_TITLE: &str = "Gotify\0";
const ACTIVATE_INSTANCE_MSG_NAME: &str = "WindowsGotifyClientRust.ActivateInstance\0";
/// Outcome of trying to claim the singleton mutex.
pub enum SingleInstance {
@@ -72,4 +82,65 @@ pub fn acquire_or_signal_existing() -> SingleInstance {
} else {
SingleInstance::Acquired(handle as isize)
}
}
/// Best-effort: locate the existing instance's tray helper window,
/// send it the registered "please surface the main window" message,
/// and then promote the main window to the foreground. Used by the
/// launcher when `acquire_or_signal_existing` returned
/// `AlreadyRunning`. Returns `true` if at least the tray helper was
/// found (so the existing instance will see the activation request).
pub fn activate_existing_instance() -> bool {
let class_w: Vec<u16> = TRAY_CLASS.encode_utf16().chain(std::iter::once(0)).collect();
// SAFETY: FindWindowW takes a wide class-name and a wide title.
// We pass null for the title so we don't accidentally match a
// window that happens to share our title.
let tray_hwnd = unsafe { FindWindowW(class_w.as_ptr(), std::ptr::null()) };
if tray_hwnd.is_null() {
tracing::warn!(
"activate_existing: tray helper window not found; \
existing instance may be mid-startup or in a different \
session"
);
} else {
let msg_w: Vec<u16> = ACTIVATE_INSTANCE_MSG_NAME
.encode_utf16()
.chain(std::iter::once(0))
.collect();
// SAFETY: RegisterWindowMessageW returns the same u32 across
// processes when called with the same string; we re-resolve
// here to avoid plumbing the id through from the live
// instance.
let msg_id = unsafe { RegisterWindowMessageW(msg_w.as_ptr()) };
if msg_id != 0 {
unsafe {
SendMessageW(tray_hwnd as HWND, msg_id, 0, 0);
}
}
}
// Belt-and-suspenders: also locate the main Slint window by
// title. The tray's WndProc will fire on_open on receipt of the
// message; this path covers the edge case where the registered
// message id changed (rare, but possible after an OS upgrade) or
// where the tray init race left no helper window yet.
let title_w: Vec<u16> = MAIN_WINDOW_TITLE
.encode_utf16()
.chain(std::iter::once(0))
.collect();
// SAFETY: FindWindowW with null class + specific title.
let main_hwnd = unsafe { FindWindowW(std::ptr::null(), title_w.as_ptr()) };
if !main_hwnd.is_null() {
// ShowWindow(SW_RESTORE) un-minimizes; SW_SHOWNORMAL handles
// the regular case. Sending both is harmless.
unsafe {
ShowWindow(main_hwnd as HWND, SW_RESTORE);
ShowWindow(main_hwnd as HWND, SW_SHOWNORMAL);
SetForegroundWindow(main_hwnd as HWND);
}
return true;
}
!tray_hwnd.is_null()
}
+232 -45
View File
@@ -1,19 +1,25 @@
//! System tray icon (AGENTS.md 搂12).
//! 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. The right-
//! click context menu has two items: `Open` (asks the caller to show
//! the main window) and `Quit` (asks the caller to exit). Left double-
//! click is a shortcut for `Open`.
//! thread; the helper owns a Shell_NotifyIconW registration.
//!
//! `Toast` notifications and a richer menu (Pause / Settings / New
//! messages: N) are Phase 7 polish work; the focus here is on getting
//! the icon + message pump plumbing right so later commits can extend
//! the menu without rewriting the scaffolding.
//! 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, AtomicUsize, Ordering};
use std::sync::atomic::{AtomicBool, AtomicU32, AtomicUsize, Ordering};
use std::sync::{Arc, Mutex};
use std::thread::JoinHandle;
use windows_sys::Win32::Foundation::{
@@ -27,24 +33,51 @@ use windows_sys::Win32::UI::Shell::{
use windows_sys::Win32::UI::WindowsAndMessaging::{
AppendMenuW, CreatePopupMenu, CreateWindowExW, DefWindowProcW, DestroyWindow,
DispatchMessageW, GetCursorPos, GetMessageW, GetWindowLongPtrW, RegisterClassExW,
SetForegroundWindow, SetWindowLongPtrW, TrackPopupMenuEx, TranslateMessage,
CS_HREDRAW, CW_USEDEFAULT, GWLP_USERDATA, MF_STRING, MSG, TPM_BOTTOMALIGN,
TPM_LEFTALIGN, TPM_RETURNCMD, WM_COMMAND, WM_DESTROY, WM_LBUTTONDBLCLK,
WM_RBUTTONUP, WM_USER, WNDCLASSEXW, WS_EX_TOOLWINDOW,
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_QUIT: u16 = 2;
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;
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";
/// Callbacks the tray dispatches to the main runtime. The `Open`
/// callback fires for both the menu item and a double-click; `Quit`
/// is the only way to actually exit the process short of Task Manager.
/// 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,
}
impl TrayState {
fn new() -> Self {
Self {
paused: AtomicBool::new(false),
new_messages: AtomicU32::new(0),
}
}
}
/// 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>,
}
@@ -52,9 +85,11 @@ pub struct TrayIcon {
hwnd: HWND,
thread: Option<JoinHandle<()>>,
quit_flag: std::sync::Arc<AtomicBool>,
/// Incremented each time the user picks `Open` or `Quit` so the
/// caller can correlate traces across threads. Not load-bearing.
/// 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 {
@@ -63,7 +98,7 @@ impl TrayIcon {
pub fn new(callbacks: TrayCallbacks) -> Result<Self, String> {
let hinstance = unsafe { GetCurrentThreadId() } as *mut _;
// RegisterClassExW 鈥?registration is process-wide, so the
// 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".
@@ -90,7 +125,7 @@ impl TrayIcon {
// 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.
// Window size 0×0 + CW_USEDEFAULT for the position.
let hwnd = unsafe {
CreateWindowExW(
WS_EX_TOOLWINDOW,
@@ -111,12 +146,25 @@ impl TrayIcon {
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 without going through TLS or statics.
// 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 {
@@ -139,9 +187,9 @@ impl TrayIcon {
})
.map_err(|e| format!("tray thread spawn: {e}"))?;
// Register the icon. NULL hIcon 鈫?Windows draws a default
// 16脳16 placeholder; we replace it with a real .ico in
// Phase 7 polish.
// 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,
@@ -185,12 +233,47 @@ impl TrayIcon {
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);
}
}
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 {
@@ -219,21 +302,14 @@ impl Drop for TrayIcon {
// 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);
let thread_id = self
.thread
.as_ref()
.map(|_| unsafe { GetCurrentThreadId() });
// PostThreadMessageW to the tray thread would be cleaner but
// we never captured the thread id. 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.
// 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();
}
let _ = thread_id; // keep variable for symmetry
}
}
@@ -241,7 +317,16 @@ impl Drop for TrayIcon {
/// 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>,
}
// 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>) {
@@ -285,11 +370,25 @@ unsafe extern "system" fn wnd_proc(
}
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;
}
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)();
}
@@ -304,9 +403,24 @@ unsafe extern "system" fn wnd_proc(
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)();
@@ -330,10 +444,64 @@ fn show_context_menu(hwnd: HWND, shared: &TrayShared) {
tracing::warn!("tray: CreatePopupMenu failed");
return;
}
let open_label: Vec<u16> = "Open\0".encode_utf16().collect();
let quit_label: Vec<u16> = "Quit\0".encode_utf16().collect();
AppendMenuW(menu, MF_STRING, IDM_OPEN as usize, open_label.as_ptr());
AppendMenuW(menu, MF_STRING, IDM_QUIT as usize, quit_label.as_ptr());
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.
@@ -348,12 +516,31 @@ fn show_context_menu(hwnd: HWND, shared: &TrayShared) {
hwnd,
std::ptr::null(),
);
if cmd == IDM_OPEN as i32 {
// `cmd == 0` means the user dismissed without picking
// anything; fall through cleanly.
if cmd > 0 {
shared.tick.fetch_add(1, Ordering::Relaxed);
(shared.callbacks.on_open)();
} else if cmd == IDM_QUIT as i32 {
shared.tick.fetch_add(1, Ordering::Relaxed);
(shared.callbacks.on_quit)();
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<()>) {}