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
+2
View File
@@ -19,6 +19,7 @@ pub struct ActiveConfig {
pub sound: bool,
pub notify_high: bool,
pub notify_max: bool,
pub notifications_paused: bool,
pub start_with_windows: bool,
pub start_minimized: bool,
pub close_to_tray: bool,
@@ -46,6 +47,7 @@ impl ActiveConfig {
sound: p.sound,
notify_high: p.notify_high,
notify_max: p.notify_max,
notifications_paused: p.notifications_paused,
start_with_windows: p.start_with_windows,
start_minimized: p.start_minimized,
close_to_tray: p.close_to_tray,
+90 -8
View File
@@ -174,6 +174,15 @@ pub fn run() -> error::AppResult<()> {
ui.set_status_text(status_text.into());
ui.set_is_connected(is_connected);
// ── Phase 7 C1: tray state cell ─────────────────────────────────────
// The tray is created further down (after the WS drain is wired so
// we have a clean order). The drain closure needs to bump the tray
// state on each new message, so we share a cell that the tray
// init later populates. `RefCell` is fine here — both the drain
// callback and the post-tray-init assignment run on the UI thread.
let tray_state_cell: Rc<RefCell<Option<std::sync::Arc<windows::tray::TrayState>>>> =
Rc::new(RefCell::new(None));
// ── Phase 4 C2 + 5 C2: WebSocket worker + UI-thread drain ───────────
let drain_timer = if let Some((base_url, token, apps)) = ws_credential {
let (tx, rx) = mpsc::channel::<MessageItem>();
@@ -199,6 +208,11 @@ pub fn run() -> error::AppResult<()> {
let timer = slint::Timer::default();
let model_for_drain = shared_model.clone();
let state_for_drain = state_rc.clone();
// Phase 7 C1: tray "New messages: N" counter. The tray owns
// the Arc<TrayState>; we capture the cell here so the UI
// drain can bump the atomic once the tray has populated the
// cell (post-init, below).
let tray_state_for_drain = tray_state_cell.clone();
timer.start(
slint::TimerMode::Repeated,
std::time::Duration::from_millis(100),
@@ -213,6 +227,12 @@ pub fn run() -> error::AppResult<()> {
s.last_message_id = id;
}
}
// Bump the tray counter so the menu
// "New messages: N" row refreshes.
if let Some(ref ts) = *tray_state_for_drain.borrow() {
ts.new_messages
.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
}
model_for_drain.push(item);
}
Err(_) => break,
@@ -234,30 +254,92 @@ pub fn run() -> error::AppResult<()> {
tracing::debug!(idx, "message card clicked");
});
// ── Phase 6 C3: tray icon (Windows only) ──────────────────────
// ── Phase 7 C1: tray icon (Windows only) with full menu ───────
// The tray owns a hidden helper window and a dedicated message-
// pump thread. `Open` shows the window; `Quit` exits the
// process. State is not saved from the quit path on purpose: any
// `close-to-tray` close already wrote state.json, and the tray
// only runs after the main loop started, so a window-resize that
// happened *between* the last close and the quit click is the
// only data at risk (Phase 7 polish will route this through
// a proper invoke_from_event_loop if it matters).
// pump thread. On top of the Phase 6 C3 Open/Quit menu we now
// also expose Pause notifications / Settings / Restart
// connection, plus a grayed-out "New messages: N" info row.
// Single-instance activation: when a second copy of the binary
// launches, it sends a registered window message to this helper
// window (see `single_instance::activate_existing_instance`);
// our WndProc treats that as an extra `on_open` invocation.
#[cfg(windows)]
let _tray = {
let ui_handle_for_open = ui.as_weak();
let ui_handle_for_settings = ui.as_weak();
let ui_handle_for_restart = ui.as_weak();
let tray_state_for_pause_init = tray_state_cell.clone();
let cfg_for_pause_init = config::ActiveConfig::load().ok();
TrayIcon::new(TrayCallbacks {
on_open: Box::new(move || {
// Phase 7 C1: the tray's own WndProc already reset
// the "New messages" counter when it decided to
// dispatch this callback (it does the same on the
// registered activate-message path). This closure
// only needs to surface the window.
if let Some(ui) = ui_handle_for_open.upgrade() {
let _ = ui.window().show();
tracing::info!("tray: Open → window shown");
}
}),
on_pause_toggle: Box::new(move |now: bool| {
tracing::info!(paused = now, "tray: Pause notifications toggled");
let mut cfg = storage::load_config();
cfg.notifications_paused = now;
if let Err(e) = storage::save_config(&cfg) {
tracing::warn!(error = %e, "tray: save_config (pause) failed");
}
}),
on_settings: Box::new(move || {
if let Some(ui) = ui_handle_for_settings.upgrade() {
ui.set_selection(SidebarSelection::Settings);
let _ = ui.window().show();
tracing::info!("tray: Settings → sidebar switched");
}
}),
on_restart: Box::new(move || {
tracing::info!("tray: Restart connection requested");
// Phase 7 C1: "restart" is just a refetch using the
// existing Test Connection path; we don't (yet)
// cycle the WS worker thread, which would require
// an additional shutdown signal. The catch-up +
// status refresh is enough for V1.
let Some(server) =
config::ActiveConfig::load().ok().and_then(|c| c.server)
else {
tracing::warn!("tray: Restart → no server configured");
return;
};
let Some((apps, msgs, text)) =
models::try_fetch_initial_data(&server.base_url, &server.token, None)
else {
tracing::warn!("tray: Restart → fetch failed");
return;
};
if let Some(ui) = ui_handle_for_restart.upgrade() {
ui.set_messages(models::build_message_model(&msgs, &apps));
ui.set_status_text(text.into());
ui.set_is_connected(true);
}
}),
on_quit: Box::new(|| {
tracing::info!("tray: Quit → exit");
std::process::exit(0);
}),
})
.map(|tray| {
// Populate the shared state cell so the WS drain can
// bump the "New messages" counter, and prime the pause
// toggle from the persisted config so the menu's
// checkmark reflects the saved value on first show.
let ts = tray.state();
if let Some(cfg) = cfg_for_pause_init.as_ref() {
ts.paused.store(cfg.notifications_paused, std::sync::atomic::Ordering::Relaxed);
}
*tray_state_for_pause_init.borrow_mut() = Some(ts);
tracing::info!("tray: installed (Phase 7 C1 menu)");
tray
})
.map_err(|e| {
tracing::warn!(error = %e, "tray: failed to install, continuing without it");
})
+13 -6
View File
@@ -9,16 +9,23 @@ use windows_gotify_client_rust::{logging, AppResult};
#[cfg(windows)]
fn enforce_single_instance() -> bool {
use windows_gotify_client_rust::windows::single_instance::{
acquire_or_signal_existing, SingleInstance,
acquire_or_signal_existing, activate_existing_instance, SingleInstance,
};
match acquire_or_signal_existing() {
SingleInstance::Acquired(_handle) => true,
SingleInstance::AlreadyRunning => {
// We don't bring the existing instance to the foreground
// here — that needs an hwnd, which Phase 6 C2 (tray) wires
// via `FindWindowW`. For now we just bail out so we don't
// double-launch.
tracing::warn!("another instance is already running; exiting");
// Phase 7 C1: forward an "activate me" request to the
// existing instance so the user lands on the existing
// window instead of just having the second launch silently
// die. `activate_existing_instance` is best-effort — if
// the existing instance is mid-startup and the tray helper
// window isn't yet created, we still bail out so we
// don't double-launch. (AGENTS.md §26.)
let _ = activate_existing_instance();
tracing::info!(
"another instance is already running; \
activation request sent, exiting"
);
false
}
}
+8
View File
@@ -73,6 +73,13 @@ pub struct AppConfig {
pub notify_high: bool,
#[serde(default = "default_true")]
pub notify_max: bool,
/// Tray-menu pause toggle (Phase 7 C1). Distinct from
/// `desktop_notifications` (master switch); the user can flip
/// this from the tray context menu to silence toasts temporarily
/// without disabling them in Settings. Persisted so the choice
/// survives restarts.
#[serde(default)]
pub notifications_paused: bool,
#[serde(default)]
pub start_with_windows: bool,
@@ -93,6 +100,7 @@ impl Default for AppConfig {
sound: true,
notify_high: true,
notify_max: true,
notifications_paused: false,
start_with_windows: false,
start_minimized: false,
close_to_tray: true,
+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 {
@@ -73,3 +83,64 @@ pub fn acquire_or_signal_existing() -> SingleInstance {
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<()>) {}