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 sound: bool,
pub notify_high: bool, pub notify_high: bool,
pub notify_max: bool, pub notify_max: bool,
pub notifications_paused: bool,
pub start_with_windows: bool, pub start_with_windows: bool,
pub start_minimized: bool, pub start_minimized: bool,
pub close_to_tray: bool, pub close_to_tray: bool,
@@ -46,6 +47,7 @@ impl ActiveConfig {
sound: p.sound, sound: p.sound,
notify_high: p.notify_high, notify_high: p.notify_high,
notify_max: p.notify_max, notify_max: p.notify_max,
notifications_paused: p.notifications_paused,
start_with_windows: p.start_with_windows, start_with_windows: p.start_with_windows,
start_minimized: p.start_minimized, start_minimized: p.start_minimized,
close_to_tray: p.close_to_tray, 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_status_text(status_text.into());
ui.set_is_connected(is_connected); 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 ─────────── // ── Phase 4 C2 + 5 C2: WebSocket worker + UI-thread drain ───────────
let drain_timer = if let Some((base_url, token, apps)) = ws_credential { let drain_timer = if let Some((base_url, token, apps)) = ws_credential {
let (tx, rx) = mpsc::channel::<MessageItem>(); let (tx, rx) = mpsc::channel::<MessageItem>();
@@ -199,6 +208,11 @@ pub fn run() -> error::AppResult<()> {
let timer = slint::Timer::default(); let timer = slint::Timer::default();
let model_for_drain = shared_model.clone(); let model_for_drain = shared_model.clone();
let state_for_drain = state_rc.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( timer.start(
slint::TimerMode::Repeated, slint::TimerMode::Repeated,
std::time::Duration::from_millis(100), std::time::Duration::from_millis(100),
@@ -213,6 +227,12 @@ pub fn run() -> error::AppResult<()> {
s.last_message_id = id; 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); model_for_drain.push(item);
} }
Err(_) => break, Err(_) => break,
@@ -234,30 +254,92 @@ pub fn run() -> error::AppResult<()> {
tracing::debug!(idx, "message card clicked"); 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- // The tray owns a hidden helper window and a dedicated message-
// pump thread. `Open` shows the window; `Quit` exits the // pump thread. On top of the Phase 6 C3 Open/Quit menu we now
// process. State is not saved from the quit path on purpose: any // also expose Pause notifications / Settings / Restart
// `close-to-tray` close already wrote state.json, and the tray // connection, plus a grayed-out "New messages: N" info row.
// only runs after the main loop started, so a window-resize that // Single-instance activation: when a second copy of the binary
// happened *between* the last close and the quit click is the // launches, it sends a registered window message to this helper
// only data at risk (Phase 7 polish will route this through // window (see `single_instance::activate_existing_instance`);
// a proper invoke_from_event_loop if it matters). // our WndProc treats that as an extra `on_open` invocation.
#[cfg(windows)] #[cfg(windows)]
let _tray = { let _tray = {
let ui_handle_for_open = ui.as_weak(); 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 { TrayIcon::new(TrayCallbacks {
on_open: Box::new(move || { 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() { if let Some(ui) = ui_handle_for_open.upgrade() {
let _ = ui.window().show(); let _ = ui.window().show();
tracing::info!("tray: Open → window shown"); 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(|| { on_quit: Box::new(|| {
tracing::info!("tray: Quit → exit"); tracing::info!("tray: Quit → exit");
std::process::exit(0); 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| { .map_err(|e| {
tracing::warn!(error = %e, "tray: failed to install, continuing without it"); 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)] #[cfg(windows)]
fn enforce_single_instance() -> bool { fn enforce_single_instance() -> bool {
use windows_gotify_client_rust::windows::single_instance::{ 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() { match acquire_or_signal_existing() {
SingleInstance::Acquired(_handle) => true, SingleInstance::Acquired(_handle) => true,
SingleInstance::AlreadyRunning => { SingleInstance::AlreadyRunning => {
// We don't bring the existing instance to the foreground // Phase 7 C1: forward an "activate me" request to the
// here — that needs an hwnd, which Phase 6 C2 (tray) wires // existing instance so the user lands on the existing
// via `FindWindowW`. For now we just bail out so we don't // window instead of just having the second launch silently
// double-launch. // die. `activate_existing_instance` is best-effort — if
tracing::warn!("another instance is already running; exiting"); // 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 false
} }
} }
+8
View File
@@ -73,6 +73,13 @@ pub struct AppConfig {
pub notify_high: bool, pub notify_high: bool,
#[serde(default = "default_true")] #[serde(default = "default_true")]
pub notify_max: bool, 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)] #[serde(default)]
pub start_with_windows: bool, pub start_with_windows: bool,
@@ -93,6 +100,7 @@ impl Default for AppConfig {
sound: true, sound: true,
notify_high: true, notify_high: true,
notify_max: true, notify_max: true,
notifications_paused: false,
start_with_windows: false, start_with_windows: false,
start_minimized: false, start_minimized: false,
close_to_tray: true, 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`) //! 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". //! so the OS, not just our process, enforces "one instance per user".
//! When `acquire_or_signal_existing` returns `AlreadyRunning`, the //! When `acquire_or_signal_existing` returns `AlreadyRunning`, the
//! launcher should give up and the existing process should be brought //! launcher should give up and ask the existing instance to surface
//! to the foreground (handled by the main entry point once we have //! itself via `activate_existing_instance`.
//! its hwnd; Phase 6 C2 wires the tray / hwnd side).
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::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 /// Stable mutex name. `Local\` would scope to the session only (good
/// for RDP-style multi-session boxes); `Global\` is single-instance /// 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 /// not refuse to start a second instance when a different user is
/// logged in. /// logged in.
const MUTEX_NAME: &str = "Local\\WindowsGotifyClientRust.SingleInstance\0"; 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. /// Outcome of trying to claim the singleton mutex.
pub enum SingleInstance { pub enum SingleInstance {
@@ -73,3 +83,64 @@ pub fn acquire_or_signal_existing() -> SingleInstance {
SingleInstance::Acquired(handle as isize) 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 //! V1 surface: a single hidden helper window + dedicated message-pump
//! thread; the helper owns a Shell_NotifyIconW registration. The right- //! thread; the helper owns a Shell_NotifyIconW registration.
//! 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`.
//! //!
//! `Toast` notifications and a richer menu (Pause / Settings / New //! Phase 7 C1 additions on top of the Phase 6 C3 scaffolding:
//! messages: N) are Phase 7 polish work; the focus here is on getting //! - Richer context menu: `Open`, grayed-out "New messages: N" info
//! the icon + message pump plumbing right so later commits can extend //! row, `Pause notifications` (checked when paused), `Settings`,
//! the menu without rewriting the scaffolding. //! `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)] #![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 std::thread::JoinHandle;
use windows_sys::Win32::Foundation::{ use windows_sys::Win32::Foundation::{
@@ -27,24 +33,51 @@ use windows_sys::Win32::UI::Shell::{
use windows_sys::Win32::UI::WindowsAndMessaging::{ use windows_sys::Win32::UI::WindowsAndMessaging::{
AppendMenuW, CreatePopupMenu, CreateWindowExW, DefWindowProcW, DestroyWindow, AppendMenuW, CreatePopupMenu, CreateWindowExW, DefWindowProcW, DestroyWindow,
DispatchMessageW, GetCursorPos, GetMessageW, GetWindowLongPtrW, RegisterClassExW, DispatchMessageW, GetCursorPos, GetMessageW, GetWindowLongPtrW, RegisterClassExW,
SetForegroundWindow, SetWindowLongPtrW, TrackPopupMenuEx, TranslateMessage, RegisterWindowMessageW, SendMessageW, SetForegroundWindow, SetWindowLongPtrW,
CS_HREDRAW, CW_USEDEFAULT, GWLP_USERDATA, MF_STRING, MSG, TPM_BOTTOMALIGN, TrackPopupMenuEx, TranslateMessage, CS_HREDRAW, CW_USEDEFAULT, GWLP_USERDATA,
TPM_LEFTALIGN, TPM_RETURNCMD, WM_COMMAND, WM_DESTROY, WM_LBUTTONDBLCLK, MF_CHECKED, MF_GRAYED, MF_SEPARATOR, MF_STRING, MSG, TPM_BOTTOMALIGN, TPM_LEFTALIGN,
WM_RBUTTONUP, WM_USER, WNDCLASSEXW, WS_EX_TOOLWINDOW, TPM_RETURNCMD, WM_COMMAND, WM_DESTROY, WM_LBUTTONDBLCLK, WM_RBUTTONUP, WM_USER,
WNDCLASSEXW, WS_EX_TOOLWINDOW,
}; };
use windows_sys::core::GUID; use windows_sys::core::GUID;
const IDM_OPEN: u16 = 1; 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 WM_TRAYICON: u32 = WM_USER + 1;
const TRAY_CLASS_NAME: &str = "WindowsGotifyClientRust.TrayHidden\0"; const TRAY_CLASS_NAME: &str = "WindowsGotifyClientRust.TrayHidden\0";
const TRAY_TIP: &str = "Windows Gotify Client\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` /// Shared, runtime-mutable state surfaced through the tray menu. Lives
/// callback fires for both the menu item and a double-click; `Quit` /// behind `Arc` so callers (`lib.rs` WS drain + the menu builder) can
/// is the only way to actually exit the process short of Task Manager. /// 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 struct TrayCallbacks {
pub on_open: Box<dyn Fn() + Send + Sync + 'static>, pub on_open: Box<dyn Fn() + Send + Sync + 'static>,
pub on_pause_toggle: Box<dyn Fn(bool) + Send + Sync + 'static>,
pub on_settings: Box<dyn Fn() + Send + Sync + 'static>,
pub on_restart: Box<dyn Fn() + Send + Sync + 'static>,
pub on_quit: Box<dyn Fn() + Send + Sync + 'static>, pub on_quit: Box<dyn Fn() + Send + Sync + 'static>,
} }
@@ -52,9 +85,11 @@ pub struct TrayIcon {
hwnd: HWND, hwnd: HWND,
thread: Option<JoinHandle<()>>, thread: Option<JoinHandle<()>>,
quit_flag: std::sync::Arc<AtomicBool>, quit_flag: std::sync::Arc<AtomicBool>,
/// Incremented each time the user picks `Open` or `Quit` so the /// Incremented each time the user picks a menu item or a registered
/// caller can correlate traces across threads. Not load-bearing. /// message arrives so the caller can correlate traces across
/// threads. Not load-bearing.
tick: std::sync::Arc<AtomicUsize>, tick: std::sync::Arc<AtomicUsize>,
state: Arc<TrayState>,
} }
impl TrayIcon { impl TrayIcon {
@@ -63,7 +98,7 @@ impl TrayIcon {
pub fn new(callbacks: TrayCallbacks) -> Result<Self, String> { pub fn new(callbacks: TrayCallbacks) -> Result<Self, String> {
let hinstance = unsafe { GetCurrentThreadId() } as *mut _; 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 // class name is namespaced ("...TrayHidden") to avoid colliding
// with anything else. CS_HREDRAW is the smallest constant that // with anything else. CS_HREDRAW is the smallest constant that
// satisfies the win32 docs requirement of "any class style". // satisfies the win32 docs requirement of "any class style".
@@ -90,7 +125,7 @@ impl TrayIcon {
// CreateWindowExW with HWND_MESSAGE-style (we leave the hWndParent // CreateWindowExW with HWND_MESSAGE-style (we leave the hWndParent
// argument null and pick an explicit WS_EX_TOOLWINDOW + zero // argument null and pick an explicit WS_EX_TOOLWINDOW + zero
// size to keep the helper out of the taskbar / Alt-Tab list). // 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 { let hwnd = unsafe {
CreateWindowExW( CreateWindowExW(
WS_EX_TOOLWINDOW, WS_EX_TOOLWINDOW,
@@ -111,12 +146,25 @@ impl TrayIcon {
return Err(format!("CreateWindowExW failed: err={}", unsafe { GetLastError() })); 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 // 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 tick = std::sync::Arc::new(AtomicUsize::new(0));
let state = Arc::new(TrayState::new());
let shared = Box::new(TrayShared { let shared = Box::new(TrayShared {
callbacks, callbacks,
activate_msg_id,
tick: tick.clone(), tick: tick.clone(),
state: state.clone(),
}); });
let shared_ptr = Box::into_raw(shared) as isize; let shared_ptr = Box::into_raw(shared) as isize;
unsafe { unsafe {
@@ -139,9 +187,9 @@ impl TrayIcon {
}) })
.map_err(|e| format!("tray thread spawn: {e}"))?; .map_err(|e| format!("tray thread spawn: {e}"))?;
// Register the icon. NULL hIcon 鈫?Windows draws a default // Register the icon. NULL hIcon → Windows draws a default
// 16脳16 placeholder; we replace it with a real .ico in // 16×16 placeholder; the C2 polish pass will swap in a real
// Phase 7 polish. // `.ico`.
let tip_w: Vec<u16> = TRAY_TIP.encode_utf16().collect(); let tip_w: Vec<u16> = TRAY_TIP.encode_utf16().collect();
let mut nid = NOTIFYICONDATAW { let mut nid = NOTIFYICONDATAW {
cbSize: std::mem::size_of::<NOTIFYICONDATAW>() as u32, cbSize: std::mem::size_of::<NOTIFYICONDATAW>() as u32,
@@ -185,12 +233,47 @@ impl TrayIcon {
thread: Some(thread), thread: Some(thread),
quit_flag, quit_flag,
tick: std::sync::Arc::new(AtomicUsize::new(0)), 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 { pub fn tick(&self) -> usize {
self.tick.load(Ordering::Relaxed) 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 { impl Drop for TrayIcon {
@@ -219,21 +302,14 @@ impl Drop for TrayIcon {
// Tell the message loop to exit, then post WM_DESTROY so the // Tell the message loop to exit, then post WM_DESTROY so the
// window's HWND is gone before the thread joins. // window's HWND is gone before the thread joins.
self.quit_flag.store(true, Ordering::SeqCst); self.quit_flag.store(true, Ordering::SeqCst);
let thread_id = self // Posting via the helper window's DestroyWindow is enough: it
.thread // queues WM_DESTROY on the same thread, which causes the
.as_ref() // message loop to fall through.
.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.
unsafe { DestroyWindow(self.hwnd); } unsafe { DestroyWindow(self.hwnd); }
if let Some(handle) = self.thread.take() { if let Some(handle) = self.thread.take() {
let _ = handle.join(); 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. /// thread. The pointer lives in the helper window's GWLP_USERDATA slot.
struct TrayShared { struct TrayShared {
callbacks: TrayCallbacks, callbacks: TrayCallbacks,
activate_msg_id: u32,
tick: std::sync::Arc<AtomicUsize>, 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>) { 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) }; 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 { match msg {
WM_TRAYICON => { WM_TRAYICON => {
let event = lparam as u32; let event = lparam as u32;
match event { match event {
WM_LBUTTONDBLCLK => { WM_LBUTTONDBLCLK => {
shared.state.new_messages.store(0, Ordering::Relaxed);
shared.tick.fetch_add(1, Ordering::Relaxed); shared.tick.fetch_add(1, Ordering::Relaxed);
(shared.callbacks.on_open)(); (shared.callbacks.on_open)();
} }
@@ -304,9 +403,24 @@ unsafe extern "system" fn wnd_proc(
let id = (wparam & 0xFFFF) as u16; let id = (wparam & 0xFFFF) as u16;
match id { match id {
IDM_OPEN => { IDM_OPEN => {
shared.state.new_messages.store(0, Ordering::Relaxed);
shared.tick.fetch_add(1, Ordering::Relaxed); shared.tick.fetch_add(1, Ordering::Relaxed);
(shared.callbacks.on_open)(); (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 => { IDM_QUIT => {
shared.tick.fetch_add(1, Ordering::Relaxed); shared.tick.fetch_add(1, Ordering::Relaxed);
(shared.callbacks.on_quit)(); (shared.callbacks.on_quit)();
@@ -330,10 +444,64 @@ fn show_context_menu(hwnd: HWND, shared: &TrayShared) {
tracing::warn!("tray: CreatePopupMenu failed"); tracing::warn!("tray: CreatePopupMenu failed");
return; return;
} }
let open_label: Vec<u16> = "Open\0".encode_utf16().collect();
let quit_label: Vec<u16> = "Quit\0".encode_utf16().collect(); AppendMenuW(
AppendMenuW(menu, MF_STRING, IDM_OPEN as usize, open_label.as_ptr()); menu,
AppendMenuW(menu, MF_STRING, IDM_QUIT as usize, quit_label.as_ptr()); 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 // TrackPopupMenuEx requires the window to be the foreground
// window or the menu dismisses immediately. // window or the menu dismisses immediately.
@@ -348,12 +516,31 @@ fn show_context_menu(hwnd: HWND, shared: &TrayShared) {
hwnd, hwnd,
std::ptr::null(), 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.tick.fetch_add(1, Ordering::Relaxed);
(shared.callbacks.on_open)(); match cmd as u16 {
} else if cmd == IDM_QUIT as i32 { IDM_OPEN => {
shared.tick.fetch_add(1, Ordering::Relaxed); shared.state.new_messages.store(0, Ordering::Relaxed);
(shared.callbacks.on_quit)(); (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<()>) {}