use windows::core::PCWSTR;
use windows::Win32::Foundation::{CloseHandle, GetLastError, ERROR_ALREADY_EXISTS, HANDLE};
use windows::Win32::System::Threading::CreateMutexW;
use crate::error::{Error, Result};
use crate::win::to_wide_str;
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum InstanceScope {
CurrentSession,
Global,
}
impl InstanceScope {
fn namespace_prefix(self) -> &'static str {
match self {
Self::CurrentSession => "Local",
Self::Global => "Global",
}
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct SingleInstanceOptions {
app_id: String,
scope: InstanceScope,
}
impl SingleInstanceOptions {
pub fn new(app_id: impl Into<String>) -> Self {
Self {
app_id: app_id.into(),
scope: InstanceScope::CurrentSession,
}
}
pub fn current_session(app_id: impl Into<String>) -> Self {
Self::new(app_id)
}
pub fn global(app_id: impl Into<String>) -> Self {
Self::new(app_id).scope(InstanceScope::Global)
}
pub fn scope(mut self, scope: InstanceScope) -> Self {
self.scope = scope;
self
}
pub fn app_id(&self) -> &str {
&self.app_id
}
pub fn configured_scope(&self) -> InstanceScope {
self.scope
}
pub fn acquire(&self) -> Result<Option<InstanceGuard>> {
single_instance_with_options(self)
}
}
#[must_use = "keep this guard alive for as long as you want to hold the single-instance lock"]
#[derive(Debug)]
pub struct InstanceGuard {
handle: HANDLE,
}
impl Drop for InstanceGuard {
fn drop(&mut self) {
unsafe {
let _ = CloseHandle(self.handle);
}
}
}
fn validate_app_id(app_id: &str) -> Result<()> {
if app_id.trim().is_empty() {
return Err(Error::InvalidInput("app_id cannot be empty"));
}
if app_id.contains('\0') {
return Err(Error::InvalidInput("app_id cannot contain NUL bytes"));
}
if app_id.contains('\\') {
return Err(Error::InvalidInput("app_id cannot contain backslashes"));
}
Ok(())
}
fn mutex_name(app_id: &str, scope: InstanceScope) -> String {
format!("{}\\win_desktop_utils_{app_id}", scope.namespace_prefix())
}
#[must_use = "store the returned guard for as long as the process should be considered the active instance"]
pub fn single_instance(app_id: &str) -> Result<Option<InstanceGuard>> {
single_instance_with_scope(app_id, InstanceScope::CurrentSession)
}
#[must_use = "store the returned guard for as long as the process should be considered the active instance"]
pub fn single_instance_with_scope(
app_id: &str,
scope: InstanceScope,
) -> Result<Option<InstanceGuard>> {
validate_app_id(app_id)?;
let mutex_name = mutex_name(app_id, scope);
let mutex_name_w = to_wide_str(&mutex_name);
let handle =
unsafe { CreateMutexW(None, false, PCWSTR(mutex_name_w.as_ptr())) }.map_err(|e| {
Error::WindowsApi {
context: "CreateMutexW",
code: e.code().0,
}
})?;
let last_error = unsafe { GetLastError() };
if last_error == ERROR_ALREADY_EXISTS {
unsafe {
let _ = CloseHandle(handle);
}
Ok(None)
} else {
Ok(Some(InstanceGuard { handle }))
}
}
#[must_use = "store the returned guard for as long as the process should be considered the active instance"]
pub fn single_instance_with_options(
options: &SingleInstanceOptions,
) -> Result<Option<InstanceGuard>> {
single_instance_with_scope(options.app_id(), options.configured_scope())
}
#[cfg(test)]
mod tests {
use super::{mutex_name, validate_app_id, InstanceScope, SingleInstanceOptions};
#[test]
fn validate_app_id_rejects_empty_string() {
let result = validate_app_id("");
assert!(matches!(
result,
Err(crate::Error::InvalidInput("app_id cannot be empty"))
));
}
#[test]
fn validate_app_id_rejects_backslashes() {
let result = validate_app_id(r"demo\app");
assert!(matches!(
result,
Err(crate::Error::InvalidInput(
"app_id cannot contain backslashes"
))
));
}
#[test]
fn validate_app_id_rejects_nul_bytes() {
let result = validate_app_id("demo\0app");
assert!(matches!(
result,
Err(crate::Error::InvalidInput(
"app_id cannot contain NUL bytes"
))
));
}
#[test]
fn mutex_name_uses_local_namespace_for_current_session_scope() {
assert_eq!(
mutex_name("demo-app", InstanceScope::CurrentSession),
"Local\\win_desktop_utils_demo-app"
);
}
#[test]
fn mutex_name_uses_global_namespace_for_global_scope() {
assert_eq!(
mutex_name("demo-app", InstanceScope::Global),
"Global\\win_desktop_utils_demo-app"
);
}
#[test]
fn options_default_to_current_session_scope() {
let options = SingleInstanceOptions::new("demo-app");
assert_eq!(options.app_id(), "demo-app");
assert_eq!(options.configured_scope(), InstanceScope::CurrentSession);
}
#[test]
fn options_can_use_global_scope() {
let options = SingleInstanceOptions::global("demo-app");
assert_eq!(options.configured_scope(), InstanceScope::Global);
}
}