app-info 0.1.1

Get the installed apps and icons on the device
Documentation
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};

/// Gets installed applications by recursively scanning standard macOS roots.
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(),
                    }),
                }
                // Never recurse into an application bundle.
                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,
    ))
}

/// Gets a file icon through AppKit and normalizes it to tightly packed RGBA.
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)
    })
}