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