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}