1use anyhow::{bail, Context, Result};
2use serde::Deserialize;
3use std::ffi::OsString;
4use std::fs;
5use std::path::{Path, PathBuf};
6use std::process::Command;
7
8#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq)]
9pub struct MacosPackageConfig {
10 pub bundle_id: Option<String>,
11 pub minimum_os: Option<String>,
12 pub entitlements: Option<String>,
13 pub provisioning_profile: Option<String>,
14 pub signing_identity: Option<String>,
15 pub installer_identity: Option<String>,
16 pub notarize: Option<bool>,
17}
18
19#[derive(Debug, Default, Deserialize)]
20struct PackageManifest {
21 package: Option<PackageRoot>,
22 run: Option<RunRoot>,
23}
24
25#[derive(Debug, Default, Deserialize)]
26struct PackageRoot {
27 macos: Option<MacosPackageManifest>,
28}
29
30#[derive(Debug, Default, Deserialize)]
31struct RunRoot {
32 macos: Option<MacosRunConfig>,
33}
34
35#[derive(Debug, Default, Deserialize)]
36struct MacosRunConfig {
37 entitlements: Option<String>,
38 provisioning_profile: Option<String>,
39 signing_identity: Option<String>,
40}
41
42#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq)]
43struct MacosPackageManifest {
44 #[serde(flatten)]
45 base: MacosPackageConfig,
46 release: Option<MacosReleasePackageConfig>,
47}
48
49#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq)]
50struct MacosReleasePackageConfig {
51 entitlements: Option<String>,
52 provisioning_profile: Option<String>,
53 signing_identity: Option<String>,
54 installer_identity: Option<String>,
55 notarize: Option<bool>,
56}
57
58impl MacosPackageManifest {
59 fn effective(&self, release: bool) -> MacosPackageConfig {
60 let mut config = self.base.clone();
61 if release {
62 if let Some(overlay) = &self.release {
63 overlay.apply_to(&mut config);
64 }
65 }
66 config
67 }
68}
69
70impl MacosReleasePackageConfig {
71 fn apply_to(&self, config: &mut MacosPackageConfig) {
72 if self.entitlements.is_some() {
73 config.entitlements.clone_from(&self.entitlements);
74 }
75 if self.provisioning_profile.is_some() {
76 config
77 .provisioning_profile
78 .clone_from(&self.provisioning_profile);
79 }
80 if self.signing_identity.is_some() {
81 config.signing_identity.clone_from(&self.signing_identity);
82 }
83 if self.installer_identity.is_some() {
84 config
85 .installer_identity
86 .clone_from(&self.installer_identity);
87 }
88 if self.notarize.is_some() {
89 config.notarize = self.notarize;
90 }
91 }
92}
93
94pub fn read_macos_package_config(project_dir: &Path) -> Result<MacosPackageConfig> {
95 read_macos_package_config_for_profile(project_dir, false)
96}
97
98pub fn read_macos_package_config_for_profile(
99 project_dir: &Path,
100 release: bool,
101) -> Result<MacosPackageConfig> {
102 let manifest = read_manifest(project_dir)?;
103 Ok(package_config(manifest.package.as_ref(), release))
104}
105
106pub fn read_macos_run_config(project_dir: &Path) -> Result<MacosPackageConfig> {
107 read_macos_run_config_for_profile(project_dir, false)
108}
109
110pub fn read_macos_run_config_for_profile(
111 project_dir: &Path,
112 release: bool,
113) -> Result<MacosPackageConfig> {
114 Ok(run_config(&read_manifest(project_dir)?, release))
115}
116
117fn run_config(manifest: &PackageManifest, release: bool) -> MacosPackageConfig {
118 let run = manifest.run.as_ref().and_then(|run| run.macos.as_ref());
119 let mut config = package_config(manifest.package.as_ref(), release);
120 if let Some(run) = run {
121 if run.entitlements.is_some() {
122 config.entitlements.clone_from(&run.entitlements);
123 }
124 if run.provisioning_profile.is_some() {
125 config
126 .provisioning_profile
127 .clone_from(&run.provisioning_profile);
128 }
129 if run.signing_identity.is_some() {
130 config.signing_identity.clone_from(&run.signing_identity);
131 }
132 }
133 config
134}
135
136fn read_manifest(project_dir: &Path) -> Result<PackageManifest> {
137 let path = project_dir.join("fission.toml");
138 let data =
139 fs::read_to_string(&path).with_context(|| format!("failed to read {}", path.display()))?;
140 toml::from_str(&data).with_context(|| format!("failed to parse {}", path.display()))
141}
142
143fn package_config(package: Option<&PackageRoot>, release: bool) -> MacosPackageConfig {
144 package
145 .and_then(|package| package.macos.as_ref())
146 .map(|macos| macos.effective(release))
147 .unwrap_or_default()
148}
149
150pub fn sign_macos_app_if_configured(
151 project_dir: &Path,
152 app_bundle: &Path,
153 macos: &MacosPackageConfig,
154) -> Result<()> {
155 let identity = macos
156 .signing_identity
157 .as_deref()
158 .filter(|value| !value.trim().is_empty());
159 let profile = macos
160 .provisioning_profile
161 .as_deref()
162 .filter(|value| !value.trim().is_empty());
163
164 if profile.is_some() && identity.is_none_or(|value| value == "-") {
165 bail!(
166 "macOS provisioning_profile requires a real effective package.macos signing identity or run.macos.signing_identity; ad-hoc signing with `-` cannot embed a provisioning profile"
167 );
168 }
169 if let Some(profile) = profile {
170 embed_macos_provisioning_profile(project_dir, app_bundle, profile)?;
171 }
172
173 let Some(identity) = identity else {
174 return Ok(());
175 };
176
177 let status = Command::new("codesign")
178 .args(codesign_arguments(project_dir, identity, macos))
179 .arg(app_bundle)
180 .status()
181 .context("failed to run codesign")?;
182 if !status.success() {
183 bail!("codesign failed with {status}");
184 }
185
186 let verify = Command::new("codesign")
187 .args(["--verify", "--deep", "--strict", "--verbose=2"])
188 .arg(app_bundle)
189 .status()
190 .context("failed to verify macOS code signature")?;
191 if !verify.success() {
192 bail!("codesign verification failed with {verify}");
193 }
194 Ok(())
195}
196
197fn embed_macos_provisioning_profile(
198 project_dir: &Path,
199 app_bundle: &Path,
200 profile: &str,
201) -> Result<()> {
202 let source = resolve_project_path(project_dir, profile);
203 if !source.is_file() {
204 bail!(
205 "macOS provisioning profile does not exist or is not a file: {}",
206 source.display()
207 );
208 }
209
210 let destination = app_bundle.join("Contents/embedded.provisionprofile");
211 fs::copy(&source, &destination).with_context(|| {
212 format!(
213 "failed to embed macOS provisioning profile {} at {}",
214 source.display(),
215 destination.display()
216 )
217 })?;
218 Ok(())
219}
220
221fn codesign_arguments(
222 project_dir: &Path,
223 identity: &str,
224 macos: &MacosPackageConfig,
225) -> Vec<OsString> {
226 let mut arguments = vec![
227 "--force".into(),
228 "--timestamp".into(),
229 "--options".into(),
230 "runtime".into(),
231 "--sign".into(),
232 identity.into(),
233 ];
234 if let Some(entitlements) = macos
235 .entitlements
236 .as_deref()
237 .filter(|value| !value.trim().is_empty())
238 {
239 arguments.push("--entitlements".into());
240 arguments.push(resolve_project_path(project_dir, entitlements).into_os_string());
241 }
242 arguments
243}
244
245fn resolve_project_path(project_dir: &Path, path: &str) -> PathBuf {
246 let path = PathBuf::from(path);
247 if path.is_absolute() {
248 path
249 } else {
250 project_dir.join(path)
251 }
252}
253
254#[cfg(test)]
255mod tests {
256 use super::*;
257
258 #[test]
259 fn parses_macos_package_signing_configuration() {
260 let manifest: PackageManifest = toml::from_str(
261 r#"
262[package.macos]
263bundle_id = "com.example.app"
264minimum_os = "14.0"
265entitlements = "platforms/macos/App.entitlements"
266provisioning_profile = "profiles/Developer.provisionprofile"
267signing_identity = "Apple Development"
268installer_identity = "Developer ID Installer"
269notarize = true
270
271[run.macos]
272entitlements = "platforms/macos/Development.entitlements"
273provisioning_profile = "profiles/Developer-Local.provisionprofile"
274signing_identity = "-"
275"#,
276 )
277 .unwrap();
278
279 assert_eq!(
280 package_config(manifest.package.as_ref(), false),
281 MacosPackageConfig {
282 bundle_id: Some("com.example.app".into()),
283 minimum_os: Some("14.0".into()),
284 entitlements: Some("platforms/macos/App.entitlements".into()),
285 provisioning_profile: Some("profiles/Developer.provisionprofile".into()),
286 signing_identity: Some("Apple Development".into()),
287 installer_identity: Some("Developer ID Installer".into()),
288 notarize: Some(true),
289 }
290 );
291 let run = manifest.run.as_ref().unwrap().macos.as_ref().unwrap();
292 assert_eq!(
293 run.entitlements.as_deref(),
294 Some("platforms/macos/Development.entitlements")
295 );
296 assert_eq!(
297 run.provisioning_profile.as_deref(),
298 Some("profiles/Developer-Local.provisionprofile")
299 );
300 assert_eq!(run.signing_identity.as_deref(), Some("-"));
301 }
302
303 #[test]
304 fn release_signing_overlay_is_ignored_for_debug_packages() {
305 let manifest: PackageManifest = toml::from_str(
306 r#"
307[package.macos]
308bundle_id = "com.example.app"
309minimum_os = "14.0"
310entitlements = "platforms/macos/Development.entitlements"
311signing_identity = "-"
312
313[package.macos.release]
314entitlements = "platforms/macos/Release.entitlements"
315provisioning_profile = "profiles/Distribution.provisionprofile"
316signing_identity = "Developer ID Application: Example Ltd"
317installer_identity = "Developer ID Installer: Example Ltd"
318notarize = true
319"#,
320 )
321 .unwrap();
322
323 let debug = package_config(manifest.package.as_ref(), false);
324 assert_eq!(
325 debug.entitlements.as_deref(),
326 Some("platforms/macos/Development.entitlements")
327 );
328 assert_eq!(debug.signing_identity.as_deref(), Some("-"));
329 assert_eq!(debug.provisioning_profile, None);
330 assert_eq!(debug.installer_identity, None);
331 assert_eq!(debug.notarize, None);
332
333 let release = package_config(manifest.package.as_ref(), true);
334 assert_eq!(
335 release.entitlements.as_deref(),
336 Some("platforms/macos/Release.entitlements")
337 );
338 assert_eq!(
339 release.provisioning_profile.as_deref(),
340 Some("profiles/Distribution.provisionprofile")
341 );
342 assert_eq!(
343 release.signing_identity.as_deref(),
344 Some("Developer ID Application: Example Ltd")
345 );
346 assert_eq!(
347 release.installer_identity.as_deref(),
348 Some("Developer ID Installer: Example Ltd")
349 );
350 assert_eq!(release.notarize, Some(true));
351
352 let release_run = run_config(&manifest, true);
353 assert_eq!(
354 release_run.signing_identity.as_deref(),
355 Some("Developer ID Application: Example Ltd")
356 );
357 }
358
359 #[test]
360 fn codesign_arguments_resolve_relative_entitlements() {
361 let config = MacosPackageConfig {
362 entitlements: Some("platforms/macos/App.entitlements".into()),
363 ..Default::default()
364 };
365
366 assert_eq!(
367 codesign_arguments(Path::new("/project"), "-", &config),
368 vec![
369 OsString::from("--force"),
370 OsString::from("--timestamp"),
371 OsString::from("--options"),
372 OsString::from("runtime"),
373 OsString::from("--sign"),
374 OsString::from("-"),
375 OsString::from("--entitlements"),
376 OsString::from("/project/platforms/macos/App.entitlements"),
377 ]
378 );
379 }
380
381 #[test]
382 fn run_signing_overrides_package_signing_only() {
383 let manifest: PackageManifest = toml::from_str(
384 r#"
385[package.macos]
386bundle_id = "com.example.app"
387minimum_os = "14.0"
388entitlements = "platforms/macos/Release.entitlements"
389provisioning_profile = "profiles/Release.provisionprofile"
390signing_identity = "Apple Development"
391
392[run.macos]
393entitlements = "platforms/macos/Development.entitlements"
394provisioning_profile = "profiles/Development.provisionprofile"
395signing_identity = "-"
396"#,
397 )
398 .unwrap();
399 let config = run_config(&manifest, false);
400
401 assert_eq!(config.bundle_id.as_deref(), Some("com.example.app"));
402 assert_eq!(config.minimum_os.as_deref(), Some("14.0"));
403 assert_eq!(
404 config.entitlements.as_deref(),
405 Some("platforms/macos/Development.entitlements")
406 );
407 assert_eq!(
408 config.provisioning_profile.as_deref(),
409 Some("profiles/Development.provisionprofile")
410 );
411 assert_eq!(config.signing_identity.as_deref(), Some("-"));
412 }
413
414 #[test]
415 fn provisioning_profile_rejects_ad_hoc_signing_identity() {
416 let config = MacosPackageConfig {
417 provisioning_profile: Some("profiles/Development.provisionprofile".into()),
418 signing_identity: Some("-".into()),
419 ..Default::default()
420 };
421
422 let error = sign_macos_app_if_configured(
423 Path::new("/project"),
424 Path::new("/project/Demo.app"),
425 &config,
426 )
427 .unwrap_err();
428
429 assert!(error.to_string().contains("ad-hoc signing"));
430 }
431
432 #[test]
433 fn embeds_relative_macos_provisioning_profile() {
434 let root =
435 std::env::temp_dir().join(format!("fission-macos-profile-{}", std::process::id()));
436 let project = root.join("project");
437 let app = root.join("Demo.app");
438 fs::remove_dir_all(&root).ok();
439 fs::create_dir_all(project.join("profiles")).unwrap();
440 fs::create_dir_all(app.join("Contents")).unwrap();
441 fs::write(
442 project.join("profiles/Development.provisionprofile"),
443 b"profile-data",
444 )
445 .unwrap();
446
447 embed_macos_provisioning_profile(&project, &app, "profiles/Development.provisionprofile")
448 .unwrap();
449
450 assert_eq!(
451 fs::read(app.join("Contents/embedded.provisionprofile")).unwrap(),
452 b"profile-data"
453 );
454 fs::remove_dir_all(root).unwrap();
455 }
456
457 #[test]
458 fn provisioning_profile_requires_signing_identity() {
459 let config = MacosPackageConfig {
460 provisioning_profile: Some("profiles/Development.provisionprofile".into()),
461 ..Default::default()
462 };
463
464 let error = sign_macos_app_if_configured(
465 Path::new("/project"),
466 Path::new("/project/Demo.app"),
467 &config,
468 )
469 .unwrap_err();
470
471 assert!(error.to_string().contains("requires"));
472 }
473}