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:
+44
@@ -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
@@ -8,4 +8,6 @@
|
||||
#![cfg(windows)]
|
||||
|
||||
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