// Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html //! Munge the output of PyOxidizer into a macOS app bundle, and combine it //! with our other runtime dependencies. use std::{ os::unix::prelude::PermissionsExt, path::{Path, PathBuf}, process::Command, str::FromStr, }; use anyhow::{bail, Context, Result}; use apple_bundles::MacOsApplicationBundleBuilder; use plist::Value; use tugger_file_manifest::FileEntry; use walkdir::WalkDir; const CODESIGN_ARGS: &[&str] = &["-vvvv", "-o", "runtime", "-s", "Developer ID Application:"]; #[derive(Clone, Copy, Debug)] enum Variant { StandardX86, StandardArm, AlternateX86, } impl FromStr for Variant { type Err = anyhow::Error; fn from_str(s: &str) -> Result { Ok(match s { "qt6_arm64" => Variant::StandardArm, "qt6_amd64" => Variant::StandardX86, "qt5_amd64" => Variant::AlternateX86, other => bail!("unexpected variant: {other}"), }) } } impl Variant { fn output_base(&self) -> &str { match self { Variant::StandardX86 => "qt6_amd64", Variant::StandardArm => "qt6_arm64", Variant::AlternateX86 => "qt5_amd64", } } fn macos_min(&self) -> &str { match self { Variant::StandardX86 => "10.14.4", Variant::StandardArm => "11", Variant::AlternateX86 => "10.13.4", } } fn qt_repo(&self) -> &str { match self { Variant::StandardX86 => "pyqt6.3_mac_bundle_amd64", Variant::StandardArm => "pyqt6.3_mac_bundle_arm64", Variant::AlternateX86 => "pyqt5.14_mac_bundle_amd64", } } fn audio_repo(&self) -> &str { match self { Variant::StandardX86 | Variant::AlternateX86 => "audio_mac_amd64", Variant::StandardArm => "audio_mac_arm64", } } } fn main() -> anyhow::Result<()> { let args: Vec<_> = std::env::args().collect(); let variant: Variant = args.get(1).context("variant")?.parse()?; let bundle_folder = PathBuf::from(args.get(2).context("bundle folder")?); let anki_version = args.get(3).context("anki version")?; let bazel_external = PathBuf::from(args.get(4).context("bazel external folder")?); let plist = get_plist(anki_version); make_app(variant, &bundle_folder, plist, &bazel_external) } fn make_app( variant: Variant, input_folder: &Path, mut plist: plist::Dictionary, bazel_external: &Path, ) -> Result<()> { let output_folder = input_folder .with_file_name("app") .join(variant.output_base()) .join("Anki.app"); if output_folder.exists() { std::fs::remove_dir_all(&output_folder)?; } std::fs::create_dir_all(&output_folder)?; let mut builder = MacOsApplicationBundleBuilder::new("Anki")?; plist.insert( "LSMinimumSystemVersion".into(), Value::from(variant.macos_min()), ); builder.set_info_plist_from_dictionary(plist)?; builder.add_file_resources("Assets.car", &include_bytes!("../icon/Assets.car")[..])?; for entry in WalkDir::new(&input_folder) .into_iter() .map(Result::unwrap) .filter(|e| !e.file_type().is_dir()) { let path = entry.path(); let entry = FileEntry::try_from(path)?; let relative_path = path.strip_prefix(&input_folder)?; let path_str = relative_path.to_str().unwrap(); if path_str.contains("libankihelper") { builder.add_file_macos("libankihelper.dylib", entry)?; } else if path_str.contains("aqt/data") { builder.add_file_resources(relative_path.strip_prefix("lib").unwrap(), entry)?; } else { if path_str.contains("__pycache__") { continue; } builder.add_file_macos(relative_path, entry)?; } } let dry_run = false; if dry_run { for file in builder.files().iter_files() { println!("{}", file.path_string()); } } else { builder.files().materialize_files(&output_folder)?; fix_rpath(output_folder.join("Contents/MacOS/anki"))?; codesign_python_libs(&output_folder)?; copy_in_audio(&output_folder, variant, bazel_external)?; copy_in_qt(&output_folder, variant, bazel_external)?; codesign_app(&output_folder)?; fixup_perms(&output_folder)?; } Ok(()) } /// The bundle builder writes some files without world read/execute perms, /// which prevents them from being opened by a non-admin user. fn fixup_perms(dir: &Path) -> Result<()> { let status = Command::new("find") .arg(dir) .args(["-not", "-perm", "-a=r", "-exec", "chmod", "a+r", "{}", ";"]) .status()?; if !status.success() { bail!("error setting perms"); } std::fs::set_permissions( dir.join("Contents/MacOS/anki"), PermissionsExt::from_mode(0o755), )?; Ok(()) } /// Copy everything at the provided path into the Contents/ folder of our app. /// Excludes standard Bazel repo files. fn extend_app_contents(source: &Path, bundle_dir: &Path) -> Result<()> { let status = Command::new("rsync") .arg("-a") .args(["--exclude", "BUILD.bazel", "--exclude", "WORKSPACE"]) .arg(format!("{}/", source.to_string_lossy())) .arg(bundle_dir.join("Contents/")) .status()?; if !status.success() { bail!("error syncing {source:?}"); } Ok(()) } fn copy_in_audio(bundle_dir: &Path, variant: Variant, bazel_external: &Path) -> Result<()> { println!("Copying in audio..."); extend_app_contents(&bazel_external.join(variant.audio_repo()), bundle_dir) } fn copy_in_qt(bundle_dir: &Path, variant: Variant, bazel_external: &Path) -> Result<()> { println!("Copying in Qt..."); extend_app_contents(&bazel_external.join(variant.qt_repo()), bundle_dir) } fn codesign_file(path: &Path, extra_args: &[&str]) -> Result<()> { if option_env!("ANKI_CODESIGN").is_some() { let status = Command::new("codesign") .args(CODESIGN_ARGS) .args(extra_args) .arg(path.to_str().unwrap()) .status()?; if !status.success() { bail!("codesign failed"); } } Ok(()) } fn codesign_python_libs(bundle_dir: &PathBuf) -> Result<()> { for entry in glob::glob( bundle_dir .join("Contents/MacOS/lib/**/*.so") .to_str() .unwrap(), )? { let entry = entry?; codesign_file(&entry, &[])?; } codesign_file(&bundle_dir.join("Contents/MacOS/libankihelper.dylib"), &[]) } fn codesign_app(bundle_dir: &PathBuf) -> Result<()> { codesign_file(bundle_dir, &["--entitlements", "entitlements.python.xml"]) } fn fix_rpath(exe_path: PathBuf) -> Result<()> { let status = Command::new("install_name_tool") .arg("-add_rpath") .arg("@executable_path/../Frameworks") .arg(exe_path.to_str().unwrap()) .status()?; assert!(status.success()); Ok(()) } fn get_plist(anki_version: &str) -> plist::Dictionary { let reader = std::io::Cursor::new(include_bytes!("Info.plist")); let mut plist = plist::Value::from_reader(reader) .unwrap() .into_dictionary() .unwrap(); plist.insert( "CFBundleShortVersionString".into(), Value::from(anki_version), ); plist }