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
13pub const MAX_ICON_SIZE: u16 = 2048;
18
19#[derive(Debug, Clone)]
21#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
22pub struct AppInfo {
23 pub name: String,
25 pub version: Option<String>,
27 pub path: PathBuf,
29 pub icon: Option<Icon>,
31 pub identifier: Option<String>,
33 pub publisher: Option<String>,
35 pub install_date: Option<String>,
37}
38
39#[derive(Debug, Clone)]
43#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
44pub struct Icon {
45 pub width: u32,
47 pub height: u32,
49 pub pixels: Arc<[u8]>,
51}
52
53impl Icon {
54 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#[derive(Debug, Clone, Copy, Default)]
76pub struct ListOptions {
77 icon_size: Option<NonZeroU16>,
78 strict: bool,
79}
80
81impl ListOptions {
82 pub const fn new() -> Self {
84 Self {
85 icon_size: None,
86 strict: false,
87 }
88 }
89
90 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 pub const fn strict(mut self, strict: bool) -> Self {
102 self.strict = strict;
103 self
104 }
105
106 pub const fn icon_size(&self) -> Option<NonZeroU16> {
108 self.icon_size
109 }
110
111 pub const fn is_strict(&self) -> bool {
113 self.strict
114 }
115}
116
117#[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#[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
133pub 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
148pub 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
161pub 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
183pub 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
195pub 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
219pub 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}