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/// Scope used when creating the named mutex for single-instance enforcement.
13#[derive(Clone, Copy, Debug, Eq, PartialEq)]
14pub enum InstanceScope {
15    /// Use the current Windows session namespace (`Local\...`).
16    CurrentSession,
17    /// Use the global Windows namespace (`Global\...`) so instances across
18    /// all sessions contend for the same named mutex.
19    Global,
20}
21
22impl InstanceScope {
23    fn namespace_prefix(self) -> &'static str {
24        match self {
25            Self::CurrentSession => "Local",
26            Self::Global => "Global",
27        }
28    }
29}
30
31/// Guard that keeps the named single-instance mutex alive for the current process.
32///
33/// Dropping this value releases the underlying mutex handle.
34#[must_use = "keep this guard alive for as long as you want to hold the single-instance lock"]
35#[derive(Debug)]
36pub struct InstanceGuard {
37    handle: HANDLE,
38}
39
40impl Drop for InstanceGuard {
41    fn drop(&mut self) {
42        unsafe {
43            let _ = CloseHandle(self.handle);
44        }
45    }
46}
47
48fn to_wide_str(value: &str) -> Vec<u16> {
49    OsStr::new(value)
50        .encode_wide()
51        .chain(std::iter::once(0))
52        .collect()
53}
54
55fn validate_app_id(app_id: &str) -> Result<()> {
56    if app_id.trim().is_empty() {
57        return Err(Error::InvalidInput("app_id cannot be empty"));
58    }
59
60    if app_id.contains('\0') {
61        return Err(Error::InvalidInput("app_id cannot contain NUL bytes"));
62    }
63
64    if app_id.contains('\\') {
65        return Err(Error::InvalidInput("app_id cannot contain backslashes"));
66    }
67
68    Ok(())
69}
70
71fn mutex_name(app_id: &str, scope: InstanceScope) -> String {
72    format!("{}\\win_desktop_utils_{app_id}", scope.namespace_prefix())
73}
74
75/// Attempts to acquire a named process-wide single-instance guard.
76///
77/// Returns `Ok(Some(InstanceGuard))` for the first instance and `Ok(None)` if another
78/// instance with the same `app_id` is already running.
79///
80/// This is a convenience wrapper around [`single_instance_with_scope`] using
81/// [`InstanceScope::CurrentSession`].
82///
83/// The mutex name is derived from `app_id` using a `Local\...` namespace, so the
84/// single-instance behavior is scoped to the current Windows session.
85///
86/// Keep the returned guard alive for as long as the current process should continue
87/// to own the single-instance lock.
88///
89/// # Errors
90///
91/// Returns [`Error::InvalidInput`] if `app_id` is empty, contains only whitespace,
92/// contains NUL bytes, or contains backslashes. Windows reserves backslashes in
93/// named kernel objects for namespace separators such as `Local\` and `Global\`.
94/// Returns [`Error::WindowsApi`] if `CreateMutexW` fails.
95///
96/// # Examples
97///
98/// ```
99/// let app_id = format!("demo-app-{}", std::process::id());
100/// let guard = win_desktop_utils::single_instance(&app_id)?;
101/// assert!(guard.is_some());
102/// # Ok::<(), win_desktop_utils::Error>(())
103/// ```
104#[must_use = "store the returned guard for as long as the process should be considered the active instance"]
105pub fn single_instance(app_id: &str) -> Result<Option<InstanceGuard>> {
106    single_instance_with_scope(app_id, InstanceScope::CurrentSession)
107}
108
109/// Attempts to acquire a named single-instance guard in the requested Windows namespace.
110///
111/// Returns `Ok(Some(InstanceGuard))` for the first instance in the selected scope and
112/// `Ok(None)` if another instance with the same `app_id` is already running in that scope.
113///
114/// Use [`InstanceScope::CurrentSession`] to enforce a single instance per logged-in session,
115/// or [`InstanceScope::Global`] to enforce a single instance across sessions.
116///
117/// Keep the returned guard alive for as long as the current process should continue
118/// to own the single-instance lock.
119///
120/// # Errors
121///
122/// Returns [`Error::InvalidInput`] if `app_id` is empty, contains only whitespace,
123/// contains NUL bytes, or contains backslashes.
124/// Returns [`Error::WindowsApi`] if `CreateMutexW` fails.
125///
126/// # Examples
127///
128/// ```
129/// let app_id = format!("demo-app-global-{}", std::process::id());
130/// let guard = win_desktop_utils::single_instance_with_scope(
131///     &app_id,
132///     win_desktop_utils::InstanceScope::Global,
133/// )?;
134/// assert!(guard.is_some());
135/// # Ok::<(), win_desktop_utils::Error>(())
136/// ```
137#[must_use = "store the returned guard for as long as the process should be considered the active instance"]
138pub fn single_instance_with_scope(
139    app_id: &str,
140    scope: InstanceScope,
141) -> Result<Option<InstanceGuard>> {
142    validate_app_id(app_id)?;
143
144    let mutex_name = mutex_name(app_id, scope);
145    let mutex_name_w = to_wide_str(&mutex_name);
146
147    let handle =
148        unsafe { CreateMutexW(None, false, PCWSTR(mutex_name_w.as_ptr())) }.map_err(|e| {
149            Error::WindowsApi {
150                context: "CreateMutexW",
151                code: e.code().0,
152            }
153        })?;
154
155    let last_error = unsafe { GetLastError() };
156
157    if last_error == ERROR_ALREADY_EXISTS {
158        unsafe {
159            let _ = CloseHandle(handle);
160        }
161        Ok(None)
162    } else {
163        Ok(Some(InstanceGuard { handle }))
164    }
165}
166
167#[cfg(test)]
168mod tests {
169    use super::{mutex_name, validate_app_id, InstanceScope};
170
171    #[test]
172    fn validate_app_id_rejects_empty_string() {
173        let result = validate_app_id("");
174        assert!(matches!(
175            result,
176            Err(crate::Error::InvalidInput("app_id cannot be empty"))
177        ));
178    }
179
180    #[test]
181    fn validate_app_id_rejects_backslashes() {
182        let result = validate_app_id(r"demo\app");
183        assert!(matches!(
184            result,
185            Err(crate::Error::InvalidInput(
186                "app_id cannot contain backslashes"
187            ))
188        ));
189    }
190
191    #[test]
192    fn validate_app_id_rejects_nul_bytes() {
193        let result = validate_app_id("demo\0app");
194        assert!(matches!(
195            result,
196            Err(crate::Error::InvalidInput(
197                "app_id cannot contain NUL bytes"
198            ))
199        ));
200    }
201
202    #[test]
203    fn mutex_name_uses_local_namespace_for_current_session_scope() {
204        assert_eq!(
205            mutex_name("demo-app", InstanceScope::CurrentSession),
206            "Local\\win_desktop_utils_demo-app"
207        );
208    }
209
210    #[test]
211    fn mutex_name_uses_global_namespace_for_global_scope() {
212        assert_eq!(
213            mutex_name("demo-app", InstanceScope::Global),
214            "Global\\win_desktop_utils_demo-app"
215        );
216    }
217}