Skip to main content

app_info/
lib.rs

1pub mod error;
2
3#[cfg(target_os = "macos")]
4mod macos;
5#[cfg(target_os = "windows")]
6mod window;
7
8use error::{AppInfoError, FileIconError, Result};
9use std::num::NonZeroU16;
10use std::path::{Path, PathBuf};
11use std::sync::Arc;
12
13/// Largest icon edge accepted by the library.
14///
15/// A 2048×2048 RGBA icon already occupies 16 MiB, so accepting larger values
16/// would make accidental out-of-memory failures too easy when listing many apps.
17pub const MAX_ICON_SIZE: u16 = 2048;
18
19/// Application information.
20#[derive(Debug, Clone)]
21#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
22pub struct AppInfo {
23    /// Application name.
24    pub name: String,
25    /// Application version.
26    pub version: Option<String>,
27    /// Best-known application or executable path.
28    pub path: PathBuf,
29    /// Application icon in RGBA format, when requested and available.
30    pub icon: Option<Icon>,
31    /// Bundle identifier on macOS or product code on Windows.
32    pub identifier: Option<String>,
33    /// Developer or publisher.
34    pub publisher: Option<String>,
35    /// Platform-provided installation date in its original representation.
36    pub install_date: Option<String>,
37}
38
39/// Shared RGBA icon data.
40///
41/// Cloning an icon is cheap because the pixel storage is reference-counted.
42#[derive(Debug, Clone)]
43#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
44pub struct Icon {
45    /// Icon width in pixels.
46    pub width: u32,
47    /// Icon height in pixels.
48    pub height: u32,
49    /// Pixel data in row-major RGBA format.
50    pub pixels: Arc<[u8]>,
51}
52
53impl Icon {
54    /// Creates an icon after validating the RGBA buffer length.
55    pub fn from_rgba(width: u32, height: u32, pixels: impl Into<Arc<[u8]>>) -> Result<Self> {
56        let pixels = pixels.into();
57        let expected = rgba_buffer_len(width, height)?;
58        if pixels.len() != expected {
59            return Err(FileIconError::InvalidPixelBuffer {
60                expected,
61                actual: pixels.len(),
62            }
63            .into());
64        }
65
66        Ok(Self {
67            width,
68            height,
69            pixels,
70        })
71    }
72}
73
74/// Controls how installed applications are enumerated.
75#[derive(Debug, Clone, Copy, Default)]
76pub struct ListOptions {
77    icon_size: Option<NonZeroU16>,
78    strict: bool,
79}
80
81impl ListOptions {
82    /// Creates options that list metadata without loading icons.
83    pub const fn new() -> Self {
84        Self {
85            icon_size: None,
86            strict: false,
87        }
88    }
89
90    /// Requests an icon for every discovered application.
91    pub fn with_icon_size(mut self, size: u16) -> Result<Self> {
92        validate_icon_size(size)?;
93        self.icon_size = NonZeroU16::new(size);
94        Ok(self)
95    }
96
97    /// Makes malformed or inaccessible entries fail the whole scan.
98    ///
99    /// The default best-effort mode returns valid applications and records
100    /// individual failures in [`AppInfoReport::warnings`].
101    pub const fn strict(mut self, strict: bool) -> Self {
102        self.strict = strict;
103        self
104    }
105
106    /// Requested icon size, or `None` when icons should be loaded lazily.
107    pub const fn icon_size(&self) -> Option<NonZeroU16> {
108        self.icon_size
109    }
110
111    /// Whether the scan should stop at the first invalid entry.
112    pub const fn is_strict(&self) -> bool {
113        self.strict
114    }
115}
116
117/// A non-fatal problem encountered during best-effort enumeration.
118#[derive(Debug, Clone)]
119#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
120pub struct AppInfoWarning {
121    pub path: Option<PathBuf>,
122    pub message: String,
123}
124
125/// Installed applications plus per-entry diagnostics.
126#[derive(Debug, Clone, Default)]
127#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
128pub struct AppInfoReport {
129    pub apps: Vec<AppInfo>,
130    pub warnings: Vec<AppInfoWarning>,
131}
132
133/// Gets installed desktop applications using configurable behavior.
134pub fn get_installed_apps_with_options(options: ListOptions) -> Result<AppInfoReport> {
135    #[cfg(target_os = "macos")]
136    return macos::get_installed_apps(options);
137
138    #[cfg(target_os = "windows")]
139    return window::get_installed_apps(options);
140
141    #[cfg(not(any(target_os = "macos", target_os = "windows")))]
142    {
143        let _ = options;
144        Err(AppInfoError::UnsupportedPlatform)
145    }
146}
147
148/// Gets installed applications.
149///
150/// This compatibility entry point runs a best-effort scan. Pass `0` to avoid
151/// loading icons, or use [`get_installed_apps_with_options`] to retain warnings.
152pub fn get_installed_apps(icon_size: u16) -> Result<Vec<AppInfo>> {
153    let options = if icon_size == 0 {
154        ListOptions::new()
155    } else {
156        ListOptions::new().with_icon_size(icon_size)?
157    };
158    Ok(get_installed_apps_with_options(options)?.apps)
159}
160
161/// Finds all applications whose names match using Unicode lowercase mapping.
162pub fn find_apps_by_name(name: &str, icon_size: u16) -> Result<Vec<AppInfo>> {
163    if icon_size > 0 {
164        validate_icon_size(icon_size)?;
165    }
166
167    let normalized_name = name.to_lowercase();
168    let mut matches: Vec<_> = get_installed_apps_with_options(ListOptions::new())?
169        .apps
170        .into_iter()
171        .filter(|app| app.name.to_lowercase() == normalized_name)
172        .collect();
173
174    if icon_size > 0 {
175        for app in &mut matches {
176            app.icon = get_file_icon(&app.path, icon_size).ok();
177        }
178    }
179
180    Ok(matches)
181}
182
183/// Finds the first application with the requested name.
184///
185/// Prefer [`find_apps_by_name`] when duplicate display names matter.
186pub fn find_app_by_name(name: &str, icon_size: u16) -> Result<AppInfo> {
187    find_apps_by_name(name, icon_size)?
188        .into_iter()
189        .next()
190        .ok_or_else(|| AppInfoError::AppNotFound {
191            name: name.to_string(),
192        })
193}
194
195/// Finds an application by bundle identifier or Windows product code.
196pub fn find_app_by_identifier(identifier: &str, icon_size: u16) -> Result<AppInfo> {
197    if icon_size > 0 {
198        validate_icon_size(icon_size)?;
199    }
200
201    let mut app = get_installed_apps_with_options(ListOptions::new())?
202        .apps
203        .into_iter()
204        .find(|app| {
205            app.identifier
206                .as_deref()
207                .is_some_and(|value| value.eq_ignore_ascii_case(identifier))
208        })
209        .ok_or_else(|| AppInfoError::AppNotFound {
210            name: identifier.to_string(),
211        })?;
212
213    if icon_size > 0 {
214        app.icon = get_file_icon(&app.path, icon_size).ok();
215    }
216    Ok(app)
217}
218
219/// Gets the icon for a file path.
220pub fn get_file_icon(path: impl AsRef<Path>, size: u16) -> Result<Icon> {
221    let path = path.as_ref();
222    if !path.exists() {
223        return Err(FileIconError::PathDoesNotExist.into());
224    }
225    validate_icon_size(size)?;
226
227    #[cfg(target_os = "macos")]
228    return macos::get_file_icon(path, size);
229
230    #[cfg(target_os = "windows")]
231    return window::get_file_icon(path, size);
232
233    #[cfg(not(any(target_os = "macos", target_os = "windows")))]
234    Err(FileIconError::PlatformNotSupported.into())
235}
236
237pub(crate) fn validate_icon_size(size: u16) -> Result<()> {
238    if size == 0 {
239        return Err(FileIconError::NullIconSize.into());
240    }
241    if size > MAX_ICON_SIZE {
242        return Err(FileIconError::SizeTooLarge {
243            requested: size,
244            maximum: MAX_ICON_SIZE,
245        }
246        .into());
247    }
248    let _ = rgba_buffer_len(size.into(), size.into())?;
249    Ok(())
250}
251
252pub(crate) fn rgba_buffer_len(width: u32, height: u32) -> Result<usize> {
253    let length = usize::try_from(width)
254        .ok()
255        .and_then(|width| {
256            usize::try_from(height)
257                .ok()
258                .and_then(|height| width.checked_mul(height))
259        })
260        .and_then(|pixels| pixels.checked_mul(4))
261        .ok_or_else(|| FileIconError::Failed("RGBA buffer size overflow".to_string()))?;
262    Ok(length)
263}
264
265#[cfg(test)]
266mod tests {
267    use super::*;
268
269    #[test]
270    fn validates_icon_buffers() {
271        let icon = Icon::from_rgba(2, 2, vec![0; 16]).unwrap();
272        assert_eq!(icon.pixels.len(), 16);
273
274        assert!(matches!(
275            Icon::from_rgba(2, 2, vec![0; 15]),
276            Err(AppInfoError::FileIconError(
277                FileIconError::InvalidPixelBuffer { .. }
278            ))
279        ));
280    }
281
282    #[test]
283    fn rejects_unsafe_icon_sizes() {
284        assert!(matches!(
285            validate_icon_size(0),
286            Err(AppInfoError::FileIconError(FileIconError::NullIconSize))
287        ));
288        assert!(matches!(
289            validate_icon_size(MAX_ICON_SIZE + 1),
290            Err(AppInfoError::FileIconError(
291                FileIconError::SizeTooLarge { .. }
292            ))
293        ));
294    }
295
296    #[cfg(any(target_os = "macos", target_os = "windows"))]
297    #[test]
298    fn lists_installed_apps_without_icons() {
299        let report = get_installed_apps_with_options(ListOptions::new()).unwrap();
300        assert!(!report.apps.is_empty());
301        assert!(report.apps.iter().all(|app| app.icon.is_none()));
302    }
303
304    #[cfg(any(target_os = "macos", target_os = "windows"))]
305    #[test]
306    fn finds_an_existing_app_without_rescanning_icons() {
307        let apps = get_installed_apps(0).unwrap();
308        let Some(first) = apps.first() else {
309            return;
310        };
311        let found = find_app_by_name(&first.name, 0).unwrap();
312        assert_eq!(found.name, first.name);
313        assert!(found.icon.is_none());
314    }
315
316    #[cfg(any(target_os = "macos", target_os = "windows"))]
317    #[test]
318    fn extracts_a_real_platform_icon() {
319        let path = if cfg!(target_os = "macos") {
320            Path::new("/System/Applications/Calculator.app")
321        } else {
322            Path::new(r"C:\Windows\System32\notepad.exe")
323        };
324        if !path.exists() {
325            return;
326        }
327
328        let icon = get_file_icon(path, 64).unwrap();
329        assert_eq!((icon.width, icon.height), (64, 64));
330        assert_eq!(icon.pixels.len(), 64 * 64 * 4);
331    }
332}