1use 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
12use 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#[derive(Debug, Clone)]
47pub struct MacOsUsageDescription {
48 pub plist_key: &'static str,
50 pub description: String,
52}
53
54pub 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#[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#[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 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#[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#[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}