diff --git a/Cargo.toml b/Cargo.toml index 7ac6d66..183f30b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -56,6 +56,7 @@ windows-sys = { version = "0.59", features = [ "Win32_Security", "Win32_System_Threading", "Win32_System_Registry", + "Win32_Graphics_Gdi", "Win32_UI_WindowsAndMessaging", "Win32_UI_Shell", ] } diff --git a/src/lib.rs b/src/lib.rs index 9c74ec2..e05c85c 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -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(); diff --git a/src/windows/mod.rs b/src/windows/mod.rs index fd9f2d1..c9bb37b 100644 --- a/src/windows/mod.rs +++ b/src/windows/mod.rs @@ -8,4 +8,6 @@ #![cfg(windows)] pub mod single_instance; -pub mod startup; \ No newline at end of file +pub mod startup; +pub mod tray; +pub mod url; \ No newline at end of file diff --git a/src/windows/tray.rs b/src/windows/tray.rs new file mode 100644 index 0000000..419238a --- /dev/null +++ b/src/windows/tray.rs @@ -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, + pub on_quit: Box, +} + +pub struct TrayIcon { + hwnd: HWND, + thread: Option>, + quit_flag: std::sync::Arc, + /// Incremented each time the user picks `Open` or `Quit` so the + /// caller can correlate traces across threads. Not load-bearing. + tick: std::sync::Arc, +} + +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 { + 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 = TRAY_CLASS_NAME.encode_utf16().collect(); + let wc = WNDCLASSEXW { + cbSize: std::mem::size_of::() 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 = TRAY_TIP.encode_utf16().collect(); + let mut nid = NOTIFYICONDATAW { + cbSize: std::mem::size_of::() 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::() 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, +} + +fn run_message_loop(_hwnd: usize, shared_ptr: isize, quit_flag: std::sync::Arc) { + // 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 = "Open\0".encode_utf16().collect(); + let quit_label: Vec = "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)(); + } + } +} diff --git a/src/windows/url.rs b/src/windows/url.rs new file mode 100644 index 0000000..a4e20da --- /dev/null +++ b/src/windows/url.rs @@ -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 = 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. +}