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}