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