Skip to main content

win_desktop_utils/
instance.rs

1//! Single-instance helpers backed by named Windows mutexes.
2
3use std::ffi::OsStr;
4use std::os::windows::ffi::OsStrExt;
5
6use windows::core::PCWSTR;
7use windows::Win32::Foundation::{CloseHandle, GetLastError, ERROR_ALREADY_EXISTS, HANDLE};
8use windows::Win32::System::Threading::CreateMutexW;
9
10use crate::error::{Error, Result};
11
12/// Guard that keeps the named single-instance mutex alive for the current process.
13///
14/// Dropping this value releases the underlying mutex handle.
15#[must_use = "keep this guard alive for as long as you want to hold the single-instance lock"]
16#[derive(Debug)]
17pub struct InstanceGuard {
18    handle: HANDLE,
19}
20
21impl Drop for InstanceGuard {
22    fn drop(&mut self) {
23        unsafe {
24            let _ = CloseHandle(self.handle);
25        }
26    }
27}
28
29fn to_wide_str(value: &str) -> Vec<u16> {
30    OsStr::new(value)
31        .encode_wide()
32        .chain(std::iter::once(0))
33        .collect()
34}
35
36fn validate_app_id(app_id: &str) -> Result<()> {
37    if app_id.trim().is_empty() {
38        return Err(Error::InvalidInput("app_id cannot be empty"));
39    }
40
41    if app_id.contains('\0') {
42        return Err(Error::InvalidInput("app_id cannot contain NUL bytes"));
43    }
44
45    if app_id.contains('\\') {
46        return Err(Error::InvalidInput("app_id cannot contain backslashes"));
47    }
48
49    Ok(())
50}
51
52/// Attempts to acquire a named process-wide single-instance guard.
53///
54/// Returns `Ok(Some(InstanceGuard))` for the first instance and `Ok(None)` if another
55/// instance with the same `app_id` is already running.
56///
57/// The mutex name is derived from `app_id` using a `Local\...` namespace, so the
58/// single-instance behavior is scoped to the current Windows session.
59///
60/// Keep the returned guard alive for as long as the current process should continue
61/// to own the single-instance lock.
62///
63/// # Errors
64///
65/// Returns [`Error::InvalidInput`] if `app_id` is empty, contains only whitespace,
66/// contains NUL bytes, or contains backslashes. Windows reserves backslashes in
67/// named kernel objects for namespace separators such as `Local\` and `Global\`.
68/// Returns [`Error::WindowsApi`] if `CreateMutexW` fails.
69///
70/// # Examples
71///
72/// ```
73/// let app_id = format!("demo-app-{}", std::process::id());
74/// let guard = win_desktop_utils::single_instance(&app_id)?;
75/// assert!(guard.is_some());
76/// # Ok::<(), win_desktop_utils::Error>(())
77/// ```
78#[must_use = "store the returned guard for as long as the process should be considered the active instance"]
79pub fn single_instance(app_id: &str) -> Result<Option<InstanceGuard>> {
80    validate_app_id(app_id)?;
81
82    let mutex_name = format!("Local\\win_desktop_utils_{app_id}");
83    let mutex_name_w = to_wide_str(&mutex_name);
84
85    let handle =
86        unsafe { CreateMutexW(None, false, PCWSTR(mutex_name_w.as_ptr())) }.map_err(|e| {
87            Error::WindowsApi {
88                context: "CreateMutexW",
89                code: e.code().0,
90            }
91        })?;
92
93    let last_error = unsafe { GetLastError() };
94
95    if last_error == ERROR_ALREADY_EXISTS {
96        unsafe {
97            let _ = CloseHandle(handle);
98        }
99        Ok(None)
100    } else {
101        Ok(Some(InstanceGuard { handle }))
102    }
103}
104
105#[cfg(test)]
106mod tests {
107    use super::validate_app_id;
108
109    #[test]
110    fn validate_app_id_rejects_empty_string() {
111        let result = validate_app_id("");
112        assert!(matches!(
113            result,
114            Err(crate::Error::InvalidInput("app_id cannot be empty"))
115        ));
116    }
117
118    #[test]
119    fn validate_app_id_rejects_backslashes() {
120        let result = validate_app_id(r"demo\app");
121        assert!(matches!(
122            result,
123            Err(crate::Error::InvalidInput(
124                "app_id cannot contain backslashes"
125            ))
126        ));
127    }
128
129    #[test]
130    fn validate_app_id_rejects_nul_bytes() {
131        let result = validate_app_id("demo\0app");
132        assert!(matches!(
133            result,
134            Err(crate::Error::InvalidInput(
135                "app_id cannot contain NUL bytes"
136            ))
137        ));
138    }
139}