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:
@@ -56,6 +56,7 @@ windows-sys = { version = "0.59", features = [
|
|||||||
"Win32_Security",
|
"Win32_Security",
|
||||||
"Win32_System_Threading",
|
"Win32_System_Threading",
|
||||||
"Win32_System_Registry",
|
"Win32_System_Registry",
|
||||||
|
"Win32_Graphics_Gdi",
|
||||||
"Win32_UI_WindowsAndMessaging",
|
"Win32_UI_WindowsAndMessaging",
|
||||||
"Win32_UI_Shell",
|
"Win32_UI_Shell",
|
||||||
] }
|
] }
|
||||||
|
|||||||
+44
@@ -39,6 +39,11 @@ use slint::{ModelRc, VecModel};
|
|||||||
|
|
||||||
use storage::AppState;
|
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
|
/// Map the persisted theme string (`state.json::last_theme`) back into
|
||||||
/// the Slint `AppearanceMode` enum. Unknown values fall back to Dark so
|
/// 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
|
/// 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");
|
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 ───────────
|
// ── Phase 3 C2: Settings → Connection → Test button ───────────
|
||||||
ui.on_test_connection_requested({
|
ui.on_test_connection_requested({
|
||||||
let ui_handle = ui.as_weak();
|
let ui_handle = ui.as_weak();
|
||||||
|
|||||||
+3
-1
@@ -8,4 +8,6 @@
|
|||||||
#![cfg(windows)]
|
#![cfg(windows)]
|
||||||
|
|
||||||
pub mod single_instance;
|
pub mod single_instance;
|
||||||
pub mod startup;
|
pub mod startup;
|
||||||
|
pub mod tray;
|
||||||
|
pub mod url;
|
||||||
@@ -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)();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user