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