Skip to main content

waterui_cli/platforming/
macos_bundle.rs

1//! Helpers for packaging native binaries into macOS `.app` bundles.
2
3use std::path::{Path, PathBuf};
4
5use askama::Template;
6use eyre::bail;
7use fs_extra::dir::CopyOptions;
8use smol::fs;
9#[cfg(target_os = "macos")]
10use smol::stream::StreamExt as _;
11
12// `copy_file` is used by `package_binary_as_app`, which compiles on every
13// host; only the codesign helpers below are macOS-gated.
14use crate::utils::copy_file;
15#[cfg(target_os = "macos")]
16use crate::utils::run_command_os;
17
18#[cfg(target_os = "macos")]
19const CEF_HELPER_VARIANTS: [(&str, &str); 5] = [
20    ("", ""),
21    (" (Alerts)", ".alerts"),
22    (" (GPU)", ".gpu"),
23    (" (Plugin)", ".plugin"),
24    (" (Renderer)", ".renderer"),
25];
26
27#[derive(Template)]
28#[template(path = "macos/Info.plist.tpl", escape = "none")]
29struct InfoPlistTemplate<'a> {
30    bundle_identifier: &'a str,
31    app_name: &'a str,
32    executable_name: &'a str,
33    usage_descriptions: &'a [MacOsUsageDescription],
34}
35
36#[cfg(target_os = "macos")]
37#[derive(Template)]
38#[template(path = "macos/CefHelperInfo.plist.tpl", escape = "none")]
39struct CefHelperInfoPlistTemplate<'a> {
40    bundle_identifier: &'a str,
41    helper_name: &'a str,
42    product_name: &'a str,
43}
44
45/// Apple Info.plist usage-description entry for a macOS app bundle.
46#[derive(Debug, Clone)]
47pub struct MacOsUsageDescription {
48    /// Raw Info.plist key such as `NSCameraUsageDescription`.
49    pub plist_key: &'static str,
50    /// User-facing reason declared in `Water.toml`.
51    pub description: String,
52}
53
54/// The two names a macOS `.app` bundle carries.
55#[derive(Debug, Clone, Copy)]
56pub struct MacOsAppNames<'a> {
57    /// Human-readable bundle name — `<app_name>.app` and `CFBundleName`.
58    pub app_name: &'a str,
59    /// Shipped `Contents/MacOS` and `CFBundleExecutable` name — the product
60    /// name, not the artifact's file name, which may carry a build-internal
61    /// tag.
62    pub executable_name: &'a str,
63}
64
65/// Package a compiled binary as a macOS `.app` bundle.
66///
67/// `resources_dir` is optional and copied to `Contents/Resources` when
68/// present. `icns` is the encoded app-icon family, written as
69/// `Contents/Resources/AppIcon.icns` and referenced from `Info.plist`.
70///
71/// # Errors
72/// Returns an error if the binary is missing, template rendering fails, or bundle files cannot be created.
73pub async fn package_binary_as_app(
74    binary_path: &Path,
75    bundle_id: &str,
76    names: MacOsAppNames<'_>,
77    usage_descriptions: &[MacOsUsageDescription],
78    resources_dir: Option<&Path>,
79    icns: &[u8],
80    output_root: &Path,
81) -> eyre::Result<PathBuf> {
82    if !binary_path.exists() {
83        bail!(
84            "Binary not found at {}. Build must succeed before packaging.",
85            binary_path.display()
86        );
87    }
88
89    let app_dir = output_root.join(format!("{}.app", names.app_name));
90    let contents_dir = app_dir.join("Contents");
91    let macos_dir = contents_dir.join("MacOS");
92    let bundle_resources_dir = contents_dir.join("Resources");
93    if app_dir.exists() {
94        fs::remove_dir_all(&app_dir).await?;
95    }
96    fs::create_dir_all(&macos_dir).await?;
97    fs::create_dir_all(&bundle_resources_dir).await?;
98
99    let executable_dest = macos_dir.join(names.executable_name);
100    copy_file(binary_path, &executable_dest).await?;
101    #[cfg(unix)]
102    {
103        use std::os::unix::fs::PermissionsExt;
104        let mut perms = fs::metadata(&executable_dest).await?.permissions();
105        perms.set_mode(0o755);
106        fs::set_permissions(&executable_dest, perms).await?;
107    }
108
109    if let Some(src_resources) = resources_dir
110        && src_resources.exists()
111    {
112        copy_dir(src_resources, &bundle_resources_dir).await?;
113    }
114
115    fs::write(bundle_resources_dir.join("AppIcon.icns"), icns).await?;
116
117    let plist = InfoPlistTemplate {
118        bundle_identifier: bundle_id,
119        app_name: names.app_name,
120        executable_name: names.executable_name,
121        usage_descriptions,
122    }
123    .render()
124    .map_err(|error| eyre::eyre!("Failed to render Info.plist template: {error}"))?;
125    fs::write(contents_dir.join("Info.plist"), plist).await?;
126
127    Ok(app_dir)
128}
129
130/// Signs a local macOS app bundle with an installed development identity.
131///
132/// Apps declaring protected-resource usage descriptions require a stable
133/// identity so macOS can persist privacy grants across local rebuilds. Apps
134/// without protected resources use ad-hoc signing when no identity is installed.
135///
136/// # Errors
137///
138/// Returns an error when a protected-resource app has no development identity,
139/// or when `security`/`codesign` cannot inspect or sign the assembled bundle.
140#[cfg(target_os = "macos")]
141pub async fn sign_macos_app(
142    app_path: &Path,
143    bundle_id: &str,
144    requires_stable_identity: bool,
145) -> eyre::Result<()> {
146    let identities = run_command_os(
147        "security",
148        [
149            std::ffi::OsStr::new("find-identity"),
150            std::ffi::OsStr::new("-v"),
151            std::ffi::OsStr::new("-p"),
152            std::ffi::OsStr::new("codesigning"),
153        ],
154    )
155    .await?;
156    let identity = if requires_stable_identity {
157        first_codesigning_identity(&identities).ok_or_else(|| {
158            eyre::eyre!(
159                "macOS apps using protected resources require an installed code-signing identity"
160            )
161        })?
162    } else {
163        "-"
164    };
165
166    let frameworks_dir = app_path.join("Contents").join("Frameworks");
167    if frameworks_dir.exists() {
168        let mut entries = fs::read_dir(&frameworks_dir).await?;
169        let mut framework_paths = Vec::new();
170        while let Some(entry) = entries.next().await {
171            let path = entry?.path();
172            if path.is_file()
173                || matches!(
174                    path.extension().and_then(std::ffi::OsStr::to_str),
175                    Some("app" | "framework")
176                )
177            {
178                framework_paths.push(path);
179            }
180        }
181        framework_paths.sort();
182        for framework_path in framework_paths {
183            codesign_path(&framework_path, identity, None).await?;
184        }
185    }
186
187    codesign_path(app_path, identity, Some(bundle_id)).await?;
188    Ok(())
189}
190
191/// Signs the libraries staged into a device bundle's `Frameworks/` directory.
192///
193/// `xcodebuild` signs what it embeds; `water package` copies the Rust dylibs
194/// into `Frameworks/` afterwards, so they reach a device with no signature at
195/// all and `dyld` refuses them. Each staged file is re-signed with the leaf
196/// identity Xcode used for the app itself, read back from the app's
197/// `Authority` chain — the generated project cannot know which of the
198/// keychain's development certificates automatic signing resolved.
199///
200/// # Errors
201///
202/// Returns an error if `codesign` cannot read the app's signature or sign a
203/// staged library.
204#[cfg(target_os = "macos")]
205pub async fn sign_staged_device_libraries(
206    app_path: &Path,
207    frameworks_dir: &Path,
208) -> eyre::Result<()> {
209    use crate::toolchain::Host;
210
211    if !frameworks_dir.exists() {
212        return Ok(());
213    }
214
215    // `codesign` reports the signature on stderr, and the `Authority=` chain
216    // only appears at `-vvv`; its first line is the leaf certificate that
217    // signed the app.
218    let output = Host::current()
219        .output(
220            "codesign",
221            [std::ffi::OsStr::new("-dvvv"), app_path.as_os_str()],
222        )
223        .await?;
224    let info = String::from_utf8_lossy(&output.stderr);
225    let identity = info
226        .lines()
227        .find_map(|line| line.strip_prefix("Authority="))
228        .ok_or_else(|| {
229            eyre::eyre!(
230                "codesign reported no signing authority for {}",
231                app_path.display()
232            )
233        })?;
234
235    let mut staged_paths = Vec::new();
236    let mut entries = fs::read_dir(frameworks_dir).await?;
237    while let Some(entry) = entries.next().await {
238        let path = entry?.path();
239        if path.is_file()
240            || matches!(
241                path.extension().and_then(std::ffi::OsStr::to_str),
242                Some("app" | "framework")
243            )
244        {
245            staged_paths.push(path);
246        }
247    }
248    staged_paths.sort();
249    for staged in staged_paths {
250        codesign_path(&staged, identity, None).await?;
251    }
252    Ok(())
253}
254
255#[cfg(target_os = "macos")]
256fn first_codesigning_identity(output: &str) -> Option<&str> {
257    output.lines().find_map(|line| {
258        let (_, identity_and_name) = line.split_once(')')?;
259        let identity = identity_and_name.split_whitespace().next()?;
260        (identity.len() == 40 && identity.bytes().all(|byte| byte.is_ascii_hexdigit()))
261            .then_some(identity)
262    })
263}
264
265#[cfg(target_os = "macos")]
266async fn codesign_path(path: &Path, identity: &str, bundle_id: Option<&str>) -> eyre::Result<()> {
267    let mut arguments = vec![
268        std::ffi::OsString::from("--force"),
269        std::ffi::OsString::from("--sign"),
270        std::ffi::OsString::from(identity),
271        std::ffi::OsString::from("--timestamp=none"),
272    ];
273    if let Some(bundle_id) = bundle_id {
274        arguments.push(std::ffi::OsString::from("--identifier"));
275        arguments.push(std::ffi::OsString::from(bundle_id));
276    }
277    arguments.push(path.as_os_str().to_owned());
278    run_command_os("codesign", arguments).await?;
279    Ok(())
280}
281
282/// Packages the current application executable as the invisible helper variants
283/// required by CEF on macOS.
284///
285/// # Errors
286/// Returns an error if the main bundle layout is malformed or the helper cannot
287/// be copied and described.
288#[cfg(target_os = "macos")]
289pub async fn package_cef_helper_app(
290    app_dir: &Path,
291    main_binary_path: &Path,
292    helper_binary_path: &Path,
293    bundle_identifier: &str,
294) -> eyre::Result<Vec<PathBuf>> {
295    let executable_name = main_binary_path
296        .file_name()
297        .and_then(std::ffi::OsStr::to_str)
298        .ok_or_else(|| eyre::eyre!("main application has no valid executable name"))?;
299    if !helper_binary_path.is_file() {
300        bail!(
301            "CEF helper executable is missing at {}",
302            helper_binary_path.display()
303        );
304    }
305    let main_frameworks_dir = app_dir.join("Contents/Frameworks");
306    let mut dynamic_libraries = Vec::new();
307    let mut frameworks = fs::read_dir(&main_frameworks_dir).await?;
308    while let Some(entry) = frameworks.next().await {
309        let entry = entry?;
310        let path = entry.path();
311        if path.extension().and_then(std::ffi::OsStr::to_str) == Some("dylib") {
312            dynamic_libraries.push(entry.file_name());
313        }
314    }
315
316    let mut helper_dirs = Vec::with_capacity(CEF_HELPER_VARIANTS.len());
317    for (name_suffix, identifier_suffix) in CEF_HELPER_VARIANTS {
318        let helper_name = format!("{executable_name} Helper{name_suffix}");
319        let helper_dir = main_frameworks_dir.join(format!("{helper_name}.app"));
320        if helper_dir.exists() {
321            fs::remove_dir_all(&helper_dir).await?;
322        }
323
324        let helper_contents_dir = helper_dir.join("Contents");
325        let helper_macos_dir = helper_contents_dir.join("MacOS");
326        let helper_frameworks_dir = helper_contents_dir.join("Frameworks");
327        fs::create_dir_all(&helper_macos_dir).await?;
328        fs::create_dir_all(&helper_frameworks_dir).await?;
329
330        let helper_executable = helper_macos_dir.join(&helper_name);
331        copy_file(helper_binary_path, &helper_executable).await?;
332        {
333            use std::os::unix::fs::PermissionsExt as _;
334
335            let mut permissions = fs::metadata(&helper_executable).await?.permissions();
336            permissions.set_mode(0o755);
337            fs::set_permissions(&helper_executable, permissions).await?;
338        }
339        for name in &dynamic_libraries {
340            std::os::unix::fs::symlink(
341                Path::new("../../..").join(name),
342                helper_frameworks_dir.join(name),
343            )?;
344        }
345
346        let helper_bundle_identifier = format!("{bundle_identifier}.helper{identifier_suffix}");
347        let plist = CefHelperInfoPlistTemplate {
348            bundle_identifier: &helper_bundle_identifier,
349            helper_name: &helper_name,
350            product_name: executable_name,
351        }
352        .render()
353        .map_err(|error| eyre::eyre!("Failed to render CEF helper Info.plist: {error}"))?;
354        fs::write(helper_contents_dir.join("Info.plist"), plist).await?;
355        fs::write(helper_contents_dir.join("PkgInfo"), b"APPL????").await?;
356        helper_dirs.push(helper_dir);
357    }
358
359    Ok(helper_dirs)
360}
361
362/// Removes CEF helper applications added after a previous macOS build.
363///
364/// # Errors
365///
366/// Returns an error when an existing helper application cannot be removed.
367#[cfg(target_os = "macos")]
368pub async fn remove_cef_helper_apps(app_dir: &Path, executable_name: &str) -> eyre::Result<()> {
369    let frameworks_dir = app_dir.join("Contents/Frameworks");
370    for (name_suffix, _) in CEF_HELPER_VARIANTS {
371        let helper_name = format!("{executable_name} Helper{name_suffix}.app");
372        let helper_dir = frameworks_dir.join(helper_name);
373        if helper_dir.exists() {
374            fs::remove_dir_all(helper_dir).await?;
375        }
376    }
377    Ok(())
378}
379
380async fn copy_dir(from: &Path, to: &Path) -> eyre::Result<()> {
381    let source = from.to_path_buf();
382    let destination = to.to_path_buf();
383    smol::unblock(move || {
384        let mut options = CopyOptions::new();
385        options.copy_inside = true;
386        options.overwrite = true;
387        fs_extra::dir::copy(&source, &destination, &options)
388            .map(|_| ())
389            .map_err(|error| {
390                eyre::eyre!(
391                    "Failed to copy resources from {} to {}: {error}",
392                    source.display(),
393                    destination.display()
394                )
395            })
396    })
397    .await
398}
399
400#[cfg(all(test, target_os = "macos"))]
401mod tests {
402    use std::os::unix::fs::PermissionsExt as _;
403
404    use super::{first_codesigning_identity, package_cef_helper_app, remove_cef_helper_apps};
405
406    #[test]
407    fn parses_first_valid_codesigning_identity() {
408        let output = "  1) 645DCB18E20044A687FFE48B0E62D31BF9F6A443 \"Apple Development\"\n     1 valid identities found\n";
409        assert_eq!(
410            first_codesigning_identity(output),
411            Some("645DCB18E20044A687FFE48B0E62D31BF9F6A443")
412        );
413    }
414
415    #[test]
416    fn reports_no_codesigning_identity() {
417        assert_eq!(
418            first_codesigning_identity("     0 valid identities found\n"),
419            None
420        );
421    }
422
423    #[test]
424    fn packaged_app_carries_icon_and_plist_references() {
425        smol::block_on(async {
426            let temporary = tempfile::tempdir().expect("temporary directory must be available");
427            // The built binary carries the generated crate's project-root
428            // tag; the bundle ships it under the product name.
429            let binary = temporary.path().join("demo-hydrolysis-deadbeef");
430            std::fs::write(&binary, b"demo").expect("fake executable must be written");
431            std::fs::set_permissions(&binary, std::fs::Permissions::from_mode(0o755))
432                .expect("fake executable must be executable");
433
434            let app = super::package_binary_as_app(
435                &binary,
436                "dev.waterui.demo",
437                super::MacOsAppNames {
438                    app_name: "Demo",
439                    executable_name: "demo-hydrolysis",
440                },
441                &[],
442                None,
443                b"fake-icns-bytes",
444                temporary.path(),
445            )
446            .await
447            .expect("bundle must package");
448
449            assert_eq!(
450                std::fs::read(app.join("Contents/Resources/AppIcon.icns"))
451                    .expect("bundle must contain the icon family"),
452                b"fake-icns-bytes"
453            );
454            assert!(app.join("Contents/MacOS/demo-hydrolysis").is_file());
455            assert!(!app.join("Contents/MacOS/demo-hydrolysis-deadbeef").exists());
456            let plist = std::fs::read_to_string(app.join("Contents/Info.plist"))
457                .expect("bundle plist must be readable");
458            assert!(plist.contains("<key>CFBundleIconFile</key>"));
459            assert!(plist.contains("<key>CFBundleIconName</key>"));
460            assert!(plist.contains("<string>demo-hydrolysis</string>"));
461            assert!(!plist.contains("deadbeef"));
462        });
463    }
464
465    #[test]
466    fn cef_helpers_are_invisible_variant_bundles_with_shared_runtime_links() {
467        smol::block_on(async {
468            let temporary = tempfile::tempdir().expect("temporary directory must be available");
469            let app = temporary.path().join("Browser.app");
470            let frameworks = app.join("Contents/Frameworks");
471            let binary = temporary.path().join("browser");
472            let helper_binary = temporary.path().join("waterui-cef-helper");
473            std::fs::create_dir_all(&frameworks).expect("frameworks directory must be created");
474            std::fs::write(&binary, b"browser").expect("fake executable must be written");
475            std::fs::write(&helper_binary, b"helper")
476                .expect("fake helper executable must be written");
477            std::fs::set_permissions(&binary, std::fs::Permissions::from_mode(0o755))
478                .expect("fake executable must be executable");
479            std::fs::set_permissions(&helper_binary, std::fs::Permissions::from_mode(0o755))
480                .expect("fake helper executable must be executable");
481            std::fs::write(frameworks.join("libwaterui.dylib"), b"runtime")
482                .expect("fake runtime must be written");
483
484            let helpers =
485                package_cef_helper_app(&app, &binary, &helper_binary, "dev.waterui.browser")
486                    .await
487                    .expect("CEF helper must package");
488            assert_eq!(helpers.len(), 5);
489            let helper = &helpers[0];
490            let helper_name = "browser Helper";
491            let plist = std::fs::read_to_string(helper.join("Contents/Info.plist"))
492                .expect("helper plist must be readable");
493            assert!(plist.contains("<key>LSUIElement</key>"));
494            assert!(plist.contains("dev.waterui.browser.helper"));
495            assert!(helper.join("Contents/MacOS").join(helper_name).is_file());
496            assert_eq!(
497                std::fs::read_link(helper.join("Contents/Frameworks/libwaterui.dylib"))
498                    .expect("helper runtime must be linked"),
499                std::path::PathBuf::from("../../../libwaterui.dylib")
500            );
501            let renderer = &helpers[4];
502            let renderer_plist = std::fs::read_to_string(renderer.join("Contents/Info.plist"))
503                .expect("renderer helper plist must be readable");
504            assert!(renderer_plist.contains("browser Helper (Renderer)"));
505            assert!(renderer_plist.contains("dev.waterui.browser.helper.renderer"));
506
507            remove_cef_helper_apps(&app, "browser")
508                .await
509                .expect("CEF helpers must be removable before an incremental build");
510            for helper in helpers {
511                assert!(!helper.exists());
512            }
513        });
514    }
515}