defmodule BDS.MacBundle do @moduledoc """ Assembles a self-contained, ad-hoc-signed macOS `.app` bundle for bDS2. The bundle wraps the `:bds` Elixir release (which already embeds ERTS), a red-pen `AppIcon.icns` (`BDS.MacBundle.Icon`), and the wxWidgets dylibs the `:wx` NIF needs, relocated to run on a clean Mac (`BDS.MacBundle.Dylibs`). Its `Info.plist` registers the `bds2://` URL scheme so the OS forwards blogmark deep links to the running app (`Desktop.Env` → `BDS.Desktop.DeepLink`). Layout: BDS2.app/Contents/ Info.plist PkgInfo MacOS/bds2 # native arm64 launcher (execs the release) Resources/AppIcon.icns Resources/rel/ # the mix release (ERTS bundled) Frameworks/ # relocated wx + transitive dylibs """ alias BDS.MacBundle.Dylibs @identifier "de.rfc1437.bds2" @display_name "Blogging Desktop Server" @executable "bds2" @icon_name "AppIcon" @scheme "bds2" @min_system "13.0" @release_name "bds" @app_dir_name "BDS2.app" @doc "Render the `Info.plist` XML for the bundle." @spec info_plist(keyword()) :: String.t() def info_plist(opts \\ []) do assigns = [ identifier: Keyword.get(opts, :identifier, @identifier), name: Keyword.get(opts, :name, @display_name), executable: Keyword.get(opts, :executable, @executable), icon: Keyword.get(opts, :icon, @icon_name), scheme: Keyword.get(opts, :scheme, @scheme), min_system: Keyword.get(opts, :min_system, @min_system), version: Keyword.get(opts, :version, "0.0.0") ] EEx.eval_file(template_path("Info.plist.eex"), assigns) end @doc """ Assemble the `.app` from a built release directory. Options: * `:release_dir` — path to the `:bds` release (required) * `:output_dir` — where to place `BDS2.app` (required) * `:icns_path` — path to `AppIcon.icns` (required) * `:version` — version string for `Info.plist` (default "0.0.0") * `:skip_dylibs` — skip wx dylib relocation (tests; default false) * `:skip_codesign`— skip ad-hoc codesign (tests; default false) """ @spec assemble(keyword()) :: {:ok, String.t()} | {:error, term()} def assemble(opts) do release_dir = Keyword.fetch!(opts, :release_dir) output_dir = Keyword.fetch!(opts, :output_dir) icns_path = Keyword.fetch!(opts, :icns_path) version = Keyword.get(opts, :version, "0.0.0") app = Path.join(output_dir, @app_dir_name) contents = Path.join(app, "Contents") macos = Path.join(contents, "MacOS") resources = Path.join(contents, "Resources") frameworks = Path.join(contents, "Frameworks") rel = Path.join(resources, "rel") File.rm_rf!(app) Enum.each([macos, resources, frameworks], &File.mkdir_p!/1) with {:ok, _} <- File.cp_r(release_dir, rel), :ok <- File.cp(icns_path, Path.join(resources, "#{@icon_name}.icns")), :ok <- File.write(Path.join(contents, "Info.plist"), info_plist(version: version)), :ok <- File.write(Path.join(contents, "PkgInfo"), "APPL????"), :ok <- write_launcher(Path.join(macos, @executable)), :ok <- maybe_relocate_dylibs(rel, frameworks, opts), :ok <- maybe_codesign(app, opts) do {:ok, app} end end # The main executable must be a real arm64 Mach-O, not a shell script — a # script has no Mach-O slices, so LaunchServices advertises x86_64 and offers # Rosetta. Compile the native stub (launcher.c) in its place. defp write_launcher(path) do src = template_path("launcher.c") case System.cmd( "cc", ["-arch", "arm64", "-O2", ~s(-DBDS_RELEASE_NAME="#{@release_name}"), "-o", path, src], stderr_to_stdout: true ) do {_, 0} -> File.chmod(path, 0o755) {out, code} -> {:error, "launcher compile failed (#{code}): #{out}"} end end defp maybe_relocate_dylibs(_rel, _frameworks, opts) do if Keyword.get(opts, :skip_dylibs, false) do :ok else relocate_dylibs(opts[:release_dir], opts) end end defp relocate_dylibs(release_dir, opts) do app = Path.join(opts[:output_dir], @app_dir_name) frameworks = Path.join(app, "Contents/Frameworks") rel = Path.join(app, "Contents/Resources/rel") # Every loose Mach-O in the release (NIFs, beam.smp, …) may link a Homebrew # dylib, so relocate the closure reachable from all of them — not just wx. case macho_files(rel) do [] -> {:error, {:no_release_binaries, release_dir}} nifs -> prefix_for = fn nif -> "@loader_path/" <> relative_up(Path.dirname(nif), frameworks) end with {:ok, _copied} <- Dylibs.bundle(nifs, frameworks, prefix_for) do verify_standalone(app) end end end @doc """ Fail the build unless the bundle is self-contained: no Mach-O under the `.app` may reference a Homebrew/local (`/opt/homebrew`, `/usr/local`) path. This is a hard requirement — the app must run on a Mac without Homebrew installed. """ @spec verify_standalone(String.t()) :: :ok | {:error, {:external_refs, [{String.t(), String.t()}]}} def verify_standalone(app) do offenders = app |> macho_files() |> Enum.flat_map(fn file -> external_refs(file, ["-L"], &Dylibs.parse_otool/1) ++ external_refs(file, ["-l"], &Dylibs.parse_rpaths/1) end) if offenders == [], do: :ok, else: {:error, {:external_refs, offenders}} end defp external_refs(file, otool_args, parse) do case System.cmd("otool", otool_args ++ [file], stderr_to_stdout: true) do {out, 0} -> out |> parse.() |> Enum.filter(&Dylibs.external?/1) |> Enum.map(&{file, &1}) _ -> [] end end @doc """ Compute a `../`-style relative path from `from_dir` to `to` (both absolute), e.g. the path from the wx NIF's directory up to `Contents/Frameworks`. """ @spec relative_up(String.t(), String.t()) :: String.t() def relative_up(from_dir, to) do from = Path.split(Path.expand(from_dir)) target = Path.split(Path.expand(to)) {rest_from, rest_to} = drop_common(from, target) (List.duplicate("..", length(rest_from)) ++ rest_to) |> Path.join() end defp drop_common([h | from], [h | to]), do: drop_common(from, to) defp drop_common(from, to), do: {from, to} defp maybe_codesign(app, opts) do if Keyword.get(opts, :skip_codesign, false), do: :ok, else: codesign(app) end # Mach-O magic numbers (thin 32/64-bit, both endiannesses, and fat) as the # first four bytes appear on disk. @macho_magics [ <<0xCF, 0xFA, 0xED, 0xFE>>, <<0xCE, 0xFA, 0xED, 0xFE>>, <<0xFE, 0xED, 0xFA, 0xCF>>, <<0xFE, 0xED, 0xFA, 0xCE>>, <<0xCA, 0xFE, 0xBA, 0xBE>>, <<0xBE, 0xBA, 0xFE, 0xCA>> ] @doc """ Ad-hoc codesign the bundle. `codesign --deep` only recurses into nested *bundles* and the main executable; it ignores the loose Mach-O files the release ships — `beam.smp`, `erlexec`, and every NIF `.so`/`.dylib` under `Resources/rel/…`. Those keep their original *linker-signed* ad-hoc signatures, which the macOS code-signing monitor rejects once the files are copied into a new bundle (the process is SIGKILLed with "Code Signature Invalid" the moment it `dlopen`s such a NIF). So we sign every Mach-O explicitly with a fresh ad-hoc signature (inside-out), then seal the outer bundle and verify it. """ @spec codesign(String.t()) :: :ok | {:error, term()} def codesign(app) do with :ok <- sign_machos(macho_files(app)), :ok <- run("codesign", ["--force", "--sign", "-", "--timestamp=none", app]) do run("codesign", ["--verify", "--strict", app]) end end @doc """ List every Mach-O file under `root`, detected by magic number. These are the loose binaries `codesign --deep` skips and that must be signed individually. """ @spec macho_files(String.t()) :: [String.t()] def macho_files(root) do root |> regular_files() |> Enum.filter(&macho?/1) end defp regular_files(path) do case File.ls(path) do {:ok, entries} -> Enum.flat_map(entries, fn entry -> full = Path.join(path, entry) case File.lstat(full) do {:ok, %File.Stat{type: :directory}} -> regular_files(full) {:ok, %File.Stat{type: :regular}} -> [full] _ -> [] end end) {:error, _} -> [] end end defp macho?(file) do case File.open(file, [:read, :binary], &IO.binread(&1, 4)) do {:ok, magic} when magic in @macho_magics -> true _ -> false end end defp sign_machos(files) do Enum.reduce_while(files, :ok, fn file, :ok -> case run("codesign", ["--force", "--sign", "-", "--timestamp=none", file]) do :ok -> {:cont, :ok} error -> {:halt, error} end end) end defp run(cmd, args) do case System.cmd(cmd, args, stderr_to_stdout: true) do {_out, 0} -> :ok {out, status} -> {:error, {:command_failed, cmd, status, out}} end end defp template_path(name), do: Path.join(:code.priv_dir(:bds), "desktop/macos/#{name}") end