Skip to main content

win_desktop_utils/
app.rs

1//! Friendly facade for common desktop app startup workflows.
2
3use std::path::PathBuf;
4
5use crate::error::{Error, Result};
6use crate::instance::{
7    single_instance_with_options, InstanceGuard, InstanceScope, SingleInstanceOptions,
8};
9use crate::paths::{
10    ensure_local_app_data, ensure_roaming_app_data, local_app_data, roaming_app_data,
11};
12
13/// A small convenience wrapper around common Windows desktop app identity tasks.
14///
15/// `DesktopApp` keeps a validated app identity in one place and uses it for app-data
16/// paths and single-instance locking. It does not own any global state.
17///
18/// # Examples
19///
20/// ```
21/// let app = win_desktop_utils::DesktopApp::new(format!(
22///     "demo-app-{}",
23///     std::process::id()
24/// ))?;
25///
26/// let local = app.ensure_local_data_dir()?;
27/// assert!(local.exists());
28/// # Ok::<(), win_desktop_utils::Error>(())
29/// ```
30#[derive(Clone, Debug, Eq, PartialEq)]
31pub struct DesktopApp {
32    company_name: Option<String>,
33    app_name: String,
34    app_dir_name: String,
35    app_id: String,
36    instance_scope: InstanceScope,
37}
38
39impl DesktopApp {
40    /// Creates a desktop app identity without a company namespace.
41    ///
42    /// # Errors
43    ///
44    /// Returns [`Error::InvalidInput`] if `app_name` is empty, contains NUL bytes,
45    /// or contains characters that are invalid in Windows file names.
46    pub fn new(app_name: impl Into<String>) -> Result<Self> {
47        let app_name = validate_identity_part("app_name", app_name.into())?;
48
49        Ok(Self {
50            company_name: None,
51            app_dir_name: app_name.clone(),
52            app_id: app_name.clone(),
53            app_name,
54            instance_scope: InstanceScope::CurrentSession,
55        })
56    }
57
58    /// Creates a desktop app identity grouped under a company namespace.
59    ///
60    /// App-data paths are nested as `Company\App`, while the default single-instance
61    /// mutex ID uses `Company.App`.
62    ///
63    /// # Errors
64    ///
65    /// Returns [`Error::InvalidInput`] if either identity part is empty, contains NUL
66    /// bytes, or contains characters that are invalid in Windows file names.
67    pub fn with_company(
68        company_name: impl Into<String>,
69        app_name: impl Into<String>,
70    ) -> Result<Self> {
71        let company_name = validate_identity_part("company_name", company_name.into())?;
72        let app_name = validate_identity_part("app_name", app_name.into())?;
73
74        Ok(Self {
75            app_dir_name: format!("{company_name}\\{app_name}"),
76            app_id: format!("{company_name}.{app_name}"),
77            company_name: Some(company_name),
78            app_name,
79            instance_scope: InstanceScope::CurrentSession,
80        })
81    }
82
83    /// Sets the default single-instance mutex namespace scope used by [`Self::single_instance`].
84    pub fn instance_scope(mut self, scope: InstanceScope) -> Self {
85        self.instance_scope = scope;
86        self
87    }
88
89    /// Returns the optional company name.
90    pub fn company_name(&self) -> Option<&str> {
91        self.company_name.as_deref()
92    }
93
94    /// Returns the app name.
95    pub fn app_name(&self) -> &str {
96        &self.app_name
97    }
98
99    /// Returns the app-data directory name used by the paths helpers.
100    pub fn app_dir_name(&self) -> &str {
101        &self.app_dir_name
102    }
103
104    /// Returns the default app ID used by the single-instance helpers.
105    pub fn app_id(&self) -> &str {
106        &self.app_id
107    }
108
109    /// Returns the configured single-instance scope.
110    pub fn configured_instance_scope(&self) -> InstanceScope {
111        self.instance_scope
112    }
113
114    /// Returns the per-user local app-data directory for this app without creating it.
115    pub fn local_data_dir(&self) -> Result<PathBuf> {
116        local_app_data(&self.app_dir_name)
117    }
118
119    /// Returns the per-user roaming app-data directory for this app without creating it.
120    pub fn roaming_data_dir(&self) -> Result<PathBuf> {
121        roaming_app_data(&self.app_dir_name)
122    }
123
124    /// Creates and returns the per-user local app-data directory for this app.
125    pub fn ensure_local_data_dir(&self) -> Result<PathBuf> {
126        ensure_local_app_data(&self.app_dir_name)
127    }
128
129    /// Creates and returns the per-user roaming app-data directory for this app.
130    pub fn ensure_roaming_data_dir(&self) -> Result<PathBuf> {
131        ensure_roaming_app_data(&self.app_dir_name)
132    }
133
134    /// Returns single-instance options for this app.
135    pub fn single_instance_options(&self) -> SingleInstanceOptions {
136        SingleInstanceOptions::new(self.app_id.clone()).scope(self.instance_scope)
137    }
138
139    /// Attempts to acquire the configured single-instance guard for this app.
140    pub fn single_instance(&self) -> Result<Option<InstanceGuard>> {
141        single_instance_with_options(&self.single_instance_options())
142    }
143}
144
145fn validate_identity_part(label: &'static str, value: String) -> Result<String> {
146    let trimmed = value.trim();
147
148    if trimmed.is_empty() {
149        return Err(Error::InvalidInput(match label {
150            "company_name" => "company_name cannot be empty",
151            _ => "app_name cannot be empty",
152        }));
153    }
154
155    if trimmed.contains('\0') {
156        return Err(Error::InvalidInput(match label {
157            "company_name" => "company_name cannot contain NUL bytes",
158            _ => "app_name cannot contain NUL bytes",
159        }));
160    }
161
162    if trimmed
163        .chars()
164        .any(|ch| matches!(ch, '<' | '>' | ':' | '"' | '/' | '\\' | '|' | '?' | '*'))
165    {
166        return Err(Error::InvalidInput(match label {
167            "company_name" => "company_name contains invalid Windows file-name characters",
168            _ => "app_name contains invalid Windows file-name characters",
169        }));
170    }
171
172    Ok(trimmed.to_owned())
173}
174
175#[cfg(test)]
176mod tests {
177    use super::{validate_identity_part, DesktopApp};
178    use crate::InstanceScope;
179
180    #[test]
181    fn desktop_app_uses_app_name_for_dir_and_id() {
182        let app = DesktopApp::new("Demo App").unwrap();
183        assert_eq!(app.company_name(), None);
184        assert_eq!(app.app_name(), "Demo App");
185        assert_eq!(app.app_dir_name(), "Demo App");
186        assert_eq!(app.app_id(), "Demo App");
187        assert_eq!(
188            app.configured_instance_scope(),
189            InstanceScope::CurrentSession
190        );
191    }
192
193    #[test]
194    fn desktop_app_with_company_uses_nested_dir_and_dotted_id() {
195        let app = DesktopApp::with_company("Demo Company", "Demo App")
196            .unwrap()
197            .instance_scope(InstanceScope::Global);
198
199        assert_eq!(app.company_name(), Some("Demo Company"));
200        assert_eq!(app.app_dir_name(), "Demo Company\\Demo App");
201        assert_eq!(app.app_id(), "Demo Company.Demo App");
202        assert_eq!(app.configured_instance_scope(), InstanceScope::Global);
203    }
204
205    #[test]
206    fn validate_identity_rejects_empty_string() {
207        let result = validate_identity_part("app_name", "   ".to_owned());
208        assert!(matches!(
209            result,
210            Err(crate::Error::InvalidInput("app_name cannot be empty"))
211        ));
212    }
213
214    #[test]
215    fn validate_identity_rejects_nul_bytes() {
216        let result = validate_identity_part("company_name", "Demo\0Company".to_owned());
217        assert!(matches!(
218            result,
219            Err(crate::Error::InvalidInput(
220                "company_name cannot contain NUL bytes"
221            ))
222        ));
223    }
224
225    #[test]
226    fn validate_identity_rejects_path_separators() {
227        let result = validate_identity_part("app_name", "Demo\\App".to_owned());
228        assert!(matches!(
229            result,
230            Err(crate::Error::InvalidInput(
231                "app_name contains invalid Windows file-name characters"
232            ))
233        ));
234    }
235}