From 3fca404fe290f947ca68427df2b9a30df8f80d23 Mon Sep 17 00:00:00 2001 From: Dominic Letz Date: Mon, 3 Aug 2026 16:47:34 +0800 Subject: [PATCH 1/3] Package macOS apps with DesktopWebView as the bundle entrypoint. Nest the Mix release under Resources/beam, write DesktopWebView.ini, and set CFBundleExecutable plus mic/camera usage strings. Legacy wx packaging remains available via package.webview_backend: :wx. Co-authored-by: Cursor --- README.md | 21 ++++- lib/package.ex | 8 ++ lib/package/macos.ex | 189 ++++++++++++++++++++++++++++++++++---- rel/macosx/Info.plist.eex | 8 +- 4 files changed, 203 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index c32dc45..2ee0707 100644 --- a/README.md +++ b/README.md @@ -77,7 +77,26 @@ To support windows code signing the user has to create two certificate files `ap ### MacOS -> DMG -MacOS has two variants ARM (M1,2,3...) and legacy x86 if you want to enable your builds on both platforms the easiest way is to have the CI (e.g. GitHub) have the build done on a x86_64 machine. Then the rosetta compatibility layer will make it runnable on ARM too. For this rosetta is enabled explicitly in the `.plist` file for ARM machines. +**Default packaging uses DesktopWebView host-first layout** (see elixir-desktop/webview): + +``` +MyApp.app/Contents/ + MacOS/DesktopWebView + Resources/ + DesktopWebView.ini + beam/ # Mix release + icons.icns + Info.plist # CFBundleExecutable = DesktopWebView +``` + +Locate the host binary via (first match wins): + +1. `package.webview_binary` +2. `DESKTOP_WEBVIEW_BINARY` +3. `:desktop_webview` app `priv/native/macos/DesktopWebView` +4. `deps/desktop_webview/priv/...` or sibling `../webview/priv/...` + +Set `webview_backend: :wx` in `package()` to keep the legacy BEAM-first `MacOS/run` + OTP `:wx` layout (including optional CEF `webview` helper). Code signing is done as part of the `desktop.installer` task if and only if a developer_id is provided. This can be provided through one of these environment variables: diff --git a/lib/package.ex b/lib/package.ex index e4310bd..203cd7a 100644 --- a/lib/package.ex +++ b/lib/package.ex @@ -20,6 +20,14 @@ defmodule Desktop.Deployment.Package do env: %{}, # import options import_inofitywait: false, + # macOS DesktopWebView host-first packaging (default on MacOS) + webview_backend: :desktop_webview, + webview_binary: nil, + beam_app_name: nil, + microphone_usage: + "This app may use the microphone for calls and voice features inside the app.", + camera_usage: + "This app may use the camera for calls and video features inside the app.", # defined during the process app_name: nil, release: nil, diff --git a/lib/package/macos.ex b/lib/package/macos.ex index 56efca1..13c7977 100644 --- a/lib/package/macos.ex +++ b/lib/package/macos.ex @@ -16,16 +16,51 @@ defmodule Desktop.Deployment.Package.MacOS do pkg end - def release(%Package{release: %Mix.Release{path: path} = rel} = pkg) do + def release(%Package{} = pkg) do + if pkg.webview_backend == :desktop_webview do + release_desktop_webview(pkg) + else + release_legacy_wx(pkg) + end + end + + defp release_desktop_webview(%Package{release: %Mix.Release{path: path} = rel} = pkg) do build_root = Path.join([path, "..", ".."]) |> Path.expand() root = Path.join(build_root, "#{pkg.name}.app") - # Remove crust File.rm_rf(root) + contents = Path.join(root, "Contents") bindir = Path.join(contents, "MacOS") resources = Path.join(contents, "Resources") + beam_root = Path.join(resources, "beam") File.mkdir_p!(bindir) + File.mkdir_p!(beam_root) + + host_bin = resolve_webview_binary!(pkg) + File.cp!(host_bin, Path.join(bindir, "DesktopWebView")) + File.chmod!(Path.join(bindir, "DesktopWebView"), 0o755) + + beam_app = pkg.beam_app_name || to_string(pkg.app_name || Mix.Project.config()[:app]) + + ini = """ + [beam] + path = beam + app_name = #{beam_app} + args = start + working_dir = beam + + [network] + host = 127.0.0.1 + port = 0 + + [lifetime] + mode = reconnect + """ + + File.write!(Path.join(resources, "DesktopWebView.ini"), ini) + + pkg = %{pkg | priv: Map.put(pkg.priv, :bundle_executable, "DesktopWebView")} content = eval_eex(Package.toolpath("rel/macosx/InfoPlist.strings.eex"), rel, pkg) utf8bom = :unicode.encoding_to_bom(:utf8) @@ -39,37 +74,96 @@ defmodule Desktop.Deployment.Package.MacOS do content = eval_eex(Package.toolpath("rel/macosx/Info.plist.eex"), rel, pkg) File.write!(Path.join(contents, "Info.plist"), content) File.write!(Path.join(contents, "PkgInfo"), "APPL????") - content_run = eval_eex(Package.toolpath("rel/linux/run.eex"), rel, pkg) - File.write!(Path.join(bindir, "run"), content_run) - File.chmod!(Path.join(bindir, "run"), 0o755) File.ls!(path) |> Enum.each(fn file -> - File.cp_r!(Path.join(path, file), Path.join(resources, file), fn src, dst -> + File.cp_r!(Path.join(path, file), Path.join(beam_root, file), fn src, dst -> file_md5(src) != file_md5(dst) end) end) - # Creating/copying the icon - icon_path = Path.absname("rel/macosx/icons.icns") + icon_path = ensure_icns!(pkg, build_root) + cp!(icon_path, resources) - if not File.exists?(icon_path) do - iconset = Path.join(build_root, "icons.iconset") - File.mkdir_p!(iconset) + with [beam_smp] <- wildcard(root, "**/*.smp") do + oldbin = File.read!(beam_smp) - for size <- [16, 32, 128, 256, 512] do - outfile = Path.join(iconset, "icon_#{size}x#{size}.png") - cmd!("sips", ["-z", size, size, pkg.icon, "--out", outfile]) - outfile = Path.join(iconset, "icon_#{size}x#{size}@2.png") - cmd!("sips", ["-z", 2 * size, 2 * size, pkg.icon, "--out", outfile]) + with [match] <- + Regex.run( + ~r/<\!--PLIST_TEMPLATE_START_64f5fc2af15ab6092d25ede0fdc039e0789aa6e9.+PLIST_TEMPLATE_END_64f5fc2af15ab6092d25ede0fdc039e0789aa6e9-->/s, + oldbin + ) do + size = byte_size(match) + [_all, replacement] = Regex.run(~r/]*>(.+)<\/plist>/s, content) + replacement = String.pad_trailing(replacement, size, " ") + bin = String.replace(oldbin, match, replacement) + IO.puts("Embedding Info.plist into beam.smp[#{byte_size(oldbin)} -> #{byte_size(bin)}]") + File.write!(beam_smp, bin) + cmd!("codesign", ["-s", "-", beam_smp]) end + end - outfile = Path.join(iconset, "icon_512x512@2x.png") - cmd!("sips", ["-z", 1024, 1024, pkg.icon, "--out", outfile]) - File.mkdir_p!(Path.dirname(icon_path)) - cmd!("iconutil", ["-c", "icns", iconset, "-o", icon_path]) + for bin <- find_binaries(root) do + rewrite_deps(bin, fn dep -> + if should_rewrite?(bin, dep) do + rewrite_to_approot(pkg, bin, dep, root) + end + end) + end + + developer_id = find_developer_id() + + if developer_id != nil do + codesign(root) + end + + dmg = make_dmg(pkg) + maybe_make_pkg(pkg) + + if developer_id != nil do + package_sign(developer_id, dmg) end + %{pkg | priv: Map.put(pkg.priv, :installer_name, dmg)} + end + + defp release_legacy_wx(%Package{release: %Mix.Release{path: path} = rel} = pkg) do + build_root = Path.join([path, "..", ".."]) |> Path.expand() + root = Path.join(build_root, "#{pkg.name}.app") + # Remove crust + File.rm_rf(root) + contents = Path.join(root, "Contents") + bindir = Path.join(contents, "MacOS") + resources = Path.join(contents, "Resources") + + File.mkdir_p!(bindir) + + pkg = %{pkg | priv: Map.put(pkg.priv, :bundle_executable, "run")} + + content = eval_eex(Package.toolpath("rel/macosx/InfoPlist.strings.eex"), rel, pkg) + utf8bom = :unicode.encoding_to_bom(:utf8) + + for lang <- ["en", "Base"] do + langdir = Path.join(resources, "#{lang}.lproj") + File.mkdir_p!(langdir) + File.write!(Path.join(langdir, "InfoPlist.strings"), utf8bom <> content) + end + + content = eval_eex(Package.toolpath("rel/macosx/Info.plist.eex"), rel, pkg) + File.write!(Path.join(contents, "Info.plist"), content) + File.write!(Path.join(contents, "PkgInfo"), "APPL????") + content_run = eval_eex(Package.toolpath("rel/linux/run.eex"), rel, pkg) + File.write!(Path.join(bindir, "run"), content_run) + File.chmod!(Path.join(bindir, "run"), 0o755) + + File.ls!(path) + |> Enum.each(fn file -> + File.cp_r!(Path.join(path, file), Path.join(resources, file), fn src, dst -> + file_md5(src) != file_md5(dst) + end) + end) + + icon_path = ensure_icns!(pkg, build_root) cp!(icon_path, resources) maybe_import_webview(pkg, contents) @@ -116,6 +210,61 @@ defmodule Desktop.Deployment.Package.MacOS do %{pkg | priv: Map.put(pkg.priv, :installer_name, dmg)} end + defp ensure_icns!(%Package{} = pkg, build_root) do + icon_path = Path.absname("rel/macosx/icons.icns") + + if not File.exists?(icon_path) do + iconset = Path.join(build_root, "icons.iconset") + File.mkdir_p!(iconset) + + for size <- [16, 32, 128, 256, 512] do + outfile = Path.join(iconset, "icon_#{size}x#{size}.png") + cmd!("sips", ["-z", size, size, pkg.icon, "--out", outfile]) + outfile = Path.join(iconset, "icon_#{size}x#{size}@2.png") + cmd!("sips", ["-z", 2 * size, 2 * size, pkg.icon, "--out", outfile]) + end + + outfile = Path.join(iconset, "icon_512x512@2x.png") + cmd!("sips", ["-z", 1024, 1024, pkg.icon, "--out", outfile]) + File.mkdir_p!(Path.dirname(icon_path)) + cmd!("iconutil", ["-c", "icns", iconset, "-o", icon_path]) + end + + icon_path + end + + defp resolve_webview_binary!(%Package{} = pkg) do + candidates = + [ + pkg.webview_binary, + System.get_env("DESKTOP_WEBVIEW_BINARY"), + desktop_webview_priv_binary(), + Path.expand("deps/desktop_webview/priv/native/macos/DesktopWebView"), + Path.expand("../webview/priv/native/macos/DesktopWebView"), + Path.expand("../../webview/priv/native/macos/DesktopWebView") + ] + |> Enum.filter(&(is_binary(&1) and &1 != "")) + + case Enum.find(candidates, &File.exists?/1) do + nil -> + raise """ + DesktopWebView host binary not found for macOS packaging. + + Set DESKTOP_WEBVIEW_BINARY, package.webview_binary, or build/install + :desktop_webview so priv/native/macos/DesktopWebView exists. + """ + + path -> + path + end + end + + defp desktop_webview_priv_binary do + Application.app_dir(:desktop_webview, "priv/native/macos/DesktopWebView") + rescue + _ -> nil + end + def package_sign(developer_id, dmg) do System.cmd( "codesign", diff --git a/rel/macosx/Info.plist.eex b/rel/macosx/Info.plist.eex index 9ca35d8..fe72bf1 100644 --- a/rel/macosx/Info.plist.eex +++ b/rel/macosx/Info.plist.eex @@ -9,7 +9,7 @@ CFBundleIdentifier <%= @package.identifier %> CFBundleExecutable - run + <%= Map.get(@package.priv, :bundle_executable, "DesktopWebView") %> CFBundleIconFile icons.icns CFBundlePackageType @@ -21,9 +21,13 @@ CFBundleInfoDictionaryVersion 6.0 LSMinimumSystemVersion - 10.13 + 13.0 NSUserNotificationAlertStyle alert + NSMicrophoneUsageDescription + <%= @package.microphone_usage %> + NSCameraUsageDescription + <%= @package.camera_usage %> CFBundleURLTypes From 21cc5eab3dc17da1cabfab38448ffb8789280c0d Mon Sep 17 00:00:00 2001 From: Dominic Letz Date: Mon, 3 Aug 2026 17:03:29 +0800 Subject: [PATCH 2/3] Reduce nesting in macOS packaging helpers for Credo. Extract plist embed, dep rewrite, and installer finalize helpers shared by DesktopWebView and legacy wx release paths. Co-authored-by: Cursor --- lib/package/macos.ex | 96 ++++++++++++++++---------------------------- 1 file changed, 34 insertions(+), 62 deletions(-) diff --git a/lib/package/macos.ex b/lib/package/macos.ex index 13c7977..aa83d23 100644 --- a/lib/package/macos.ex +++ b/lib/package/macos.ex @@ -85,46 +85,9 @@ defmodule Desktop.Deployment.Package.MacOS do icon_path = ensure_icns!(pkg, build_root) cp!(icon_path, resources) - with [beam_smp] <- wildcard(root, "**/*.smp") do - oldbin = File.read!(beam_smp) - - with [match] <- - Regex.run( - ~r/<\!--PLIST_TEMPLATE_START_64f5fc2af15ab6092d25ede0fdc039e0789aa6e9.+PLIST_TEMPLATE_END_64f5fc2af15ab6092d25ede0fdc039e0789aa6e9-->/s, - oldbin - ) do - size = byte_size(match) - [_all, replacement] = Regex.run(~r/]*>(.+)<\/plist>/s, content) - replacement = String.pad_trailing(replacement, size, " ") - bin = String.replace(oldbin, match, replacement) - IO.puts("Embedding Info.plist into beam.smp[#{byte_size(oldbin)} -> #{byte_size(bin)}]") - File.write!(beam_smp, bin) - cmd!("codesign", ["-s", "-", beam_smp]) - end - end - - for bin <- find_binaries(root) do - rewrite_deps(bin, fn dep -> - if should_rewrite?(bin, dep) do - rewrite_to_approot(pkg, bin, dep, root) - end - end) - end - - developer_id = find_developer_id() - - if developer_id != nil do - codesign(root) - end - - dmg = make_dmg(pkg) - maybe_make_pkg(pkg) - - if developer_id != nil do - package_sign(developer_id, dmg) - end - - %{pkg | priv: Map.put(pkg.priv, :installer_name, dmg)} + maybe_embed_plist_in_beam(root, content) + rewrite_app_deps(pkg, root) + finalize_macos_installer(pkg, root) end defp release_legacy_wx(%Package{release: %Mix.Release{path: path} = rel} = pkg) do @@ -167,15 +130,26 @@ defmodule Desktop.Deployment.Package.MacOS do cp!(icon_path, resources) maybe_import_webview(pkg, contents) - # Maybe embedding Info.plist into the beam.smp - with [beam_smp] <- wildcard(root, "**/*.smp") do - oldbin = File.read!(beam_smp) + maybe_embed_plist_in_beam(root, content) + rewrite_app_deps(pkg, root) + finalize_macos_installer(pkg, root) + end + + defp maybe_embed_plist_in_beam(root, content) do + case wildcard(root, "**/*.smp") do + [beam_smp] -> embed_plist_template(beam_smp, content) + _ -> :ok + end + end - with [match] <- - Regex.run( - ~r/<\!--PLIST_TEMPLATE_START_64f5fc2af15ab6092d25ede0fdc039e0789aa6e9.+PLIST_TEMPLATE_END_64f5fc2af15ab6092d25ede0fdc039e0789aa6e9-->/s, - oldbin - ) do + defp embed_plist_template(beam_smp, content) do + oldbin = File.read!(beam_smp) + + case Regex.run( + ~r/<\!--PLIST_TEMPLATE_START_64f5fc2af15ab6092d25ede0fdc039e0789aa6e9.+PLIST_TEMPLATE_END_64f5fc2af15ab6092d25ede0fdc039e0789aa6e9-->/s, + oldbin + ) do + [match] -> size = byte_size(match) [_all, replacement] = Regex.run(~r/]*>(.+)<\/plist>/s, content) replacement = String.pad_trailing(replacement, size, " ") @@ -183,29 +157,27 @@ defmodule Desktop.Deployment.Package.MacOS do IO.puts("Embedding Info.plist into beam.smp[#{byte_size(oldbin)} -> #{byte_size(bin)}]") File.write!(beam_smp, bin) cmd!("codesign", ["-s", "-", beam_smp]) - end + + _ -> + :ok end + end - for bin <- find_binaries(root) do + defp rewrite_app_deps(pkg, root) do + Enum.each(find_binaries(root), fn bin -> rewrite_deps(bin, fn dep -> - if should_rewrite?(bin, dep) do - rewrite_to_approot(pkg, bin, dep, root) - end + if should_rewrite?(bin, dep), do: rewrite_to_approot(pkg, bin, dep, root) end) - end + end) + end + defp finalize_macos_installer(pkg, root) do developer_id = find_developer_id() - - if developer_id != nil do - codesign(root) - end + if developer_id != nil, do: codesign(root) dmg = make_dmg(pkg) maybe_make_pkg(pkg) - - if developer_id != nil do - package_sign(developer_id, dmg) - end + if developer_id != nil, do: package_sign(developer_id, dmg) %{pkg | priv: Map.put(pkg.priv, :installer_name, dmg)} end From e4e83beb65fa2db9c89e0ae317047dd1e4ef965e Mon Sep 17 00:00:00 2001 From: Dominic Letz Date: Mon, 3 Aug 2026 17:04:02 +0800 Subject: [PATCH 3/3] Flatten rewrite_app_deps nesting for Credo max depth. Co-authored-by: Cursor --- lib/package/macos.ex | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/lib/package/macos.ex b/lib/package/macos.ex index aa83d23..1479176 100644 --- a/lib/package/macos.ex +++ b/lib/package/macos.ex @@ -164,10 +164,12 @@ defmodule Desktop.Deployment.Package.MacOS do end defp rewrite_app_deps(pkg, root) do - Enum.each(find_binaries(root), fn bin -> - rewrite_deps(bin, fn dep -> - if should_rewrite?(bin, dep), do: rewrite_to_approot(pkg, bin, dep, root) - end) + Enum.each(find_binaries(root), &rewrite_binary_deps(&1, pkg, root)) + end + + defp rewrite_binary_deps(bin, pkg, root) do + rewrite_deps(bin, fn dep -> + if should_rewrite?(bin, dep), do: rewrite_to_approot(pkg, bin, dep, root) end) end