From d78cb5c07b1e67f299bf4a266d3a10c10c87585e Mon Sep 17 00:00:00 2001 From: Chili Palmer Date: Mon, 22 Jun 2026 15:02:08 +0200 Subject: [PATCH] fix: mac bundle now proper arm64 --- lib/bds/mac_bundle.ex | 31 +++++++++--------- priv/desktop/macos/Info.plist.eex | 5 +++ priv/desktop/macos/launcher.c | 51 ++++++++++++++++++++++++++++++ priv/desktop/macos/launcher.sh.eex | 17 ---------- test/bds/mac_bundle_test.exs | 18 ++++++++--- 5 files changed, 84 insertions(+), 38 deletions(-) create mode 100644 priv/desktop/macos/launcher.c delete mode 100644 priv/desktop/macos/launcher.sh.eex diff --git a/lib/bds/mac_bundle.ex b/lib/bds/mac_bundle.ex index 7c08d97..bcc5796 100644 --- a/lib/bds/mac_bundle.ex +++ b/lib/bds/mac_bundle.ex @@ -12,7 +12,7 @@ defmodule BDS.MacBundle do BDS2.app/Contents/ Info.plist PkgInfo - MacOS/bds2 # launcher script (execs the release) + MacOS/bds2 # native arm64 launcher (execs the release) Resources/AppIcon.icns Resources/rel/ # the mix release (ERTS bundled) Frameworks/ # relocated wx + transitive dylibs @@ -45,18 +45,6 @@ defmodule BDS.MacBundle do EEx.eval_file(template_path("Info.plist.eex"), assigns) end - @doc "Render the `Contents/MacOS` launcher shell script." - @spec launcher(keyword()) :: String.t() - def launcher(opts \\ []) do - assigns = [ - identifier: Keyword.get(opts, :identifier, @identifier), - name: Keyword.get(opts, :name, @display_name), - release_name: Keyword.get(opts, :release_name, @release_name) - ] - - EEx.eval_file(template_path("launcher.sh.eex"), assigns) - end - @doc """ Assemble the `.app` from a built release directory. @@ -96,9 +84,19 @@ defmodule BDS.MacBundle do 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 - with :ok <- File.write(path, launcher()) do - File.chmod(path, 0o755) + 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 @@ -135,7 +133,8 @@ defmodule BDS.MacBundle do 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()}]}} + @spec verify_standalone(String.t()) :: + :ok | {:error, {:external_refs, [{String.t(), String.t()}]}} def verify_standalone(app) do offenders = app diff --git a/priv/desktop/macos/Info.plist.eex b/priv/desktop/macos/Info.plist.eex index b599e0f..e65dac4 100644 --- a/priv/desktop/macos/Info.plist.eex +++ b/priv/desktop/macos/Info.plist.eex @@ -26,6 +26,11 @@ <%= min_system %> NSHighResolutionCapable + + LSRequiresNativeExecution + LSApplicationCategoryType public.app-category.developer-tools CFBundleURLTypes diff --git a/priv/desktop/macos/launcher.c b/priv/desktop/macos/launcher.c new file mode 100644 index 0000000..70cd6e0 --- /dev/null +++ b/priv/desktop/macos/launcher.c @@ -0,0 +1,51 @@ +// Native launcher for the BDS2 .app — compiled to a pure arm64 Mach-O by +// mix bds.bundle.macos. A shell-script main executable has no Mach-O slices, so +// LaunchServices advertises x86_64 and offers Rosetta; a real arm64 binary makes +// the bundle unambiguously Apple Silicon. +// +// Mirrors launcher.sh: resolve , prepend Frameworks to the dyld +// fallback path, then exec /Resources/rel/bin/ start. +#include +#include +#include +#include +#include +#include +#include + +#ifndef BDS_RELEASE_NAME +#define BDS_RELEASE_NAME "bds" +#endif + +int main(void) { + char exec_path[PATH_MAX]; + uint32_t size = sizeof(exec_path); + if (_NSGetExecutablePath(exec_path, &size) != 0) return 1; + + char resolved[PATH_MAX]; + if (realpath(exec_path, resolved) == NULL) return 1; + + // resolved = /MacOS/bds2 ; climb two dirs to . + char macos[PATH_MAX]; + strncpy(macos, dirname(resolved), sizeof(macos) - 1); + macos[sizeof(macos) - 1] = '\0'; + char *contents = dirname(macos); + + char frameworks[PATH_MAX]; + snprintf(frameworks, sizeof(frameworks), "%s/Frameworks", contents); + + const char *existing = getenv("DYLD_FALLBACK_LIBRARY_PATH"); + char fallback[2 * PATH_MAX]; + if (existing && *existing) + snprintf(fallback, sizeof(fallback), "%s:%s", frameworks, existing); + else + snprintf(fallback, sizeof(fallback), "%s", frameworks); + setenv("DYLD_FALLBACK_LIBRARY_PATH", fallback, 1); + + char bin[PATH_MAX]; + snprintf(bin, sizeof(bin), "%s/Resources/rel/bin/%s", contents, BDS_RELEASE_NAME); + + execl(bin, BDS_RELEASE_NAME, "start", (char *)NULL); + perror("execl"); // only reached if exec failed + return 127; +} diff --git a/priv/desktop/macos/launcher.sh.eex b/priv/desktop/macos/launcher.sh.eex deleted file mode 100644 index 77483e4..0000000 --- a/priv/desktop/macos/launcher.sh.eex +++ /dev/null @@ -1,17 +0,0 @@ -#!/bin/sh -# Launcher for <%= name %> (<%= identifier %>). -# Generated by mix bds.bundle.macos — do not edit inside the .app. -# -# Resolves the bundle's own location and execs the embedded Elixir release. -# wxWidgets dylibs live in ../Frameworks and are referenced via @loader_path, so -# the app is self-contained regardless of where the BEAM executable runs from. -set -e - -HERE="$(cd "$(dirname "$0")" && pwd)" -APP_CONTENTS="$(cd "$HERE/.." && pwd)" -RELEASE_ROOT="$APP_CONTENTS/Resources/rel" - -# Belt-and-suspenders fallback for dyld (primary resolution is @loader_path). -export DYLD_FALLBACK_LIBRARY_PATH="$APP_CONTENTS/Frameworks:${DYLD_FALLBACK_LIBRARY_PATH:-}" - -exec "$RELEASE_ROOT/bin/<%= release_name %>" start diff --git a/test/bds/mac_bundle_test.exs b/test/bds/mac_bundle_test.exs index 41a8526..f255cb0 100644 --- a/test/bds/mac_bundle_test.exs +++ b/test/bds/mac_bundle_test.exs @@ -168,7 +168,10 @@ defmodule BDS.MacBundleTest do end test "honors a NIF-relative loader prefix" do - assert Dylibs.relink_changes(["/opt/homebrew/lib/libfoo.dylib"], "@loader_path/../../Frameworks") == + assert Dylibs.relink_changes( + ["/opt/homebrew/lib/libfoo.dylib"], + "@loader_path/../../Frameworks" + ) == [{"/opt/homebrew/lib/libfoo.dylib", "@loader_path/../../Frameworks/libfoo.dylib"}] end end @@ -299,6 +302,10 @@ defmodule BDS.MacBundleTest do end describe "MacBundle.assemble/1" do + # assemble/1 compiles the native arm64 launcher with `cc -arch arm64`, so it + # needs the macOS toolchain (the only platform that builds the .app anyway). + @describetag :macos_tools + setup do tmp = Path.join(System.tmp_dir!(), "bds-macbundle-#{System.unique_integer([:positive])}") release = Path.join(tmp, "rel") @@ -344,14 +351,15 @@ defmodule BDS.MacBundleTest do assert File.read!(Path.join(app, "Contents/PkgInfo")) == "APPL????" end - test "installs an executable launcher that execs the release", %{app: app} do + test "installs an executable, arm64-only Mach-O launcher", %{app: app} do launcher = Path.join(app, "Contents/MacOS/bds2") assert File.exists?(launcher) %File.Stat{mode: mode} = File.stat!(launcher) assert (mode &&& 0o111) != 0, "launcher must be executable" - script = File.read!(launcher) - assert script =~ "Resources/rel" - assert script =~ "bin/bds" + # Must be a native arm64 Mach-O, not a script — a script has no Mach-O + # slices so LaunchServices advertises x86_64 and offers Rosetta. + {archs, 0} = System.cmd("lipo", ["-archs", launcher]) + assert String.trim(archs) == "arm64" end test "copies the release tree and the icon into Resources", %{app: app} do