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