Build tagged macOS releases
This commit is contained in:
154
tools/macos-packager/src/main.rs
Normal file
154
tools/macos-packager/src/main.rs
Normal file
@@ -0,0 +1,154 @@
|
||||
use apple_bundles::MacOsApplicationBundleBuilder;
|
||||
use apple_codesign::{SigningSettings, UnifiedSigner};
|
||||
use simple_file_manifest::FileEntry;
|
||||
use std::{
|
||||
env,
|
||||
error::Error,
|
||||
ffi::OsString,
|
||||
fs,
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
const APP_NAME: &str = "DS4Server";
|
||||
const APP_EXECUTABLE: &str = "ds4-server";
|
||||
const BUNDLE_ID: &str = "de.rfc1437.ds4server";
|
||||
const MIB: u64 = 1024 * 1024;
|
||||
|
||||
fn main() -> Result<(), Box<dyn Error>> {
|
||||
let args: Vec<OsString> = env::args_os().collect();
|
||||
if args.len() != 4 {
|
||||
return Err("usage: ds4-macos-packager <version> <executable> <output.dmg>".into());
|
||||
}
|
||||
package(
|
||||
args[1].to_str().ok_or("version is not valid UTF-8")?,
|
||||
Path::new(&args[2]),
|
||||
Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."),
|
||||
Path::new(&args[3]),
|
||||
)
|
||||
}
|
||||
|
||||
fn package(
|
||||
release_tag: &str,
|
||||
executable: &Path,
|
||||
repository: PathBuf,
|
||||
output: &Path,
|
||||
) -> Result<(), Box<dyn Error>> {
|
||||
let version = bundle_version(release_tag)?;
|
||||
if !executable.is_file() {
|
||||
return Err(format!("release executable is missing: {}", executable.display()).into());
|
||||
}
|
||||
let output_dir = output.parent().unwrap_or_else(|| Path::new("."));
|
||||
fs::create_dir_all(output_dir)?;
|
||||
if output.try_exists()? {
|
||||
return Err(format!("output already exists: {}", output.display()).into());
|
||||
}
|
||||
let bundle_path = output_dir.join(format!("{APP_NAME}.app"));
|
||||
if bundle_path.try_exists()? {
|
||||
return Err(format!("staging bundle already exists: {}", bundle_path.display()).into());
|
||||
}
|
||||
|
||||
let mut bundle = MacOsApplicationBundleBuilder::new(APP_NAME)?;
|
||||
bundle.set_info_plist_required_keys(APP_NAME, BUNDLE_ID, version, "DS4S", APP_EXECUTABLE)?;
|
||||
bundle.set_info_plist_key("CFBundleShortVersionString", version)?;
|
||||
bundle.set_info_plist_key("CFBundleIconFile", format!("{APP_NAME}.icns"))?;
|
||||
bundle.set_info_plist_key(
|
||||
"LSApplicationCategoryType",
|
||||
"public.app-category.developer-tools",
|
||||
)?;
|
||||
bundle.set_info_plist_key("NSHighResolutionCapable", true)?;
|
||||
bundle.add_icon(FileEntry::new_from_path(
|
||||
repository.join("assets/DS4Server.icns"),
|
||||
false,
|
||||
))?;
|
||||
bundle.add_file_macos(APP_EXECUTABLE, FileEntry::new_from_path(executable, true))?;
|
||||
add_resources(&mut bundle, &repository.join("metal"), Path::new("metal"))?;
|
||||
add_resources(
|
||||
&mut bundle,
|
||||
&repository.join("assets/dev-brain"),
|
||||
Path::new("dev-brain"),
|
||||
)?;
|
||||
let materialized = bundle.materialize_bundle(output_dir)?;
|
||||
|
||||
let signer = UnifiedSigner::new(SigningSettings::default());
|
||||
signer.sign_path_in_place(&materialized)?;
|
||||
apple_dmg::create_dmg(
|
||||
&materialized,
|
||||
output,
|
||||
APP_NAME,
|
||||
dmg_sectors(directory_size(&materialized)?)?,
|
||||
)?;
|
||||
signer.sign_path_in_place(output)?;
|
||||
fs::remove_dir_all(materialized)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn add_resources(
|
||||
bundle: &mut MacOsApplicationBundleBuilder,
|
||||
source: &Path,
|
||||
destination: &Path,
|
||||
) -> Result<(), Box<dyn Error>> {
|
||||
for entry in fs::read_dir(source)? {
|
||||
let entry = entry?;
|
||||
let path = entry.path();
|
||||
let destination = destination.join(entry.file_name());
|
||||
if entry.file_type()?.is_dir() {
|
||||
add_resources(bundle, &path, &destination)?;
|
||||
} else {
|
||||
bundle.add_file_resources(destination, FileEntry::new_from_path(path, false))?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn bundle_version(tag: &str) -> Result<&str, Box<dyn Error>> {
|
||||
let version = tag.strip_prefix('v').unwrap_or(tag);
|
||||
let version = version.split(['-', '+']).next().unwrap_or_default();
|
||||
let parts: Vec<&str> = version.split('.').collect();
|
||||
if !(1..=3).contains(&parts.len())
|
||||
|| parts
|
||||
.iter()
|
||||
.any(|part| part.is_empty() || !part.bytes().all(|byte| byte.is_ascii_digit()))
|
||||
{
|
||||
return Err(format!("release tag is not a numeric Apple bundle version: {tag}").into());
|
||||
}
|
||||
Ok(version)
|
||||
}
|
||||
|
||||
fn directory_size(path: &Path) -> std::io::Result<u64> {
|
||||
fs::read_dir(path)?.try_fold(0_u64, |total, entry| {
|
||||
let entry = entry?;
|
||||
let metadata = entry.metadata()?;
|
||||
let size = if metadata.is_dir() {
|
||||
directory_size(&entry.path())?
|
||||
} else {
|
||||
metadata.len()
|
||||
};
|
||||
total
|
||||
.checked_add(size)
|
||||
.ok_or_else(|| std::io::Error::other("bundle size overflow"))
|
||||
})
|
||||
}
|
||||
|
||||
fn dmg_sectors(bundle_bytes: u64) -> Result<u32, Box<dyn Error>> {
|
||||
let capacity = bundle_bytes
|
||||
.checked_add((bundle_bytes / 10).max(16 * MIB))
|
||||
.ok_or("DMG capacity overflow")?
|
||||
.max(64 * MIB);
|
||||
Ok(u32::try_from(capacity.div_ceil(512))?)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn release_inputs_produce_valid_bundle_metadata_and_capacity() {
|
||||
assert_eq!(bundle_version("v1.2.3").unwrap(), "1.2.3");
|
||||
assert_eq!(bundle_version("2.0.0-rc.1").unwrap(), "2.0.0");
|
||||
assert!(bundle_version("nightly").is_err());
|
||||
assert!(bundle_version("1.2.3.4").is_err());
|
||||
let sectors = dmg_sectors(100 * MIB).unwrap();
|
||||
assert!(u64::from(sectors) * 512 >= 116 * MIB);
|
||||
assert_eq!(dmg_sectors(1).unwrap(), 131_072);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user