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();
|
||||
|
||||
Reference in New Issue
Block a user