Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
1 change: 1 addition & 0 deletions changelog.d/10240-macos-app-bundles.md
Original file line number Diff line number Diff line change
@@ -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.
27 changes: 23 additions & 4 deletions crates/perry/src/commands/attest.rs
Original file line number Diff line number Diff line change
Expand Up @@ -124,13 +124,13 @@ pub fn build_attestation(binary_path: &Path, project_root: &Path) -> Result<Atte
})
}

/// Write the manifest to `<binary>.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<std::path::PathBuf> {
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()))?;
Expand All @@ -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<AttestationManifest> {
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\
Expand Down Expand Up @@ -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");
Expand Down
1 change: 1 addition & 0 deletions crates/perry/src/commands/compile.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
211 changes: 211 additions & 0 deletions crates/perry/src/commands/compile/bundle_macos.rs
Original file line number Diff line number Diff line change
@@ -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<Option<MacosBundleLayout>> {
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#"<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleInfoDictionaryVersion</key><string>6.0</string>
<key>CFBundleIdentifier</key><string>{bundle_id}</string>
<key>CFBundleName</key><string>{display_name}</string>
<key>CFBundleDisplayName</key><string>{display_name}</string>
<key>CFBundleExecutable</key><string>{filename}</string>
<key>CFBundlePackageType</key><string>APPL</string>
<key>CFBundleShortVersionString</key><string>{version}</string>
<key>CFBundleVersion</key><string>{build_number}</string>
<key>NSHighResolutionCapable</key><true/>
<key>NSCameraUsageDescription</key>
<string>This app uses the camera for WebView video calls.</string>
<key>NSMicrophoneUsageDescription</key>
<string>This app uses the microphone for WebView video calls.</string>
</dict>
</plist>
"#,
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"
);
}
}
34 changes: 3 additions & 31 deletions crates/perry/src/commands/compile/link/build_and_run.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand All @@ -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::<String>()
.trim_matches('-')
);
let info_plist = format!(
r#"<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleIdentifier</key>
<string>{bundle_id}</string>
<key>CFBundleName</key>
<string>{exe_stem}</string>
<key>CFBundleExecutable</key>
<string>{exe_stem}</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>NSCameraUsageDescription</key>
<string>This app uses the camera for WebView video calls.</string>
<key>NSMicrophoneUsageDescription</key>
<string>This app uses the microphone for WebView video calls.</string>
</dict>
</plist>
"#
);
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(),
Expand Down
41 changes: 41 additions & 0 deletions crates/perry/src/commands/compile/post_link.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<binary>.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 `<binary>.attest.json` AFTER strip/codesign so the
/// captured SHA-256 matches what users will actually download.
/// Best-effort — errors log and continue.
Expand Down
Loading
Loading