use crate::common::App;
use anyhow::Result;
use core_foundation::{bundle::CFBundle, url::CFURL};
use glob::glob;
use serde_derive::Deserialize;
use serde_derive::Serialize;
use std::path::PathBuf;
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MacSystemProfilerAppList {
#[serde(rename = "SPApplicationsDataType")]
pub spapplications_data_type: Vec<MacSystemProfilterAppInfo>,
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MacSystemProfilterAppInfo {
#[serde(rename = "_name")]
pub name: String,
#[serde(rename = "arch_kind")]
pub arch_kind: String,
pub last_modified: String,
#[serde(rename = "obtained_from")]
pub obtained_from: String,
pub path: String,
#[serde(rename = "signed_by")]
pub signed_by: Option<Vec<String>>,
pub version: Option<String>,
pub info: Option<String>,
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "PascalCase")]
pub struct CFBundlePrimaryIcon {
#[serde(rename = "CFBundleIconName")]
cf_bundle_icon_name: Option<String>,
#[serde(rename = "CFBundleIconFiles")]
cf_bundle_icon_files: Option<Vec<String>>,
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "PascalCase")]
pub struct CFBundleIcons {
#[serde(rename = "CFBundlePrimaryIcon")]
cf_bundle_primary_icon: Option<CFBundlePrimaryIcon>,
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "PascalCase")]
pub struct InfoPlist {
#[serde(rename = "CFBundleIconFile")]
cf_bundle_icon_file: Option<String>,
#[serde(rename = "CFBundleIcons")]
cf_bundle_icons: Option<CFBundleIcons>,
#[serde(rename = "CFBundleIcons~ipad")]
cf_bundle_icons_ipad: Option<CFBundleIcons>,
#[serde(rename = "CFBundleExecutable")]
cf_bundle_executable: Option<String>,
#[serde(rename = "CFBundleIconName")]
cf_bundle_icon_name: Option<String>,
#[serde(rename = "CFBundleIdentifier")]
cf_bundle_identifier: Option<String>,
#[serde(rename = "CFBundleInfoDictionaryVersion")]
cf_bundle_info_dictionary_version: Option<String>,
#[serde(rename = "CFBundleName")]
cf_bundle_name: Option<String>,
#[serde(rename = "CFBundlePackageType")]
cf_bundle_package_type: Option<String>,
#[serde(rename = "CFBundleShortVersionString")]
cf_bundle_short_version_string: Option<String>,
#[serde(rename = "CFBundleVersion")]
cf_bundle_version: Option<String>,
#[serde(rename = "CFBundleDisplayName")]
cf_bundle_display_name: Option<String>,
}
impl InfoPlist {
pub fn from_value(value: &plist::Value) -> Result<InfoPlist> {
let info_plist = plist::from_value(value).unwrap();
Ok(info_plist)
}
pub fn from_file(path: &PathBuf) -> Result<InfoPlist> {
match plist::from_file(path) {
Ok(info_plist) => Ok(info_plist),
Err(_) => match plist::Value::from_file(path) {
Ok(value) => Ok(InfoPlist::from_value(&value).unwrap()),
Err(err) => Err(anyhow::Error::msg(format!(
"Fail to parse plist: {}",
err.to_string()
))),
},
}
}
pub fn from_string(s: &str) -> Result<InfoPlist> {
Ok(plist::from_bytes(s.as_bytes()).expect("failed to read info.plist"))
}
}
pub fn run_system_profiler_to_get_app_list() -> Result<String> {
let output = std::process::Command::new("system_profiler")
.arg("SPApplicationsDataType")
.arg("-json")
.output()?;
Ok(std::str::from_utf8(&output.stdout).unwrap().to_string())
}
pub fn run_mdfind_to_get_app_list() -> Result<Vec<String>> {
let output = std::process::Command::new("mdfind")
.arg("kMDItemKind == 'Application'")
.output()?;
let output = String::from_utf8(output.stdout)?;
let lines1: Vec<String> = output.split("\n").map(|line| line.to_string()).collect();
let output = std::process::Command::new("mdfind")
.arg("kMDItemContentType == 'com.apple.application-bundle'")
.output()?;
let output = String::from_utf8(output.stdout)?;
let lines2: Vec<String> = output.split("\n").map(|line| line.to_string()).collect();
let lines: Vec<String> = lines1
.into_iter()
.chain(lines2.into_iter())
.collect::<std::collections::HashSet<String>>()
.into_iter()
.collect();
Ok(lines)
}
pub struct MacAppPath(PathBuf);
impl MacAppPath {
pub fn new(path: PathBuf) -> Self {
MacAppPath(path)
}
pub fn exists(&self) -> bool {
self.0.exists()
}
pub fn is_app(&self) -> bool {
self.exists() && self.has_info_plist()
}
pub fn has_wrapper(&self) -> bool {
match self.get_wrapper_path() {
Some(path) => path.exists(),
None => false,
}
}
pub fn get_wrapper_path(&self) -> Option<PathBuf> {
match self.0.join("Wrapper") {
path if path.exists() => Some(path),
_ => None,
}
}
pub fn get_app_path_in_wrapper(&self) -> Option<PathBuf> {
let wrapper_path = self.get_wrapper_path()?;
let wrapper_path_str = wrapper_path.to_str()?;
let glob_path = format!("{}/*.app", wrapper_path_str);
if let Some(e) = glob(&glob_path)
.expect("Failed to read glob pattern")
.next()
{
return Some(e.unwrap());
}
None
}
pub fn get_bundle(&self) -> CFBundle {
CFBundle::new(CFURL::from_path(&self.0, true).expect("Fail to create CFURL"))
.expect("Fail to create CFBundle")
}
pub fn get_executable_path_with_bundle(&self) -> Option<PathBuf> {
let bundle = self.get_bundle();
match bundle.executable_url() {
Some(url) => url.to_path(),
None => None,
}
}
pub fn get_executable_path(&self) -> Option<PathBuf> {
let plist_path = self.get_info_plist_path()?;
match InfoPlist::from_file(&plist_path) {
Ok(info_plist) => match info_plist.cf_bundle_executable {
Some(executable) => Some(PathBuf::from(executable)),
None => None,
},
Err(_) => None,
}
}
pub fn has_info_plist(&self) -> bool {
self.get_info_plist_path().is_some()
}
pub fn get_info_plist_path(&self) -> Option<PathBuf> {
if self.has_wrapper() {
let app_path_in_wrapper = self.get_app_path_in_wrapper()?;
let path = app_path_in_wrapper.join("Info.plist"); match path.exists() {
true => Some(path),
false => None,
}
} else {
let path = self.0.join("Contents").join("Info.plist");
match path.exists() {
true => Some(path),
false => None,
}
}
}
pub fn to_app(&self) -> Option<App> {
if !self.is_app() {
return None;
}
let info_plist_path = self.get_info_plist_path()?;
let info_plist = InfoPlist::from_file(&info_plist_path).ok()?;
let name = self.0.file_stem()?.to_str()?.to_string();
let is_ios_app = self.has_wrapper();
let icon_file_name = if is_ios_app {
let icons = info_plist.cf_bundle_icons;
match icons {
Some(icons) => {
let primary_icon = icons.cf_bundle_primary_icon;
match primary_icon {
Some(icon) => {
let icon_files = icon.cf_bundle_icon_files;
match icon_files {
Some(icon_files) => {
let first_icon_file: Option<String> =
icon_files.first().map(|s| s.to_string());
first_icon_file
}
None => None,
}
}
None => None,
}
}
None => None,
}
} else {
info_plist.cf_bundle_icon_file
};
let contents_path = self.0.join("Contents");
let resources_path = contents_path.join("Resources");
let macos_path = contents_path.join("MacOS");
let icon_path = match icon_file_name {
Some(icon_file_name) => {
let icon_file_name = if icon_file_name.ends_with(".icns") {
icon_file_name
} else {
format!("{}.icns", icon_file_name)
};
let icon_path = resources_path.join(icon_file_name);
if icon_path.exists() {
Some(icon_path)
} else {
None
}
}
None => None,
};
let app_path_exe = match info_plist.cf_bundle_executable {
Some(executable) => {
let app_path_exe = macos_path.join(executable);
if app_path_exe.exists() {
Some(app_path_exe)
} else {
None
}
}
None => None,
};
Some(App {
name,
icon_path,
app_path_exe,
app_desktop_path: self.0.clone(),
})
}
}
mod tests {
use super::*;
#[test]
fn test_path_is_app() {
let output = run_system_profiler_to_get_app_list().unwrap();
let app_list_json = serde_json::from_str::<MacSystemProfilerAppList>(&output);
assert!(app_list_json.is_ok());
let app_list_json = app_list_json.unwrap();
app_list_json
.spapplications_data_type
.iter()
.for_each(|app| {
let path = PathBuf::from(&app.path);
let mac_app_path = MacAppPath::new(path.clone());
if !mac_app_path.is_app() {
println!("Path is not an app: {:?}", path);
}
});
}
#[test]
fn test_get_app_path_in_wrapper() {
let mac_app_path = MacAppPath::new(PathBuf::from("/Applications/Shadowrocket.app"));
if !mac_app_path.exists() {
return;
}
let app_path_in_wrapper = mac_app_path.get_app_path_in_wrapper();
assert_eq!(
app_path_in_wrapper.unwrap(),
PathBuf::from(format!(
"/Applications/Shadowrocket.app/Wrapper/Shadowrocket.app"
))
);
let mac_app_path = MacAppPath::new(PathBuf::from("/Applications/全民K歌.app/"));
if !mac_app_path.exists() {
return;
}
let app_path_in_wrapper = mac_app_path.get_app_path_in_wrapper();
assert_eq!(
app_path_in_wrapper.unwrap(),
PathBuf::from(format!("/Applications/全民K歌.app/Wrapper/QQKSong.app"))
);
}
#[test]
fn test_load_info_plist() {
let output = run_system_profiler_to_get_app_list().unwrap();
let app_list_json = serde_json::from_str::<MacSystemProfilerAppList>(&output);
assert!(app_list_json.is_ok());
let app_list_json = app_list_json.unwrap();
app_list_json
.spapplications_data_type
.iter()
.for_each(|app| {
let path = PathBuf::from(&app.path);
let mac_app_path = MacAppPath::new(path.clone());
let plist_path = mac_app_path.get_info_plist_path();
if plist_path.is_none() {
return;
}
let plist_path = plist_path.unwrap();
let info_plist =
InfoPlist::from_file(&plist_path).expect("failed to load info.plist");
});
}
#[test]
fn test_to_app() {
let mac_app_path = MacAppPath::new(PathBuf::from("/Applications/Discord.app"));
let app = mac_app_path.to_app();
println!("App: {:?}", app);
}
}