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 977b8e0..302d15b 100644 --- a/lib/package/macos.ex +++ b/lib/package/macos.ex @@ -16,7 +16,81 @@ 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") + 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) + + 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????") + + File.ls!(path) + |> Enum.each(fn file -> + File.cp_r!(Path.join(path, file), Path.join(beam_root, file), fn src, dst -> + file_md5(src) != file_md5(dst) + end) + end) + + icon_path = ensure_icns!(pkg, build_root) + cp!(icon_path, resources) + + 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 build_root = Path.join([path, "..", ".."]) |> Path.expand() root = Path.join(build_root, "#{pkg.name}.app") # Remove crust @@ -27,6 +101,8 @@ defmodule Desktop.Deployment.Package.MacOS do 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) @@ -50,38 +126,30 @@ defmodule Desktop.Deployment.Package.MacOS do end) end) - # Creating/copying the icon - 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) + icon_path = ensure_icns!(pkg, build_root) + cp!(icon_path, resources) + maybe_import_webview(pkg, contents) - 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 + maybe_embed_plist_in_beam(root, content) + rewrite_app_deps(pkg, root) + finalize_macos_installer(pkg, root) + 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]) + 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 - 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) + defp embed_plist_template(beam_smp, content) do + oldbin = File.read!(beam_smp) - with [match] <- - Regex.run( - ~r/<\!--PLIST_TEMPLATE_START_64f5fc2af15ab6092d25ede0fdc039e0789aa6e9.+PLIST_TEMPLATE_END_64f5fc2af15ab6092d25ede0fdc039e0789aa6e9-->/s, - oldbin - ) do + 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, " ") @@ -89,13 +157,21 @@ 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 - end - for bin <- find_binaries(root) do - rewrite_deps(bin, &maybe_rewrite_dep_to_approot(pkg, bin, &1, root)) + _ -> + :ok end + end + + defp rewrite_app_deps(pkg, root) do + Enum.each(find_binaries(root), &rewrite_binary_deps(&1, pkg, root)) + end + defp rewrite_binary_deps(bin, pkg, root) do + rewrite_deps(bin, &maybe_rewrite_dep_to_approot(pkg, bin, &1, root)) + end + + defp finalize_macos_installer(pkg, root) do developer_id = find_developer_id() if developer_id != nil do @@ -106,12 +182,64 @@ defmodule Desktop.Deployment.Package.MacOS do dmg = make_dmg(pkg) maybe_make_pkg(pkg) + if developer_id != nil, do: package_sign(developer_id, dmg) - if developer_id != nil do - package_sign(developer_id, dmg) + %{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 - %{pkg | priv: Map.put(pkg.priv, :installer_name, dmg)} + 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 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