Skip to main content

win_desktop_utils/
app.rs

1//! App-startup facade for identity, app-data paths, and single-instance guards.
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/// Validated Windows desktop app identity used by the app-data and single-instance helpers.
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    /// # Examples
64    ///
65    /// ```
66    /// let app = win_desktop_utils::DesktopApp::with_company("Acme", "Editor")?;
67    ///
68    /// assert_eq!(app.company_name(), Some("Acme"));
69    /// assert_eq!(app.app_name(), "Editor");
70    /// assert_eq!(app.app_dir_name(), "Acme\\Editor");
71    /// assert_eq!(app.app_id(), "Acme.Editor");
72    /// # Ok::<(), win_desktop_utils::Error>(())
73    /// ```
74    ///
75    /// # Errors
76    ///
77    /// Returns [`Error::InvalidInput`] if either identity part is empty, contains NUL
78    /// bytes, or contains characters that are invalid in Windows file names.
79    pub fn with_company(
80        company_name: impl Into<String>,
81        app_name: impl Into<String>,
82    ) -> Result<Self> {
83        let company_name = validate_identity_part("company_name", company_name.into())?;
84        let app_name = validate_identity_part("app_name", app_name.into())?;
85
86        Ok(Self {
87            app_dir_name: format!("{company_name}\\{app_name}"),
88            app_id: format!("{company_name}.{app_name}"),
89            company_name: Some(company_name),
90            app_name,
91            instance_scope: InstanceScope::CurrentSession,
92        })
93    }
94
95    /// Sets the default single-instance mutex namespace scope used by [`Self::single_instance`].
96    ///
97    /// # Examples
98    ///
99    /// ```
100    /// let app = win_desktop_utils::DesktopApp::new("Admin Tool")?
101    ///     .instance_scope(win_desktop_utils::InstanceScope::Global);
102    ///
103    /// assert_eq!(
104    ///     app.configured_instance_scope(),
105    ///     win_desktop_utils::InstanceScope::Global,
106    /// );
107    /// # Ok::<(), win_desktop_utils::Error>(())
108    /// ```
109    pub fn instance_scope(mut self, scope: InstanceScope) -> Self {
110        self.instance_scope = scope;
111        self
112    }
113
114    /// Returns the optional company name.
115    pub fn company_name(&self) -> Option<&str> {
116        self.company_name.as_deref()
117    }
118
119    /// Returns the app name.
120    pub fn app_name(&self) -> &str {
121        &self.app_name
122    }
123
124    /// Returns the app-data directory name used by the paths helpers.
125    pub fn app_dir_name(&self) -> &str {
126        &self.app_dir_name
127    }
128
129    /// Returns the default app ID used by the single-instance helpers.
130    pub fn app_id(&self) -> &str {
131        &self.app_id
132    }
133
134    /// Returns the configured single-instance scope.
135    pub fn configured_instance_scope(&self) -> InstanceScope {
136        self.instance_scope
137    }
138
139    /// Returns the per-user local app-data directory for this app without creating it.
140    pub fn local_data_dir(&self) -> Result<PathBuf> {
141        local_app_data(&self.app_dir_name)
142    }
143
144    /// Returns the per-user roaming app-data directory for this app without creating it.
145    pub fn roaming_data_dir(&self) -> Result<PathBuf> {
146        roaming_app_data(&self.app_dir_name)
147    }
148
149    /// Creates and returns the per-user local app-data directory for this app.
150    pub fn ensure_local_data_dir(&self) -> Result<PathBuf> {
151        ensure_local_app_data(&self.app_dir_name)
152    }
153
154    /// Creates and returns the per-user roaming app-data directory for this app.
155    pub fn ensure_roaming_data_dir(&self) -> Result<PathBuf> {
156        ensure_roaming_app_data(&self.app_dir_name)
157    }
158
159    /// Returns single-instance options for this app.
160    ///
161    /// # Examples
162    ///
163    /// ```
164    /// let app = win_desktop_utils::DesktopApp::with_company("Acme", "Editor")?;
165    /// let options = app.single_instance_options();
166    ///
167    /// assert_eq!(options.app_id(), "Acme.Editor");
168    /// assert_eq!(
169    ///     options.configured_scope(),
170    ///     win_desktop_utils::InstanceScope::CurrentSession,
171    /// );
172    /// # Ok::<(), win_desktop_utils::Error>(())
173    /// ```
174    pub fn single_instance_options(&self) -> SingleInstanceOptions {
175        SingleInstanceOptions::new(self.app_id.clone()).scope(self.instance_scope)
176    }
177
178    /// Attempts to acquire the configured single-instance guard for this app.
179    pub fn single_instance(&self) -> Result<Option<InstanceGuard>> {
180        single_instance_with_options(&self.single_instance_options())
181    }
182}
183
184fn validate_identity_part(label: &'static str, value: String) -> Result<String> {
185    let trimmed = value.trim();
186
187    if trimmed.is_empty() {
188        return Err(Error::InvalidInput(match label {
189            "company_name" => "company_name cannot be empty",
190            _ => "app_name cannot be empty",
191        }));
192    }
193
194    if trimmed.contains('\0') {
195        return Err(Error::InvalidInput(match label {
196            "company_name" => "company_name cannot contain NUL bytes",
197            _ => "app_name cannot contain NUL bytes",
198        }));
199    }
200
201    if trimmed
202        .chars()
203        .any(|ch| matches!(ch, '<' | '>' | ':' | '"' | '/' | '\\' | '|' | '?' | '*'))
204    {
205        return Err(Error::InvalidInput(match label {
206            "company_name" => "company_name contains invalid Windows file-name characters",
207            _ => "app_name contains invalid Windows file-name characters",
208        }));
209    }
210
211    Ok(trimmed.to_owned())
212}
213
214#[cfg(test)]
215mod tests {
216    use super::{validate_identity_part, DesktopApp};
217    use crate::InstanceScope;
218
219    #[test]
220    fn desktop_app_uses_app_name_for_dir_and_id() {
221        let app = DesktopApp::new("Demo App").unwrap();
222        assert_eq!(app.company_name(), None);
223        assert_eq!(app.app_name(), "Demo App");
224        assert_eq!(app.app_dir_name(), "Demo App");
225        assert_eq!(app.app_id(), "Demo App");
226        assert_eq!(
227            app.configured_instance_scope(),
228            InstanceScope::CurrentSession
229        );
230    }
231
232    #[test]
233    fn desktop_app_with_company_uses_nested_dir_and_dotted_id() {
234        let app = DesktopApp::with_company("Demo Company", "Demo App")
235            .unwrap()
236            .instance_scope(InstanceScope::Global);
237
238        assert_eq!(app.company_name(), Some("Demo Company"));
239        assert_eq!(app.app_dir_name(), "Demo Company\\Demo App");
240        assert_eq!(app.app_id(), "Demo Company.Demo App");
241        assert_eq!(app.configured_instance_scope(), InstanceScope::Global);
242    }
243
244    #[test]
245    fn validate_identity_rejects_empty_string() {
246        let result = validate_identity_part("app_name", "   ".to_owned());
247        assert!(matches!(
248            result,
249            Err(crate::Error::InvalidInput("app_name cannot be empty"))
250        ));
251    }
252
253    #[test]
254    fn validate_identity_rejects_nul_bytes() {
255        let result = validate_identity_part("company_name", "Demo\0Company".to_owned());
256        assert!(matches!(
257            result,
258            Err(crate::Error::InvalidInput(
259                "company_name cannot contain NUL bytes"
260            ))
261        ));
262    }
263
264    #[test]
265    fn validate_identity_rejects_path_separators() {
266        let result = validate_identity_part("app_name", "Demo\\App".to_owned());
267        assert!(matches!(
268            result,
269            Err(crate::Error::InvalidInput(
270                "app_name contains invalid Windows file-name characters"
271            ))
272        ));
273    }
274
275    #[test]
276    fn validate_identity_rejects_all_windows_file_name_reserved_characters() {
277        for reserved in ['<', '>', ':', '"', '/', '\\', '|', '?', '*'] {
278            let value = format!("Demo{reserved}App");
279            let result = validate_identity_part("app_name", value);
280
281            assert!(matches!(
282                result,
283                Err(crate::Error::InvalidInput(
284                    "app_name contains invalid Windows file-name characters"
285                ))
286            ));
287        }
288    }
289
290    #[test]
291    fn validate_identity_trims_surrounding_whitespace() {
292        assert_eq!(
293            validate_identity_part("company_name", "  Demo Company  ".to_owned()).unwrap(),
294            "Demo Company"
295        );
296    }
297}