appbundle 0.3.0

Library for creating and signing appbundles.
Documentation
use anyhow::{Context, Result};
use apple_codesign::dmg::DmgSigner;
use apple_codesign::notarization::{
    notary_api::SubmissionResponseStatus, NotarizationUpload, Notarizer,
};
use apple_codesign::stapling::Stapler;
use apple_codesign::{BundleSigner, CodeSignatureFlags, SettingsScope, SigningSettings};
use icns::{IconFamily, Image};
use pkcs8::EncodePrivateKey;
use plist::Value;
use rasn_cms::{ContentInfo, SignedData};
use std::fs::{File, OpenOptions};
use std::io::{BufWriter, Cursor};
use std::path::{Path, PathBuf};
use std::time::{Duration, Instant};
use x509_certificate::{CapturedX509Certificate, InMemorySigningKeyPair};
use xcommon::{Scaler, ScalerOpts, Signer};

mod info;

pub use info::InfoPlist;

const MACOS_ICON_SIZES: [u32; 6] = [16, 32, 64, 128, 256, 512];
const IOS_ICON_SIZES: [u32; 7] = [58, 76, 80, 120, 152, 167, 1024];

pub struct AppBundle {
    appdir: PathBuf,
    info: InfoPlist,
    entitlements: Option<Value>,
    development: bool,
}

impl AppBundle {
    pub fn new(build_dir: &Path, info: InfoPlist) -> Result<Self> {
        anyhow::ensure!(info.cf_bundle_name.is_some(), "missing info.name");
        let appdir = build_dir.join(format!("{}.app", info.cf_bundle_name.as_ref().unwrap()));
        std::fs::remove_dir_all(&appdir).ok();
        std::fs::create_dir_all(&appdir)?;
        Ok(Self {
            appdir,
            info,
            entitlements: None,
            development: false,
        })
    }

    pub fn appdir(&self) -> &Path {
        &self.appdir
    }

    fn ios(&self) -> bool {
        self.info.ls_requires_ios == Some(true)
    }

    fn content_dir(&self) -> PathBuf {
        if self.ios() {
            self.appdir.to_path_buf()
        } else {
            self.appdir.join("Contents")
        }
    }

    fn resource_dir(&self) -> PathBuf {
        if self.ios() {
            self.content_dir()
        } else {
            self.content_dir().join("Resources")
        }
    }

    fn framework_dir(&self) -> PathBuf {
        self.content_dir().join("Frameworks")
    }

    fn executable_dir(&self) -> PathBuf {
        let contents = self.content_dir();
        if self.ios() {
            contents
        } else {
            contents.join("MacOS")
        }
    }

    pub fn add_icon(&mut self, path: &Path) -> Result<()> {
        let scaler = Scaler::open(path)?;
        let sizes = if self.ios() {
            &IOS_ICON_SIZES[..]
        } else {
            &MACOS_ICON_SIZES[..]
        };

        if self.ios() {
            for size in sizes {
                let filename = format!("icon_{}x{}.png", size, size);
                let icon = self.appdir.join(&filename);
                let mut icon = BufWriter::new(File::create(icon)?);
                scaler.write(&mut icon, ScalerOpts::new(*size))?;
                self.info.cf_bundle_icon_files.push(filename);
            }
        } else {
            let mut icns = IconFamily::new();
            let mut buf = vec![];
            for size in sizes {
                buf.clear();
                let mut cursor = Cursor::new(&mut buf);
                scaler.write(&mut cursor, ScalerOpts::new(*size))?;
                let image = Image::read_png(&*buf)?;
                icns.add_icon(&image)?;
            }
            let path = self.resource_dir().join("AppIcon.icns");
            if let Some(parent) = path.parent() {
                std::fs::create_dir_all(parent)?;
            }
            icns.write(BufWriter::new(File::create(path)?))?;
            self.info.cf_bundle_icon_file = Some("AppIcon".to_string());
        }

        Ok(())
    }

    pub fn add_file(&self, path: &Path, dest: &Path) -> Result<()> {
        let dest = self.resource_dir().join(dest);
        if let Some(parent) = dest.parent() {
            std::fs::create_dir_all(parent)?;
        }
        std::fs::copy(path, dest)?;
        Ok(())
    }

    pub fn add_directory(&self, source: &Path, dest: &Path) -> Result<()> {
        let resource_dir = self.resource_dir().join(dest);
        std::fs::create_dir_all(&resource_dir)?;
        xcommon::copy_dir_all(source, &resource_dir)?;
        Ok(())
    }

    pub fn add_executable(&mut self, path: &Path) -> Result<()> {
        let file_name = path.file_name().unwrap().to_str().unwrap();
        let exe_dir = self.executable_dir();
        std::fs::create_dir_all(&exe_dir)?;
        std::fs::copy(path, exe_dir.join(file_name))?;
        if self.info.cf_bundle_executable.is_none() {
            self.info.cf_bundle_executable = Some(file_name.to_string());
        }
        Ok(())
    }

    pub fn add_framework(&self, path: &Path) -> Result<()> {
        let framework_dir = self.framework_dir().join(path.file_name().unwrap());
        std::fs::create_dir_all(&framework_dir)?;
        xcommon::copy_dir_all(path, &framework_dir)?;
        Ok(())
    }

    pub fn add_lib(&self, path: &Path) -> Result<()> {
        let file_name = path.file_name().unwrap();
        let framework_dir = self.framework_dir();
        std::fs::create_dir_all(&framework_dir)?;
        std::fs::copy(path, framework_dir.join(file_name))?;
        Ok(())
    }

    pub fn add_provisioning_profile(&mut self, raw_profile: &[u8]) -> Result<()> {
        let info = rasn::der::decode::<ContentInfo>(raw_profile)
            .map_err(|err| anyhow::anyhow!("{}", err))?;
        let data = rasn::der::decode::<SignedData>(info.content.as_bytes())
            .map_err(|err| anyhow::anyhow!("{}", err))?;
        let xml = data.encap_content_info.content.as_ref().unwrap().as_ref();
        let profile: plist::Value = plist::from_reader_xml(xml)?;
        log::debug!("provisioning profile: {:?}", profile);
        let dict = profile
            .as_dictionary()
            .context("invalid provisioning profile")?;
        let entitlements = dict
            .get("Entitlements")
            .context("missing key Entitlements")?
            .clone();
        let app_id = entitlements
            .as_dictionary()
            .context("invalid entitlements")?
            .get("application-identifier")
            .context("missing application identifier")?
            .as_string()
            .context("missing application identifier")?;
        let bundle_prefix = app_id
            .split_once('.')
            .with_context(|| format!("invalid app id {}", app_id))?
            .1;
        self.development = dict.get("ProvisionedDevices").is_some();

        if let Some(bundle_identifier) = self.info.cf_bundle_identifier.as_ref() {
            let bundle_prefix = if bundle_prefix.ends_with('*') {
                bundle_prefix.strip_suffix('*').unwrap()
            } else {
                bundle_prefix
            };
            anyhow::ensure!(
                bundle_identifier.starts_with(bundle_prefix),
                "bundle identifier mismatch"
            );
        }
        self.entitlements = Some(entitlements);
        std::fs::write(self.appdir().join("embedded.mobileprovision"), raw_profile)?;
        Ok(())
    }

    pub fn finish(&self, signer: Option<Signer>) -> Result<()> {
        let path = self.content_dir().join("Info.plist");
        plist::to_file_xml(path, &self.info)?;

        if let Some(signer) = signer {
            println!("signing {}", self.appdir().display());
            anyhow::ensure!(
                self.info.cf_bundle_identifier.is_some(),
                "missing bundle identifier"
            );
            let mut signing_settings = SigningSettings::default();
            let cert =
                CapturedX509Certificate::from_der(rasn::der::encode(signer.cert()).unwrap())?;
            let secret = signer.key().to_pkcs8_der().unwrap();
            let key = InMemorySigningKeyPair::from_pkcs8_der(secret.as_bytes())?;
            signing_settings.set_signing_key(&key, cert);
            signing_settings.chain_apple_certificates();
            signing_settings
                .set_team_id_from_signing_certificate()
                .context("signing certificate is missing team id")?;
            if self.development {
                signing_settings.set_time_stamp_url("http://timestamp.apple.com/ts01")?;
            }
            if let Some(entitlements) = self.entitlements.as_ref() {
                let mut buf = vec![];
                entitlements.to_writer_xml(&mut buf)?;
                let entitlements = std::str::from_utf8(&buf)?;
                signing_settings.set_entitlements_xml(SettingsScope::Main, entitlements)?;
            }
            if !self.ios() {
                signing_settings
                    .set_code_signature_flags(SettingsScope::Main, CodeSignatureFlags::RUNTIME);
            }
            let bundle_signer = BundleSigner::new_from_path(self.appdir())?;
            bundle_signer.write_signed_bundle(self.appdir(), &signing_settings)?;
        }
        Ok(())
    }

    pub fn sign_dmg(&self, path: &Path, signer: &Signer) -> Result<()> {
        println!("signing {}", path.display());
        let mut f = OpenOptions::new().read(true).write(true).open(path)?;
        let mut signing_settings = SigningSettings::default();
        let cert = CapturedX509Certificate::from_der(rasn::der::encode(signer.cert()).unwrap())?;
        let secret = signer.key().to_pkcs8_der().unwrap();
        let key = InMemorySigningKeyPair::from_pkcs8_der(secret.as_bytes())?;
        signing_settings.set_signing_key(&key, cert);
        signing_settings.chain_apple_certificates();
        signing_settings
            .set_team_id_from_signing_certificate()
            .context("signing certificate is missing team id")?;
        signing_settings.set_time_stamp_url("http://timestamp.apple.com/ts01")?;
        signing_settings.set_binary_identifier(
            SettingsScope::Main,
            self.info.cf_bundle_identifier.as_ref().unwrap(),
        );
        DmgSigner::default().sign_file(&signing_settings, &mut f)?;
        Ok(())
    }
}

pub fn app_bundle_identifier(bundle: &Path) -> Result<String> {
    let plist = if bundle.join("Contents").exists() {
        bundle.join("Contents").join("Info.plist")
    } else {
        bundle.join("Info.plist")
    };
    let info = std::fs::read(plist)?;
    let info: plist::Value = plist::from_reader_xml(&*info)?;
    let bundle_identifier = info
        .as_dictionary()
        .context("invalid Info.plist")?
        .get("CFBundleIdentifier")
        .context("invalid Info.plist")?
        .as_string()
        .context("invalid Info.plist")?;
    Ok(bundle_identifier.to_string())
}

pub fn notarize(path: &Path, api_key: &Path) -> Result<()> {
    println!("notarizing {}", path.display());
    let notarizer = Notarizer::from_api_key(api_key)?;
    let submission_id =
        if let NotarizationUpload::UploadId(submission_id) = notarizer.notarize_path(path, None)? {
            submission_id
        } else {
            anyhow::bail!("impossible");
        };
    println!("submission id: {}", submission_id);
    let start_time = Instant::now();
    loop {
        let resp = notarizer.get_submission(&submission_id)?;
        let status = resp.data.attributes.status;
        let elapsed = start_time.elapsed();
        println!("poll state after {}s: {:?}", elapsed.as_secs(), status,);
        if status != SubmissionResponseStatus::InProgress {
            let log = notarizer.fetch_notarization_log(&submission_id)?;
            println!("{}", log);
            resp.into_result()?;
            break;
        }
        std::thread::sleep(Duration::from_secs(3));
    }
    let stapler = Stapler::new()?;
    stapler.staple_path(path)?;
    Ok(())
}