win_desktop_utils/
instance.rs1use 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#[derive(Clone, Copy, Debug, Eq, PartialEq)]
14pub enum InstanceScope {
15 CurrentSession,
17 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#[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#[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#[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}