From 2614909c4cbfc3ad4f0f2531dc7f03e6d6c8028d Mon Sep 17 00:00:00 2001 From: kennethcheng Date: Sat, 19 Sep 2026 08:13:36 +0800 Subject: [PATCH] =?UTF-8?q?feat(windows):=20Phase=207=20C1=20=E2=80=94=20?= =?UTF-8?q?=E6=89=98=E7=9B=98=E8=8F=9C=E5=8D=95=E5=A2=9E=E5=BC=BA=20+=20?= =?UTF-8?q?=E5=8D=95=E5=AE=9E=E4=BE=8B=E6=BF=80=E6=B4=BB=E7=8E=B0=E6=9C=89?= =?UTF-8?q?=E7=AA=97=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 托盘菜单加 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>>> 与托盘共享 new_messages 计数器 - WndProc 在调用 on_open 前自己清零计数器,免去回调侧的 RefCell 捕获(保持 Send+Sync) --- src/config.rs | 2 + src/lib.rs | 98 +++++++++++- src/main.rs | 19 ++- src/storage/config.rs | 8 + src/windows/single_instance.rs | 79 +++++++++- src/windows/tray.rs | 277 +++++++++++++++++++++++++++------ 6 files changed, 420 insertions(+), 63 deletions(-) diff --git a/src/config.rs b/src/config.rs index f378bab..ab53741 100644 --- a/src/config.rs +++ b/src/config.rs @@ -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, diff --git a/src/lib.rs b/src/lib.rs index e05c85c..83c9ead 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -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>>> = + 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::(); @@ -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; 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"); }) diff --git a/src/main.rs b/src/main.rs index 231aca5..664df15 100644 --- a/src/main.rs +++ b/src/main.rs @@ -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 } } diff --git a/src/storage/config.rs b/src/storage/config.rs index 5287f6e..b64e4bc 100644 --- a/src/storage/config.rs +++ b/src/storage/config.rs @@ -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, diff --git a/src/windows/single_instance.rs b/src/windows/single_instance.rs index cff94fe..94b3f64 100644 --- a/src/windows/single_instance.rs +++ b/src/windows/single_instance.rs @@ -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 = 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 = 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 = 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() } \ No newline at end of file diff --git a/src/windows/tray.rs b/src/windows/tray.rs index 419238a..b58b611 100644 --- a/src/windows/tray.rs +++ b/src/windows/tray.rs @@ -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` paused + `Arc` +//! 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, + pub on_pause_toggle: Box, + pub on_settings: Box, + pub on_restart: Box, pub on_quit: Box, } @@ -52,9 +85,11 @@ pub struct TrayIcon { hwnd: HWND, thread: Option>, quit_flag: std::sync::Arc, - /// 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, + state: Arc, } impl TrayIcon { @@ -63,7 +98,7 @@ impl TrayIcon { pub fn new(callbacks: TrayCallbacks) -> Result { 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 = 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 = TRAY_TIP.encode_utf16().collect(); let mut nid = NOTIFYICONDATAW { cbSize: std::mem::size_of::() 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 { + 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 = 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, + state: Arc, +} + +// 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 { + s.encode_utf16().chain(std::iter::once(0)).collect() } fn run_message_loop(_hwnd: usize, shared_ptr: isize, quit_flag: std::sync::Arc) { @@ -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 = "Open\0".encode_utf16().collect(); - let quit_label: Vec = "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<()>) {} \ No newline at end of file