Browse Source

feat(cli): iOS signing for CI usage (#9963)

* feat(cli): iOS signing for CI usage

* license headers

* change file

* chore: support more cert types

* xplicit method arg

* keep keychain alive

* fix early keychano drop

* set team id

* use common name as cert name
Lucas Fernandes Nogueira 1 year ago
parent
commit
7c7fa0964d

+ 6 - 2
.changes/config.json

@@ -188,10 +188,14 @@
       "path": "./core/tauri-utils",
       "manager": "rust"
     },
+    "tauri-macos-sign": {
+      "path": "./tooling/macos-sign",
+      "manager": "rust"
+    },
     "tauri-bundler": {
       "path": "./tooling/bundler",
       "manager": "rust",
-      "dependencies": ["tauri-utils"]
+      "dependencies": ["tauri-utils", "tauri-macos-sign"]
     },
     "tauri-runtime": {
       "path": "./core/tauri-runtime",
@@ -262,7 +266,7 @@
     "tauri-cli": {
       "path": "./tooling/cli",
       "manager": "rust",
-      "dependencies": ["tauri-bundler", "tauri-utils"],
+      "dependencies": ["tauri-bundler", "tauri-utils", "tauri-macos-sign"],
       "postversion": [
         "cargo check",
         "cargo build --manifest-path ../../core/tauri-config-schema/Cargo.toml"

+ 6 - 0
.changes/ios-export-method.md

@@ -0,0 +1,6 @@
+---
+"tauri-cli": patch:feat
+"@tauri-apps/cli": patch:feat
+---
+
+Added `--method` argument for `ios build` to select the export options' method.

+ 6 - 0
.changes/ios-signing.md

@@ -0,0 +1,6 @@
+---
+"tauri-cli": patch:feat
+"@tauri-apps/cli": patch:feat
+---
+
+Setup iOS signing by reading `IOS_CERTIFICATE`, `IOS_CERTIFICATE_PASSWORD` and `IOS_MOBILE_PROVISION` environment variables.

+ 5 - 0
.changes/macos-sign-initial-release.md

@@ -0,0 +1,5 @@
+---
+"tauri-macos-sign": minor:feat
+---
+
+Initial release.

+ 1 - 0
tooling/bundler/Cargo.toml

@@ -58,6 +58,7 @@ glob = "0.3"
 icns = { package = "tauri-icns", version = "0.1" }
 time = { version = "0.3", features = [ "formatting" ] }
 plist = "1"
+tauri-macos-sign = { version = "0.0.0", path = "../macos-sign" }
 
 [target."cfg(any(target_os = \"macos\", target_os = \"windows\"))".dependencies]
 regex = "1"

+ 2 - 2
tooling/bundler/src/bundle/macos/app.rs

@@ -118,12 +118,12 @@ pub fn bundle_project(settings: &Settings) -> crate::Result<Vec<PathBuf>> {
     remove_extra_attr(&app_bundle_path)?;
 
     // sign application
-    sign(sign_paths, identity, settings)?;
+    let keychain = sign(sign_paths, identity, settings)?;
 
     // notarization is required for distribution
     match notarize_auth() {
       Ok(auth) => {
-        notarize(app_bundle_path.clone(), auth, settings)?;
+        notarize(&keychain, app_bundle_path.clone(), &auth)?;
       }
       Err(e) => {
         if matches!(e, NotarizeAuthError::MissingTeamId) {

+ 32 - 366
tooling/bundler/src/bundle/macos/sign.rs

@@ -6,390 +6,53 @@
 use std::{
   env::{var, var_os},
   ffi::OsString,
-  fs::File,
-  io::prelude::*,
-  path::PathBuf,
-  process::Command,
+  path::{Path, PathBuf},
 };
 
-use crate::{bundle::common::CommandExt, Settings};
-use anyhow::Context;
-use serde::Deserialize;
-
-const KEYCHAIN_ID: &str = "tauri-build.keychain";
-const KEYCHAIN_PWD: &str = "tauri-build";
-
-// Import certificate from ENV variables.
-// APPLE_CERTIFICATE is the p12 certificate base64 encoded.
-// By example you can use; openssl base64 -in MyCertificate.p12 -out MyCertificate-base64.txt
-// Then use the value of the base64 in APPLE_CERTIFICATE env variable.
-// You need to set APPLE_CERTIFICATE_PASSWORD to the password you set when you exported your certificate.
-// https://help.apple.com/xcode/mac/current/#/dev154b28f09 see: `Export a signing certificate`
-pub fn setup_keychain(
-  certificate_encoded: OsString,
-  certificate_password: OsString,
-) -> crate::Result<()> {
-  // we delete any previous version of our keychain if present
-  delete_keychain();
-  log::info!("setup keychain from environment variables...");
-
-  let keychain_list_output = Command::new("security")
-    .args(["list-keychain", "-d", "user"])
-    .output()?;
-
-  let tmp_dir = tempfile::tempdir()?;
-  let cert_path = tmp_dir
-    .path()
-    .join("cert.p12")
-    .to_string_lossy()
-    .to_string();
-  let cert_path_tmp = tmp_dir
-    .path()
-    .join("cert.p12.tmp")
-    .to_string_lossy()
-    .to_string();
-  let certificate_encoded = certificate_encoded
-    .to_str()
-    .expect("failed to convert APPLE_CERTIFICATE to string")
-    .as_bytes();
-
-  let certificate_password = certificate_password
-    .to_str()
-    .expect("failed to convert APPLE_CERTIFICATE_PASSWORD to string")
-    .to_string();
-
-  // as certificate contain whitespace decoding may be broken
-  // https://github.com/marshallpierce/rust-base64/issues/105
-  // we'll use builtin base64 command from the OS
-  let mut tmp_cert = File::create(cert_path_tmp.clone())?;
-  tmp_cert.write_all(certificate_encoded)?;
-
-  Command::new("base64")
-    .args(["--decode", "-i", &cert_path_tmp, "-o", &cert_path])
-    .output_ok()
-    .context("failed to decode certificate")?;
-
-  Command::new("security")
-    .args(["create-keychain", "-p", KEYCHAIN_PWD, KEYCHAIN_ID])
-    .output_ok()
-    .context("failed to create keychain")?;
-
-  Command::new("security")
-    .args(["unlock-keychain", "-p", KEYCHAIN_PWD, KEYCHAIN_ID])
-    .output_ok()
-    .context("failed to set unlock keychain")?;
-
-  Command::new("security")
-    .args([
-      "import",
-      &cert_path,
-      "-k",
-      KEYCHAIN_ID,
-      "-P",
-      &certificate_password,
-      "-T",
-      "/usr/bin/codesign",
-      "-T",
-      "/usr/bin/pkgbuild",
-      "-T",
-      "/usr/bin/productbuild",
-    ])
-    .output_ok()
-    .context("failed to import keychain certificate")?;
-
-  Command::new("security")
-    .args(["set-keychain-settings", "-t", "3600", "-u", KEYCHAIN_ID])
-    .output_ok()
-    .context("failed to set keychain settings")?;
-
-  Command::new("security")
-    .args([
-      "set-key-partition-list",
-      "-S",
-      "apple-tool:,apple:,codesign:",
-      "-s",
-      "-k",
-      KEYCHAIN_PWD,
-      KEYCHAIN_ID,
-    ])
-    .output_ok()
-    .context("failed to set keychain settings")?;
-
-  let current_keychains = String::from_utf8_lossy(&keychain_list_output.stdout)
-    .split('\n')
-    .map(|line| {
-      line
-        .trim_matches(|c: char| c.is_whitespace() || c == '"')
-        .to_string()
-    })
-    .filter(|l| !l.is_empty())
-    .collect::<Vec<String>>();
-
-  Command::new("security")
-    .args(["list-keychain", "-d", "user", "-s"])
-    .args(current_keychains)
-    .arg(KEYCHAIN_ID)
-    .output_ok()
-    .context("failed to list keychain")?;
-
-  Ok(())
-}
-
-pub fn delete_keychain() {
-  // delete keychain if needed and skip any error
-  let _ = Command::new("security")
-    .arg("delete-keychain")
-    .arg(KEYCHAIN_ID)
-    .output_ok();
-}
+use crate::Settings;
 
 pub struct SignTarget {
   pub path: PathBuf,
   pub is_an_executable: bool,
 }
 
-pub fn sign(targets: Vec<SignTarget>, identity: &str, settings: &Settings) -> crate::Result<()> {
+pub fn sign(
+  targets: Vec<SignTarget>,
+  identity: &str,
+  settings: &Settings,
+) -> crate::Result<tauri_macos_sign::Keychain> {
   log::info!(action = "Signing"; "with identity \"{}\"", identity);
 
-  let setup_keychain = if let (Some(certificate_encoded), Some(certificate_password)) = (
+  let keychain = if let (Some(certificate_encoded), Some(certificate_password)) = (
     var_os("APPLE_CERTIFICATE"),
     var_os("APPLE_CERTIFICATE_PASSWORD"),
   ) {
     // setup keychain allow you to import your certificate
     // for CI build
-    setup_keychain(certificate_encoded, certificate_password)?;
-    true
+    tauri_macos_sign::Keychain::with_certificate(&certificate_encoded, &certificate_password)?
   } else {
-    false
+    tauri_macos_sign::Keychain::with_signing_identity(identity)
   };
 
   log::info!("Signing app bundle...");
 
   for target in targets {
-    try_sign(
-      target.path,
-      identity,
-      settings,
-      target.is_an_executable,
-      setup_keychain,
+    keychain.sign(
+      &target.path,
+      settings.macos().entitlements.as_ref().map(Path::new),
+      target.is_an_executable && settings.macos().hardened_runtime,
     )?;
   }
 
-  if setup_keychain {
-    // delete the keychain again after signing
-    delete_keychain();
-  }
-
-  Ok(())
-}
-
-fn try_sign(
-  path_to_sign: PathBuf,
-  identity: &str,
-  settings: &Settings,
-  is_an_executable: bool,
-  tauri_keychain: bool,
-) -> crate::Result<()> {
-  log::info!(action = "Signing"; "{}", path_to_sign.display());
-
-  let mut args = vec!["--force", "-s", identity];
-
-  if tauri_keychain {
-    args.push("--keychain");
-    args.push(KEYCHAIN_ID);
-  }
-
-  if let Some(entitlements_path) = &settings.macos().entitlements {
-    log::info!("using entitlements file at {}", entitlements_path);
-    args.push("--entitlements");
-    args.push(entitlements_path);
-  }
-
-  // add runtime flag by default
-
-  if is_an_executable && settings.macos().hardened_runtime {
-    args.push("--options");
-    args.push("runtime");
-  }
-
-  Command::new("codesign")
-    .args(args)
-    .arg(path_to_sign)
-    .output_ok()
-    .context("failed to sign app")?;
-
-  Ok(())
-}
-
-#[derive(Deserialize)]
-struct NotarytoolSubmitOutput {
-  id: String,
-  status: String,
-  message: String,
+  Ok(keychain)
 }
 
 pub fn notarize(
+  keychain: &tauri_macos_sign::Keychain,
   app_bundle_path: PathBuf,
-  auth: NotarizeAuth,
-  settings: &Settings,
+  credentials: &tauri_macos_sign::AppleNotarizationCredentials,
 ) -> crate::Result<()> {
-  let bundle_stem = app_bundle_path
-    .file_stem()
-    .expect("failed to get bundle filename");
-
-  let tmp_dir = tempfile::tempdir()?;
-  let zip_path = tmp_dir
-    .path()
-    .join(format!("{}.zip", bundle_stem.to_string_lossy()));
-  let zip_args = vec![
-    "-c",
-    "-k",
-    "--keepParent",
-    "--sequesterRsrc",
-    app_bundle_path
-      .to_str()
-      .expect("failed to convert bundle_path to string"),
-    zip_path
-      .to_str()
-      .expect("failed to convert zip_path to string"),
-  ];
-
-  // use ditto to create a PKZip almost identical to Finder
-  // this remove almost 99% of false alarm in notarization
-  Command::new("ditto")
-    .args(zip_args)
-    .output_ok()
-    .context("failed to zip app with ditto")?;
-
-  // sign the zip file
-  if let Some(identity) = &settings.macos().signing_identity {
-    sign(
-      vec![SignTarget {
-        path: zip_path.clone(),
-        is_an_executable: false,
-      }],
-      identity,
-      settings,
-    )?;
-  };
-
-  let notarize_args = vec![
-    "notarytool",
-    "submit",
-    zip_path
-      .to_str()
-      .expect("failed to convert zip_path to string"),
-    "--wait",
-    "--output-format",
-    "json",
-  ];
-
-  log::info!(action = "Notarizing"; "{}", app_bundle_path.display());
-
-  let output = Command::new("xcrun")
-    .args(notarize_args)
-    .notarytool_args(&auth)
-    .output_ok()
-    .context("failed to upload app to Apple's notarization servers.")?;
-
-  if !output.status.success() {
-    return Err(anyhow::anyhow!("failed to notarize app").into());
-  }
-
-  let output_str = String::from_utf8_lossy(&output.stdout);
-  if let Ok(submit_output) = serde_json::from_str::<NotarytoolSubmitOutput>(&output_str) {
-    let log_message = format!(
-      "Finished with status {} for id {} ({})",
-      submit_output.status, submit_output.id, submit_output.message
-    );
-    if submit_output.status == "Accepted" {
-      log::info!(action = "Notarizing"; "{}", log_message);
-      staple_app(app_bundle_path)?;
-      Ok(())
-    } else if let Ok(output) = Command::new("xcrun")
-      .args(["notarytool", "log"])
-      .arg(&submit_output.id)
-      .notarytool_args(&auth)
-      .output_ok()
-    {
-      Err(
-        anyhow::anyhow!(
-          "{log_message}\nLog:\n{}",
-          String::from_utf8_lossy(&output.stdout)
-        )
-        .into(),
-      )
-    } else {
-      Err(anyhow::anyhow!("{log_message}").into())
-    }
-  } else {
-    Err(anyhow::anyhow!("failed to parse notarytool output as JSON: `{output_str}`").into())
-  }
-}
-
-fn staple_app(mut app_bundle_path: PathBuf) -> crate::Result<()> {
-  let app_bundle_path_clone = app_bundle_path.clone();
-  let filename = app_bundle_path_clone
-    .file_name()
-    .expect("failed to get bundle filename")
-    .to_str()
-    .expect("failed to convert bundle filename to string");
-
-  app_bundle_path.pop();
-
-  Command::new("xcrun")
-    .args(vec!["stapler", "staple", "-v", filename])
-    .current_dir(app_bundle_path)
-    .output_ok()
-    .context("failed to staple app.")?;
-
-  Ok(())
-}
-
-pub enum NotarizeAuth {
-  AppleId {
-    apple_id: OsString,
-    password: OsString,
-    team_id: OsString,
-  },
-  ApiKey {
-    key: OsString,
-    key_path: PathBuf,
-    issuer: OsString,
-  },
-}
-
-pub trait NotarytoolCmdExt {
-  fn notarytool_args(&mut self, auth: &NotarizeAuth) -> &mut Self;
-}
-
-impl NotarytoolCmdExt for Command {
-  fn notarytool_args(&mut self, auth: &NotarizeAuth) -> &mut Self {
-    match auth {
-      NotarizeAuth::AppleId {
-        apple_id,
-        password,
-        team_id,
-      } => self
-        .arg("--apple-id")
-        .arg(apple_id)
-        .arg("--password")
-        .arg(password)
-        .arg("--team-id")
-        .arg(team_id),
-      NotarizeAuth::ApiKey {
-        key,
-        key_path,
-        issuer,
-      } => self
-        .arg("--key-id")
-        .arg(key)
-        .arg("--key")
-        .arg(key_path)
-        .arg("--issuer")
-        .arg(issuer),
-    }
-  }
+  tauri_macos_sign::notarize(keychain, &app_bundle_path, credentials).map_err(Into::into)
 }
 
 #[derive(Debug, thiserror::Error)]
@@ -402,26 +65,29 @@ pub enum NotarizeAuthError {
   Anyhow(#[from] anyhow::Error),
 }
 
-pub fn notarize_auth() -> Result<NotarizeAuth, NotarizeAuthError> {
+pub fn notarize_auth() -> Result<tauri_macos_sign::AppleNotarizationCredentials, NotarizeAuthError>
+{
   match (
     var_os("APPLE_ID"),
     var_os("APPLE_PASSWORD"),
     var_os("APPLE_TEAM_ID"),
   ) {
-    (Some(apple_id), Some(password), Some(team_id)) => Ok(NotarizeAuth::AppleId {
-      apple_id,
-      password,
-      team_id,
-    }),
+    (Some(apple_id), Some(password), Some(team_id)) => {
+      Ok(tauri_macos_sign::AppleNotarizationCredentials::AppleId {
+        apple_id,
+        password,
+        team_id,
+      })
+    }
     (Some(_apple_id), Some(_password), None) => Err(NotarizeAuthError::MissingTeamId),
     _ => {
       match (var_os("APPLE_API_KEY"), var_os("APPLE_API_ISSUER"), var("APPLE_API_KEY_PATH")) {
-        (Some(key), Some(issuer), Ok(key_path)) => {
-          Ok(NotarizeAuth::ApiKey { key, key_path: key_path.into(), issuer })
+        (Some(key_id), Some(issuer), Ok(key_path)) => {
+          Ok(tauri_macos_sign::AppleNotarizationCredentials::ApiKey { key_id, key: tauri_macos_sign::ApiKey::Path( key_path.into()), issuer })
         },
-        (Some(key), Some(issuer), Err(_)) => {
+        (Some(key_id), Some(issuer), Err(_)) => {
           let mut api_key_file_name = OsString::from("AuthKey_");
-          api_key_file_name.push(&key);
+          api_key_file_name.push(&key_id);
           api_key_file_name.push(".p8");
           let mut key_path = None;
 
@@ -440,7 +106,7 @@ pub fn notarize_auth() -> Result<NotarizeAuth, NotarizeAuthError> {
           }
 
           if let Some(key_path) = key_path {
-          Ok(NotarizeAuth::ApiKey { key, key_path, issuer })
+          Ok(tauri_macos_sign::AppleNotarizationCredentials::ApiKey { key_id, key: tauri_macos_sign::ApiKey::Path(key_path), issuer })
           } else {
             Err(anyhow::anyhow!("could not find API key file. Please set the APPLE_API_KEY_PATH environment variables to the path to the {api_key_file_name:?} file").into())
           }

+ 39 - 0
tooling/cli/Cargo.lock

@@ -1161,6 +1161,16 @@ dependencies = [
  "dirs-sys",
 ]
 
+[[package]]
+name = "dirs-next"
+version = "2.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b98cf8ebf19c3d1b223e151f99a4f9f0690dca41414773390fc824184ac833e1"
+dependencies = [
+ "cfg-if",
+ "dirs-sys-next",
+]
+
 [[package]]
 name = "dirs-sys"
 version = "0.4.1"
@@ -1173,6 +1183,17 @@ dependencies = [
  "windows-sys 0.48.0",
 ]
 
+[[package]]
+name = "dirs-sys-next"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4ebda144c4fe02d1f7ea1a7d9641b6fc6b580adcfa024ae48797ecdeb6825b4d"
+dependencies = [
+ "libc",
+ "redox_users",
+ "winapi",
+]
+
 [[package]]
 name = "dsa"
 version = "0.6.3"
@@ -5081,6 +5102,7 @@ dependencies = [
  "strsim 0.11.0",
  "tar",
  "tauri-icns",
+ "tauri-macos-sign",
  "tauri-utils 2.0.0-beta.18",
  "tempfile",
  "thiserror",
@@ -5151,6 +5173,7 @@ dependencies = [
  "sublime_fuzzy",
  "tauri-bundler",
  "tauri-icns",
+ "tauri-macos-sign",
  "tauri-utils 1.5.4",
  "tauri-utils 2.0.0-beta.18",
  "tokio",
@@ -5182,6 +5205,22 @@ dependencies = [
  "png",
 ]
 
+[[package]]
+name = "tauri-macos-sign"
+version = "0.0.0"
+dependencies = [
+ "anyhow",
+ "dirs-next",
+ "once-cell-regex",
+ "os_pipe",
+ "plist",
+ "rand 0.8.5",
+ "serde",
+ "serde_json",
+ "tempfile",
+ "x509-certificate",
+]
+
 [[package]]
 name = "tauri-utils"
 version = "1.5.4"

+ 1 - 0
tooling/cli/Cargo.toml

@@ -112,6 +112,7 @@ libc = "0.2"
 
 [target."cfg(target_os = \"macos\")".dependencies]
 plist = "1"
+tauri-macos-sign = { version = "0.0.0", path = "../macos-sign" }
 
 [features]
 default = [ "rustls" ]

+ 44 - 2
tooling/cli/src/mobile/init.rs

@@ -24,6 +24,7 @@ use cargo_mobile2::{
 use handlebars::{
   Context, Handlebars, Helper, HelperResult, Output, RenderContext, RenderError, RenderErrorReason,
 };
+use serde::Serialize;
 
 use std::{env::var_os, path::PathBuf};
 
@@ -35,8 +36,23 @@ pub fn command(
 ) -> Result<()> {
   let wrapper = TextWrapper::default();
 
-  exec(target, &wrapper, ci, reinstall_deps, skip_targets_install)
-    .map_err(|e| anyhow::anyhow!("{:#}", e))?;
+  let tauri_init_config = TauriInitConfig {
+    #[cfg(target_os = "macos")]
+    ios: {
+      let (keychain, provisioning_profile) = super::ios::signing_from_env()?;
+      super::ios::init_config(keychain.as_ref(), provisioning_profile.as_ref())?
+    },
+  };
+
+  exec(
+    target,
+    &wrapper,
+    &tauri_init_config,
+    ci,
+    reinstall_deps,
+    skip_targets_install,
+  )
+  .map_err(|e| anyhow::anyhow!("{:#}", e))?;
   Ok(())
 }
 
@@ -77,9 +93,33 @@ pub fn configure_cargo(
   dot_cargo.write(app).map_err(Into::into)
 }
 
+#[cfg(target_os = "macos")]
+#[derive(Serialize)]
+pub enum CodeSignStyle {
+  Manual,
+  Automatic,
+}
+
+#[cfg(target_os = "macos")]
+#[derive(Serialize)]
+#[serde(rename_all = "kebab-case")]
+pub struct IosInitConfig {
+  pub code_sign_style: CodeSignStyle,
+  pub code_sign_identity: Option<String>,
+  pub team_id: Option<String>,
+  pub provisioning_profile_uuid: Option<String>,
+}
+
+#[derive(Serialize)]
+pub struct TauriInitConfig {
+  #[cfg(target_os = "macos")]
+  ios: IosInitConfig,
+}
+
 pub fn exec(
   target: Target,
   wrapper: &TextWrapper,
+  tauri_init_config: &TauriInitConfig,
   #[allow(unused_variables)] non_interactive: bool,
   #[allow(unused_variables)] reinstall_deps: bool,
   skip_targets_install: bool,
@@ -93,6 +133,8 @@ pub fn exec(
 
   let (handlebars, mut map) = handlebars(&app);
 
+  map.insert("tauri", tauri_init_config);
+
   let mut args = std::env::args_os();
 
   let (binary, mut build_args) = args

+ 90 - 4
tooling/cli/src/mobile/ios/build.rs

@@ -17,7 +17,7 @@ use crate::{
   mobile::{write_options, CliOptions},
   ConfigValue, Result,
 };
-use clap::{ArgAction, Parser};
+use clap::{ArgAction, Parser, ValueEnum};
 
 use anyhow::Context;
 use cargo_mobile2::{
@@ -63,6 +63,41 @@ pub struct Options {
   /// Skip prompting for values
   #[clap(long, env = "CI")]
   pub ci: bool,
+  /// Describes how Xcode should export the archive.
+  ///
+  /// Use this to create a package ready for the App Store (app-store-connect option) or TestFlight (release-testing option).
+  #[clap(long, value_enum)]
+  pub export_method: Option<ExportMethod>,
+}
+
+#[derive(Debug, Clone, Copy, ValueEnum)]
+pub enum ExportMethod {
+  AppStoreConnect,
+  ReleaseTesting,
+  Debugging,
+}
+
+impl std::fmt::Display for ExportMethod {
+  fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+    match self {
+      Self::AppStoreConnect => write!(f, "app-store-connect"),
+      Self::ReleaseTesting => write!(f, "release-testing"),
+      Self::Debugging => write!(f, "debugging"),
+    }
+  }
+}
+
+impl std::str::FromStr for ExportMethod {
+  type Err = &'static str;
+
+  fn from_str(s: &str) -> Result<Self, Self::Err> {
+    match s {
+      "app-store-connect" => Ok(Self::AppStoreConnect),
+      "release-testing" => Ok(Self::ReleaseTesting),
+      "debugging" => Ok(Self::Debugging),
+      _ => Err("unknown ios target"),
+    }
+  }
 }
 
 impl From<Options> for BuildOptions {
@@ -129,9 +164,9 @@ pub fn command(options: Options, noise_level: NoiseLevel) -> Result<()> {
     .join(config.scheme())
     .join("Info.plist");
   merge_plist(
-    &[
-      tauri_path.join("Info.plist"),
-      tauri_path.join("Info.ios.plist"),
+    vec![
+      tauri_path.join("Info.plist").into(),
+      tauri_path.join("Info.ios.plist").into(),
     ],
     &info_plist_path,
   )?;
@@ -139,6 +174,22 @@ pub fn command(options: Options, noise_level: NoiseLevel) -> Result<()> {
   let mut env = env()?;
   configure_cargo(&app, None)?;
 
+  let (keychain, provisioning_profile) = super::signing_from_env()?;
+  let init_config = super::init_config(keychain.as_ref(), provisioning_profile.as_ref())?;
+  if let Some(export_options_plist) =
+    create_export_options(&app, &init_config, options.export_method)
+  {
+    let export_options_plist_path = config.project_dir().join("ExportOptions.plist");
+
+    merge_plist(
+      vec![
+        export_options_plist_path.clone().into(),
+        export_options_plist.into(),
+      ],
+      &export_options_plist_path,
+    )?;
+  }
+
   let open = options.open;
   let _handle = run_build(
     interface,
@@ -157,6 +208,41 @@ pub fn command(options: Options, noise_level: NoiseLevel) -> Result<()> {
   Ok(())
 }
 
+fn create_export_options(
+  app: &cargo_mobile2::config::app::App,
+  config: &super::super::init::IosInitConfig,
+  export_method: Option<ExportMethod>,
+) -> Option<plist::Value> {
+  let mut plist = plist::Dictionary::new();
+
+  if let Some(method) = export_method {
+    plist.insert("method".to_string(), method.to_string().into());
+  }
+
+  if config.code_sign_identity.is_some() || config.provisioning_profile_uuid.is_some() {
+    plist.insert("signingStyle".to_string(), "manual".into());
+  }
+
+  if let Some(identity) = &config.code_sign_identity {
+    plist.insert("signingCertificate".to_string(), identity.clone().into());
+  }
+
+  if let Some(id) = &config.team_id {
+    plist.insert("teamID".to_string(), id.clone().into());
+  }
+
+  if let Some(profile_uuid) = &config.provisioning_profile_uuid {
+    let mut provisioning_profiles = plist::Dictionary::new();
+    provisioning_profiles.insert(app.reverse_identifier(), profile_uuid.clone().into());
+    plist.insert(
+      "provisioningProfiles".to_string(),
+      provisioning_profiles.into(),
+    );
+  }
+
+  (!plist.is_empty()).then(|| plist.into())
+}
+
 fn run_build(
   interface: AppInterface,
   options: Options,

+ 3 - 3
tooling/cli/src/mobile/ios/dev.rs

@@ -148,9 +148,9 @@ fn run_command(options: Options, noise_level: NoiseLevel) -> Result<()> {
     .join(config.scheme())
     .join("Info.plist");
   merge_plist(
-    &[
-      tauri_path.join("Info.plist"),
-      tauri_path.join("Info.ios.plist"),
+    vec![
+      tauri_path.join("Info.plist").into(),
+      tauri_path.join("Info.ios.plist").into(),
     ],
     &info_plist_path,
   )?;

+ 62 - 5
tooling/cli/src/mobile/ios/mod.rs

@@ -30,7 +30,7 @@ use super::{
 use crate::{helpers::config::Config as TauriConfig, Result};
 
 use std::{
-  env::set_var,
+  env::{set_var, var_os},
   fs::create_dir_all,
   path::{Path, PathBuf},
   thread::sleep,
@@ -275,16 +275,36 @@ fn inject_assets(config: &AppleConfig) -> Result<()> {
   Ok(())
 }
 
-fn merge_plist(src: &[PathBuf], dest: &Path) -> Result<()> {
+enum PlistKind {
+  Path(PathBuf),
+  Plist(plist::Value),
+}
+
+impl From<PathBuf> for PlistKind {
+  fn from(p: PathBuf) -> Self {
+    Self::Path(p)
+  }
+}
+impl From<plist::Value> for PlistKind {
+  fn from(p: plist::Value) -> Self {
+    Self::Plist(p)
+  }
+}
+
+fn merge_plist(src: Vec<PlistKind>, dest: &Path) -> Result<()> {
   let mut dest_plist = None;
 
-  for src_path in src {
-    if let Ok(src_plist) = plist::Value::from_file(src_path) {
+  for plist_kind in src {
+    let plist = match plist_kind {
+      PlistKind::Path(p) => plist::Value::from_file(p),
+      PlistKind::Plist(v) => Ok(v),
+    };
+    if let Ok(src_plist) = plist {
       if dest_plist.is_none() {
         dest_plist.replace(plist::Value::from_file(dest)?);
       }
 
-      let plist = dest_plist.as_mut().expect("Info.plist not loaded");
+      let plist = dest_plist.as_mut().expect("plist not loaded");
       if let Some(plist) = plist.as_dictionary_mut() {
         if let Some(dict) = src_plist.into_dictionary() {
           for (key, value) in dict {
@@ -301,3 +321,40 @@ fn merge_plist(src: &[PathBuf], dest: &Path) -> Result<()> {
 
   Ok(())
 }
+
+pub fn signing_from_env() -> Result<(
+  Option<tauri_macos_sign::Keychain>,
+  Option<tauri_macos_sign::ProvisioningProfile>,
+)> {
+  let keychain = if let (Some(certificate), Some(certificate_password)) = (
+    var_os("IOS_CERTIFICATE"),
+    var_os("IOS_CERTIFICATE_PASSWORD"),
+  ) {
+    tauri_macos_sign::Keychain::with_certificate(&certificate, &certificate_password).map(Some)?
+  } else {
+    None
+  };
+  let provisioning_profile = if let Some(provisioning_profile) = var_os("IOS_MOBILE_PROVISION") {
+    tauri_macos_sign::ProvisioningProfile::from_base64(&provisioning_profile).map(Some)?
+  } else {
+    None
+  };
+
+  Ok((keychain, provisioning_profile))
+}
+
+pub fn init_config(
+  keychain: Option<&tauri_macos_sign::Keychain>,
+  provisioning_profile: Option<&tauri_macos_sign::ProvisioningProfile>,
+) -> Result<super::init::IosInitConfig> {
+  Ok(super::init::IosInitConfig {
+    code_sign_style: if keychain.is_some() && provisioning_profile.is_some() {
+      super::init::CodeSignStyle::Manual
+    } else {
+      super::init::CodeSignStyle::Automatic
+    },
+    code_sign_identity: keychain.map(|k| k.signing_identity()),
+    team_id: keychain.and_then(|k| k.team_id().map(ToString::to_string)),
+    provisioning_profile_uuid: provisioning_profile.and_then(|p| p.uuid().ok()),
+  })
+}

+ 7 - 0
tooling/cli/templates/mobile/ios/project.yml

@@ -15,6 +15,13 @@ settingGroups:
       {{#if apple.development-team}}
       DEVELOPMENT_TEAM: {{apple.development-team}}
       {{/if}}
+      CODE_SIGN_STYLE: {{tauri.ios.code-sign-style}}
+      {{#if tauri.ios.code-sign-identity}}
+      CODE_SIGN_IDENTITY: "{{tauri.ios.code-sign-identity}}"
+      {{/if}}
+      {{#if tauri.ios.provisioning-profile-uuid}}
+      PROVISIONING_PROFILE_SPECIFIER: "{{tauri.ios.provisioning-profile-uuid}}"
+      {{/if}}
 targetTemplates:
   app:
     type: application

+ 869 - 0
tooling/macos-sign/Cargo.lock

@@ -0,0 +1,869 @@
+# This file is automatically @generated by Cargo.
+# It is not intended for manual editing.
+version = 3
+
+[[package]]
+name = "aho-corasick"
+version = "1.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916"
+dependencies = [
+ "memchr",
+]
+
+[[package]]
+name = "android-tzdata"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0"
+
+[[package]]
+name = "android_system_properties"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "anyhow"
+version = "1.0.82"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f538837af36e6f6a9be0faa67f9a314f8119e4e4b5867c6ab40ed60360142519"
+
+[[package]]
+name = "autocfg"
+version = "1.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0c4b4d0bd25bd0b74681c0ad21497610ce1b7c91b1022cd21c80c6fbdd9476b0"
+
+[[package]]
+name = "base64"
+version = "0.21.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567"
+
+[[package]]
+name = "base64"
+version = "0.22.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
+
+[[package]]
+name = "base64ct"
+version = "1.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8c3c1a368f70d6cf7302d78f8f7093da241fb8e8807c05cc9e51a125895a6d5b"
+
+[[package]]
+name = "bcder"
+version = "0.7.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c627747a6774aab38beb35990d88309481378558875a41da1a4b2e373c906ef0"
+dependencies = [
+ "bytes",
+ "smallvec",
+]
+
+[[package]]
+name = "bitflags"
+version = "2.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cf4b9d6a944f767f8e5e0db018570623c85f3d925ac718db4e06d0187adb21c1"
+
+[[package]]
+name = "bumpalo"
+version = "3.16.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "79296716171880943b8470b5f8d03aa55eb2e645a4874bdbb28adb49162e012c"
+
+[[package]]
+name = "bytes"
+version = "1.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "514de17de45fdb8dc022b1a7975556c53c86f9f0aa5f534b98977b171857c2c9"
+
+[[package]]
+name = "cc"
+version = "1.0.97"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "099a5357d84c4c61eb35fc8eafa9a79a902c2f76911e5747ced4e032edd8d9b4"
+
+[[package]]
+name = "cfg-if"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd"
+
+[[package]]
+name = "chrono"
+version = "0.4.38"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a21f936df1771bf62b77f047b726c4625ff2e8aa607c01ec06e5a05bd8463401"
+dependencies = [
+ "android-tzdata",
+ "iana-time-zone",
+ "num-traits",
+ "windows-targets",
+]
+
+[[package]]
+name = "const-oid"
+version = "0.9.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
+
+[[package]]
+name = "core-foundation-sys"
+version = "0.8.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "06ea2b9bc92be3c2baa9334a323ebca2d6f074ff852cd1d7b11064035cd3868f"
+
+[[package]]
+name = "der"
+version = "0.7.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f55bf8e7b65898637379c1b74eb1551107c8294ed26d855ceb9fd1a09cfc9bc0"
+dependencies = [
+ "const-oid",
+ "zeroize",
+]
+
+[[package]]
+name = "deranged"
+version = "0.3.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b42b6fa04a440b495c8b04d0e71b707c585f83cb9cb28cf8cd0d976c315e31b4"
+dependencies = [
+ "powerfmt",
+]
+
+[[package]]
+name = "dirs-next"
+version = "2.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b98cf8ebf19c3d1b223e151f99a4f9f0690dca41414773390fc824184ac833e1"
+dependencies = [
+ "cfg-if",
+ "dirs-sys-next",
+]
+
+[[package]]
+name = "dirs-sys-next"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4ebda144c4fe02d1f7ea1a7d9641b6fc6b580adcfa024ae48797ecdeb6825b4d"
+dependencies = [
+ "libc",
+ "redox_users",
+ "winapi",
+]
+
+[[package]]
+name = "equivalent"
+version = "1.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5"
+
+[[package]]
+name = "errno"
+version = "0.3.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a258e46cdc063eb8519c00b9fc845fc47bcfca4130e2f08e88665ceda8474245"
+dependencies = [
+ "libc",
+ "windows-sys",
+]
+
+[[package]]
+name = "fastrand"
+version = "2.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9fc0510504f03c51ada170672ac806f1f105a88aa97a5281117e1ddc3368e51a"
+
+[[package]]
+name = "getrandom"
+version = "0.2.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c4567c8db10ae91089c99af84c68c38da3ec2f087c3f82960bcdbf3656b6f4d7"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "wasi",
+]
+
+[[package]]
+name = "hashbrown"
+version = "0.14.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
+
+[[package]]
+name = "hex"
+version = "0.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
+
+[[package]]
+name = "iana-time-zone"
+version = "0.1.60"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e7ffbb5a1b541ea2561f8c41c087286cc091e21e556a4f09a8f6cbf17b69b141"
+dependencies = [
+ "android_system_properties",
+ "core-foundation-sys",
+ "iana-time-zone-haiku",
+ "js-sys",
+ "wasm-bindgen",
+ "windows-core",
+]
+
+[[package]]
+name = "iana-time-zone-haiku"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f"
+dependencies = [
+ "cc",
+]
+
+[[package]]
+name = "indexmap"
+version = "2.2.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "168fb715dda47215e360912c096649d23d58bf392ac62f73919e831745e40f26"
+dependencies = [
+ "equivalent",
+ "hashbrown",
+]
+
+[[package]]
+name = "itoa"
+version = "1.0.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "49f1f14873335454500d59611f1cf4a4b0f786f9ac11f4312a78e4cf2566695b"
+
+[[package]]
+name = "js-sys"
+version = "0.3.69"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "29c15563dc2726973df627357ce0c9ddddbea194836909d655df6a75d2cf296d"
+dependencies = [
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "libc"
+version = "0.2.154"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ae743338b92ff9146ce83992f766a31066a91a8c84a45e0e9f21e7cf6de6d346"
+
+[[package]]
+name = "libredox"
+version = "0.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c0ff37bd590ca25063e35af745c343cb7a0271906fb7b37e4813e8f79f00268d"
+dependencies = [
+ "bitflags",
+ "libc",
+]
+
+[[package]]
+name = "line-wrap"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dd1bc4d24ad230d21fb898d1116b1801d7adfc449d42026475862ab48b11e70e"
+
+[[package]]
+name = "linux-raw-sys"
+version = "0.4.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "01cda141df6706de531b6c46c3a33ecca755538219bd484262fa09410c13539c"
+
+[[package]]
+name = "log"
+version = "0.4.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "90ed8c1e510134f979dbc4f070f87d4313098b704861a105fe34231c70a3901c"
+
+[[package]]
+name = "memchr"
+version = "2.7.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6c8640c5d730cb13ebd907d8d04b52f55ac9a2eec55b440c8892f40d56c76c1d"
+
+[[package]]
+name = "num-conv"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9"
+
+[[package]]
+name = "num-traits"
+version = "0.2.19"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
+dependencies = [
+ "autocfg",
+]
+
+[[package]]
+name = "once-cell-regex"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b3de7e389a5043420c8f2b95ed03f3f104ad6f4c41f7d7e27298f033abc253e8"
+dependencies = [
+ "once_cell",
+ "regex",
+]
+
+[[package]]
+name = "once_cell"
+version = "1.19.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92"
+
+[[package]]
+name = "os_pipe"
+version = "1.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "57119c3b893986491ec9aa85056780d3a0f3cf4da7cc09dd3650dbd6c6738fb9"
+dependencies = [
+ "libc",
+ "windows-sys",
+]
+
+[[package]]
+name = "pem"
+version = "3.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8e459365e590736a54c3fa561947c84837534b8e9af6fc5bf781307e82658fae"
+dependencies = [
+ "base64 0.22.1",
+ "serde",
+]
+
+[[package]]
+name = "plist"
+version = "1.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d9d34169e64b3c7a80c8621a48adaf44e0cf62c78a9b25dd9dd35f1881a17cf9"
+dependencies = [
+ "base64 0.21.7",
+ "indexmap",
+ "line-wrap",
+ "quick-xml",
+ "serde",
+ "time",
+]
+
+[[package]]
+name = "powerfmt"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
+
+[[package]]
+name = "ppv-lite86"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5b40af805b3121feab8a3c29f04d8ad262fa8e0561883e7653e024ae4479e6de"
+
+[[package]]
+name = "proc-macro2"
+version = "1.0.81"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3d1597b0c024618f09a9c3b8655b7e430397a36d23fdafec26d6965e9eec3eba"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "quick-xml"
+version = "0.31.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1004a344b30a54e2ee58d66a71b32d2db2feb0a31f9a2d302bf0536f15de2a33"
+dependencies = [
+ "memchr",
+]
+
+[[package]]
+name = "quote"
+version = "1.0.36"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0fa76aaf39101c457836aec0ce2316dbdc3ab723cdda1c6bd4e6ad4208acaca7"
+dependencies = [
+ "proc-macro2",
+]
+
+[[package]]
+name = "rand"
+version = "0.8.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404"
+dependencies = [
+ "libc",
+ "rand_chacha",
+ "rand_core",
+]
+
+[[package]]
+name = "rand_chacha"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
+dependencies = [
+ "ppv-lite86",
+ "rand_core",
+]
+
+[[package]]
+name = "rand_core"
+version = "0.6.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
+dependencies = [
+ "getrandom",
+]
+
+[[package]]
+name = "redox_users"
+version = "0.4.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bd283d9651eeda4b2a83a43c1c91b266c40fd76ecd39a50a8c630ae69dc72891"
+dependencies = [
+ "getrandom",
+ "libredox",
+ "thiserror",
+]
+
+[[package]]
+name = "regex"
+version = "1.10.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c117dbdfde9c8308975b6a18d71f3f385c89461f7b3fb054288ecf2a2058ba4c"
+dependencies = [
+ "aho-corasick",
+ "memchr",
+ "regex-automata",
+ "regex-syntax",
+]
+
+[[package]]
+name = "regex-automata"
+version = "0.4.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "86b83b8b9847f9bf95ef68afb0b8e6cdb80f498442f5179a29fad448fcc1eaea"
+dependencies = [
+ "aho-corasick",
+ "memchr",
+ "regex-syntax",
+]
+
+[[package]]
+name = "regex-syntax"
+version = "0.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "adad44e29e4c806119491a7f06f03de4d1af22c3a680dd47f1e6e179439d1f56"
+
+[[package]]
+name = "ring"
+version = "0.17.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c17fa4cb658e3583423e915b9f3acc01cceaee1860e33d59ebae66adc3a2dc0d"
+dependencies = [
+ "cc",
+ "cfg-if",
+ "getrandom",
+ "libc",
+ "spin",
+ "untrusted",
+ "windows-sys",
+]
+
+[[package]]
+name = "rustix"
+version = "0.38.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "70dc5ec042f7a43c4a73241207cecc9873a06d45debb38b329f8541d85c2730f"
+dependencies = [
+ "bitflags",
+ "errno",
+ "libc",
+ "linux-raw-sys",
+ "windows-sys",
+]
+
+[[package]]
+name = "ryu"
+version = "1.0.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f3cb5ba0dc43242ce17de99c180e96db90b235b8a9fdc9543c96d2209116bd9f"
+
+[[package]]
+name = "serde"
+version = "1.0.199"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0c9f6e76df036c77cd94996771fb40db98187f096dd0b9af39c6c6e452ba966a"
+dependencies = [
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_derive"
+version = "1.0.199"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "11bd257a6541e141e42ca6d24ae26f7714887b47e89aa739099104c7e4d3b7fc"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "serde_json"
+version = "1.0.117"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "455182ea6142b14f93f4bc5320a2b31c1f266b66a4a5c858b013302a5d8cbfc3"
+dependencies = [
+ "itoa",
+ "ryu",
+ "serde",
+]
+
+[[package]]
+name = "signature"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
+dependencies = [
+ "rand_core",
+]
+
+[[package]]
+name = "smallvec"
+version = "1.13.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3c5e1a9a646d36c3599cd173a41282daf47c44583ad367b8e6837255952e5c67"
+
+[[package]]
+name = "spin"
+version = "0.9.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67"
+
+[[package]]
+name = "spki"
+version = "0.7.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d"
+dependencies = [
+ "base64ct",
+ "der",
+]
+
+[[package]]
+name = "syn"
+version = "2.0.60"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "909518bc7b1c9b779f1bbf07f2929d35af9f0f37e47c6e9ef7f9dddc1e1821f3"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "tauri-macos-sign"
+version = "0.0.0"
+dependencies = [
+ "anyhow",
+ "dirs-next",
+ "once-cell-regex",
+ "os_pipe",
+ "plist",
+ "rand",
+ "serde",
+ "serde_json",
+ "tempfile",
+ "x509-certificate",
+]
+
+[[package]]
+name = "tempfile"
+version = "3.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "85b77fafb263dd9d05cbeac119526425676db3784113aa9295c88498cbf8bff1"
+dependencies = [
+ "cfg-if",
+ "fastrand",
+ "rustix",
+ "windows-sys",
+]
+
+[[package]]
+name = "thiserror"
+version = "1.0.60"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "579e9083ca58dd9dcf91a9923bb9054071b9ebbd800b342194c9feb0ee89fc18"
+dependencies = [
+ "thiserror-impl",
+]
+
+[[package]]
+name = "thiserror-impl"
+version = "1.0.60"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e2470041c06ec3ac1ab38d0356a6119054dedaea53e12fbefc0de730a1c08524"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "time"
+version = "0.3.36"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5dfd88e563464686c916c7e46e623e520ddc6d79fa6641390f2e3fa86e83e885"
+dependencies = [
+ "deranged",
+ "itoa",
+ "num-conv",
+ "powerfmt",
+ "serde",
+ "time-core",
+ "time-macros",
+]
+
+[[package]]
+name = "time-core"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ef927ca75afb808a4d64dd374f00a2adf8d0fcff8e7b184af886c3c87ec4a3f3"
+
+[[package]]
+name = "time-macros"
+version = "0.2.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3f252a68540fde3a3877aeea552b832b40ab9a69e318efd078774a01ddee1ccf"
+dependencies = [
+ "num-conv",
+ "time-core",
+]
+
+[[package]]
+name = "unicode-ident"
+version = "1.0.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b"
+
+[[package]]
+name = "untrusted"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
+
+[[package]]
+name = "wasi"
+version = "0.11.0+wasi-snapshot-preview1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423"
+
+[[package]]
+name = "wasm-bindgen"
+version = "0.2.92"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4be2531df63900aeb2bca0daaaddec08491ee64ceecbee5076636a3b026795a8"
+dependencies = [
+ "cfg-if",
+ "wasm-bindgen-macro",
+]
+
+[[package]]
+name = "wasm-bindgen-backend"
+version = "0.2.92"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "614d787b966d3989fa7bb98a654e369c762374fd3213d212cfc0251257e747da"
+dependencies = [
+ "bumpalo",
+ "log",
+ "once_cell",
+ "proc-macro2",
+ "quote",
+ "syn",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-macro"
+version = "0.2.92"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a1f8823de937b71b9460c0c34e25f3da88250760bec0ebac694b49997550d726"
+dependencies = [
+ "quote",
+ "wasm-bindgen-macro-support",
+]
+
+[[package]]
+name = "wasm-bindgen-macro-support"
+version = "0.2.92"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e94f17b526d0a461a191c78ea52bbce64071ed5c04c9ffe424dcb38f74171bb7"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+ "wasm-bindgen-backend",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-shared"
+version = "0.2.92"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "af190c94f2773fdb3729c55b007a722abb5384da03bc0986df4c289bf5567e96"
+
+[[package]]
+name = "winapi"
+version = "0.3.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
+dependencies = [
+ "winapi-i686-pc-windows-gnu",
+ "winapi-x86_64-pc-windows-gnu",
+]
+
+[[package]]
+name = "winapi-i686-pc-windows-gnu"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
+
+[[package]]
+name = "winapi-x86_64-pc-windows-gnu"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
+
+[[package]]
+name = "windows-core"
+version = "0.52.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "33ab640c8d7e35bf8ba19b884ba838ceb4fba93a4e8c65a9059d08afcfc683d9"
+dependencies = [
+ "windows-targets",
+]
+
+[[package]]
+name = "windows-sys"
+version = "0.52.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
+dependencies = [
+ "windows-targets",
+]
+
+[[package]]
+name = "windows-targets"
+version = "0.52.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6f0713a46559409d202e70e28227288446bf7841d3211583a4b53e3f6d96e7eb"
+dependencies = [
+ "windows_aarch64_gnullvm",
+ "windows_aarch64_msvc",
+ "windows_i686_gnu",
+ "windows_i686_gnullvm",
+ "windows_i686_msvc",
+ "windows_x86_64_gnu",
+ "windows_x86_64_gnullvm",
+ "windows_x86_64_msvc",
+]
+
+[[package]]
+name = "windows_aarch64_gnullvm"
+version = "0.52.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7088eed71e8b8dda258ecc8bac5fb1153c5cffaf2578fc8ff5d61e23578d3263"
+
+[[package]]
+name = "windows_aarch64_msvc"
+version = "0.52.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9985fd1504e250c615ca5f281c3f7a6da76213ebd5ccc9561496568a2752afb6"
+
+[[package]]
+name = "windows_i686_gnu"
+version = "0.52.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "88ba073cf16d5372720ec942a8ccbf61626074c6d4dd2e745299726ce8b89670"
+
+[[package]]
+name = "windows_i686_gnullvm"
+version = "0.52.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "87f4261229030a858f36b459e748ae97545d6f1ec60e5e0d6a3d32e0dc232ee9"
+
+[[package]]
+name = "windows_i686_msvc"
+version = "0.52.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "db3c2bf3d13d5b658be73463284eaf12830ac9a26a90c717b7f771dfe97487bf"
+
+[[package]]
+name = "windows_x86_64_gnu"
+version = "0.52.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4e4246f76bdeff09eb48875a0fd3e2af6aada79d409d33011886d3e1581517d9"
+
+[[package]]
+name = "windows_x86_64_gnullvm"
+version = "0.52.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "852298e482cd67c356ddd9570386e2862b5673c85bd5f88df9ab6802b334c596"
+
+[[package]]
+name = "windows_x86_64_msvc"
+version = "0.52.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bec47e5bfd1bff0eeaf6d8b485cc1074891a197ab4225d504cb7a1ab88b02bf0"
+
+[[package]]
+name = "x509-certificate"
+version = "0.23.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "66534846dec7a11d7c50a74b7cdb208b9a581cad890b7866430d438455847c85"
+dependencies = [
+ "bcder",
+ "bytes",
+ "chrono",
+ "der",
+ "hex",
+ "pem",
+ "ring",
+ "signature",
+ "spki",
+ "thiserror",
+ "zeroize",
+]
+
+[[package]]
+name = "zeroize"
+version = "1.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "525b4ec142c6b68a2d10f01f7bbf6755599ca3f81ea53b8431b7dd348f5fdb2d"
+dependencies = [
+ "zeroize_derive",
+]
+
+[[package]]
+name = "zeroize_derive"
+version = "1.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ce36e65b0d2999d2aafac989fb249189a141aee1f53c612c1f37d72631959f69"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]

+ 24 - 0
tooling/macos-sign/Cargo.toml

@@ -0,0 +1,24 @@
+workspace = {}
+
+[package]
+name = "tauri-macos-sign"
+version = "0.0.0"
+authors = ["Tauri Programme within The Commons Conservancy"]
+license = "Apache-2.0 OR MIT"
+keywords = ["codesign", "signing", "macos", "ios", "tauri"]
+repository = "https://github.com/tauri-apps/tauri"
+description = "Code signing utilities for macOS and iOS apps"
+edition = "2021"
+rust-version = "1.70"
+
+[dependencies]
+anyhow = "1"
+serde = { version = "1", features = ["derive"] }
+serde_json = "1"
+tempfile = "3"
+x509-certificate = "0.23"
+once-cell-regex = "0.2"
+os_pipe = "1"
+plist = "1"
+rand = "0.8"
+dirs-next = "2"

+ 3 - 0
tooling/macos-sign/README.md

@@ -0,0 +1,3 @@
+# Tauri MacOS Sign
+
+Utilities for setting up macOS certificates, code signing and notarization for macOS and iOS apps.

+ 216 - 0
tooling/macos-sign/src/keychain.rs

@@ -0,0 +1,216 @@
+// Copyright 2019-2024 Tauri Programme within The Commons Conservancy
+// SPDX-License-Identifier: Apache-2.0
+// SPDX-License-Identifier: MIT
+
+use std::{
+  ffi::OsString,
+  path::{Path, PathBuf},
+  process::Command,
+};
+
+use crate::assert_command;
+use anyhow::Result;
+use rand::distributions::{Alphanumeric, DistString};
+
+mod identity;
+
+pub use identity::Team;
+
+pub enum SigningIdentity {
+  Team(Team),
+  Identifier(String),
+}
+
+pub struct Keychain {
+  // none means the default keychain must be used
+  path: Option<PathBuf>,
+  signing_identity: SigningIdentity,
+}
+
+impl Drop for Keychain {
+  fn drop(&mut self) {
+    if let Some(path) = &self.path {
+      let _ = Command::new("security")
+        .arg("delete-keychain")
+        .arg(path)
+        .status();
+    }
+  }
+}
+
+impl Keychain {
+  /// Use a certificate in the default keychain.
+  pub fn with_signing_identity(identity: impl Into<String>) -> Self {
+    Self {
+      path: None,
+      signing_identity: SigningIdentity::Identifier(identity.into()),
+    }
+  }
+
+  /// Import certificate from base64 string.
+  /// certificate_encoded is the p12 certificate base64 encoded.
+  /// By example you can use; openssl base64 -in MyCertificate.p12 -out MyCertificate-base64.txt
+  /// Then use the value of the base64 as `certificate_encoded`.
+  /// You need to set certificate_password to the password you set when you exported your certificate.
+  /// https://help.apple.com/xcode/mac/current/#/dev154b28f09 see: `Export a signing certificate`
+  pub fn with_certificate(
+    certificate_encoded: &OsString,
+    certificate_password: &OsString,
+  ) -> Result<Self> {
+    let home_dir =
+      dirs_next::home_dir().ok_or_else(|| anyhow::anyhow!("failed to resolve home dir"))?;
+    let keychain_path = home_dir.join("Library").join("Keychains").join(format!(
+      "{}.keychain-db",
+      Alphanumeric.sample_string(&mut rand::thread_rng(), 16)
+    ));
+    let keychain_password = Alphanumeric.sample_string(&mut rand::thread_rng(), 16);
+
+    let keychain_list_output = Command::new("security")
+      .args(["list-keychain", "-d", "user"])
+      .output()?;
+
+    let tmp_dir = tempfile::tempdir()?;
+    let cert_path = tmp_dir.path().join("cert.p12");
+    super::decode_base64(certificate_encoded, &cert_path)?;
+
+    assert_command(
+      Command::new("security")
+        .args(["create-keychain", "-p", &keychain_password])
+        .arg(&keychain_path)
+        .status(),
+      "failed to create keychain",
+    )?;
+
+    assert_command(
+      Command::new("security")
+        .args(["unlock-keychain", "-p", &keychain_password])
+        .arg(&keychain_path)
+        .status(),
+      "failed to set unlock keychain",
+    )?;
+
+    assert_command(
+      Command::new("security")
+        .arg("import")
+        .arg(&cert_path)
+        .arg("-P")
+        .arg(certificate_password)
+        .args([
+          "-T",
+          "/usr/bin/codesign",
+          "-T",
+          "/usr/bin/pkgbuild",
+          "-T",
+          "/usr/bin/productbuild",
+        ])
+        .arg("-k")
+        .arg(&keychain_path)
+        .status(),
+      "failed to import keychain certificate",
+    )?;
+
+    assert_command(
+      Command::new("security")
+        .args(["set-keychain-settings", "-t", "3600", "-u"])
+        .arg(&keychain_path)
+        .status(),
+      "failed to set keychain settings",
+    )?;
+
+    assert_command(
+      Command::new("security")
+        .args([
+          "set-key-partition-list",
+          "-S",
+          "apple-tool:,apple:,codesign:",
+          "-s",
+          "-k",
+          &keychain_password,
+        ])
+        .arg(&keychain_path)
+        .status(),
+      "failed to set keychain settings",
+    )?;
+
+    let current_keychains = String::from_utf8_lossy(&keychain_list_output.stdout)
+      .split('\n')
+      .map(|line| {
+        line
+          .trim_matches(|c: char| c.is_whitespace() || c == '"')
+          .to_string()
+      })
+      .filter(|l| !l.is_empty())
+      .collect::<Vec<String>>();
+
+    assert_command(
+      Command::new("security")
+        .args(["list-keychain", "-d", "user", "-s"])
+        .args(current_keychains)
+        .arg(&keychain_path)
+        .status(),
+      "failed to list keychain",
+    )?;
+
+    let signing_identity = identity::list(&keychain_path)
+      .map(|l| l.first().cloned())?
+      .ok_or_else(|| anyhow::anyhow!("failed to resolve signing identity"))?;
+
+    Ok(Self {
+      path: Some(keychain_path),
+      signing_identity: SigningIdentity::Team(signing_identity),
+    })
+  }
+
+  pub fn signing_identity(&self) -> String {
+    match &self.signing_identity {
+      SigningIdentity::Team(t) => t.certificate_name(),
+      SigningIdentity::Identifier(i) => i.to_string(),
+    }
+  }
+
+  pub fn team_id(&self) -> Option<&str> {
+    match &self.signing_identity {
+      SigningIdentity::Team(t) => Some(&t.id),
+      SigningIdentity::Identifier(_) => None,
+    }
+  }
+
+  pub fn sign(
+    &self,
+    path: &Path,
+    entitlements_path: Option<&Path>,
+    hardened_runtime: bool,
+  ) -> Result<()> {
+    let identity = match &self.signing_identity {
+      SigningIdentity::Team(t) => t.certificate_name(),
+      SigningIdentity::Identifier(i) => i.clone(),
+    };
+    println!("Signing with identity \"{}\"", identity);
+
+    println!("Signing {}", path.display());
+
+    let mut args = vec!["--force", "-s", &identity];
+
+    if hardened_runtime {
+      args.push("--options");
+      args.push("runtime");
+    }
+
+    let mut codesign = Command::new("codesign");
+    codesign.args(args);
+    if let Some(p) = &self.path {
+      codesign.arg("--keychain").arg(p);
+    }
+
+    if let Some(entitlements_path) = entitlements_path {
+      codesign.arg("--entitlements");
+      codesign.arg(entitlements_path);
+    }
+
+    codesign.arg(path);
+
+    assert_command(codesign.status(), "failed to sign app")?;
+
+    Ok(())
+  }
+}

+ 117 - 0
tooling/macos-sign/src/keychain/identity.rs

@@ -0,0 +1,117 @@
+// Copyright 2019-2024 Tauri Programme within The Commons Conservancy
+// SPDX-License-Identifier: Apache-2.0
+// SPDX-License-Identifier: MIT
+
+use anyhow::Context;
+use once_cell_regex::regex;
+use std::{collections::BTreeSet, path::Path, process::Command};
+use x509_certificate::certificate::X509Certificate;
+
+use crate::Result;
+
+fn get_pem_list(keychain_path: &Path, name_substr: &str) -> std::io::Result<std::process::Output> {
+  Command::new("security")
+    .arg("find-certificate")
+    .args(["-p", "-a"])
+    .arg("-c")
+    .arg(name_substr)
+    .arg(keychain_path)
+    .stderr(os_pipe::dup_stderr().unwrap())
+    .output()
+}
+
+#[derive(Debug, Clone, Eq, Ord, PartialEq, PartialOrd)]
+pub struct Team {
+  pub name: String,
+  pub certificate_name: String,
+  pub id: String,
+  pub cert_prefix: &'static str,
+}
+
+impl Team {
+  fn from_x509(cert_prefix: &'static str, cert: X509Certificate) -> Result<Self> {
+    let common_name = cert
+      .subject_common_name()
+      .ok_or_else(|| anyhow::anyhow!("skipping cert, missing common name"))?;
+
+    let organization = cert
+      .subject_name()
+      .iter_organization()
+      .next()
+      .and_then(|v| v.to_string().ok());
+
+    let name = if let Some(organization) = organization {
+      println!(
+        "found cert {:?} with organization {:?}",
+        common_name, organization
+      );
+      organization
+    } else {
+      println!(
+        "found cert {:?} but failed to get organization; falling back to displaying common name",
+        common_name
+      );
+      regex!(r"Apple Develop\w+: (.*) \(.+\)")
+                .captures(&common_name)
+                .map(|caps| caps[1].to_owned())
+                .unwrap_or_else(|| {
+                    println!("regex failed to capture nice part of name in cert {:?}; falling back to displaying full name", common_name);
+                    common_name.clone()
+                })
+    };
+
+    let id = cert
+      .subject_name()
+      .iter_organizational_unit()
+      .next()
+      .and_then(|v| v.to_string().ok())
+      .ok_or_else(|| anyhow::anyhow!("skipping cert {common_name}: missing Organization Unit"))?;
+
+    Ok(Self {
+      name,
+      certificate_name: common_name,
+      id,
+      cert_prefix,
+    })
+  }
+
+  pub fn certificate_name(&self) -> String {
+    self.certificate_name.clone()
+  }
+}
+
+pub fn list(keychain_path: &Path) -> Result<Vec<Team>> {
+  let certs = {
+    let mut certs = Vec::new();
+    for cert_prefix in [
+      "iOS Distribution:",
+      "Apple Distribution:",
+      "Developer ID Application:",
+      "Mac App Distribution:",
+      "Apple Development:",
+      "iOS App Development:",
+      "Mac Development:",
+    ] {
+      let pem_list_out =
+        get_pem_list(keychain_path, cert_prefix).context("Failed to call `security` command")?;
+      let cert_list = X509Certificate::from_pem_multiple(pem_list_out.stdout)
+        .context("Failed to parse X509 cert")?;
+      certs.extend(cert_list.into_iter().map(|cert| (cert_prefix, cert)));
+    }
+    certs
+  };
+  Ok(
+    certs
+      .into_iter()
+      .flat_map(|(cert_prefix, cert)| {
+        Team::from_x509(cert_prefix, cert).map_err(|err| {
+          eprintln!("{}", err);
+          err
+        })
+      })
+      // Silly way to sort this and ensure no dupes
+      .collect::<BTreeSet<_>>()
+      .into_iter()
+      .collect(),
+  )
+}

+ 251 - 0
tooling/macos-sign/src/lib.rs

@@ -0,0 +1,251 @@
+// Copyright 2019-2024 Tauri Programme within The Commons Conservancy
+// SPDX-License-Identifier: Apache-2.0
+// SPDX-License-Identifier: MIT
+
+use std::{
+  ffi::{OsStr, OsString},
+  path::{Path, PathBuf},
+  process::Command,
+};
+
+use anyhow::{Context, Result};
+use serde::Deserialize;
+
+mod keychain;
+mod provisioning_profile;
+
+pub use keychain::{Keychain, Team};
+pub use provisioning_profile::ProvisioningProfile;
+
+pub enum ApiKey {
+  Path(PathBuf),
+  Raw(Vec<u8>),
+}
+
+pub enum AppleNotarizationCredentials {
+  AppleId {
+    apple_id: OsString,
+    password: OsString,
+    team_id: OsString,
+  },
+  ApiKey {
+    issuer: OsString,
+    key_id: OsString,
+    key: ApiKey,
+  },
+}
+
+#[derive(Deserialize)]
+struct NotarytoolSubmitOutput {
+  id: String,
+  status: String,
+  message: String,
+}
+
+pub fn notarize(
+  keychain: &Keychain,
+  app_bundle_path: &Path,
+  auth: &AppleNotarizationCredentials,
+) -> Result<()> {
+  let bundle_stem = app_bundle_path
+    .file_stem()
+    .expect("failed to get bundle filename");
+
+  let tmp_dir = tempfile::tempdir()?;
+  let zip_path = tmp_dir
+    .path()
+    .join(format!("{}.zip", bundle_stem.to_string_lossy()));
+  let zip_args = vec![
+    "-c",
+    "-k",
+    "--keepParent",
+    "--sequesterRsrc",
+    app_bundle_path
+      .to_str()
+      .expect("failed to convert bundle_path to string"),
+    zip_path
+      .to_str()
+      .expect("failed to convert zip_path to string"),
+  ];
+
+  // use ditto to create a PKZip almost identical to Finder
+  // this remove almost 99% of false alarm in notarization
+  assert_command(
+    Command::new("ditto").args(zip_args).status(),
+    "failed to zip app with ditto",
+  )?;
+
+  // sign the zip file
+  keychain.sign(&zip_path, None, false)?;
+
+  let notarize_args = vec![
+    "notarytool",
+    "submit",
+    zip_path
+      .to_str()
+      .expect("failed to convert zip_path to string"),
+    "--wait",
+    "--output-format",
+    "json",
+  ];
+
+  println!("Notarizing {}", app_bundle_path.display());
+
+  let output = Command::new("xcrun")
+    .args(notarize_args)
+    .notarytool_args(auth, tmp_dir.path())?
+    .output()
+    .context("failed to upload app to Apple's notarization servers.")?;
+
+  if !output.status.success() {
+    return Err(anyhow::anyhow!("failed to notarize app"));
+  }
+
+  let output_str = String::from_utf8_lossy(&output.stdout);
+  if let Ok(submit_output) = serde_json::from_str::<NotarytoolSubmitOutput>(&output_str) {
+    let log_message = format!(
+      "Finished with status {} for id {} ({})",
+      submit_output.status, submit_output.id, submit_output.message
+    );
+    if submit_output.status == "Accepted" {
+      println!("Notarizing {}", log_message);
+      staple_app(app_bundle_path.to_path_buf())?;
+      Ok(())
+    } else if let Ok(output) = Command::new("xcrun")
+      .args(["notarytool", "log"])
+      .arg(&submit_output.id)
+      .notarytool_args(auth, tmp_dir.path())?
+      .output()
+    {
+      Err(anyhow::anyhow!(
+        "{log_message}\nLog:\n{}",
+        String::from_utf8_lossy(&output.stdout)
+      ))
+    } else {
+      Err(anyhow::anyhow!("{log_message}"))
+    }
+  } else {
+    Err(anyhow::anyhow!(
+      "failed to parse notarytool output as JSON: `{output_str}`"
+    ))
+  }
+}
+
+fn staple_app(mut app_bundle_path: PathBuf) -> Result<()> {
+  let app_bundle_path_clone = app_bundle_path.clone();
+  let filename = app_bundle_path_clone
+    .file_name()
+    .expect("failed to get bundle filename")
+    .to_str()
+    .expect("failed to convert bundle filename to string");
+
+  app_bundle_path.pop();
+
+  Command::new("xcrun")
+    .args(vec!["stapler", "staple", "-v", filename])
+    .current_dir(app_bundle_path)
+    .output()
+    .context("failed to staple app.")?;
+
+  Ok(())
+}
+
+pub trait NotarytoolCmdExt {
+  fn notarytool_args(
+    &mut self,
+    auth: &AppleNotarizationCredentials,
+    temp_dir: &Path,
+  ) -> Result<&mut Self>;
+}
+
+impl NotarytoolCmdExt for Command {
+  fn notarytool_args(
+    &mut self,
+    auth: &AppleNotarizationCredentials,
+    temp_dir: &Path,
+  ) -> Result<&mut Self> {
+    match auth {
+      AppleNotarizationCredentials::AppleId {
+        apple_id,
+        password,
+        team_id,
+      } => Ok(
+        self
+          .arg("--apple-id")
+          .arg(apple_id)
+          .arg("--password")
+          .arg(password)
+          .arg("--team-id")
+          .arg(team_id),
+      ),
+      AppleNotarizationCredentials::ApiKey {
+        key,
+        key_id,
+        issuer,
+      } => {
+        let key_path = match key {
+          ApiKey::Raw(k) => {
+            let key_path = temp_dir.join("AuthKey.p8");
+            std::fs::write(&key_path, k)?;
+            key_path
+          }
+          ApiKey::Path(p) => p.to_owned(),
+        };
+
+        Ok(
+          self
+            .arg("--key-id")
+            .arg(key_id)
+            .arg("--key")
+            .arg(key_path)
+            .arg("--issuer")
+            .arg(issuer),
+        )
+      }
+    }
+  }
+}
+
+fn decode_base64(base64: &OsStr, out_path: &Path) -> Result<()> {
+  let tmp_dir = tempfile::tempdir()?;
+
+  let src_path = tmp_dir.path().join("src");
+  let base64 = base64
+    .to_str()
+    .expect("failed to convert base64 to string")
+    .as_bytes();
+
+  // as base64 contain whitespace decoding may be broken
+  // https://github.com/marshallpierce/rust-base64/issues/105
+  // we'll use builtin base64 command from the OS
+  std::fs::write(&src_path, base64)?;
+
+  assert_command(
+    std::process::Command::new("base64")
+      .arg("--decode")
+      .arg("-i")
+      .arg(&src_path)
+      .arg("-o")
+      .arg(out_path)
+      .status(),
+    "failed to decode certificate",
+  )?;
+
+  Ok(())
+}
+
+fn assert_command(
+  response: Result<std::process::ExitStatus, std::io::Error>,
+  error_message: &str,
+) -> std::io::Result<()> {
+  let status =
+    response.map_err(|e| std::io::Error::new(e.kind(), format!("{error_message}: {e}")))?;
+  if !status.success() {
+    Err(std::io::Error::new(
+      std::io::ErrorKind::Other,
+      error_message,
+    ))
+  } else {
+    Ok(())
+  }
+}

+ 52 - 0
tooling/macos-sign/src/provisioning_profile.rs

@@ -0,0 +1,52 @@
+// Copyright 2019-2024 Tauri Programme within The Commons Conservancy
+// SPDX-License-Identifier: Apache-2.0
+// SPDX-License-Identifier: MIT
+
+use std::{ffi::OsStr, path::PathBuf, process::Command};
+
+use anyhow::{Context, Result};
+use rand::distributions::{Alphanumeric, DistString};
+
+pub struct ProvisioningProfile {
+  path: PathBuf,
+}
+
+impl ProvisioningProfile {
+  pub fn from_base64(base64: &OsStr) -> Result<Self> {
+    let home_dir = dirs_next::home_dir().unwrap();
+    let provisioning_profiles_folder = home_dir
+      .join("Library")
+      .join("MobileDevice")
+      .join("Provisioning Profiles");
+    std::fs::create_dir_all(&provisioning_profiles_folder).unwrap();
+
+    let provisioning_profile_path = provisioning_profiles_folder.join(format!(
+      "{}.mobileprovision",
+      Alphanumeric.sample_string(&mut rand::thread_rng(), 16)
+    ));
+    super::decode_base64(base64, &provisioning_profile_path)?;
+
+    Ok(Self {
+      path: provisioning_profile_path,
+    })
+  }
+
+  pub fn uuid(&self) -> Result<String> {
+    let output = Command::new("security")
+      .args(["cms", "-D", "-i"])
+      .arg(&self.path)
+      .output()?;
+
+    if !output.status.success() {
+      return Err(anyhow::anyhow!("failed to decode provisioning profile"));
+    }
+
+    let plist = plist::from_bytes::<plist::Dictionary>(&output.stdout)
+      .context("failed to decode provisioning profile as plist")?;
+
+    plist
+      .get("UUID")
+      .and_then(|v| v.as_string().map(ToString::to_string))
+      .ok_or_else(|| anyhow::anyhow!("could not find provisioning profile UUID"))
+  }
+}