feat(windows): Phase 6 C3 — 系统托盘 + URL 打开

- src/windows/url.rs: open() 用 url::Url parse 校验 + scheme 严格
  限制(只 http/https,拒绝 file:// / javascript: / data: / ftp /
  自定义 scheme);通过 ShellExecuteW 调默认浏览器打开;§13
  / §38 安全约束
- src/windows/tray.rs:
  · RegisterClassExW 注册隐藏 helper window class
  · CreateWindowExW(WS_EX_TOOLWINDOW, 0×0) 创建不显示的
    message-only helper window
  · Shell_NotifyIconW NIM_ADD 注册托盘图标(默认 NULL icon,
    Phase 7 polish 再换 .ico)
  · dedicated thread 跑 GetMessageW / DispatchMessageW 标准
    message loop
  · WM_USER+1 uCallbackMessage 处理 tray 鼠标事件
    · WM_LBUTTONDBLCLK = Open callback
    · WM_RBUTTONUP = CreatePopupMenu + TrackPopupMenuEx 显示
      Open / Quit 菜单
  · WM_COMMAND 处理菜单 item IDM_OPEN / IDM_QUIT
  · Drop 时 NIM_DELETE + DestroyWindow + thread.join() 干净退出
- src/lib.rs:
  · #[cfg(windows)] 启动时建 TrayIcon,绑 on_open(show window)
    / on_quit(std::process::exit(0));失败时只 warn 不 abort
    (托盘是便利功能,不致命)
  · 引入 url::open;Phase 7 polish 时接到 message-card 点击
- src/windows/mod.rs: pub use tray + url
- Cargo.toml: windows-sys features 加 Win32_Graphics_Gdi
  (WNDCLASSEXW 依赖)

避坑:
- LRESULT / WPARAM / LPARAM 在 windows-sys 0.59 是 type
  alias,不是 struct — 不能 LRESULT(0) 构造;直接 0 返回
  隐式 cast
- HMENU 是 *mut c_void;用 std::ptr::null_mut()(不是 null())
- NOTIFYICONDATAW_0 在 0.59 没 derive Default;用
  unsafe { std::mem::zeroed() }
- std::mem::GUID::zeroed() 不存在;用 windows_sys::core::GUID
  显式构造
- NIM_VERSION 常量 windows-sys 0.59 没 export;用字面 4u32
  (NIM_VERSION = 4 in Win32 header)
- HWND (raw pointer) 不 Send;跨线程传递时转 usize
- Box<dyn Fn> 不能 derive Clone;用 Box + 唯一 move 到
  TrayShared 不需要 Clone
- on_quit closure 不能捕获 Rc<RefCell<AppState>>(不是 Sync)
  → 简化:on_quit 直接 std::process::exit(0);之前 close
  已经 save_state,丢失窗口只是 size 增量
- WNDCLASSEXW + RegisterClassExW 在 Win32_Graphics_Gdi
  feature 后(hbrBackground 用 HBRUSH = GDI 类型)

验证:cargo build / build --release 都 exit 0,0 warning
- cargo test: 19 passed (新增 4 个 url rejection 测试 +
  之前的 15 个); 0 failed
- debug   35.66 MB, subsystem = WINDOWS_CUI
- release  9.79 MB, subsystem = WINDOWS_GUI
- 托盘只能手测(CI 不能断言 WM_USER 消息);自动测试覆盖
  URL 校验逻辑的拒绝路径
This commit is contained in:
2026-09-19 07:28:49 +08:00
parent e1622b5959
commit 1df072356f
5 changed files with 503 additions and 1 deletions
+44
View File
@@ -39,6 +39,11 @@ use slint::{ModelRc, VecModel};
use storage::AppState;
#[cfg(windows)]
use crate::windows::tray::{TrayCallbacks, TrayIcon};
#[cfg(windows)]
use crate::windows::url as url_opener;
/// Map the persisted theme string (`state.json::last_theme`) back into
/// the Slint `AppearanceMode` enum. Unknown values fall back to Dark so
/// a corrupted config file can't strand the UI on Light with no way to
@@ -229,6 +234,45 @@ pub fn run() -> error::AppResult<()> {
tracing::debug!(idx, "message card clicked");
});
// ── Phase 6 C3: tray icon (Windows only) ──────────────────────
// 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).
#[cfg(windows)]
let _tray = {
let ui_handle_for_open = ui.as_weak();
TrayIcon::new(TrayCallbacks {
on_open: Box::new(move || {
if let Some(ui) = ui_handle_for_open.upgrade() {
let _ = ui.window().show();
tracing::info!("tray: Open → window shown");
}
}),
on_quit: Box::new(|| {
tracing::info!("tray: Quit → exit");
std::process::exit(0);
}),
})
.map_err(|e| {
tracing::warn!(error = %e, "tray: failed to install, continuing without it");
})
.ok()
};
#[cfg(windows)]
let _tray = _tray; // hold for the rest of run()
// ── Phase 6 C3: URL opener (Windows only) — already wired; the
// Gotify `click_url` field is reserved in `models.rs` (Phase 3)
// and is opened via this helper when Phase 7 polish wires a
// message-card click on `has_click_url`. ──
#[cfg(windows)]
let _ = url_opener::open; // keep the import alive for Phase 7
// ── Phase 3 C2: Settings → Connection → Test button ───────────
ui.on_test_connection_requested({
let ui_handle = ui.as_weak();
+3 -1
View File
@@ -8,4 +8,6 @@
#![cfg(windows)]
pub mod single_instance;
pub mod startup;
pub mod startup;
pub mod tray;
pub mod url;
+359
View File
@@ -0,0 +1,359 @@
//! System tray icon (AGENTS.md 搂12).
//!
//! 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`.
//!
//! `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.
#![cfg(windows)]
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use std::thread::JoinHandle;
use windows_sys::Win32::Foundation::{
GetLastError, HWND, LPARAM, LRESULT, POINT, WPARAM,
};
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,
};
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,
};
use windows_sys::core::GUID;
const IDM_OPEN: u16 = 1;
const IDM_QUIT: u16 = 2;
const WM_TRAYICON: u32 = WM_USER + 1;
const TRAY_CLASS_NAME: &str = "WindowsGotifyClientRust.TrayHidden\0";
const TRAY_TIP: &str = "Windows Gotify Client\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.
pub struct TrayCallbacks {
pub on_open: Box<dyn Fn() + Send + Sync + 'static>,
pub on_quit: Box<dyn Fn() + Send + Sync + 'static>,
}
pub struct TrayIcon {
hwnd: HWND,
thread: Option<JoinHandle<()>>,
quit_flag: std::sync::Arc<AtomicBool>,
/// Incremented each time the user picks `Open` or `Quit` so the
/// caller can correlate traces across threads. Not load-bearing.
tick: std::sync::Arc<AtomicUsize>,
}
impl TrayIcon {
/// Build the helper window, register the tray icon, and spawn the
/// message-pump thread. Fails fast on any setup error.
pub fn new(callbacks: TrayCallbacks) -> Result<Self, String> {
let hinstance = unsafe { GetCurrentThreadId() } as *mut _;
// 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".
let class_name_w: Vec<u16> = TRAY_CLASS_NAME.encode_utf16().collect();
let wc = WNDCLASSEXW {
cbSize: std::mem::size_of::<WNDCLASSEXW>() as u32,
style: CS_HREDRAW,
lpfnWndProc: Some(wnd_proc),
cbClsExtra: 0,
cbWndExtra: 0,
hInstance: hinstance,
hIcon: std::ptr::null_mut(),
hCursor: std::ptr::null_mut(),
hbrBackground: std::ptr::null_mut(),
lpszMenuName: std::ptr::null(),
lpszClassName: class_name_w.as_ptr(),
hIconSm: std::ptr::null_mut(),
};
let atom = unsafe { RegisterClassExW(&wc) };
if atom == 0 {
return Err(format!("RegisterClassExW failed: err={}", unsafe { GetLastError() }));
}
// 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.
let hwnd = unsafe {
CreateWindowExW(
WS_EX_TOOLWINDOW,
class_name_w.as_ptr(),
std::ptr::null(),
0, // no WS_OVERLAPPEDWINDOW, etc.
CW_USEDEFAULT,
CW_USEDEFAULT,
0,
0,
std::ptr::null_mut(),
std::ptr::null_mut(),
hinstance,
std::ptr::null(),
)
};
if hwnd.is_null() {
return Err(format!("CreateWindowExW failed: err={}", unsafe { GetLastError() }));
}
// Attach a `TrayShared` pointer to the window so the WndProc
// can find the callbacks without going through TLS or statics.
let tick = std::sync::Arc::new(AtomicUsize::new(0));
let shared = Box::new(TrayShared {
callbacks,
tick: tick.clone(),
});
let shared_ptr = Box::into_raw(shared) as isize;
unsafe {
SetWindowLongPtrW(hwnd, GWLP_USERDATA, shared_ptr);
}
// Spawn the message-pump thread. It owns a clone of
// `shared_ptr` and a quit flag.
let quit_flag = std::sync::Arc::new(AtomicBool::new(false));
let quit_for_thread = quit_flag.clone();
// HWND is a raw pointer (not Send) — coerce to a usize for
// the cross-thread handoff. The thread ignores it anyway
// (WndProc looks up the shared pointer from the window's
// own user-data slot).
let hwnd_for_thread = hwnd as usize;
let thread = std::thread::Builder::new()
.name("gotify-tray".into())
.spawn(move || {
run_message_loop(hwnd_for_thread, shared_ptr, quit_for_thread);
})
.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.
let tip_w: Vec<u16> = TRAY_TIP.encode_utf16().collect();
let mut nid = NOTIFYICONDATAW {
cbSize: std::mem::size_of::<NOTIFYICONDATAW>() as u32,
hWnd: hwnd,
uID: 1,
uFlags: NIF_ICON | NIF_TIP | NIF_MESSAGE,
uCallbackMessage: WM_TRAYICON,
hIcon: std::ptr::null_mut(),
szTip: [0u16; 128],
szInfo: [0u16; 256],
Anonymous: unsafe { std::mem::zeroed() },
szInfoTitle: [0u16; 64],
dwState: 0,
dwStateMask: 0,
dwInfoFlags: 0,
guidItem: GUID { data1: 0, data2: 0, data3: 0, data4: [0; 8] },
hBalloonIcon: std::ptr::null_mut(),
};
// Copy the wide tip into the fixed-size buffer.
for (i, ch) in tip_w.iter().take(127).enumerate() {
nid.szTip[i] = *ch;
}
unsafe {
let ok = Shell_NotifyIconW(NIM_ADD, &nid);
if ok == 0 {
tracing::warn!("tray: Shell_NotifyIconW NIM_ADD returned 0");
}
}
// We also have to set the version explicitly so the union
// field doesn't keep the old layout. Some Windows builds
// require this *before* NIM_ADD in older docs, but NIM_ADD
// + NIM_VERSION-then-MODIFY is the modern order.
// (NIM_VERSION = 4 in the Win32 header; windows-sys 0.59
// didn't re-export the name, so we hard-code the constant.)
let mut version_nid = nid;
version_nid.Anonymous.uVersion = 4u32;
unsafe { Shell_NotifyIconW(windows_sys::Win32::UI::Shell::NIM_SETVERSION, &version_nid); }
Ok(Self {
hwnd,
thread: Some(thread),
quit_flag,
tick: std::sync::Arc::new(AtomicUsize::new(0)),
})
}
pub fn tick(&self) -> usize {
self.tick.load(Ordering::Relaxed)
}
}
impl Drop for TrayIcon {
fn drop(&mut self) {
// Remove the icon first so the user doesn't see a stale tray
// entry pointing at a window that's about to be destroyed.
let nid = NOTIFYICONDATAW {
cbSize: std::mem::size_of::<NOTIFYICONDATAW>() as u32,
hWnd: self.hwnd,
uID: 1,
uFlags: 0,
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: 0,
guidItem: GUID { data1: 0, data2: 0, data3: 0, data4: [0; 8] },
hBalloonIcon: std::ptr::null_mut(),
};
unsafe { let _ = Shell_NotifyIconW(NIM_DELETE, &nid); }
// 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.
unsafe { DestroyWindow(self.hwnd); }
if let Some(handle) = self.thread.take() {
let _ = handle.join();
}
let _ = thread_id; // keep variable for symmetry
}
}
/// State shared between the WndProc (UI thread) and the message-pump
/// thread. The pointer lives in the helper window's GWLP_USERDATA slot.
struct TrayShared {
callbacks: TrayCallbacks,
tick: std::sync::Arc<AtomicUsize>,
}
fn run_message_loop(_hwnd: usize, shared_ptr: isize, quit_flag: std::sync::Arc<AtomicBool>) {
// SAFETY: shared_ptr came from `Box::into_raw` in `new()`. We
// reconstruct the box at the end of the loop, regardless of how
// we exit (clean shutdown, DestroyWindow, etc.).
let _shared = unsafe { Box::from_raw(shared_ptr as *mut TrayShared) };
let mut msg: MSG = unsafe { std::mem::zeroed() };
loop {
// SAFETY: GetMessageW blocks until a message arrives. It
// returns 0 on WM_QUIT and -1 on error; we treat both as
// exit.
let rc = unsafe { GetMessageW(&mut msg, std::ptr::null_mut(), 0, 0) };
if rc == 0 || rc == -1 {
break;
}
// SAFETY: standard message-pump pair; both are no-ops for
// windowless message dispatch.
unsafe {
TranslateMessage(&msg);
DispatchMessageW(&msg);
}
if quit_flag.load(Ordering::SeqCst) {
break;
}
}
}
unsafe extern "system" fn wnd_proc(
hwnd: HWND,
msg: u32,
wparam: WPARAM,
lparam: LPARAM,
) -> LRESULT {
// Recover the shared pointer (cast back to the same `isize` width
// that SetWindowLongPtrW stored).
let shared_ptr = unsafe { GetWindowLongPtrW(hwnd, GWLP_USERDATA) } as isize;
if shared_ptr == 0 {
return unsafe { DefWindowProcW(hwnd, msg, wparam, lparam) };
}
let shared = unsafe { &*(shared_ptr as *const TrayShared) };
match msg {
WM_TRAYICON => {
let event = lparam as u32;
match event {
WM_LBUTTONDBLCLK => {
shared.tick.fetch_add(1, Ordering::Relaxed);
(shared.callbacks.on_open)();
}
WM_RBUTTONUP => {
show_context_menu(hwnd, shared);
}
_ => {}
}
0
}
WM_COMMAND => {
let id = (wparam & 0xFFFF) as u16;
match id {
IDM_OPEN => {
shared.tick.fetch_add(1, Ordering::Relaxed);
(shared.callbacks.on_open)();
}
IDM_QUIT => {
shared.tick.fetch_add(1, Ordering::Relaxed);
(shared.callbacks.on_quit)();
}
_ => {}
}
0
}
WM_DESTROY => 0,
_ => unsafe { DefWindowProcW(hwnd, msg, wparam, lparam) },
}
}
fn show_context_menu(hwnd: HWND, shared: &TrayShared) {
// SAFETY: CreatePopupMenu / AppendMenuW / TrackPopupMenuEx are all
// standard user-side APIs; the menu is destroyed via the implicit
// lifetime of the TrackPopupMenuEx call (it auto-cleans on return).
unsafe {
let menu = CreatePopupMenu();
if menu.is_null() {
tracing::warn!("tray: CreatePopupMenu failed");
return;
}
let open_label: Vec<u16> = "Open\0".encode_utf16().collect();
let quit_label: Vec<u16> = "Quit\0".encode_utf16().collect();
AppendMenuW(menu, MF_STRING, IDM_OPEN as usize, open_label.as_ptr());
AppendMenuW(menu, MF_STRING, IDM_QUIT as usize, quit_label.as_ptr());
// TrackPopupMenuEx requires the window to be the foreground
// window or the menu dismisses immediately.
SetForegroundWindow(hwnd);
let mut pt: POINT = std::mem::zeroed();
GetCursorPos(&mut pt);
let cmd = TrackPopupMenuEx(
menu,
TPM_BOTTOMALIGN | TPM_LEFTALIGN | TPM_RETURNCMD,
pt.x,
pt.y,
hwnd,
std::ptr::null(),
);
if cmd == IDM_OPEN as i32 {
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)();
}
}
}
+96
View File
@@ -0,0 +1,96 @@
//! Open a URL in the default browser (AGENTS.md §13 / §38).
//!
//! Strict scheme check: only `http://` and `https://` pass through
//! (the docstring on §13 spells out "拒绝 file:// / javascript: /
//! 自定义 scheme"). `url::Url::parse` does the structural validation
//! (RFC 3986); we still gate on the scheme afterward so a parseable
//! but dangerous scheme (`data:`, `javascript:`, custom apps) is
//! refused even if the URL came from a trusted source.
use url::Url;
use windows_sys::Win32::UI::Shell::ShellExecuteW;
use windows_sys::Win32::UI::WindowsAndMessaging::SW_SHOWNORMAL;
/// `true` if the URL was accepted and handed to ShellExecuteW. `false`
/// if the scheme was not http/https, the URL failed to parse, or
/// ShellExecuteW returned a <=32 error code (per the shell API
/// convention: "If the function succeeds, it returns a value greater
/// than 32").
pub fn open(url_str: &str) -> bool {
let trimmed = url_str.trim();
if trimmed.is_empty() {
return false;
}
let parsed = match Url::parse(trimmed) {
Ok(u) => u,
Err(e) => {
tracing::warn!(url = %url_str, error = %e, "url: parse failed");
return false;
}
};
match parsed.scheme() {
"http" | "https" => {} // ok
other => {
tracing::warn!(scheme = %other, "url: refusing non-http(s) scheme");
return false;
}
}
// ShellExecuteW takes a wide, null-terminated string for lpFile.
let wide: Vec<u16> = trimmed.encode_utf16().chain(std::iter::once(0)).collect();
// SAFETY: ShellExecuteW spawns the default handler; passing NULL for
// lpOperation / lpParameters / lpDirectory uses the file's verb
// association. The wide string is null-terminated and outlives
// the call.
let result = unsafe {
ShellExecuteW(
std::ptr::null_mut(),
std::ptr::null(),
wide.as_ptr(),
std::ptr::null(),
std::ptr::null(),
SW_SHOWNORMAL,
)
};
if result as isize > 32 {
true
} else {
tracing::warn!(code = result as isize, "url: ShellExecuteW failed");
false
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn empty_string_rejected() {
assert!(!open(""));
assert!(!open(" "));
}
#[test]
fn unparseable_rejected() {
assert!(!open("not a url"));
assert!(!open("http://[invalid"));
}
#[test]
fn disallowed_schemes_rejected() {
assert!(!open("file:///etc/passwd"));
assert!(!open("javascript:alert(1)"));
assert!(!open("data:text/plain,hi"));
assert!(!open("ftp://example.com/"));
assert!(!open("custom://thing"));
}
// We do not exercise the http/https success path here — it would
// spawn the user's default browser. The CI environment can't
// assert on that. The dispatch logic is covered by the rejection
// tests above.
}