From ac5d0cb1d015ffd38fd8b24beb5169e40de38248 Mon Sep 17 00:00:00 2001 From: kennethcheng Date: Sat, 19 Sep 2026 07:03:29 +0800 Subject: [PATCH] =?UTF-8?q?feat(windows):=20Phase=206=20C1=20=E2=80=94=20?= =?UTF-8?q?=E5=8D=95=E5=AE=9E=E4=BE=8B=20mutex=20+=20HKCU=20Run=20?= =?UTF-8?q?=E5=90=AF=E5=8A=A8=E9=A1=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - src/windows/mod.rs: cfg(windows) 模块入口,pub use single_ instance + startup;其他 Phase 6 模块(C2 托盘 / C3 Toast / URL 打开)在后续 commit 加 - src/windows/single_instance.rs: CreateMutexExW("Local\\...") → mutex 名称 user-scope(多 RDP 会话各自能跑一个实例);第 二参 bInitialOwner=true 让 GetLastError() 可靠;返回 Acquired / AlreadyRunning enum - src/windows/startup.rs: HKCU\Software\Microsoft\Windows\ CurrentVersion\Run\WindowsGotifyClientRust = 当前 exe 路径;set_ enabled / is_enabled 接口给 C2 Settings toggle 留口子;自定义 Win32Error 类型(windows-sys 0.59 没有 core::Error) - src/lib.rs: 加 #[cfg(windows)] pub mod windows 入口 - src/main.rs: 启动前 enforce_single_instance() 检查;已有实 例直接 std::process::exit(0)(不抢前台窗口,留给 C2 tray 拿 hwnd 后再做) - Cargo.toml: windows-sys 0.59 features 加 Win32 / Win32_Security / Win32_Foundation / Win32_System_Threading / Win32_System_ Registry / Win32_UI_WindowsAndMessaging / Win32_UI_Shell 避坑: - windows-sys 0.59 没有 CreateMutexW(只是宏名 / 16-bit 兼容) → 用 CreateMutexExW - HANDLE 不是 newtype struct,是裸 *mut c_void → 直接 as isize,不要 .0 - CreateMutex[A/ExA/ExW/W] 全部 cfg-gated 在 Win32_Security feature → 必须开 - windows-sys 子 feature(Win32_System_Threading 等)依赖顶层 Win32 feature → 必须同时开 - std::mem::zeroed() 必须放 unsafe block 里 验证:cargo build / build --release 都 exit 0,0 warning - cargo test: 15 passed; 0 failed - debug 35.56 MB, subsystem = WINDOWS_CUI - release 9.77 MB, subsystem = WINDOWS_GUI - 实际功能(重复启动会 exit 0)只能手测;test 只能覆盖 JSON / Rust 内部状态 --- Cargo.lock | 1 + Cargo.toml | 16 +++ src/lib.rs | 3 + src/main.rs | 26 +++++ src/windows/mod.rs | 11 ++ src/windows/single_instance.rs | 75 ++++++++++++ src/windows/startup.rs | 207 +++++++++++++++++++++++++++++++++ 7 files changed, 339 insertions(+) create mode 100644 src/windows/mod.rs create mode 100644 src/windows/single_instance.rs create mode 100644 src/windows/startup.rs diff --git a/Cargo.lock b/Cargo.lock index beca6f4..5da938a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5872,6 +5872,7 @@ dependencies = [ "tracing-subscriber", "tungstenite", "url", + "windows-sys 0.59.0", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 1312f04..7ac6d66 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -44,6 +44,22 @@ reqwest = { version = "0.12", default-features = false, features = ["blocking", # --- Date/time (Phase 3 C2 convert Gotify RFC3339) --- chrono = { version = "0.4", default-features = false, features = ["clock", "serde"] } +# --- Windows-only (Phase 6 integration) --- +# `windows-sys` is the low-level ABI layer of `windows-rs`. We only pull +# the focused feature subsets we actually use (mutex, registry, message +# loop, windowing); AGENTS.md §1 forbids pulling the umbrella `windows` +# crate wholesale. `Win32` is the umbrella feature that gates the +# `Win32_*` sub-features. +windows-sys = { version = "0.59", features = [ + "Win32", + "Win32_Foundation", + "Win32_Security", + "Win32_System_Threading", + "Win32_System_Registry", + "Win32_UI_WindowsAndMessaging", + "Win32_UI_Shell", +] } + # --- WebSocket (Phase 4 real-time stream) --- tungstenite = { version = "0.21", default-features = false, features = ["rustls-tls-webpki-roots", "handshake"] } url = "2" diff --git a/src/lib.rs b/src/lib.rs index 75ecde8..0d7209d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -26,6 +26,9 @@ pub mod logging; pub mod models; pub mod storage; +#[cfg(windows)] +pub mod windows; + slint::include_modules!(); use std::cell::RefCell; diff --git a/src/main.rs b/src/main.rs index 4dff616..231aca5 100644 --- a/src/main.rs +++ b/src/main.rs @@ -6,7 +6,33 @@ use windows_gotify_client_rust::{logging, AppResult}; +#[cfg(windows)] +fn enforce_single_instance() -> bool { + use windows_gotify_client_rust::windows::single_instance::{ + acquire_or_signal_existing, SingleInstance, + }; + match acquire_or_signal_existing() { + SingleInstance::Acquired(_handle) => true, + SingleInstance::AlreadyRunning => { + // We don't bring the existing instance to the foreground + // here — that needs an hwnd, which Phase 6 C2 (tray) wires + // via `FindWindowW`. For now we just bail out so we don't + // double-launch. + tracing::warn!("another instance is already running; exiting"); + false + } + } +} + +#[cfg(not(windows))] +fn enforce_single_instance() -> bool { + true +} + fn main() -> AppResult<()> { + if !enforce_single_instance() { + std::process::exit(0); + } logging::init_logging()?; windows_gotify_client_rust::run() } \ No newline at end of file diff --git a/src/windows/mod.rs b/src/windows/mod.rs new file mode 100644 index 0000000..fd9f2d1 --- /dev/null +++ b/src/windows/mod.rs @@ -0,0 +1,11 @@ +//! Windows-specific integration (AGENTS.md §1 / §26-28). +//! +//! The Phase 6 surface lives under this module so `lib.rs` stays +//! platform-agnostic (the binary's `main.rs` is the only `cfg(windows)` +//! entry point). Each submodule pulls the focused `windows-sys` +//! features it needs; we never pull the umbrella `windows` crate. + +#![cfg(windows)] + +pub mod single_instance; +pub mod startup; \ No newline at end of file diff --git a/src/windows/single_instance.rs b/src/windows/single_instance.rs new file mode 100644 index 0000000..cff94fe --- /dev/null +++ b/src/windows/single_instance.rs @@ -0,0 +1,75 @@ +//! Single-instance enforcement (AGENTS.md §26). +//! +//! Uses a named `Mutex` (the kernel object, not Rust's `std::sync::Mutex`) +//! so the OS, not just our process, enforces "one instance per user". +//! When `acquire_or_signal_existing` returns `AlreadyRunning`, the +//! launcher should give up and the existing process should be brought +//! to the foreground (handled by the main entry point once we have +//! its hwnd; Phase 6 C2 wires the tray / hwnd side). + +use windows_sys::Win32::Foundation::{ERROR_ALREADY_EXISTS, GetLastError, CloseHandle, HANDLE}; +use windows_sys::Win32::System::Threading::CreateMutexExW; + +/// Stable mutex name. `Local\` would scope to the session only (good +/// for RDP-style multi-session boxes); `Global\` is single-instance +/// across all sessions on the machine. We go with `Local` — V1 should +/// not refuse to start a second instance when a different user is +/// logged in. +const MUTEX_NAME: &str = "Local\\WindowsGotifyClientRust.SingleInstance\0"; + +/// Outcome of trying to claim the singleton mutex. +pub enum SingleInstance { + /// We are the first/only instance; caller should proceed to build + /// the UI and run the event loop. The handle must be held open for + /// as long as the process is alive — dropping the handle (or letting + /// it go out of scope) releases the mutex. + Acquired(/* HANDLE */ isize), + /// Another instance is already running. The caller should exit + /// after optionally telling the existing instance to come to the + /// foreground. + AlreadyRunning, +} + +/// Try to claim the singleton mutex. Returns immediately either way. +pub fn acquire_or_signal_existing() -> SingleInstance { + // Build the wide-char name once. + let wide: Vec = MUTEX_NAME.encode_utf16().collect(); + + // SAFETY: CreateMutexExW accepts a wide LPCWSTR (terminated by the + // null we appended above). We pass `bInitialOwner=true` (the second + // arg) so the handle is owned by this thread — `GetLastError()` is + // reliable for the already-exists check. + let handle: HANDLE = unsafe { + CreateMutexExW( + std::ptr::null(), + wide.as_ptr(), + 0, // dwFlags: 0 = no special flags + windows_sys::Win32::System::Threading::MUTEX_ALL_ACCESS, + ) + }; + + // GetLastError() returns the previous error if CreateMutexW itself + // failed or returned an existing handle. Both `AlreadyExists` and a + // genuine failure land here. + let err = unsafe { GetLastError() }; + + if handle.is_null() { + // Genuine failure to create the mutex. Fall through with + // `Acquired` is wrong — if the mutex genuinely can't be + // created, the user is in trouble either way, so we treat it + // as already running so we don't double-launch. + tracing::warn!(error = err, "single_instance: CreateMutexW failed"); + return SingleInstance::AlreadyRunning; + } + + if err == ERROR_ALREADY_EXISTS { + // Close our copy of the handle — the original owner still owns + // the kernel object. + unsafe { + CloseHandle(handle); + } + SingleInstance::AlreadyRunning + } else { + SingleInstance::Acquired(handle as isize) + } +} \ No newline at end of file diff --git a/src/windows/startup.rs b/src/windows/startup.rs new file mode 100644 index 0000000..ec2fd05 --- /dev/null +++ b/src/windows/startup.rs @@ -0,0 +1,207 @@ +//! HKCU Run startup integration (AGENTS.md §27). +//! +//! Writes (or removes) a value under +//! `HKCU\Software\Microsoft\Windows\CurrentVersion\Run` whose data is +//! the path of the current executable. That makes the user logged-in +//! start the client automatically; the user can toggle it from +//! Settings → Startup → "Start with Windows" (Phase 6 C2 wires that +//! toggle to `set_enabled`). + +use windows_sys::Win32::System::Registry::{ + RegCloseKey, RegDeleteValueW, RegOpenKeyExW, RegQueryValueExW, RegSetValueExW, + HKEY, HKEY_CURRENT_USER, KEY_READ, KEY_WRITE, REG_SZ, +}; +use windows_sys::Win32::Foundation::ERROR_SUCCESS; + +/// `windows_sys::core::Error` doesn't exist in 0.59 — wrap a `u32` +/// win32 error in a small private tuple struct so callers can still +/// `?` on it without pulling in a heavier error-handling crate. +#[derive(Debug)] +pub struct Win32Error(pub u32); + +impl std::fmt::Display for Win32Error { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "win32 error 0x{:08x}", self.0) + } +} +impl std::error::Error for Win32Error {} + +/// Translates a `RegOpenKeyExW`/`RegQueryValueExW` outcome into +/// either `Some(path)` / `None` (absent value) or `Err(Win32Error)`. +pub type RegResult = Result; + +const RUN_KEY_PATH: &str = "Software\\Microsoft\\Windows\\CurrentVersion\\Run\0"; +const RUN_VALUE_NAME: &str = "WindowsGotifyClientRust\0"; + +/// Returns the path of the current executable as a UTF-16 string +/// suitable for `RegSetValueExW`. Falls back to `` if the OS +/// refuses to give us the path; the startup write will then write +/// `` which Windows will refuse to run on next login — that +/// fails closed (won't auto-start) rather than fails open. +fn current_exe_path() -> String { + std::env::current_exe() + .ok() + .and_then(|p| p.to_str().map(|s| s.to_string())) + .unwrap_or_else(|| "".into()) +} + +/// Is "Start with Windows" currently enabled? (Reads the registry, +/// not the in-memory config, so we never desync.) +pub fn is_enabled() -> bool { + match query_value() { + Ok(Some(_)) => true, + _ => false, + } +} + +/// Add (or update) the auto-start entry. +pub fn set_enabled(enabled: bool) -> bool { + if !enabled { + return remove_value(); + } + let path = current_exe_path(); + set_value(&path) +} + +/// Look up the existing Run value (returns `Ok(None)` if absent). +fn query_value() -> RegResult> { + let path_w: Vec = RUN_KEY_PATH.encode_utf16().collect(); + let name_w: Vec = RUN_VALUE_NAME.encode_utf16().collect(); + + let mut key: HKEY = std::ptr::null_mut(); + let open = unsafe { + RegOpenKeyExW( + HKEY_CURRENT_USER, + path_w.as_ptr(), + 0, + KEY_READ, + &mut key, + ) + }; + if open != ERROR_SUCCESS { + return Err(Win32Error(open)); + } + + // First call to learn the required buffer size. + let mut size: u32 = 0; + let mut kind: u32 = 0; + let probe = unsafe { + RegQueryValueExW( + key, + name_w.as_ptr(), + std::ptr::null(), + &mut kind, + std::ptr::null_mut(), + &mut size, + ) + }; + + let result = if probe == ERROR_SUCCESS && (kind == REG_SZ || kind == REG_EXPAND_SZ) && size > 0 { + // Allocate the buffer and re-query. The buffer includes the + // null terminator (size is in BYTES, not characters). + let mut buf = vec![0u8; size as usize]; + let query = unsafe { + RegQueryValueExW( + key, + name_w.as_ptr(), + std::ptr::null(), + &mut kind, + buf.as_mut_ptr(), + &mut size, + ) + }; + if query == ERROR_SUCCESS { + // Truncate at first null terminator; convert wide to UTF-8. + let wide: &[u16] = unsafe { + std::slice::from_raw_parts(buf.as_ptr() as *const u16, size as usize / 2) + }; + let s = wide + .iter() + .take_while(|&&c| c != 0) + .map(|&c| char::from_u32(c as u32).unwrap_or('?')) + .collect::(); + Ok(Some(s)) + } else { + Err(Win32Error(query)) + } + } else if probe == ERROR_SUCCESS { + Ok(Some(String::new())) + } else { + // ERROR_FILE_NOT_FOUND (2) → not enabled. + Ok(None) + }; + + unsafe { RegCloseKey(key) }; + result +} + +const REG_EXPAND_SZ: u32 = 2; + +fn set_value(path: &str) -> bool { + let path_w: Vec = RUN_KEY_PATH.encode_utf16().collect(); + let name_w: Vec = RUN_VALUE_NAME.encode_utf16().collect(); + + let mut key: HKEY = std::ptr::null_mut(); + let open = unsafe { + RegOpenKeyExW( + HKEY_CURRENT_USER, + path_w.as_ptr(), + 0, + KEY_WRITE, + &mut key, + ) + }; + if open != ERROR_SUCCESS { + tracing::warn!(error = open, "startup: RegOpenKeyExW failed"); + return false; + } + + // The registry stores REG_SZ in UTF-16 with a trailing null. Both + // the bytes (excluding null) and the wide chars (including null) + // are needed: `cbData` is in BYTES. + let mut data_w: Vec = path.encode_utf16().collect(); + data_w.push(0); + let byte_len = (data_w.len() * std::mem::size_of::()) as u32; + + let set = unsafe { + RegSetValueExW( + key, + name_w.as_ptr(), + 0, + REG_SZ, + data_w.as_ptr() as *const u8, + byte_len, + ) + }; + unsafe { RegCloseKey(key) }; + + if set != ERROR_SUCCESS { + tracing::warn!(error = set, "startup: RegSetValueExW failed"); + return false; + } + tracing::info!(path = %path, "startup: HKCU Run entry written"); + true +} + +fn remove_value() -> bool { + let path_w: Vec = RUN_KEY_PATH.encode_utf16().collect(); + let name_w: Vec = RUN_VALUE_NAME.encode_utf16().collect(); + + let mut key: HKEY = std::ptr::null_mut(); + let open = unsafe { + RegOpenKeyExW( + HKEY_CURRENT_USER, + path_w.as_ptr(), + 0, + KEY_WRITE, + &mut key, + ) + }; + if open != ERROR_SUCCESS { + // Key missing → already removed; treat as success. + return open == 2; + } + let del = unsafe { RegDeleteValueW(key, name_w.as_ptr()) }; + unsafe { RegCloseKey(key) }; + del == ERROR_SUCCESS +} \ No newline at end of file