use semver::Version;
use serde::Serialize;
use std::{
collections::HashMap,
fs, io,
path::{Path, PathBuf},
process::Command,
};
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("I/O error: {0:?}")]
Io(#[from] io::Error),
#[error("Metadata error: {0:?}")]
Metadata(#[from] super::metadata::Error),
#[error("Plist error: {0:?}")]
Plist(#[from] plist::Error),
}
pub type Result<T> = std::result::Result<T, Error>;
#[derive(Clone, Serialize)]
pub struct BundleInfo {
#[serde(rename = "CFBundleName")]
pub name: String,
#[serde(rename = "CFBundleIdentifier")]
pub identifier: String,
#[serde(rename = "CFBundleDisplayName")]
pub display_name: String,
#[serde(rename = "CFBundleDevelopmentRegion")]
pub development_region: String,
#[serde(rename = "CFBundleVersion", serialize_with = "serialize_version")]
pub version: Version,
}
impl BundleInfo {
pub fn new(
name: &str,
identifier: &str,
display_name: &str,
development_region: &str,
version: Version,
) -> Self {
Self {
name: name.to_owned(),
identifier: identifier.to_owned(),
display_name: display_name.to_owned(),
development_region: development_region.to_owned(),
version,
}
}
}
pub fn bundle(
app_path: &Path,
target_path: &Path,
executable_name: &str,
helper_name: &str,
resources_path: Option<PathBuf>,
bundle_info: BundleInfo,
) -> Result<PathBuf> {
let main_app_path = create_app(
app_path,
executable_name,
false,
resources_path.as_deref(),
bundle_info.clone(),
&target_path.join(executable_name),
)?;
let cef_path = cef_dll_sys::get_cef_dir().unwrap();
let to = main_app_path.join(FRAMEWORKS_PATH).join(FRAMEWORK);
if to.exists() {
fs::remove_dir_all(&to).unwrap();
}
copy_directory(&cef_path.join(FRAMEWORK), &to)?;
for helper in HELPERS {
let helper = format!("{executable_name} {helper}");
create_app(
&main_app_path.join(FRAMEWORKS_PATH),
&helper,
true,
None,
bundle_info.clone(),
&target_path.join(helper_name),
)?;
}
if let Some(resources_path) = resources_path {
let resources_path = resources_path.join("mac");
let target_path = main_app_path.join(RESOURCES_PATH);
copy_app_resources(&resources_path, &target_path)?;
}
Ok(main_app_path)
}
pub fn build_bundle(
app_path: &Path,
executable_name: &str,
bundle_info: BundleInfo,
) -> Result<PathBuf> {
let cargo_metadata = super::metadata::get_cargo_metadata()?;
let target_path = cargo_metadata.target_directory().join("debug");
let bundle_metadata = cargo_metadata.parse_bundle_metadata(executable_name)?;
cargo_build(executable_name)?;
cargo_build(&bundle_metadata.helper_name)?;
bundle(
app_path,
&target_path,
executable_name,
&bundle_metadata.helper_name,
bundle_metadata.resources_path,
bundle_info,
)
}
#[derive(Serialize)]
struct InfoPlist {
#[serde(flatten)]
bundle_info: BundleInfo,
#[serde(rename = "CFBundleExecutable")]
executable_name: String,
#[serde(rename = "CFBundleInfoDictionaryVersion")]
bundle_info_dictionary_version: String,
#[serde(rename = "CFBundlePackageType")]
bundle_package_type: String,
#[serde(rename = "CFBundleIconFile", skip_serializing_if = "String::is_empty")]
icon_file: String,
#[serde(rename = "CFBundleSignature")]
bundle_signature: String,
#[serde(
rename = "CFBundleShortVersionString",
serialize_with = "serialize_version"
)]
short_version: Version,
#[serde(rename = "LSEnvironment")]
environment: HashMap<String, String>,
#[serde(rename = "LSFileQuarantineEnabled")]
file_quarantine_enabled: bool,
#[serde(rename = "LSMinimumSystemVersion")]
minimum_system_version: String,
#[serde(
rename = "LSUIElement",
skip_serializing_if = "Option::is_none",
serialize_with = "serialize_ui_element"
)]
ui_element: Option<&'static str>,
#[serde(rename = "NSBluetoothAlwaysUsageDescription")]
bluetooth_always_usage_description: String,
#[serde(rename = "NSSupportsAutomaticGraphicsSwitching")]
supports_automatic_graphics_switching: bool,
#[serde(rename = "NSWebBrowserPublicKeyCredentialUsageDescription")]
web_browser_publickey_credential_usage_description: String,
#[serde(rename = "NSCameraUsageDescription")]
camera_usage_description: String,
#[serde(rename = "NSMicrophoneUsageDescription")]
microphone_usage_description: String,
}
impl InfoPlist {
fn new(
executable_name: &str,
is_helper: bool,
icon_file: Option<String>,
bundle_info: BundleInfo,
) -> Self {
Self {
executable_name: executable_name.to_owned(),
bundle_info_dictionary_version: "6.0".to_owned(),
bundle_package_type: "APPL".to_owned(),
icon_file: icon_file.unwrap_or_default(),
bundle_signature: "????".to_owned(),
short_version: Version::new(
bundle_info.version.major,
bundle_info.version.minor,
bundle_info.version.patch,
),
environment: [("MallocNanoZone".to_owned(), "0".to_owned())]
.into_iter()
.collect(),
file_quarantine_enabled: true,
minimum_system_version: "11.0".to_owned(),
ui_element: if is_helper { Some("1") } else { None },
bluetooth_always_usage_description: executable_name.to_owned(),
supports_automatic_graphics_switching: true,
web_browser_publickey_credential_usage_description: executable_name.to_owned(),
camera_usage_description: executable_name.to_owned(),
microphone_usage_description: executable_name.to_owned(),
bundle_info,
}
}
}
fn serialize_ui_element<S>(
ui_element: &Option<&'static str>,
serializer: S,
) -> std::result::Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
match ui_element {
Some(element) => serializer.serialize_str(element),
None => unreachable!("None is skipped"),
}
}
fn serialize_version<S>(version: &Version, serializer: S) -> std::result::Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
serializer.serialize_str(&version.to_string())
}
const EXEC_PATH: &str = "Contents/MacOS";
const FRAMEWORKS_PATH: &str = "Contents/Frameworks";
const RESOURCES_PATH: &str = "Contents/Resources";
const FRAMEWORK: &str = "Chromium Embedded Framework.framework";
const HELPERS: &[&str] = &[
"Helper (GPU)",
"Helper (Renderer)",
"Helper (Plugin)",
"Helper (Alerts)",
"Helper",
];
fn create_app_layout(app_path: &Path) -> Result<PathBuf> {
for path in [EXEC_PATH, RESOURCES_PATH, FRAMEWORKS_PATH] {
fs::create_dir_all(app_path.join(path))?;
}
Ok(app_path.join("Contents"))
}
fn create_app(
app_path: &Path,
executable_name: &str,
is_helper: bool,
resources_path: Option<&Path>,
bundle_info: BundleInfo,
bin: &Path,
) -> Result<PathBuf> {
let app_path = app_path.join(executable_name).with_extension("app");
let contents_path = create_app_layout(&app_path)?;
let icon_file = resources_path.and_then(|path| {
let icon_file = format!("{executable_name}.icns");
if path.join("mac").join(&icon_file).exists() {
Some(icon_file)
} else {
None
}
});
create_info_plist(
&contents_path,
executable_name,
bundle_info,
is_helper,
icon_file,
)?;
let executable_path = app_path.join(EXEC_PATH).join(executable_name);
fs::copy(bin, executable_path)?;
Ok(app_path)
}
fn create_info_plist(
contents_path: &Path,
executable_name: &str,
bundle_info: BundleInfo,
is_helper: bool,
icon_file: Option<String>,
) -> Result<()> {
let info_plist = InfoPlist::new(executable_name, is_helper, icon_file, bundle_info);
plist::to_file_xml(contents_path.join("Info.plist"), &info_plist)?;
Ok(())
}
fn copy_directory(src: &Path, dst: &Path) -> io::Result<()> {
fs::create_dir_all(dst)?;
for entry in fs::read_dir(src)? {
let entry = entry?;
let dst_path = dst.join(entry.file_name());
if entry.file_type()?.is_dir() {
copy_directory(&entry.path(), &dst_path)?;
} else {
fs::copy(entry.path(), &dst_path)?;
}
}
Ok(())
}
fn copy_app_resources(src: &Path, dst: &Path) -> io::Result<()> {
fs::create_dir_all(dst)?;
for entry in fs::read_dir(src)? {
let entry = entry?;
let mut dst_path = dst.join(entry.file_name());
if entry.file_type()?.is_dir() {
copy_app_resources(&entry.path(), &dst_path)?;
} else {
let entry = entry.path();
if entry
.extension()
.map(|ext| ext == "xib")
.unwrap_or_default()
{
dst_path.set_extension("nib");
let (Some(dst_path), Some(entry)) = (dst_path.to_str(), entry.to_str()) else {
return Err(io::Error::from(io::ErrorKind::NotFound));
};
let status = Command::new("xcrun")
.args(["ibtool", "--compile", dst_path, entry])
.status()?;
if !status.success() {
return Err(io::Error::from(io::ErrorKind::Interrupted));
}
} else {
fs::copy(entry, &dst_path)?;
}
}
}
Ok(())
}
fn cargo_build(name: &str) -> Result<()> {
println!("Building {name}...");
let status = Command::new(super::cargo_path())
.args(["build", "--bin", name])
.status()?;
if status.success() {
Ok(())
} else {
Err(io::Error::from(io::ErrorKind::Interrupted).into())
}
}