app-info 0.1.1

Get the installed apps and icons on the device
Documentation
pub mod error;

#[cfg(target_os = "macos")]
mod macos;
#[cfg(target_os = "windows")]
mod window;

use error::{AppInfoError, FileIconError, Result};
use std::num::NonZeroU16;
use std::path::{Path, PathBuf};
use std::sync::Arc;

/// Largest icon edge accepted by the library.
///
/// A 2048×2048 RGBA icon already occupies 16 MiB, so accepting larger values
/// would make accidental out-of-memory failures too easy when listing many apps.
pub const MAX_ICON_SIZE: u16 = 2048;

/// Application information.
#[derive(Debug, Clone)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct AppInfo {
    /// Application name.
    pub name: String,
    /// Application version.
    pub version: Option<String>,
    /// Best-known application or executable path.
    pub path: PathBuf,
    /// Application icon in RGBA format, when requested and available.
    pub icon: Option<Icon>,
    /// Bundle identifier on macOS or product code on Windows.
    pub identifier: Option<String>,
    /// Developer or publisher.
    pub publisher: Option<String>,
    /// Platform-provided installation date in its original representation.
    pub install_date: Option<String>,
}

/// Shared RGBA icon data.
///
/// Cloning an icon is cheap because the pixel storage is reference-counted.
#[derive(Debug, Clone)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct Icon {
    /// Icon width in pixels.
    pub width: u32,
    /// Icon height in pixels.
    pub height: u32,
    /// Pixel data in row-major RGBA format.
    pub pixels: Arc<[u8]>,
}

impl Icon {
    /// Creates an icon after validating the RGBA buffer length.
    pub fn from_rgba(width: u32, height: u32, pixels: impl Into<Arc<[u8]>>) -> Result<Self> {
        let pixels = pixels.into();
        let expected = rgba_buffer_len(width, height)?;
        if pixels.len() != expected {
            return Err(FileIconError::InvalidPixelBuffer {
                expected,
                actual: pixels.len(),
            }
            .into());
        }

        Ok(Self {
            width,
            height,
            pixels,
        })
    }
}

/// Controls how installed applications are enumerated.
#[derive(Debug, Clone, Copy, Default)]
pub struct ListOptions {
    icon_size: Option<NonZeroU16>,
    strict: bool,
}

impl ListOptions {
    /// Creates options that list metadata without loading icons.
    pub const fn new() -> Self {
        Self {
            icon_size: None,
            strict: false,
        }
    }

    /// Requests an icon for every discovered application.
    pub fn with_icon_size(mut self, size: u16) -> Result<Self> {
        validate_icon_size(size)?;
        self.icon_size = NonZeroU16::new(size);
        Ok(self)
    }

    /// Makes malformed or inaccessible entries fail the whole scan.
    ///
    /// The default best-effort mode returns valid applications and records
    /// individual failures in [`AppInfoReport::warnings`].
    pub const fn strict(mut self, strict: bool) -> Self {
        self.strict = strict;
        self
    }

    /// Requested icon size, or `None` when icons should be loaded lazily.
    pub const fn icon_size(&self) -> Option<NonZeroU16> {
        self.icon_size
    }

    /// Whether the scan should stop at the first invalid entry.
    pub const fn is_strict(&self) -> bool {
        self.strict
    }
}

/// A non-fatal problem encountered during best-effort enumeration.
#[derive(Debug, Clone)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct AppInfoWarning {
    pub path: Option<PathBuf>,
    pub message: String,
}

/// Installed applications plus per-entry diagnostics.
#[derive(Debug, Clone, Default)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct AppInfoReport {
    pub apps: Vec<AppInfo>,
    pub warnings: Vec<AppInfoWarning>,
}

/// Gets installed desktop applications using configurable behavior.
pub fn get_installed_apps_with_options(options: ListOptions) -> Result<AppInfoReport> {
    #[cfg(target_os = "macos")]
    return macos::get_installed_apps(options);

    #[cfg(target_os = "windows")]
    return window::get_installed_apps(options);

    #[cfg(not(any(target_os = "macos", target_os = "windows")))]
    {
        let _ = options;
        Err(AppInfoError::UnsupportedPlatform)
    }
}

/// Gets installed applications.
///
/// This compatibility entry point runs a best-effort scan. Pass `0` to avoid
/// loading icons, or use [`get_installed_apps_with_options`] to retain warnings.
pub fn get_installed_apps(icon_size: u16) -> Result<Vec<AppInfo>> {
    let options = if icon_size == 0 {
        ListOptions::new()
    } else {
        ListOptions::new().with_icon_size(icon_size)?
    };
    Ok(get_installed_apps_with_options(options)?.apps)
}

/// Finds all applications whose names match using Unicode lowercase mapping.
pub fn find_apps_by_name(name: &str, icon_size: u16) -> Result<Vec<AppInfo>> {
    if icon_size > 0 {
        validate_icon_size(icon_size)?;
    }

    let normalized_name = name.to_lowercase();
    let mut matches: Vec<_> = get_installed_apps_with_options(ListOptions::new())?
        .apps
        .into_iter()
        .filter(|app| app.name.to_lowercase() == normalized_name)
        .collect();

    if icon_size > 0 {
        for app in &mut matches {
            app.icon = get_file_icon(&app.path, icon_size).ok();
        }
    }

    Ok(matches)
}

/// Finds the first application with the requested name.
///
/// Prefer [`find_apps_by_name`] when duplicate display names matter.
pub fn find_app_by_name(name: &str, icon_size: u16) -> Result<AppInfo> {
    find_apps_by_name(name, icon_size)?
        .into_iter()
        .next()
        .ok_or_else(|| AppInfoError::AppNotFound {
            name: name.to_string(),
        })
}

/// Finds an application by bundle identifier or Windows product code.
pub fn find_app_by_identifier(identifier: &str, icon_size: u16) -> Result<AppInfo> {
    if icon_size > 0 {
        validate_icon_size(icon_size)?;
    }

    let mut app = get_installed_apps_with_options(ListOptions::new())?
        .apps
        .into_iter()
        .find(|app| {
            app.identifier
                .as_deref()
                .is_some_and(|value| value.eq_ignore_ascii_case(identifier))
        })
        .ok_or_else(|| AppInfoError::AppNotFound {
            name: identifier.to_string(),
        })?;

    if icon_size > 0 {
        app.icon = get_file_icon(&app.path, icon_size).ok();
    }
    Ok(app)
}

/// Gets the icon for a file path.
pub fn get_file_icon(path: impl AsRef<Path>, size: u16) -> Result<Icon> {
    let path = path.as_ref();
    if !path.exists() {
        return Err(FileIconError::PathDoesNotExist.into());
    }
    validate_icon_size(size)?;

    #[cfg(target_os = "macos")]
    return macos::get_file_icon(path, size);

    #[cfg(target_os = "windows")]
    return window::get_file_icon(path, size);

    #[cfg(not(any(target_os = "macos", target_os = "windows")))]
    Err(FileIconError::PlatformNotSupported.into())
}

pub(crate) fn validate_icon_size(size: u16) -> Result<()> {
    if size == 0 {
        return Err(FileIconError::NullIconSize.into());
    }
    if size > MAX_ICON_SIZE {
        return Err(FileIconError::SizeTooLarge {
            requested: size,
            maximum: MAX_ICON_SIZE,
        }
        .into());
    }
    let _ = rgba_buffer_len(size.into(), size.into())?;
    Ok(())
}

pub(crate) fn rgba_buffer_len(width: u32, height: u32) -> Result<usize> {
    let length = usize::try_from(width)
        .ok()
        .and_then(|width| {
            usize::try_from(height)
                .ok()
                .and_then(|height| width.checked_mul(height))
        })
        .and_then(|pixels| pixels.checked_mul(4))
        .ok_or_else(|| FileIconError::Failed("RGBA buffer size overflow".to_string()))?;
    Ok(length)
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn validates_icon_buffers() {
        let icon = Icon::from_rgba(2, 2, vec![0; 16]).unwrap();
        assert_eq!(icon.pixels.len(), 16);

        assert!(matches!(
            Icon::from_rgba(2, 2, vec![0; 15]),
            Err(AppInfoError::FileIconError(
                FileIconError::InvalidPixelBuffer { .. }
            ))
        ));
    }

    #[test]
    fn rejects_unsafe_icon_sizes() {
        assert!(matches!(
            validate_icon_size(0),
            Err(AppInfoError::FileIconError(FileIconError::NullIconSize))
        ));
        assert!(matches!(
            validate_icon_size(MAX_ICON_SIZE + 1),
            Err(AppInfoError::FileIconError(
                FileIconError::SizeTooLarge { .. }
            ))
        ));
    }

    #[cfg(any(target_os = "macos", target_os = "windows"))]
    #[test]
    fn lists_installed_apps_without_icons() {
        let report = get_installed_apps_with_options(ListOptions::new()).unwrap();
        assert!(!report.apps.is_empty());
        assert!(report.apps.iter().all(|app| app.icon.is_none()));
    }

    #[cfg(any(target_os = "macos", target_os = "windows"))]
    #[test]
    fn finds_an_existing_app_without_rescanning_icons() {
        let apps = get_installed_apps(0).unwrap();
        let Some(first) = apps.first() else {
            return;
        };
        let found = find_app_by_name(&first.name, 0).unwrap();
        assert_eq!(found.name, first.name);
        assert!(found.icon.is_none());
    }

    #[cfg(any(target_os = "macos", target_os = "windows"))]
    #[test]
    fn extracts_a_real_platform_icon() {
        let path = if cfg!(target_os = "macos") {
            Path::new("/System/Applications/Calculator.app")
        } else {
            Path::new(r"C:\Windows\System32\notepad.exe")
        };
        if !path.exists() {
            return;
        }

        let icon = get_file_icon(path, 64).unwrap();
        assert_eq!((icon.width, icon.height), (64, 64));
        assert_eq!(icon.pixels.len(), 64 * 64 * 4);
    }
}