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;
pub const MAX_ICON_SIZE: u16 = 2048;
#[derive(Debug, Clone)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct AppInfo {
pub name: String,
pub version: Option<String>,
pub path: PathBuf,
pub icon: Option<Icon>,
pub identifier: Option<String>,
pub publisher: Option<String>,
pub install_date: Option<String>,
}
#[derive(Debug, Clone)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct Icon {
pub width: u32,
pub height: u32,
pub pixels: Arc<[u8]>,
}
impl Icon {
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,
})
}
}
#[derive(Debug, Clone, Copy, Default)]
pub struct ListOptions {
icon_size: Option<NonZeroU16>,
strict: bool,
}
impl ListOptions {
pub const fn new() -> Self {
Self {
icon_size: None,
strict: false,
}
}
pub fn with_icon_size(mut self, size: u16) -> Result<Self> {
validate_icon_size(size)?;
self.icon_size = NonZeroU16::new(size);
Ok(self)
}
pub const fn strict(mut self, strict: bool) -> Self {
self.strict = strict;
self
}
pub const fn icon_size(&self) -> Option<NonZeroU16> {
self.icon_size
}
pub const fn is_strict(&self) -> bool {
self.strict
}
}
#[derive(Debug, Clone)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct AppInfoWarning {
pub path: Option<PathBuf>,
pub message: String,
}
#[derive(Debug, Clone, Default)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct AppInfoReport {
pub apps: Vec<AppInfo>,
pub warnings: Vec<AppInfoWarning>,
}
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)
}
}
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)
}
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)
}
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(),
})
}
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)
}
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);
}
}