185 lines
6.6 KiB
Elixir
185 lines
6.6 KiB
Elixir
defmodule BDS.MacBundle.Dylibs do
|
|
@moduledoc """
|
|
Makes the bundled Elixir release self-contained on macOS by relocating the
|
|
non-system dynamic libraries the wxWidgets NIF (`wxe_driver.so`) links against.
|
|
|
|
Erlang's `:wx` driver is built against the wxWidgets dylibs of whatever host
|
|
produced the OTP install (typically Homebrew under `/opt/homebrew`), so a
|
|
release copied to a clean Mac would fail to load `:wx`. We copy every external
|
|
dylib (and its transitive deps) into `Contents/Frameworks/`, then rewrite the
|
|
install names with `install_name_tool` to `@executable_path/../Frameworks/...`.
|
|
|
|
The `otool`/`install_name_tool` parsing and argument building are pure so they
|
|
can be unit-tested; `bundle/2` performs the actual filesystem + tool work.
|
|
"""
|
|
|
|
# Homebrew prefixes whose libraries must be copied into the bundle. Anything
|
|
# under /usr/lib or /System is part of macOS and stays referenced in place;
|
|
# @rpath/@executable_path/@loader_path are already relocatable.
|
|
@external_prefixes ["/opt/homebrew", "/usr/local"]
|
|
|
|
@doc "Parse `otool -L` output into the ordered list of dependency paths."
|
|
@spec parse_otool(String.t()) :: [String.t()]
|
|
def parse_otool(output) when is_binary(output) do
|
|
output
|
|
|> String.split("\n", trim: true)
|
|
# The first line is the inspected file ("path:"), not a dependency.
|
|
|> Enum.filter(&String.starts_with?(&1, "\t"))
|
|
|> Enum.map(fn line ->
|
|
line
|
|
|> String.trim()
|
|
|> String.replace(~r/\s+\(compatibility version.*\)$/, "")
|
|
|> String.trim()
|
|
end)
|
|
end
|
|
|
|
@doc "True when a dylib path must be copied into the bundle (Homebrew/local)."
|
|
@spec external?(String.t()) :: boolean()
|
|
def external?(path) when is_binary(path) do
|
|
Enum.any?(@external_prefixes, &String.starts_with?(path, &1))
|
|
end
|
|
|
|
@doc "install_name_tool args to set a dylib's own id."
|
|
@spec id_args(String.t(), String.t()) :: [String.t()]
|
|
def id_args(dylib_path, new_id), do: ["-id", new_id, dylib_path]
|
|
|
|
@doc """
|
|
install_name_tool args to rewrite zero or more dependency install names on a
|
|
binary. Returns `[]` when there are no changes so callers can skip the tool.
|
|
"""
|
|
@spec change_args(String.t(), [{String.t(), String.t()}]) :: [String.t()]
|
|
def change_args(_binary, []), do: []
|
|
|
|
def change_args(binary, changes) when is_list(changes) do
|
|
Enum.flat_map(changes, fn {old, new} -> ["-change", old, new] end) ++ [binary]
|
|
end
|
|
|
|
@doc """
|
|
Copy every external dylib reachable from `nif_path` into `frameworks_dir` and
|
|
rewrite all install names (in the NIF and the copied dylibs) to point inside
|
|
the bundle. Returns `{:ok, copied_paths}`.
|
|
|
|
References are made `@loader_path`-relative so they resolve against the binary
|
|
that triggers the load — independent of where the host process executable
|
|
lives (the release runs `beam.smp`, not the bundle launcher) and without any
|
|
`DYLD_*` env reliance:
|
|
|
|
* the NIF references each dylib as `<nif_loader_prefix>/<basename>`, where
|
|
`nif_loader_prefix` is the `@loader_path/../…/Frameworks` path from the
|
|
NIF's directory to `frameworks_dir`;
|
|
* the copied dylibs live together, so they reference each other (and their
|
|
own id) as `@loader_path/<basename>`.
|
|
"""
|
|
@spec bundle(String.t(), String.t(), String.t()) ::
|
|
{:ok, [String.t()]} | {:error, term()}
|
|
def bundle(nif_path, frameworks_dir, nif_loader_prefix) do
|
|
File.mkdir_p!(frameworks_dir)
|
|
|
|
with {:ok, {_seen, externals}} <- collect(nif_path, MapSet.new(), []) do
|
|
externals = Enum.reverse(externals)
|
|
|
|
{physical, logical_entries, _inode_map} =
|
|
externals
|
|
|> Enum.reduce({[], [], %{}}, fn src, {phys_acc, log_acc, inode_map} ->
|
|
dest = Path.join(frameworks_dir, Path.basename(src))
|
|
|
|
if File.exists?(dest) do
|
|
{phys_acc, [{src, dest} | log_acc], inode_map}
|
|
else
|
|
ino = file_inode(src)
|
|
|
|
case Map.get(inode_map, ino) do
|
|
nil ->
|
|
File.cp!(src, dest)
|
|
File.chmod!(dest, 0o644)
|
|
{[{src, dest} | phys_acc], [{src, dest} | log_acc],
|
|
Map.put(inode_map, ino, dest)}
|
|
|
|
existing_dest ->
|
|
# Same inode already copied under a different name.
|
|
# Point this logical entry to the physical copy.
|
|
{phys_acc, [{src, existing_dest} | log_acc], inode_map}
|
|
end
|
|
end
|
|
end)
|
|
|
|
copied = Enum.reverse(logical_entries)
|
|
physical = Enum.reverse(physical)
|
|
|
|
with :ok <- rewrite(nif_path, copied, nif_loader_prefix),
|
|
:ok <- rewrite_each(physical) do
|
|
{:ok, Enum.map(physical, &elem(&1, 1))}
|
|
end
|
|
end
|
|
end
|
|
|
|
defp file_inode(path) do
|
|
stat = File.stat!(path)
|
|
{stat.major_device, stat.inode}
|
|
end
|
|
|
|
# Depth-first transitive collection of external dependency paths. Returns
|
|
# `{:ok, {seen, acc}}` where `acc` is the reverse-discovery-order path list.
|
|
defp collect(binary, seen, acc) do
|
|
case otool(binary) do
|
|
{:ok, deps} ->
|
|
deps
|
|
|> Enum.filter(&external?/1)
|
|
|> Enum.reduce_while({:ok, {seen, acc}}, fn dep, {:ok, {seen_acc, list_acc}} ->
|
|
if MapSet.member?(seen_acc, dep) do
|
|
{:cont, {:ok, {seen_acc, list_acc}}}
|
|
else
|
|
seen_acc = MapSet.put(seen_acc, dep)
|
|
case collect(dep, seen_acc, [dep | list_acc]) do
|
|
{:ok, _} = ok -> {:cont, ok}
|
|
error -> {:halt, error}
|
|
end
|
|
end
|
|
end)
|
|
error ->
|
|
error
|
|
end
|
|
end
|
|
|
|
defp rewrite(binary, copied, prefix) do
|
|
changes =
|
|
Enum.map(copied, fn {src, dest} ->
|
|
{src, prefix <> "/" <> Path.basename(dest)}
|
|
end)
|
|
|
|
case change_args(binary, changes) do
|
|
[] -> :ok
|
|
args -> run("install_name_tool", args)
|
|
end
|
|
end
|
|
|
|
# Copied dylibs share Frameworks/, so they reference each other (and their own
|
|
# id) relative to their own location with @loader_path.
|
|
defp rewrite_each(copied) do
|
|
Enum.reduce_while(copied, :ok, fn {_src, dest}, _acc ->
|
|
base = Path.basename(dest)
|
|
|
|
with :ok <- run("install_name_tool", id_args(dest, "@loader_path/" <> base)),
|
|
:ok <- rewrite(dest, copied, "@loader_path") do
|
|
{:cont, :ok}
|
|
else
|
|
error -> {:halt, error}
|
|
end
|
|
end)
|
|
end
|
|
|
|
defp otool(path) do
|
|
case System.cmd("otool", ["-L", path], stderr_to_stdout: true) do
|
|
{out, 0} -> {:ok, parse_otool(out)}
|
|
{out, status} -> {:error, {:otool_failed, status, out}}
|
|
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
|
|
end
|