Skip to main content

win_desktop_utils/
instance.rs

1//! Single-instance helpers backed by named Windows mutexes.
2
3use windows::core::PCWSTR;
4use windows::Win32::Foundation::{CloseHandle, GetLastError, ERROR_ALREADY_EXISTS, HANDLE};
5use windows::Win32::System::Threading::CreateMutexW;
6
7use crate::error::{Error, Result};
8use crate::win::to_wide_str;
9
10/// Scope used when creating the named mutex for single-instance enforcement.
11#[derive(Clone, Copy, Debug, Eq, PartialEq)]
12pub enum InstanceScope {
13    /// Use the current Windows session namespace (`Local\...`).
14    CurrentSession,
15    /// Use the global Windows namespace (`Global\...`) so instances across
16    /// all sessions contend for the same named mutex.
17    Global,
18}
19
20impl InstanceScope {
21    fn namespace_prefix(self) -> &'static str {
22        match self {
23            Self::CurrentSession => "Local",
24            Self::Global => "Global",
25        }
26    }
27}
28
29/// Options for single-instance mutex acquisition.
30///
31/// This builder is useful when an application wants to keep the single-instance
32/// configuration close to startup code while still using the default current-session
33/// behavior most of the time.
34///
35/// # Examples
36///
37/// ```
38/// let options = win_desktop_utils::SingleInstanceOptions::new(format!(
39///     "demo-options-{}",
40///     std::process::id()
41/// ))
42///     .scope(win_desktop_utils::InstanceScope::CurrentSession);
43///
44/// let guard = win_desktop_utils::single_instance_with_options(&options)?;
45/// assert!(guard.is_some());
46/// # Ok::<(), win_desktop_utils::Error>(())
47/// ```
48#[derive(Clone, Debug, Eq, PartialEq)]
49pub struct SingleInstanceOptions {
50    app_id: String,
51    scope: InstanceScope,
52}
53
54impl SingleInstanceOptions {
55    /// Creates options for the current Windows session namespace.
56    pub fn new(app_id: impl Into<String>) -> Self {
57        Self {
58            app_id: app_id.into(),
59            scope: InstanceScope::CurrentSession,
60        }
61    }
62
63    /// Creates options for the current Windows session namespace.
64    pub fn current_session(app_id: impl Into<String>) -> Self {
65        Self::new(app_id)
66    }
67
68    /// Creates options for the global Windows namespace.
69    pub fn global(app_id: impl Into<String>) -> Self {
70        Self::new(app_id).scope(InstanceScope::Global)
71    }
72
73    /// Sets the mutex namespace scope.
74    pub fn scope(mut self, scope: InstanceScope) -> Self {
75        self.scope = scope;
76        self
77    }
78
79    /// Returns the configured application ID.
80    pub fn app_id(&self) -> &str {
81        &self.app_id
82    }
83
84    /// Returns the configured mutex namespace scope.
85    pub fn configured_scope(&self) -> InstanceScope {
86        self.scope
87    }
88
89    /// Attempts to acquire the configured single-instance guard.
90    pub fn acquire(&self) -> Result<Option<InstanceGuard>> {
91        single_instance_with_options(self)
92    }
93}
94
95/// Guard that keeps the named single-instance mutex alive for the current process.
96///
97/// Dropping this value releases the underlying mutex handle.
98#[must_use = "keep this guard alive for as long as you want to hold the single-instance lock"]
99#[derive(Debug)]
100pub struct InstanceGuard {
101    handle: HANDLE,
102}
103
104impl Drop for InstanceGuard {
105    fn drop(&mut self) {
106        unsafe {
107            let _ = CloseHandle(self.handle);
108        }
109    }
110}
111
112fn validate_app_id(app_id: &str) -> Result<()> {
113    if app_id.trim().is_empty() {
114        return Err(Error::InvalidInput("app_id cannot be empty"));
115    }
116
117    if app_id.contains('\0') {
118        return Err(Error::InvalidInput("app_id cannot contain NUL bytes"));
119    }
120
121    if app_id.contains('\\') {
122        return Err(Error::InvalidInput("app_id cannot contain backslashes"));
123    }
124
125    Ok(())
126}
127
128fn mutex_name(app_id: &str, scope: InstanceScope) -> String {
129    format!("{}\\win_desktop_utils_{app_id}", scope.namespace_prefix())
130}
131
132/// Attempts to acquire a named process-wide single-instance guard.
133///
134/// Returns `Ok(Some(InstanceGuard))` for the first instance and `Ok(None)` if another
135/// instance with the same `app_id` is already running.
136///
137/// This is a convenience wrapper around [`single_instance_with_scope`] using
138/// [`InstanceScope::CurrentSession`].
139///
140/// The mutex name is derived from `app_id` using a `Local\...` namespace, so the
141/// single-instance behavior is scoped to the current Windows session.
142///
143/// Keep the returned guard alive for as long as the current process should continue
144/// to own the single-instance lock.
145///
146/// # Errors
147///
148/// Returns [`Error::InvalidInput`] if `app_id` is empty, contains only whitespace,
149/// contains NUL bytes, or contains backslashes. Windows reserves backslashes in
150/// named kernel objects for namespace separators such as `Local\` and `Global\`.
151/// Returns [`Error::WindowsApi`] if `CreateMutexW` fails.
152///
153/// # Examples
154///
155/// ```
156/// let app_id = format!("demo-app-{}", std::process::id());
157/// let guard = win_desktop_utils::single_instance(&app_id)?;
158/// assert!(guard.is_some());
159/// # Ok::<(), win_desktop_utils::Error>(())
160/// ```
161#[must_use = "store the returned guard for as long as the process should be considered the active instance"]
162pub fn single_instance(app_id: &str) -> Result<Option<InstanceGuard>> {
163    single_instance_with_scope(app_id, InstanceScope::CurrentSession)
164}
165
166/// Attempts to acquire a named single-instance guard in the requested Windows namespace.
167///
168/// Returns `Ok(Some(InstanceGuard))` for the first instance in the selected scope and
169/// `Ok(None)` if another instance with the same `app_id` is already running in that scope.
170///
171/// Use [`InstanceScope::CurrentSession`] to enforce a single instance per logged-in session,
172/// or [`InstanceScope::Global`] to enforce a single instance across sessions.
173///
174/// Keep the returned guard alive for as long as the current process should continue
175/// to own the single-instance lock.
176///
177/// # Errors
178///
179/// Returns [`Error::InvalidInput`] if `app_id` is empty, contains only whitespace,
180/// contains NUL bytes, or contains backslashes.
181/// Returns [`Error::WindowsApi`] if `CreateMutexW` fails.
182///
183/// # Examples
184///
185/// ```
186/// let app_id = format!("demo-app-global-{}", std::process::id());
187/// let guard = win_desktop_utils::single_instance_with_scope(
188///     &app_id,
189///     win_desktop_utils::InstanceScope::Global,
190/// )?;
191/// assert!(guard.is_some());
192/// # Ok::<(), win_desktop_utils::Error>(())
193/// ```
194#[must_use = "store the returned guard for as long as the process should be considered the active instance"]
195pub fn single_instance_with_scope(
196    app_id: &str,
197    scope: InstanceScope,
198) -> Result<Option<InstanceGuard>> {
199    validate_app_id(app_id)?;
200
201    let mutex_name = mutex_name(app_id, scope);
202    let mutex_name_w = to_wide_str(&mutex_name);
203
204    let handle =
205        unsafe { CreateMutexW(None, false, PCWSTR(mutex_name_w.as_ptr())) }.map_err(|e| {
206            Error::WindowsApi {
207                context: "CreateMutexW",
208                code: e.code().0,
209            }
210        })?;
211
212    let last_error = unsafe { GetLastError() };
213
214    if last_error == ERROR_ALREADY_EXISTS {
215        unsafe {
216            let _ = CloseHandle(handle);
217        }
218        Ok(None)
219    } else {
220        Ok(Some(InstanceGuard { handle }))
221    }
222}
223
224/// Attempts to acquire a named single-instance guard using [`SingleInstanceOptions`].
225///
226/// This is equivalent to calling [`single_instance_with_scope`] with the configured
227/// application ID and scope.
228///
229/// # Errors
230///
231/// Returns [`Error::InvalidInput`] if the configured `app_id` is empty, contains only
232/// whitespace, contains NUL bytes, or contains backslashes.
233/// Returns [`Error::WindowsApi`] if `CreateMutexW` fails.
234///
235/// # Examples
236///
237/// ```
238/// let options = win_desktop_utils::SingleInstanceOptions::global(
239///     format!("demo-options-{}", std::process::id()),
240/// );
241/// let guard = win_desktop_utils::single_instance_with_options(&options)?;
242/// assert!(guard.is_some());
243/// # Ok::<(), win_desktop_utils::Error>(())
244/// ```
245#[must_use = "store the returned guard for as long as the process should be considered the active instance"]
246pub fn single_instance_with_options(
247    options: &SingleInstanceOptions,
248) -> Result<Option<InstanceGuard>> {
249    single_instance_with_scope(options.app_id(), options.configured_scope())
250}
251
252#[cfg(test)]
253mod tests {
254    use super::{mutex_name, validate_app_id, InstanceScope, SingleInstanceOptions};
255
256    #[test]
257    fn validate_app_id_rejects_empty_string() {
258        let result = validate_app_id("");
259        assert!(matches!(
260            result,
261            Err(crate::Error::InvalidInput("app_id cannot be empty"))
262        ));
263    }
264
265    #[test]
266    fn validate_app_id_rejects_backslashes() {
267        let result = validate_app_id(r"demo\app");
268        assert!(matches!(
269            result,
270            Err(crate::Error::InvalidInput(
271                "app_id cannot contain backslashes"
272            ))
273        ));
274    }
275
276    #[test]
277    fn validate_app_id_rejects_nul_bytes() {
278        let result = validate_app_id("demo\0app");
279        assert!(matches!(
280            result,
281            Err(crate::Error::InvalidInput(
282                "app_id cannot contain NUL bytes"
283            ))
284        ));
285    }
286
287    #[test]
288    fn mutex_name_uses_local_namespace_for_current_session_scope() {
289        assert_eq!(
290            mutex_name("demo-app", InstanceScope::CurrentSession),
291            "Local\\win_desktop_utils_demo-app"
292        );
293    }
294
295    #[test]
296    fn mutex_name_uses_global_namespace_for_global_scope() {
297        assert_eq!(
298            mutex_name("demo-app", InstanceScope::Global),
299            "Global\\win_desktop_utils_demo-app"
300        );
301    }
302
303    #[test]
304    fn options_default_to_current_session_scope() {
305        let options = SingleInstanceOptions::new("demo-app");
306        assert_eq!(options.app_id(), "demo-app");
307        assert_eq!(options.configured_scope(), InstanceScope::CurrentSession);
308    }
309
310    #[test]
311    fn options_can_use_global_scope() {
312        let options = SingleInstanceOptions::global("demo-app");
313        assert_eq!(options.configured_scope(), InstanceScope::Global);
314    }
315}