feat(windows): Phase 6 C1 — 单实例 mutex + HKCU Run 启动项
- 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 内部状态
This commit is contained in:
@@ -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;
|
||||
|
||||
+26
@@ -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()
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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<u16> = 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)
|
||||
}
|
||||
}
|
||||
@@ -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<T> = Result<T, Win32Error>;
|
||||
|
||||
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 `<unknown>` if the OS
|
||||
/// refuses to give us the path; the startup write will then write
|
||||
/// `<unknown>` 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(|| "<unknown>".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<Option<String>> {
|
||||
let path_w: Vec<u16> = RUN_KEY_PATH.encode_utf16().collect();
|
||||
let name_w: Vec<u16> = 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::<String>();
|
||||
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<u16> = RUN_KEY_PATH.encode_utf16().collect();
|
||||
let name_w: Vec<u16> = 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<u16> = path.encode_utf16().collect();
|
||||
data_w.push(0);
|
||||
let byte_len = (data_w.len() * std::mem::size_of::<u16>()) 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<u16> = RUN_KEY_PATH.encode_utf16().collect();
|
||||
let name_w: Vec<u16> = 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
|
||||
}
|
||||
Reference in New Issue
Block a user