From 6256e5afdd261a7de0b971336ca65289e8f72fe3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Mon, 14 Sep 2026 02:57:43 +0200 Subject: [PATCH] fix(macos): bundle local UI executables for desktop launch --- .github/workflows/test.yml | 8 +- changelog.d/10240-macos-app-bundles.md | 1 + crates/perry/src/commands/attest.rs | 27 ++- crates/perry/src/commands/compile.rs | 1 + .../src/commands/compile/bundle_macos.rs | 211 ++++++++++++++++++ .../commands/compile/link/build_and_run.rs | 34 +-- .../perry/src/commands/compile/post_link.rs | 41 ++++ .../src/commands/compile/run_pipeline.rs | 101 +++++---- crates/perry/src/commands/mod.rs | 1 + crates/perry/src/commands/run/launch.rs | 112 +++++++++- crates/perry/src/commands/sandbox_profile.rs | 2 +- crates/perry/src/commands/sidecar.rs | 21 ++ crates/perry/tests/macos_app_bundle.rs | 191 ++++++++++++++++ docs/src/ui/overview.md | 15 +- 14 files changed, 682 insertions(+), 84 deletions(-) create mode 100644 changelog.d/10240-macos-app-bundles.md create mode 100644 crates/perry/src/commands/compile/bundle_macos.rs create mode 100644 crates/perry/src/commands/sidecar.rs create mode 100644 crates/perry/tests/macos_app_bundle.rs diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 7c91cfff57..b7b4579dcd 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -3827,7 +3827,13 @@ jobs: # Build them in this SAME Cargo graph as perry-stdlib: otherwise each # no-auto fallback build bundles a distinct tokio TLS/runtime and the # linker rejects the unsafe pair (#507, #7629). - run: cargo build --release -p perry -p perry-runtime -p perry-stdlib -p perry-runtime-static -p perry-stdlib-static -p ${{ matrix.ui_backend }} -p perry-doc-tests -p perry-ext-ioredis -p perry-ext-mongodb -p perry-ext-mysql2 -p perry-ext-pg -p perry-ext-nodemailer + run: cargo build --release -p perry -p perry-runtime -p perry-stdlib -p perry-runtime-static -p perry-stdlib-static -p ${{ matrix.ui_backend }} -p perry-doc-tests -p perry-ext-ioredis -p perry-ext-mongodb -p perry-ext-mysql2 -p perry-ext-pg -p perry-ext-nodemailer -p perry-ext-net + + - name: Verify macOS application bundle packaging + if: matrix.os == 'macos-14' + env: + RUST_TEST_THREADS: '1' + run: PERRY_RUNTIME_DIR="$PWD/target/release" cargo test --release -p perry --test macos_app_bundle - name: Pre-build Apple UI libs for cross-compile (macOS only) if: matrix.os == 'macos-14' diff --git a/changelog.d/10240-macos-app-bundles.md b/changelog.d/10240-macos-app-bundles.md new file mode 100644 index 0000000000..fdd539dc60 --- /dev/null +++ b/changelog.d/10240-macos-app-bundles.md @@ -0,0 +1 @@ +- macOS UI builds now produce signed `.app` bundles with app metadata, assets, and localization resources, giving desktop launches a proper application identity (#10078). Explicit `-o Name.app` outputs and `perry run` support the bundle layout; standalone CLI builds keep their existing output. Sandbox and attestation sidecars stay outside the bundle seal. diff --git a/crates/perry/src/commands/attest.rs b/crates/perry/src/commands/attest.rs index 7a69aaae59..46a80c252f 100644 --- a/crates/perry/src/commands/attest.rs +++ b/crates/perry/src/commands/attest.rs @@ -124,13 +124,13 @@ pub fn build_attestation(binary_path: &Path, project_root: &Path) -> Result.attest.json` alongside the -/// binary. Returns the resolved sidecar path. +/// Write the manifest beside the binary, or beside its enclosing `.app` +/// bundle. Returns the resolved sidecar path. pub fn write_attestation( binary_path: &Path, manifest: &AttestationManifest, ) -> Result { - let out = binary_path.with_extension("attest.json"); + let out = super::sidecar::path_for_binary(binary_path, "attest.json"); let body = serde_json::to_string_pretty(manifest) .context("failed to serialize attestation manifest")?; std::fs::write(&out, body).with_context(|| format!("failed to write {}", out.display()))?; @@ -142,7 +142,7 @@ pub fn write_attestation( /// manifest on success; bails with an actionable diagnostic on /// mismatch or missing sidecar. pub fn verify_against_sidecar(binary_path: &Path) -> Result { - let sidecar = binary_path.with_extension("attest.json"); + let sidecar = super::sidecar::path_for_binary(binary_path, "attest.json"); if !sidecar.exists() { bail!( "no attestation sidecar at {}.\n\ @@ -232,6 +232,25 @@ mod tests { assert_eq!(read_back, m); } + #[test] + fn bundle_attestation_lives_outside_the_seal_and_verifies_the_inner_binary() { + let dir = tempfile::tempdir().unwrap(); + let app = dir.path().join("My App.v2.app"); + let binary = app.join("Contents/MacOS/Engine"); + std::fs::create_dir_all(binary.parent().unwrap()).unwrap(); + std::fs::write(&binary, b"signed executable").unwrap(); + let manifest = build_attestation(&binary, dir.path()).unwrap(); + let written = write_attestation(&binary, &manifest).unwrap(); + assert_eq!(written, dir.path().join("My App.v2.app.attest.json")); + assert!(!binary.with_extension("attest.json").exists()); + assert_eq!(verify_against_sidecar(&binary).unwrap(), manifest); + std::fs::write(&binary, b"modified executable").unwrap(); + assert!(verify_against_sidecar(&binary) + .unwrap_err() + .to_string() + .contains("MISMATCH")); + } + #[test] fn verify_fails_when_binary_tampered() { let (dir, path) = temp_bin(b"original"); diff --git a/crates/perry/src/commands/compile.rs b/crates/perry/src/commands/compile.rs index 4b10f5024f..91d91ae29a 100644 --- a/crates/perry/src/commands/compile.rs +++ b/crates/perry/src/commands/compile.rs @@ -20,6 +20,7 @@ mod bootstrap; mod build_cache; mod bundle_apple; mod bundle_ios; +mod bundle_macos; mod defines; // `pub(crate)` so `commands::deps` can reuse `cjs_wrap::detect`'s // comment/string masker for its source scans (D005) instead of duplicating a diff --git a/crates/perry/src/commands/compile/bundle_macos.rs b/crates/perry/src/commands/compile/bundle_macos.rs new file mode 100644 index 0000000000..69430d339b --- /dev/null +++ b/crates/perry/src/commands/compile/bundle_macos.rs @@ -0,0 +1,211 @@ +//! Local macOS UI application packaging (#10078). + +use anyhow::{anyhow, Context, Result}; +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::Command; + +use super::bundle_apple::{read_app_display_name, xml_escape}; +use super::CompilationContext; +use crate::OutputFormat; + +pub(super) struct MacosBundleLayout { + pub app_dir: PathBuf, + pub executable: PathBuf, +} + +pub(super) fn layout_for_compile( + needs_ui: bool, + output_type: &str, + target: Option<&str>, + output: &Path, +) -> Result> { + let macos = target == Some("macos") || (target.is_none() && cfg!(target_os = "macos")); + if !needs_ui || output_type != "executable" || !macos { + return Ok(None); + } + let explicit_bundle = output.extension().is_some_and(|ext| ext == "app"); + let executable_name = if explicit_bundle { + output.file_stem() + } else { + output.file_name() + } + .filter(|name| !name.is_empty()) + .ok_or_else(|| anyhow!("macOS app output needs a filename: {}", output.display()))?; + let app_dir = if explicit_bundle { + output.to_path_buf() + } else { + let mut name = executable_name.to_os_string(); + name.push(".app"); + output.with_file_name(name) + }; + let executable = app_dir.join("Contents/MacOS").join(executable_name); + Ok(Some(MacosBundleLayout { + app_dir, + executable, + })) +} + +/// The embedded and bundle plists must agree on executable and app identity. +pub(super) fn info_plist(ctx: &CompilationContext, input: &Path, executable: &Path) -> String { + let filename = executable.file_name().unwrap_or_default().to_string_lossy(); + let display_name = + read_app_display_name(input, "macos").unwrap_or_else(|| filename.to_string()); + format!( + r#" + + + + CFBundleInfoDictionaryVersion6.0 + CFBundleIdentifier{bundle_id} + CFBundleName{display_name} + CFBundleDisplayName{display_name} + CFBundleExecutable{filename} + CFBundlePackageTypeAPPL + CFBundleShortVersionString{version} + CFBundleVersion{build_number} + NSHighResolutionCapable + NSCameraUsageDescription + This app uses the camera for WebView video calls. + NSMicrophoneUsageDescription + This app uses the microphone for WebView video calls. + + +"#, + bundle_id = xml_escape(&ctx.app_metadata.bundle_id), + display_name = xml_escape(&display_name), + filename = xml_escape(&filename), + version = xml_escape(&ctx.app_metadata.version), + build_number = ctx.app_metadata.build_number, + ) +} + +fn write_bundle_files(layout: &MacosBundleLayout, linked_exe: &Path, plist: &str) -> Result<()> { + fs::create_dir_all(layout.executable.parent().unwrap())?; + fs::create_dir_all(layout.app_dir.join("Contents/Resources"))?; + if linked_exe != layout.executable { + fs::copy(linked_exe, &layout.executable) + .with_context(|| format!("copy executable into {}", layout.app_dir.display()))?; + } + fs::write(layout.app_dir.join("Contents/Info.plist"), plist)?; + Ok(()) +} + +pub(super) fn bundle_for_macos( + layout: &MacosBundleLayout, + linked_exe: &Path, + input: &Path, + ctx: &CompilationContext, + target: Option<&str>, + i18n_table: Option<&perry_transform::i18n::I18nStringTable>, + i18n_config: Option<&perry_transform::i18n::I18nConfig>, + format: OutputFormat, +) -> Result<(PathBuf, String)> { + write_bundle_files( + layout, + linked_exe, + &info_plist(ctx, input, &layout.executable), + )?; + if linked_exe != layout.executable { + if let Some(parent) = linked_exe.parent() { + super::resources::copy_standalone_resource_dirs(input, parent); + super::resources::stage_native_library_artifacts(ctx, parent, format)?; + } + } + let resources = layout.app_dir.join("Contents/Resources"); + super::resources::copy_standalone_resource_dirs(input, &resources); + super::resources::stage_native_library_artifacts(ctx, &resources, format)?; + super::i18n_emit::write_lproj_localized_strings(&resources, i18n_table, i18n_config); + super::native_addon_sidecar::stage_native_addon_sidecar(ctx, &layout.executable, target)?; + + super::post_link::emit_sandbox_sidecar(ctx, &layout.executable, format); + + // Local development needs no signing identity or provisioning profile. + // Seal the external plist and copied resources after the bundle is complete. + if cfg!(target_os = "macos") { + let signed = Command::new("codesign") + .args(["--force", "--sign", "-", "--timestamp=none"]) + .arg(&layout.app_dir) + .output() + .context("sign local macOS app bundle")?; + if !signed.status.success() { + anyhow::bail!( + "codesign failed for {}: {}", + layout.app_dir.display(), + String::from_utf8_lossy(&signed.stderr) + ); + } + } + let bundle_id = ctx.app_metadata.bundle_id.clone(); + match format { + OutputFormat::Text => println!("Wrote macOS app bundle: {}", layout.app_dir.display()), + OutputFormat::Json => println!( + "{}", + serde_json::json!({"success": true, "output": layout.app_dir, "bundle_id": bundle_id}) + ), + } + Ok((layout.app_dir.clone(), bundle_id)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn only_macos_ui_executables_are_bundled() { + let output = Path::new("demo"); + for (ui, kind, target) in [ + (false, "executable", "macos"), + (true, "dylib", "macos"), + (true, "staticlib", "macos"), + (true, "executable", "linux"), + (true, "executable", "ios-simulator"), + ] { + assert!(layout_for_compile(ui, kind, Some(target), output) + .unwrap() + .is_none()); + } + assert!( + layout_for_compile(true, "executable", Some("macos"), output) + .unwrap() + .is_some() + ); + } + + #[test] + fn bundle_paths_preserve_names_and_explicit_app_extension() { + for (output, expected) in [ + ("out/My App.v2", "out/My App.v2.app"), + ("out/My App.v2.app", "out/My App.v2.app"), + ] { + let layout = layout_for_compile(true, "executable", Some("macos"), Path::new(output)) + .unwrap() + .unwrap(); + assert_eq!(layout.app_dir, Path::new(expected)); + assert_eq!( + layout.executable, + Path::new(expected).join("Contents/MacOS/My App.v2") + ); + } + } + + #[test] + fn packaging_keeps_linked_binary_and_does_not_truncate_an_in_bundle_output() { + let dir = tempfile::tempdir().unwrap(); + let raw = dir.path().join("demo"); + fs::write(&raw, b"linked executable").unwrap(); + let layout = layout_for_compile(true, "executable", Some("macos"), &raw) + .unwrap() + .unwrap(); + write_bundle_files(&layout, &raw, "first plist").unwrap(); + assert_eq!(fs::read(&raw).unwrap(), b"linked executable"); + assert_eq!(fs::read(&layout.executable).unwrap(), b"linked executable"); + assert!(layout.app_dir.join("Contents/Resources").is_dir()); + write_bundle_files(&layout, &layout.executable, "updated plist").unwrap(); + assert_eq!(fs::read(&layout.executable).unwrap(), b"linked executable"); + assert_eq!( + fs::read_to_string(layout.app_dir.join("Contents/Info.plist")).unwrap(), + "updated plist" + ); + } +} diff --git a/crates/perry/src/commands/compile/link/build_and_run.rs b/crates/perry/src/commands/compile/link/build_and_run.rs index 832542864a..7726c22339 100644 --- a/crates/perry/src/commands/compile/link/build_and_run.rs +++ b/crates/perry/src/commands/compile/link/build_and_run.rs @@ -1793,8 +1793,8 @@ pub(crate) fn build_and_run_link( // macOS privacy APIs (including camera/microphone requests made by // WKWebView) consult the process Info.plist for usage-description keys. - // Perry's direct desktop output is a Mach-O executable, not a .app bundle, - // so embed a minimal Info.plist section when linking native macOS UI apps. + // Keep the linked binary's embedded metadata identical to its macOS app + // bundle metadata; the standalone executable remains available as well. // Without this, WKWebView media capture can be denied by the platform even // when WKUIDelegate grants the web-origin permission. let is_macos_executable = @@ -1805,35 +1805,7 @@ pub(crate) fn build_and_run_link( .file_stem() .and_then(|s| s.to_str()) .unwrap_or("perry-app"); - let bundle_id = format!( - "dev.perry.{}", - exe_stem - .chars() - .map(|c| if c.is_ascii_alphanumeric() { c } else { '-' }) - .collect::() - .trim_matches('-') - ); - let info_plist = format!( - r#" - - - - CFBundleIdentifier - {bundle_id} - CFBundleName - {exe_stem} - CFBundleExecutable - {exe_stem} - CFBundlePackageType - APPL - NSCameraUsageDescription - This app uses the camera for WebView video calls. - NSMicrophoneUsageDescription - This app uses the microphone for WebView video calls. - - -"# - ); + let info_plist = super::super::bundle_macos::info_plist(ctx, args_input, exe_path); let plist_path = std::env::temp_dir().join(format!( "perry-embedded-info-{}-{}.plist", std::process::id(), diff --git a/crates/perry/src/commands/compile/post_link.rs b/crates/perry/src/commands/compile/post_link.rs index 8894ba27b8..1d4fcd3714 100644 --- a/crates/perry/src/commands/compile/post_link.rs +++ b/crates/perry/src/commands/compile/post_link.rs @@ -69,6 +69,47 @@ pub(super) fn strip_final_binary( } } +/// Emit the optional profile before an app bundle is signed. +pub(super) fn emit_sandbox_sidecar( + ctx: &CompilationContext, + exe_path: &Path, + format: OutputFormat, +) { + // #506 — emit `.sandbox` next to the binary when + // `--emit-sandbox` (or the equivalent env / package.json + // knob) is set. macOS only for the MVP; other platforms + // log a once-per-build note that the kernel-sandbox MVP + // is macOS-only and the matching seccomp / AppContainer / + // ... support lands as #506 follow-up. + if ctx.emit_sandbox { + #[cfg(target_os = "macos")] + { + match super::super::sandbox_profile::emit_macos_sandbox_profile(ctx, exe_path) { + Ok(path) => match format { + OutputFormat::Text => { + println!("Wrote sandbox profile: {}", path.display()) + } + OutputFormat::Json => {} + }, + Err(e) => match format { + OutputFormat::Text => { + eprintln!("warning: failed to emit sandbox profile: {}", e); + } + OutputFormat::Json => {} + }, + } + } + #[cfg(not(target_os = "macos"))] + { + if let OutputFormat::Text = format { + eprintln!( + "note: `--emit-sandbox` is macOS-only in this MVP; Linux seccomp + Windows AppContainer support tracked under #506." + ); + } + } + } +} + /// #504: emit `.attest.json` AFTER strip/codesign so the /// captured SHA-256 matches what users will actually download. /// Best-effort — errors log and continue. diff --git a/crates/perry/src/commands/compile/run_pipeline.rs b/crates/perry/src/commands/compile/run_pipeline.rs index f57c5a8f96..b16df31686 100644 --- a/crates/perry/src/commands/compile/run_pipeline.rs +++ b/crates/perry/src/commands/compile/run_pipeline.rs @@ -6458,6 +6458,23 @@ pub fn run_with_parse_cache( // file this link is about to write (#5740). None => output_path::default_output_path(is_dylib, is_staticlib, target.as_deref(), stem), }; + let macos_bundle_layout = bundle_macos::layout_for_compile( + ctx.needs_ui, + &args.output_type, + target.as_deref(), + &exe_path, + )?; + // An explicit `-o Name.app` is a directory destination. Other output + // names keep the linked binary and also produce the sibling app bundle. + let exe_path = if let Some(layout) = macos_bundle_layout + .as_ref() + .filter(|l| l.app_dir == exe_path) + { + fs::create_dir_all(layout.executable.parent().unwrap())?; + layout.executable.clone() + } else { + exe_path + }; if !failed_modules.is_empty() { // The loud failure summary + abort already ran earlier (right @@ -7317,7 +7334,23 @@ pub fn run_with_parse_cache( } } - // Track iOS bundle info for CompileResult + // Finish editing the executable before copying/sealing an app bundle. + // The link-cache fingerprint is written below, after bundle signing. + if link_cache_status.stats().linked { + strip_final_binary( + &ctx, + &exe_path, + target.as_deref(), + is_dylib, + is_ios, + is_visionos, + is_tvos, + is_watchos, + is_harmonyos, + ); + } + + // Track Apple bundle info for CompileResult let mut result_bundle_id: Option = None; let mut result_app_dir: Option = None; @@ -7374,6 +7407,22 @@ pub fn run_with_parse_cache( )?; result_bundle_id = Some(bundle_id); result_app_dir = Some(app_dir); + } else if let Some(layout) = macos_bundle_layout.as_ref() { + if exe_path != layout.executable { + post_link::emit_sandbox_sidecar(&ctx, &exe_path, format); + } + let (app_dir, bundle_id) = bundle_macos::bundle_for_macos( + layout, + &exe_path, + &args.input, + &ctx, + target.as_deref(), + i18n_table.as_ref(), + i18n_config.as_ref(), + format, + )?; + result_bundle_id = Some(bundle_id); + result_app_dir = Some(app_dir); } else { // For Windows/Linux (non-bundle targets), copy asset directories next to the exe // so that resolve_asset_path can find them relative to the executable. @@ -7426,39 +7475,7 @@ pub fn run_with_parse_cache( } } - // #506 — emit `.sandbox` next to the binary when - // `--emit-sandbox` (or the equivalent env / package.json - // knob) is set. macOS only for the MVP; other platforms - // log a once-per-build note that the kernel-sandbox MVP - // is macOS-only and the matching seccomp / AppContainer / - // ... support lands as #506 follow-up. - if ctx.emit_sandbox { - #[cfg(target_os = "macos")] - { - match super::super::sandbox_profile::emit_macos_sandbox_profile(&ctx, &exe_path) { - Ok(path) => match format { - OutputFormat::Text => { - println!("Wrote sandbox profile: {}", path.display()) - } - OutputFormat::Json => {} - }, - Err(e) => match format { - OutputFormat::Text => { - eprintln!("warning: failed to emit sandbox profile: {}", e); - } - OutputFormat::Json => {} - }, - } - } - #[cfg(not(target_os = "macos"))] - { - if let OutputFormat::Text = format { - eprintln!( - "note: `--emit-sandbox` is macOS-only in this MVP; Linux seccomp + Windows AppContainer support tracked under #506." - ); - } - } - } + post_link::emit_sandbox_sidecar(&ctx, &exe_path, format); } emit_android_i18n_resources( @@ -7470,17 +7487,6 @@ pub fn run_with_parse_cache( ); if link_cache_status.stats().linked { - strip_final_binary( - &ctx, - &exe_path, - target.as_deref(), - is_dylib, - is_ios, - is_visionos, - is_tvos, - is_watchos, - is_harmonyos, - ); write_link_cache_manifest(&link_cache_status, &exe_path); } @@ -7506,6 +7512,11 @@ pub fn run_with_parse_cache( ); emit_attestation_sidecar(&ctx, &exe_path, format); + if let Some(layout) = macos_bundle_layout.as_ref() { + if exe_path != layout.executable { + emit_attestation_sidecar(&ctx, &layout.executable, format); + } + } print_binary_size(format, &exe_path); diff --git a/crates/perry/src/commands/mod.rs b/crates/perry/src/commands/mod.rs index e1c0e75366..927e3ee65d 100644 --- a/crates/perry/src/commands/mod.rs +++ b/crates/perry/src/commands/mod.rs @@ -38,6 +38,7 @@ pub mod run; pub mod sandbox_profile; pub mod sanitize; pub mod setup; +mod sidecar; pub mod stdlib_features; pub mod typecheck; pub mod types; diff --git a/crates/perry/src/commands/run/launch.rs b/crates/perry/src/commands/run/launch.rs index 127b6904d7..a8c7178ba5 100644 --- a/crates/perry/src/commands/run/launch.rs +++ b/crates/perry/src/commands/run/launch.rs @@ -99,7 +99,10 @@ pub fn launch_native(exe_path: &Path, program_args: &[String], format: OutputFor println!(); } - let status = Command::new(&exe) + // Execute inside the bundle so Foundation and AppKit see its application + // identity. Direct execution preserves argv, terminal I/O, and exit status. + let executable = native_executable_path(&exe)?; + let status = Command::new(&executable) .args(program_args) .status() .map_err(|e| anyhow!("Failed to launch {}: {}", exe.display(), e))?; @@ -110,6 +113,113 @@ pub fn launch_native(exe_path: &Path, program_args: &[String], format: OutputFor Ok(()) } +fn native_executable_path(output: &Path) -> Result { + if !cfg!(target_os = "macos") + || !output.is_dir() + || output.extension().is_none_or(|ext| ext != "app") + { + return Ok(output.to_path_buf()); + } + let plist = output.join("Contents/Info.plist"); + let result = Command::new("/usr/bin/plutil") + .args(["-extract", "CFBundleExecutable", "raw", "-o", "-"]) + .arg(&plist) + .output() + .with_context(|| format!("read application executable from {}", plist.display()))?; + if !result.status.success() { + bail!("Cannot read CFBundleExecutable from {}", plist.display()); + } + let value = + String::from_utf8(result.stdout).context("application executable name is not UTF-8")?; + let name = value.strip_suffix('\n').unwrap_or(&value); + let mut components = Path::new(name).components(); + if !matches!(components.next(), Some(std::path::Component::Normal(_))) + || components.next().is_some() + { + bail!("Invalid CFBundleExecutable in {}", plist.display()); + } + let executable = output.join("Contents/MacOS").join(name); + if !executable.is_file() { + bail!("Application executable not found: {}", executable.display()); + } + Ok(executable) +} + +#[cfg(all(test, target_os = "macos"))] +mod macos_bundle_tests { + use super::*; + use std::io::Write; + use std::os::unix::fs::PermissionsExt; + use std::process::Stdio; + + #[test] + fn launch_uses_plist_executable_even_after_the_bundle_is_renamed() { + let dir = tempfile::tempdir().unwrap(); + let bundle = dir.path().join("Renamed Application.app"); + std::fs::create_dir_all(bundle.join("Contents/MacOS")).unwrap(); + let binary = bundle.join("Contents/MacOS/Original Engine"); + std::fs::write(&binary, "executable witness").unwrap(); + std::fs::write( + bundle.join("Contents/Info.plist"), + r#" + CFBundleExecutableOriginal Engine + "#, + ) + .unwrap(); + assert_eq!(native_executable_path(&bundle).unwrap(), binary); + assert_eq!(native_executable_path(&binary).unwrap(), binary); + std::fs::remove_file(&binary).unwrap(); + assert!(native_executable_path(&bundle).is_err()); + } + + #[test] + fn launch_bundle_preserves_arguments_terminal_streams_and_exit_status() { + const CHILD: &str = "PERRY_TEST_BUNDLE_LAUNCH_CHILD"; + if let Some(bundle) = std::env::var_os(CHILD) { + launch_native( + Path::new(&bundle), + &["argument with spaces".into()], + OutputFormat::Text, + ) + .unwrap(); + unreachable!("the launched witness exits with status 7"); + } + let dir = tempfile::tempdir().unwrap(); + let bundle = dir.path().join("Terminal Witness.app"); + std::fs::create_dir_all(bundle.join("Contents/MacOS")).unwrap(); + let binary = bundle.join("Contents/MacOS/engine"); + std::fs::write(&binary, "#!/bin/sh\nread -r line\nprintf 'argv=%s input=%s\\n' \"$1\" \"$line\"\nprintf 'stderr witness\\n' >&2\nexit 7\n").unwrap(); + std::fs::set_permissions(&binary, std::fs::Permissions::from_mode(0o755)).unwrap(); + std::fs::write(bundle.join("Contents/Info.plist"), + "CFBundleExecutableengine").unwrap(); + let thread = std::thread::current(); + let mut child = Command::new(std::env::current_exe().unwrap()) + .args([ + "--exact", + thread.name().unwrap(), + "--nocapture", + "--test-threads=1", + ]) + .env(CHILD, &bundle) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap(); + child + .stdin + .take() + .unwrap() + .write_all(b"input witness\n") + .unwrap(); + let output = child.wait_with_output().unwrap(); + assert_eq!(output.status.code(), Some(7)); + assert!(String::from_utf8_lossy(&output.stdout) + .contains("argv=argument with spaces input=input witness\n")); + assert!(String::from_utf8_lossy(&output.stderr).contains("stderr witness\n")); + } +} + /// Launch on iOS Simulator: install + launch pub fn launch_ios_simulator( app_dir: &Path, diff --git a/crates/perry/src/commands/sandbox_profile.rs b/crates/perry/src/commands/sandbox_profile.rs index da358af62c..024d94a045 100644 --- a/crates/perry/src/commands/sandbox_profile.rs +++ b/crates/perry/src/commands/sandbox_profile.rs @@ -42,7 +42,7 @@ pub fn emit_macos_sandbox_profile( ctx: &CompilationContext, binary_path: &Path, ) -> std::io::Result { - let out = binary_path.with_extension("sandbox"); + let out = super::sidecar::path_for_binary(binary_path, "sandbox"); let body = build_macos_profile(ctx); let mut f = std::fs::File::create(&out)?; f.write_all(body.as_bytes())?; diff --git a/crates/perry/src/commands/sidecar.rs b/crates/perry/src/commands/sidecar.rs new file mode 100644 index 0000000000..89a3bc46f2 --- /dev/null +++ b/crates/perry/src/commands/sidecar.rs @@ -0,0 +1,21 @@ +//! Paths for files distributed alongside compiled executables. + +use std::path::{Path, PathBuf}; + +// Keep non-code sidecars out of Contents/MacOS and outside the bundle seal. +// Attestations are written after signing; sandbox profiles can be customized. +pub(super) fn path_for_binary(binary_path: &Path, extension: &str) -> PathBuf { + if let Some(macos) = binary_path.parent() { + if let Some(contents) = macos.parent() { + if let Some(app) = contents.parent() { + if macos.file_name().is_some_and(|name| name == "MacOS") + && contents.file_name().is_some_and(|name| name == "Contents") + && app.extension().is_some_and(|ext| ext == "app") + { + return app.with_extension(format!("app.{extension}")); + } + } + } + } + binary_path.with_extension(extension) +} diff --git a/crates/perry/tests/macos_app_bundle.rs b/crates/perry/tests/macos_app_bundle.rs new file mode 100644 index 0000000000..a07ff1fc4a --- /dev/null +++ b/crates/perry/tests/macos_app_bundle.rs @@ -0,0 +1,191 @@ +//! #10078: local macOS UI builds need a real application bundle. This exercises +//! linking, packaging, and signing without launching a window in the test runner. +#![cfg(target_os = "macos")] + +use std::path::{Path, PathBuf}; +use std::process::{Command, Output}; + +fn checked(mut command: Command) -> Output { + let output = command.output().expect("run command"); + assert!( + output.status.success(), + "{command:?}: {}\n{}\n{}", + output.status, + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + output +} + +fn runtime_dir() -> PathBuf { + let archives = [ + "libperry_runtime.a", + "libperry_stdlib.a", + "libperry_ext_net.a", + "libperry_ui_macos.a", + ]; + if let Some(dir) = std::env::var_os("PERRY_RUNTIME_DIR") { + let dir = PathBuf::from(dir); + if archives.iter().all(|name| dir.join(name).is_file()) { + return dir; + } + } + // Build one coherent archive set when the caller has not supplied one. + let mut build = Command::new(env!("CARGO")); + build + .current_dir(Path::new(env!("CARGO_MANIFEST_DIR")).join("../..")) + .args([ + "build", + "--profile", + "perry-dev", + "--message-format=json", + "-p", + "perry-runtime-static", + "-p", + "perry-stdlib-static", + "-p", + "perry-ext-net", + "-p", + "perry-ui-macos", + ]); + let output = checked(build); + let dir = String::from_utf8_lossy(&output.stdout) + .lines() + .filter_map(|line| serde_json::from_str::(line).ok()) + .filter(|item| item["reason"] == "compiler-artifact") + .filter_map(|item| item["filenames"].as_array().cloned()) + .flatten() + .filter_map(|file| file.as_str().map(PathBuf::from)) + .find(|file| file.file_name().is_some_and(|name| name == archives[0])) + .expect("runtime archive in Cargo output") + .parent() + .unwrap() + .to_path_buf(); + assert!(archives.iter().all(|name| dir.join(name).is_file())); + dir +} + +fn compile(root: &Path, runtime: &Path, source: &str, output: &str) -> PathBuf { + let mut command = Command::new(env!("CARGO_BIN_EXE_perry")); + command + .current_dir(root) + .env("PERRY_RUNTIME_DIR", runtime) + .env("PERRY_LL_OPT_LEVEL", "0") + .env_remove("PERRY_KEEP_SYMBOLS") + .env_remove("PERRY_DEBUG_SYMBOLS") + .args([ + "--format", + "json", + "compile", + source, + "-o", + output, + "--no-cache", + "--no-auto-optimize", + "--no-codegen", + "--emit-attest", + "--emit-sandbox", + ]); + let output = checked(command); + let result = String::from_utf8_lossy(&output.stdout) + .lines() + .filter_map(|line| serde_json::from_str::(line).ok()) + .find(|item| item["success"] == true && item["output"].is_string()) + .expect("successful compilation JSON"); + root.join(result["output"].as_str().unwrap()) +} + +fn verify_bundle(app: &Path) { + let mut plutil = Command::new("/usr/bin/plutil"); + plutil + .args(["-convert", "json", "-o", "-"]) + .arg(app.join("Contents/Info.plist")); + let plist: serde_json::Value = serde_json::from_slice(&checked(plutil).stdout).unwrap(); + assert_eq!(plist["CFBundleIdentifier"], "dev.perry.bundle10078"); + assert_eq!(plist["CFBundleDisplayName"], "Perry & Bundle"); + assert_eq!(plist["CFBundleShortVersionString"], "2.3.4"); + assert_eq!(plist["CFBundleVersion"], "7"); + assert_eq!(plist["CFBundlePackageType"], "APPL"); + assert!(plist["NSCameraUsageDescription"].is_string()); + assert!(plist["NSMicrophoneUsageDescription"].is_string()); + let executable = app + .join("Contents/MacOS") + .join(plist["CFBundleExecutable"].as_str().unwrap()); + assert!(executable.is_file()); + assert_eq!( + std::fs::read_to_string(app.join("Contents/Resources/assets/message.txt")).unwrap(), + "bundle asset\n" + ); + assert!(app.with_extension("app.sandbox").is_file()); + assert!(!executable.with_extension("sandbox").exists()); + assert!(!executable.with_extension("attest.json").exists()); + let attestation: serde_json::Value = + serde_json::from_slice(&std::fs::read(app.with_extension("app.attest.json")).unwrap()) + .unwrap(); + use sha2::{Digest, Sha256}; + let bytes = std::fs::read(&executable).unwrap(); + assert_eq!( + attestation["sha256"], + Sha256::digest(&bytes) + .iter() + .map(|byte| format!("{byte:02x}")) + .collect::() + ); + assert_eq!(attestation["size"], bytes.len() as u64); + let mut verify = Command::new("/usr/bin/codesign"); + verify.args(["--verify", "--deep", "--strict"]).arg(app); + checked(verify); +} + +#[test] +fn ui_outputs_are_signed_bundles_with_resources_and_final_binary_attestations() { + let runtime = runtime_dir(); + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + std::fs::create_dir(root.join("assets")).unwrap(); + std::fs::write(root.join("assets/message.txt"), "bundle asset\n").unwrap(); + std::fs::write(root.join("package.json"), r#"{"type":"module"}"#).unwrap(); + std::fs::write( + root.join("perry.toml"), + r#" +[project] +name = "bundle-test" +version = "2.3.4" +build_number = 7 +[macos] +bundle_id = "dev.perry.bundle10078" +display_name = "Perry & Bundle" +"#, + ) + .unwrap(); + std::fs::write( + root.join("main.ts"), + r#" +import { App, Text } from "perry/ui"; +App({ title: "Bundle test", body: Text("Bundle test") }); +"#, + ) + .unwrap(); + let app = compile(root, &runtime, "main.ts", "My App.v2"); + assert_eq!(app, root.join("My App.v2.app")); + assert!( + root.join("My App.v2").is_file(), + "keep the standalone output" + ); + verify_bundle(&app); + + // Explicit .app outputs link inside the bundle. Rebuilding must not strip + // after signing or truncate the executable while packaging it in place. + for _ in 0..2 { + let app = compile(root, &runtime, "main.ts", "nested/Explicit App.app"); + assert_eq!(app, root.join("nested/Explicit App.app")); + assert!(!root.join("nested/Explicit App").exists()); + verify_bundle(&app); + } + std::fs::write(root.join("cli.ts"), "console.log('plain cli');").unwrap(); + let cli = compile(root, &runtime, "cli.ts", "plain-cli"); + assert!(cli.is_file()); + assert!(!root.join("plain-cli.app").exists()); + let output = checked(Command::new(cli)); + assert_eq!(output.stdout, b"plain cli\n"); +} diff --git a/docs/src/ui/overview.md b/docs/src/ui/overview.md index 113f1f13c1..65a0c1982b 100644 --- a/docs/src/ui/overview.md +++ b/docs/src/ui/overview.md @@ -9,9 +9,22 @@ Perry's `perry/ui` module lets you build native desktop and mobile apps with dec ``` ```bash -perry app.ts -o app && ./app +perry run app.ts ``` +On macOS, UI builds produce an `.app` bundle so the system recognizes the +application's bundle identity. To compile and +launch separately, use `perry app.ts -o app && open app.app`. The linked +`app` executable is also kept; launch the bundle for desktop use. An explicit +`-o MyApp.app` writes the executable directly inside that bundle. + +The bundle includes project assets, localization resources, and the configured +app identity and version. `perry run` launches its executable with the supplied +arguments and terminal input/output. Command-line programs that do not use +`perry/ui` keep their standalone executable output. Optional `--emit-attest` +and `--emit-sandbox` sidecars are written beside the `.app`; the attestation +covers its signed executable in `Contents/MacOS`. + ## Mental Model Perry's UI follows the same model as SwiftUI and Flutter: you compose native widgets using stack-based layout containers (`VStack`, `HStack`, `ZStack`), control alignment and distribution, and style widgets via free functions that take the widget handle as their first argument (`textSetColor(label, r, g, b, a)`, `setPadding(stack, ...)`, etc.). If you're coming from web development, the key shift is: