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
54#[derive(Debug, Clone, Copy)]
56pub struct MacOsAppNames<'a> {
57 pub app_name: &'a str,
59 pub executable_name: &'a str,
63}
64
65pub 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#[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#[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 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#[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#[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 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}