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:
@@ -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
@@ -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<()>) {}
|
||||
Reference in New Issue
Block a user