use crate::{
error::{AppInfoError, FileIconError},
rgba_buffer_len, AppInfo, AppInfoReport, AppInfoWarning, Icon, ListOptions, Result,
};
use objc2::{
class, msg_send_id,
rc::{autoreleasepool, Allocated, Id},
};
use objc2_app_kit::{
NSBitmapImageRep, NSCompositingOperation, NSGraphicsContext, NSImage, NSWorkspace,
};
use objc2_foundation::{CGFloat, CGPoint, CGRect, CGSize, NSString};
use std::collections::HashSet;
use std::fs;
use std::path::{Path, PathBuf};
pub(crate) fn get_installed_apps(options: ListOptions) -> Result<AppInfoReport> {
let mut report = AppInfoReport::default();
let mut seen = HashSet::new();
let mut roots = vec![
PathBuf::from("/Applications"),
PathBuf::from("/System/Applications"),
];
if let Some(home_dir) = std::env::var_os("HOME") {
roots.push(PathBuf::from(home_dir).join("Applications"));
}
for root in roots {
if root.is_dir() {
scan_directory(&root, options, &mut seen, &mut report)?;
}
}
report.apps.sort_by(|left, right| {
left.name
.to_lowercase()
.cmp(&right.name.to_lowercase())
.then_with(|| left.path.cmp(&right.path))
});
Ok(report)
}
fn scan_directory(
root: &Path,
options: ListOptions,
seen: &mut HashSet<PathBuf>,
report: &mut AppInfoReport,
) -> Result<()> {
let mut pending = vec![root.to_path_buf()];
while let Some(directory) = pending.pop() {
let entries = match fs::read_dir(&directory) {
Ok(entries) => entries,
Err(source) => {
record_io_problem(
options,
report,
"reading application directory",
&directory,
source,
)?;
continue;
}
};
for entry in entries {
let entry = match entry {
Ok(entry) => entry,
Err(source) => {
record_io_problem(
options,
report,
"reading directory entry",
&directory,
source,
)?;
continue;
}
};
let path = entry.path();
let is_app = path
.extension()
.and_then(|extension| extension.to_str())
.is_some_and(|extension| extension.eq_ignore_ascii_case("app"));
if is_app {
let canonical_path = path.canonicalize().unwrap_or_else(|_| path.clone());
if !seen.insert(canonical_path) {
continue;
}
match parse_app_bundle(&path, options) {
Ok((app, warning)) => {
report.apps.push(app);
if let Some(warning) = warning {
report.warnings.push(warning);
}
}
Err(error) if options.is_strict() => return Err(error),
Err(error) => report.warnings.push(AppInfoWarning {
path: Some(path),
message: error.to_string(),
}),
}
continue;
}
match entry.file_type() {
Ok(file_type) if file_type.is_dir() && !file_type.is_symlink() => {
pending.push(path);
}
Ok(_) => {}
Err(source) => {
record_io_problem(
options,
report,
"reading directory entry type",
&path,
source,
)?;
}
}
}
}
Ok(())
}
fn record_io_problem(
options: ListOptions,
report: &mut AppInfoReport,
action: &'static str,
path: &Path,
source: std::io::Error,
) -> Result<()> {
let error = AppInfoError::Io {
action,
path: path.display().to_string(),
source,
};
if options.is_strict() {
return Err(error);
}
report.warnings.push(AppInfoWarning {
path: Some(path.to_path_buf()),
message: error.to_string(),
});
Ok(())
}
fn parse_app_bundle(
app_path: &Path,
options: ListOptions,
) -> Result<(AppInfo, Option<AppInfoWarning>)> {
let info_plist_path = app_path.join("Contents/Info.plist");
if !info_plist_path.is_file() {
return Err(AppInfoError::BundleParseError {
path: app_path.display().to_string(),
});
}
let plist = plist::Value::from_file(&info_plist_path)
.map_err(|error| AppInfoError::PlistError(error.to_string()))?;
let dict = plist
.as_dictionary()
.ok_or_else(|| AppInfoError::PlistError("Invalid plist dictionary".to_string()))?;
let name = dict
.get("CFBundleDisplayName")
.or_else(|| dict.get("CFBundleName"))
.and_then(plist::Value::as_string)
.map(str::to_owned)
.unwrap_or_else(|| {
app_path
.file_stem()
.map(|name| name.to_string_lossy().into_owned())
.unwrap_or_else(|| "Unknown".to_string())
});
let version = dict
.get("CFBundleShortVersionString")
.or_else(|| dict.get("CFBundleVersion"))
.and_then(plist::Value::as_string)
.map(str::to_owned);
let identifier = dict
.get("CFBundleIdentifier")
.and_then(plist::Value::as_string)
.map(str::to_owned);
let (icon, warning) = if let Some(icon_size) = options.icon_size() {
match get_file_icon(app_path, icon_size.get()) {
Ok(icon) => (Some(icon), None),
Err(error) if options.is_strict() => return Err(error),
Err(error) => (
None,
Some(AppInfoWarning {
path: Some(app_path.to_path_buf()),
message: error.to_string(),
}),
),
}
} else {
(None, None)
};
Ok((
AppInfo {
name,
version,
path: app_path.to_path_buf(),
icon,
identifier,
publisher: None,
install_date: None,
},
warning,
))
}
pub(crate) fn get_file_icon(path: &Path, size: u16) -> Result<Icon> {
let canonical_path = path
.canonicalize()
.map_err(|_| FileIconError::PathDoesNotExist)?;
let path_string = canonical_path
.to_str()
.ok_or_else(|| FileIconError::Failed("path is not valid UTF-8".to_string()))?;
autoreleasepool(|_| unsafe {
let file_path = NSString::from_str(path_string);
let workspace = NSWorkspace::sharedWorkspace();
let image: Id<NSImage> = workspace.iconForFile(&file_path);
let desired_size = CGSize {
width: size as CGFloat,
height: size as CGFloat,
};
let bitmap: Id<NSBitmapImageRep> = {
let allocated: Allocated<NSBitmapImageRep> =
msg_send_id![class!(NSBitmapImageRep), alloc];
msg_send_id![
allocated,
initWithBitmapDataPlanes: std::ptr::null_mut::<*mut u8>(),
pixelsWide: size as isize,
pixelsHigh: size as isize,
bitsPerSample: 8_isize,
samplesPerPixel: 4_isize,
hasAlpha: true,
isPlanar: false,
colorSpaceName: &*NSString::from_str("NSDeviceRGBColorSpace"),
bytesPerRow: size as isize * 4,
bitsPerPixel: 32_isize
]
};
let context = NSGraphicsContext::graphicsContextWithBitmapImageRep(&bitmap)
.ok_or_else(|| FileIconError::Failed("could not create bitmap context".to_string()))?;
struct GraphicsStateGuard;
impl Drop for GraphicsStateGuard {
fn drop(&mut self) {
unsafe { NSGraphicsContext::restoreGraphicsState_class() };
}
}
NSGraphicsContext::saveGraphicsState_class();
let _graphics_state = GraphicsStateGuard;
NSGraphicsContext::setCurrentContext(Some(&context));
image.setSize(desired_size);
image.drawAtPoint_fromRect_operation_fraction(
CGPoint::ZERO,
CGRect::new(CGPoint::ZERO, desired_size),
NSCompositingOperation::Copy,
1.0,
);
context.flushGraphics();
let data = bitmap.bitmapData();
if data.is_null() {
return Err(FileIconError::Failed("bitmap data is null".to_string()).into());
}
let row_length = usize::from(size) * 4;
let bytes_per_row = usize::try_from(bitmap.bytesPerRow())
.map_err(|_| FileIconError::Failed("invalid bitmap row length".to_string()))?;
let bytes_per_plane = usize::try_from(bitmap.bytesPerPlane())
.map_err(|_| FileIconError::Failed("invalid bitmap plane length".to_string()))?;
let minimum_plane_length = bytes_per_row
.checked_mul(usize::from(size))
.ok_or_else(|| FileIconError::Failed("bitmap length overflow".to_string()))?;
if bytes_per_row < row_length || bytes_per_plane < minimum_plane_length {
return Err(FileIconError::Failed(
"bitmap buffer is smaller than expected".to_string(),
)
.into());
}
let expected = rgba_buffer_len(size.into(), size.into())?;
let mut pixels = Vec::with_capacity(expected);
for row in 0..usize::from(size) {
let row_start = data.add(row * bytes_per_row);
pixels.extend_from_slice(std::slice::from_raw_parts(row_start, row_length));
}
Icon::from_rgba(size.into(), size.into(), pixels)
})
}