feat(gotify): Phase 7 C2 — Win32 Toast 通知 + URL 打开接线 + 单 VecModel
- tray.rs: 增加 show_toast 方法(PostMessageW + 堆分配 payload + WndProc 内 Shell_NotifyIconW NIM_MODIFY),遵守 NIM_MODIFY 必须 NIM_ADD 同线程的硬性要求 - WS drain 每条新消息根据 priority + cached desktop_notifications/notify_high/notify_max/paused atomics 决定是否弹 toast,icon_flags 按 High/Max 分别用 NIIF_WARNING/NIIF_ERROR - on_message_card_clicked:点击含 has_click_url 的卡片时调 windows::url::open(已有 http/https scheme 严格校验) - TrayState 增加 desktop_notifications/notify_high/notify_max/restart_requested 原子,Settings 切换实时同步,tray on_restart 只翻 restart_requested flag - 修复 Phase 6 C3 隐藏 bug:Test Connection / tray Restart 用 set_messages(new_model) 会让 WS drain 推入旧 model。新增单例 Rc<VecModel>,统一改用 set_vec - on_restart 改用 Arc<OnceLock<Arc<TrayState>>> 占位 + 设置,避免 tray 线程闭包捕获 Rc<VecModel>(!Send) - ActiveConfig 派生 Clone(托盘初始化快照用)
This commit is contained in:
+159
-9
@@ -27,17 +27,17 @@ use windows_sys::Win32::Foundation::{
|
||||
};
|
||||
use windows_sys::Win32::System::Threading::GetCurrentThreadId;
|
||||
use windows_sys::Win32::UI::Shell::{
|
||||
Shell_NotifyIconW, NIF_ICON, NIF_MESSAGE, NIF_TIP, NIM_ADD, NIM_DELETE,
|
||||
NOTIFYICONDATAW,
|
||||
Shell_NotifyIconW, NIF_ICON, NIF_INFO, NIF_MESSAGE, NIF_TIP, NIM_ADD, NIM_DELETE,
|
||||
NIM_MODIFY, NOTIFYICONDATAW,
|
||||
};
|
||||
use windows_sys::Win32::UI::WindowsAndMessaging::{
|
||||
AppendMenuW, CreatePopupMenu, CreateWindowExW, DefWindowProcW, DestroyWindow,
|
||||
DispatchMessageW, GetCursorPos, GetMessageW, GetWindowLongPtrW, RegisterClassExW,
|
||||
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,
|
||||
DispatchMessageW, GetCursorPos, GetMessageW, GetWindowLongPtrW, PostMessageW,
|
||||
RegisterClassExW, 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;
|
||||
|
||||
@@ -48,6 +48,19 @@ const IDM_SETTINGS: u16 = 4;
|
||||
const IDM_RESTART: u16 = 5;
|
||||
const IDM_QUIT: u16 = 6;
|
||||
const WM_TRAYICON: u32 = WM_USER + 1;
|
||||
// Phase 7 C2: posted from any thread to ask the tray's WndProc to
|
||||
// raise a toast via NIM_MODIFY. Payload is a heap-allocated
|
||||
// `ToastPayload`; the WndProc reconstructs the Box and drops it.
|
||||
const WM_SHOW_TOAST: u32 = WM_USER + 2;
|
||||
// Windows 10+ toast severity flags (Win32 NIIF_* constants —
|
||||
// windows-sys 0.59 doesn't re-export the names, so we hard-code).
|
||||
// `pub` so `lib.rs` can pick a severity based on Gotify priority
|
||||
// without re-hard-coding the bit patterns.
|
||||
pub const NIIF_NONE: u32 = 0x0000_0000;
|
||||
pub const NIIF_INFO: u32 = 0x0000_0001;
|
||||
pub const NIIF_WARNING: u32 = 0x0000_0002;
|
||||
pub const NIIF_ERROR: u32 = 0x0000_0003;
|
||||
pub const NIIF_NOSOUND: u32 = 0x0000_0010;
|
||||
const TRAY_CLASS_NAME: &str = "WindowsGotifyClientRust.TrayHidden\0";
|
||||
const TRAY_TIP: &str = "Windows Gotify Client\0";
|
||||
// Wide-char constant for RegisterWindowMessageW. `RegisterWindowMessageW`
|
||||
@@ -61,6 +74,22 @@ const ACTIVATE_INSTANCE_MSG_NAME: &str = "WindowsGotifyClientRust.ActivateInstan
|
||||
pub struct TrayState {
|
||||
pub paused: AtomicBool,
|
||||
pub new_messages: AtomicU32,
|
||||
/// Phase 7 C2: cached "show desktop notifications" master toggle
|
||||
/// so the WS drain's toast gating can read it lock-free without
|
||||
/// a config reload per message. `lib.rs` writes here from the
|
||||
/// SettingsPage toggle callback.
|
||||
pub desktop_notifications: AtomicBool,
|
||||
/// Phase 7 C2: cached "notify on High priority" toggle.
|
||||
pub notify_high: AtomicBool,
|
||||
/// Phase 7 C2: cached "notify on Max priority" toggle.
|
||||
pub notify_max: AtomicBool,
|
||||
/// Phase 7 C2: "Restart connection" request flag. The tray's
|
||||
/// `on_restart` callback sets it from the tray thread; the UI
|
||||
/// drain timer (which runs on the UI thread) swaps it back to
|
||||
/// `false` and triggers the actual refetch. Decouples the tray
|
||||
/// callback from the UI-bound `Rc<VecModel<…>>` so the closure
|
||||
/// can stay Send + Sync.
|
||||
pub restart_requested: AtomicBool,
|
||||
}
|
||||
|
||||
impl TrayState {
|
||||
@@ -68,6 +97,10 @@ impl TrayState {
|
||||
Self {
|
||||
paused: AtomicBool::new(false),
|
||||
new_messages: AtomicU32::new(0),
|
||||
desktop_notifications: AtomicBool::new(true),
|
||||
notify_high: AtomicBool::new(true),
|
||||
notify_max: AtomicBool::new(true),
|
||||
restart_requested: AtomicBool::new(false),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -264,6 +297,34 @@ impl TrayIcon {
|
||||
}
|
||||
}
|
||||
|
||||
/// Phase 7 C2: show a desktop toast for an incoming Gotify message.
|
||||
/// `title` becomes `szInfoTitle`, `body` becomes `szInfo`,
|
||||
/// `icon_flags` is one of the NIIF_* constants. Safe to call from
|
||||
/// any thread — we heap-allocate the payload and PostMessageW it
|
||||
/// to the tray's helper window so the actual `Shell_NotifyIconW`
|
||||
/// call (which must happen on the registration thread) runs in the
|
||||
/// WndProc.
|
||||
pub fn show_toast(&self, title: &str, body: &str, icon_flags: u32) {
|
||||
if self.hwnd.is_null() {
|
||||
return;
|
||||
}
|
||||
let payload = Box::new(ToastPayload {
|
||||
title: title.to_string(),
|
||||
body: body.to_string(),
|
||||
icon_flags,
|
||||
});
|
||||
let ptr = Box::into_raw(payload) as isize;
|
||||
// SAFETY: PostMessageW queues the message; the WndProc owns
|
||||
// the only reference to the heap-allocated payload and will
|
||||
// reconstruct + drop it. If the WndProc never runs (e.g. the
|
||||
// tray was just dropped and the helper HWND is destroyed),
|
||||
// we leak the Box — that's a hard-to-trigger edge case we
|
||||
// accept for V1.
|
||||
unsafe {
|
||||
PostMessageW(self.hwnd, WM_SHOW_TOAST, 0, ptr as LPARAM);
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
@@ -322,6 +383,14 @@ struct TrayShared {
|
||||
state: Arc<TrayState>,
|
||||
}
|
||||
|
||||
/// Heap-allocated payload for the WM_SHOW_TOAST posted message. Lives
|
||||
/// only long enough to be reconstructed by the WndProc and dropped.
|
||||
struct ToastPayload {
|
||||
title: String,
|
||||
body: String,
|
||||
icon_flags: u32,
|
||||
}
|
||||
|
||||
// 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))`.
|
||||
@@ -383,6 +452,21 @@ unsafe extern "system" fn wnd_proc(
|
||||
return 0;
|
||||
}
|
||||
|
||||
if msg == WM_SHOW_TOAST {
|
||||
// Phase 7 C2: show a desktop toast via NIM_MODIFY. Reconstruct
|
||||
// the heap payload from LPARAM and drop it before returning.
|
||||
let ptr = lparam as isize;
|
||||
if ptr != 0 {
|
||||
// SAFETY: `ptr` came from `Box::into_raw` in `show_toast`
|
||||
// and we own the only reference (the WndProc is
|
||||
// single-threaded for this message). Reconstructing the
|
||||
// Box drops the String buffers when this scope ends.
|
||||
let payload = unsafe { Box::from_raw(ptr as *mut ToastPayload) };
|
||||
show_toast(hwnd, &payload);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
match msg {
|
||||
WM_TRAYICON => {
|
||||
let event = lparam as u32;
|
||||
@@ -543,4 +627,70 @@ fn show_context_menu(hwnd: HWND, shared: &TrayShared) {
|
||||
// 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<()>) {}
|
||||
fn _mutex_keepalive(_m: &Mutex<()>) {}
|
||||
|
||||
/// Phase 7 C2: render a single Gotify message as a desktop toast
|
||||
/// via `Shell_NotifyIconW` with `NIF_INFO`. On Windows 10+ this is
|
||||
/// routed through Action Center and shows up exactly like a modern
|
||||
/// toast; on older Windows it falls back to a classic balloon. The
|
||||
/// WndProc owns the call so it runs on the same thread that called
|
||||
/// `NIM_ADD` — a hard Win32 requirement that the heap-allocated
|
||||
/// payload dance in `show_toast` is structured around.
|
||||
fn show_toast(hwnd: HWND, payload: &ToastPayload) {
|
||||
let mut nid = NOTIFYICONDATAW {
|
||||
cbSize: std::mem::size_of::<NOTIFYICONDATAW>() as u32,
|
||||
hWnd: hwnd,
|
||||
uID: 1,
|
||||
// Re-declare every flag every time — NIM_MODIFY only honors
|
||||
// the fields whose bits are set in `uFlags`. We set
|
||||
// NIF_TIP|NIF_INFO so the OS picks up the existing tip AND
|
||||
// the new info text. NIF_ICON is left alone: we don't ship
|
||||
// a custom icon yet (TODO Phase 7 C5) so reusing whatever
|
||||
// was registered at NIM_ADD keeps the visual consistent.
|
||||
uFlags: NIF_INFO | NIF_TIP,
|
||||
uCallbackMessage: 0,
|
||||
hIcon: std::ptr::null_mut(),
|
||||
szTip: [0u16; 128],
|
||||
szInfo: [0u16; 256],
|
||||
Anonymous: unsafe { std::mem::zeroed() },
|
||||
szInfoTitle: [0u16; 64],
|
||||
dwState: 0,
|
||||
dwStateMask: 0,
|
||||
dwInfoFlags: payload.icon_flags,
|
||||
guidItem: GUID { data1: 0, data2: 0, data3: 0, data4: [0; 8] },
|
||||
hBalloonIcon: std::ptr::null_mut(),
|
||||
};
|
||||
|
||||
// Truncate aggressively: szInfoTitle is 64 wide chars (incl. the
|
||||
// null terminator), szInfo is 256.
|
||||
copy_wide_trunc(&payload.title, &mut nid.szInfoTitle);
|
||||
copy_wide_trunc(&payload.body, &mut nid.szInfo);
|
||||
|
||||
unsafe {
|
||||
let ok = Shell_NotifyIconW(NIM_MODIFY, &nid);
|
||||
if ok == 0 {
|
||||
tracing::warn!(
|
||||
title = %payload.title,
|
||||
"tray: Shell_NotifyIconW NIM_MODIFY returned 0 (toast failed)"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// UTF-16 encode `s` into `dst`, truncating to `dst.len() - 1`
|
||||
/// characters and null-terminating so Win32 treats it as a C-style
|
||||
/// wide string.
|
||||
fn copy_wide_trunc(s: &str, dst: &mut [u16]) {
|
||||
debug_assert!(!dst.is_empty());
|
||||
let max = dst.len() - 1; // leave room for the null terminator
|
||||
for (i, ch) in s.encode_utf16().take(max).enumerate() {
|
||||
dst[i] = ch;
|
||||
}
|
||||
// Fill the rest with zeros so the buffer is "null-terminated"
|
||||
// for Win32 readers that look at the trailing bytes (some shell
|
||||
// versions do, surprisingly).
|
||||
let written = s.encode_utf16().count().min(max);
|
||||
for slot in &mut dst[written..] {
|
||||
*slot = 0;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user