feat: pipeline to create a full mac app
This commit is contained in:
53
README.md
53
README.md
@@ -162,3 +162,56 @@ Notes for developers:
|
||||
- [DOCUMENTATION.md](/Users/gb/Projects/bDS2/DOCUMENTATION.md) is for end users, not implementation details.
|
||||
- [API.md](/Users/gb/Projects/bDS2/API.md) is generated from the live scripting capability map and should stay in sync with runtime changes.
|
||||
- When changing persistence or localization behavior, check both the database side and the filesystem/render side before assuming the change is complete.
|
||||
|
||||
## Packaging The macOS App
|
||||
|
||||
`mix bds.bundle.macos` produces a self-contained, ad-hoc-signed `BDS2.app`. It
|
||||
bundles the `:bds` release (ERTS included) plus the wxWidgets dylibs relocated
|
||||
via `@loader_path`, so it runs on a clean Mac with nothing installed. The bundle
|
||||
registers the `bds2://` URL scheme (for blogmark deep links) and ships the red-pen
|
||||
`AppIcon`. Output: `dist/macos/BDS2.app`. (arm64 / Apple Silicon only.)
|
||||
|
||||
### 1. Build the release, then the bundle
|
||||
|
||||
```bash
|
||||
MIX_ENV=prod mix release bds --overwrite
|
||||
mix bds.bundle.macos --app-release _build/prod/rel/bds
|
||||
```
|
||||
|
||||
The icon `.icns` is generated on first run from
|
||||
[priv/desktop/macos/icon-source.svg](/Users/gb/Projects/bDS2/priv/desktop/macos/icon-source.svg)
|
||||
and committed under `priv/desktop/macos/`. Pass `--regen-icon` to rebuild it.
|
||||
|
||||
### 2. Register the `bds2://` scheme (optional)
|
||||
|
||||
`--register` tells LaunchServices about the scheme without moving the app to
|
||||
`/Applications` — useful for testing blogmark deep links locally:
|
||||
|
||||
```bash
|
||||
mix bds.bundle.macos --app-release _build/prod/rel/bds --register
|
||||
```
|
||||
|
||||
### Run / smoke-test
|
||||
|
||||
```bash
|
||||
open dist/macos/BDS2.app
|
||||
open "bds2://new-post?title=Hello&url=https://example.com" # opens a draft
|
||||
```
|
||||
|
||||
### Useful flags
|
||||
|
||||
- `--app-release PATH` — release dir to wrap (default `_build/<env>/rel/bds`).
|
||||
- `--output DIR` — output directory (default `dist/macos`).
|
||||
- `--version V` — version string for `Info.plist` (default from `mix.exs`).
|
||||
- `--regen-icon` — regenerate `AppIcon.icns` from the source SVG.
|
||||
- `--register` — register the `bds2://` scheme via LaunchServices.
|
||||
- `--no-codesign` — skip the ad-hoc codesign step.
|
||||
|
||||
### Verifying the bundle
|
||||
|
||||
```bash
|
||||
plutil -lint dist/macos/BDS2.app/Contents/Info.plist
|
||||
codesign --verify --deep --strict dist/macos/BDS2.app
|
||||
# no /opt/homebrew or /usr/local references should appear:
|
||||
otool -L dist/macos/BDS2.app/Contents/Resources/rel/lib/wx-*/priv/wxe_driver.so
|
||||
```
|
||||
|
||||
@@ -3,12 +3,13 @@ defmodule BDS.Desktop.DeepLink do
|
||||
Receives OS URL-scheme events for the `bds2://` scheme and routes them to the
|
||||
shell (spec: script.allium `BlogmarkReceived`).
|
||||
|
||||
On macOS the app bundle registers `bds2://` as a custom URL scheme (see the
|
||||
`CFBundleURLTypes` entry in the packaged `Info.plist`). When the browser
|
||||
bookmarklet navigates to `bds2://new-post?title=&url=`, the OS launches/raises
|
||||
the app and `Desktop.Env` delivers an `{:open_url, [url]}` event. This
|
||||
GenServer subscribes to those events and forwards recognised `bds2://` links to
|
||||
the live shell over PubSub, where `BDS.Blogmark` turns them into draft posts.
|
||||
On macOS the `BDS2.app` bundle registers `bds2://` as a custom URL scheme via
|
||||
the `CFBundleURLTypes` entry in its `Info.plist` (built by `BDS.MacBundle` /
|
||||
`mix bds.bundle.macos`). When the browser bookmarklet navigates to
|
||||
`bds2://new-post?title=&url=`, the OS launches/raises the app and `Desktop.Env`
|
||||
delivers an `{:open_url, [url]}` event. This GenServer subscribes to those
|
||||
events and forwards recognised `bds2://` links to the live shell over PubSub,
|
||||
where `BDS.Blogmark` turns them into draft posts.
|
||||
|
||||
The `bds2://` scheme is distinct from the legacy app's `bds://` so the two
|
||||
installs do not contend for the same registration.
|
||||
|
||||
179
lib/bds/mac_bundle.ex
Normal file
179
lib/bds/mac_bundle.ex
Normal file
@@ -0,0 +1,179 @@
|
||||
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 # launcher script (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 "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.
|
||||
|
||||
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
|
||||
|
||||
@doc "Locate the wxWidgets NIF (`wxe_driver.so`) inside a release tree."
|
||||
@spec wx_nif_path(String.t()) :: String.t() | nil
|
||||
def wx_nif_path(release_dir) do
|
||||
release_dir
|
||||
|> Path.join("lib/wx-*/priv/wxe_driver.so")
|
||||
|> Path.wildcard()
|
||||
|> List.first()
|
||||
end
|
||||
|
||||
defp write_launcher(path) do
|
||||
with :ok <- File.write(path, launcher()) do
|
||||
File.chmod(path, 0o755)
|
||||
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")
|
||||
|
||||
case wx_nif_path(Path.join(app, "Contents/Resources/rel")) do
|
||||
nil ->
|
||||
{:error, {:wx_nif_not_found, release_dir}}
|
||||
|
||||
nif ->
|
||||
prefix = "@loader_path/" <> relative_up(Path.dirname(nif), frameworks)
|
||||
|
||||
case Dylibs.bundle(nif, frameworks, prefix) do
|
||||
{:ok, _copied} -> :ok
|
||||
error -> error
|
||||
end
|
||||
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
|
||||
|
||||
@doc """
|
||||
Ad-hoc codesign the whole bundle recursively. `--deep` signs every nested
|
||||
Mach-O (the relocated dylibs and the release's `beam.smp`/`erlexec`/escripts),
|
||||
then we verify with `--deep --strict`.
|
||||
"""
|
||||
@spec codesign(String.t()) :: :ok | {:error, term()}
|
||||
def codesign(app) do
|
||||
with :ok <- run("codesign", ["--force", "--deep", "--sign", "-", "--timestamp=none", app]) do
|
||||
run("codesign", ["--verify", "--deep", "--strict", app])
|
||||
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
|
||||
158
lib/bds/mac_bundle/dylibs.ex
Normal file
158
lib/bds/mac_bundle/dylibs.ex
Normal file
@@ -0,0 +1,158 @@
|
||||
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
|
||||
copied =
|
||||
externals
|
||||
|> Enum.reverse()
|
||||
|> Enum.map(fn src ->
|
||||
dest = Path.join(frameworks_dir, Path.basename(src))
|
||||
File.cp!(src, dest)
|
||||
File.chmod!(dest, 0o644)
|
||||
{src, dest}
|
||||
end)
|
||||
|
||||
with :ok <- rewrite(nif_path, copied, nif_loader_prefix),
|
||||
:ok <- rewrite_each(copied) do
|
||||
{:ok, Enum.map(copied, &elem(&1, 1))}
|
||||
end
|
||||
end
|
||||
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.reject(&MapSet.member?(seen, &1))
|
||||
|> Enum.reduce_while({:ok, {seen, acc}}, fn dep, {:ok, {seen_acc, list_acc}} ->
|
||||
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)
|
||||
|
||||
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
|
||||
195
lib/bds/mac_bundle/icon.ex
Normal file
195
lib/bds/mac_bundle/icon.ex
Normal file
@@ -0,0 +1,195 @@
|
||||
defmodule BDS.MacBundle.Icon do
|
||||
@moduledoc """
|
||||
Builds the macOS `AppIcon.icns` for the bDS2 bundle.
|
||||
|
||||
The source artwork is the legacy app's pen icon (a gold pen on a gold disc,
|
||||
imported as `priv/desktop/macos/icon-source.svg`). The bundle icon recolours
|
||||
the **blue** pen to **red** by rotating any colour whose hue sits in the blue
|
||||
band, leaving the gold body and warm/neutral tones untouched. Recolouring the
|
||||
SVG text is fully deterministic and unit-testable; rasterisation then uses the
|
||||
bundled libvips (`Image`) so no extra system tooling is required for SVG→PNG.
|
||||
|
||||
`.icns` packaging itself shells out to the macOS-only `sips`/`iconutil`.
|
||||
"""
|
||||
|
||||
# Hue band (degrees) that counts as "blue" and gets rotated to red.
|
||||
@blue_low 195
|
||||
@blue_high 265
|
||||
# Rotation that maps the pen's ~218–221° blues onto ~358–1° (red).
|
||||
@hue_shift 220
|
||||
|
||||
# Standard macOS iconset base sizes; each is emitted at @1x and @2x.
|
||||
@iconset_sizes [16, 32, 128, 256, 512]
|
||||
|
||||
@doc "Absolute path to the (blue) source SVG bundled in priv."
|
||||
@spec source_svg_path() :: String.t()
|
||||
def source_svg_path do
|
||||
Application.get_env(:bds, :mac_icon_source_svg) ||
|
||||
Path.join(:code.priv_dir(:bds), "desktop/macos/icon-source.svg")
|
||||
end
|
||||
|
||||
@doc "Default output path for the generated `.icns`."
|
||||
@spec icns_path() :: String.t()
|
||||
def icns_path, do: Path.join(:code.priv_dir(:bds), "desktop/macos/AppIcon.icns")
|
||||
|
||||
@doc """
|
||||
Recolour a single `#RRGGBB` value. Blue-band colours are hue-rotated to red;
|
||||
everything else is returned unchanged (byte-for-byte, no rounding drift).
|
||||
"""
|
||||
@spec recolor_color(String.t()) :: String.t()
|
||||
def recolor_color("#" <> _ = hex) do
|
||||
{r, g, b} = to_rgb(hex)
|
||||
{h, s, l} = rgb_to_hsl(r, g, b)
|
||||
|
||||
if h >= @blue_low and h < @blue_high do
|
||||
new_h = :math.fmod(h - @hue_shift + 360.0, 360.0)
|
||||
{nr, ng, nb} = hsl_to_rgb(new_h, s, l)
|
||||
to_hex(nr, ng, nb)
|
||||
else
|
||||
String.upcase(hex)
|
||||
end
|
||||
end
|
||||
|
||||
@doc "Recolour every `#RRGGBB` literal found in an SVG (or any text)."
|
||||
@spec recolor_svg(String.t()) :: String.t()
|
||||
def recolor_svg(svg) when is_binary(svg) do
|
||||
Regex.replace(~r/#[0-9A-Fa-f]{6}/, svg, fn hex -> recolor_color(hex) end)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Generate the `.icns` from the source SVG. Returns `{:ok, icns_path}`.
|
||||
|
||||
Steps: recolour SVG → render a 1024² PNG via libvips → emit the iconset sizes
|
||||
with `sips` → pack with `iconutil`.
|
||||
"""
|
||||
@spec generate(keyword()) :: {:ok, String.t()} | {:error, term()}
|
||||
def generate(opts \\ []) do
|
||||
source = Keyword.get(opts, :source_svg, source_svg_path())
|
||||
icns = Keyword.get(opts, :icns_path, icns_path())
|
||||
png_1024 = Keyword.get(opts, :png_path, Path.rootname(icns) <> "-1024.png")
|
||||
|
||||
with {:ok, svg} <- File.read(source),
|
||||
:ok <- render_png(recolor_svg(svg), png_1024, 1024),
|
||||
{:ok, iconset} <- build_iconset(png_1024),
|
||||
:ok <- run("iconutil", ["-c", "icns", "-o", icns, iconset]) do
|
||||
File.rm_rf(iconset)
|
||||
{:ok, icns}
|
||||
end
|
||||
end
|
||||
|
||||
@doc "Render an SVG string to a square PNG of `size` pixels via libvips."
|
||||
@spec render_png(String.t(), String.t(), pos_integer()) :: :ok | {:error, term()}
|
||||
def render_png(svg, png_path, size) do
|
||||
with {:ok, image} <- Image.from_binary(svg),
|
||||
{:ok, resized} <- Image.thumbnail(image, size, height: size),
|
||||
{:ok, _} <- Image.write(resized, png_path) do
|
||||
:ok
|
||||
end
|
||||
end
|
||||
|
||||
defp build_iconset(png_1024) do
|
||||
iconset = Path.rootname(png_1024) <> ".iconset"
|
||||
File.rm_rf!(iconset)
|
||||
File.mkdir_p!(iconset)
|
||||
|
||||
Enum.reduce_while(@iconset_sizes, {:ok, iconset}, fn base, acc ->
|
||||
with :ok <- emit_size(png_1024, iconset, base, 1),
|
||||
:ok <- emit_size(png_1024, iconset, base, 2) do
|
||||
{:cont, acc}
|
||||
else
|
||||
error -> {:halt, error}
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
defp emit_size(png_1024, iconset, base, scale) do
|
||||
px = base * scale
|
||||
suffix = if scale == 1, do: "#{base}x#{base}", else: "#{base}x#{base}@2x"
|
||||
out = Path.join(iconset, "icon_#{suffix}.png")
|
||||
run("sips", ["-z", Integer.to_string(px), Integer.to_string(px), png_1024, "--out", out])
|
||||
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
|
||||
|
||||
# ---- colour space helpers -------------------------------------------------
|
||||
|
||||
defp to_rgb("#" <> hex) do
|
||||
{
|
||||
String.to_integer(String.slice(hex, 0, 2), 16),
|
||||
String.to_integer(String.slice(hex, 2, 2), 16),
|
||||
String.to_integer(String.slice(hex, 4, 2), 16)
|
||||
}
|
||||
end
|
||||
|
||||
defp to_hex(r, g, b) do
|
||||
"#" <> pad(r) <> pad(g) <> pad(b)
|
||||
end
|
||||
|
||||
defp pad(v) do
|
||||
v
|
||||
|> max(0)
|
||||
|> min(255)
|
||||
|> Integer.to_string(16)
|
||||
|> String.upcase()
|
||||
|> String.pad_leading(2, "0")
|
||||
end
|
||||
|
||||
defp rgb_to_hsl(r, g, b) do
|
||||
rf = r / 255.0
|
||||
gf = g / 255.0
|
||||
bf = b / 255.0
|
||||
|
||||
max = Enum.max([rf, gf, bf])
|
||||
min = Enum.min([rf, gf, bf])
|
||||
l = (max + min) / 2.0
|
||||
d = max - min
|
||||
|
||||
if d == 0.0 do
|
||||
{0.0, 0.0, l}
|
||||
else
|
||||
s = if l > 0.5, do: d / (2.0 - max - min), else: d / (max + min)
|
||||
|
||||
h =
|
||||
cond do
|
||||
max == rf -> :math.fmod((gf - bf) / d + 6.0, 6.0)
|
||||
max == gf -> (bf - rf) / d + 2.0
|
||||
true -> (rf - gf) / d + 4.0
|
||||
end
|
||||
|
||||
{h * 60.0, s, l}
|
||||
end
|
||||
end
|
||||
|
||||
defp hsl_to_rgb(_h, s, l) when s == 0.0 do
|
||||
v = round(l * 255.0)
|
||||
{v, v, v}
|
||||
end
|
||||
|
||||
defp hsl_to_rgb(h, s, l) do
|
||||
q = if l < 0.5, do: l * (1.0 + s), else: l + s - l * s
|
||||
p = 2.0 * l - q
|
||||
hk = h / 360.0
|
||||
|
||||
{
|
||||
round(hue_to_channel(p, q, hk + 1.0 / 3.0) * 255.0),
|
||||
round(hue_to_channel(p, q, hk) * 255.0),
|
||||
round(hue_to_channel(p, q, hk - 1.0 / 3.0) * 255.0)
|
||||
}
|
||||
end
|
||||
|
||||
defp hue_to_channel(p, q, t) do
|
||||
t = :math.fmod(t + 1.0, 1.0)
|
||||
|
||||
cond do
|
||||
t < 1.0 / 6.0 -> p + (q - p) * 6.0 * t
|
||||
t < 1.0 / 2.0 -> q
|
||||
t < 2.0 / 3.0 -> p + (q - p) * (2.0 / 3.0 - t) * 6.0
|
||||
true -> p
|
||||
end
|
||||
end
|
||||
end
|
||||
98
lib/mix/tasks/bds.bundle.macos.ex
Normal file
98
lib/mix/tasks/bds.bundle.macos.ex
Normal file
@@ -0,0 +1,98 @@
|
||||
defmodule Mix.Tasks.Bds.Bundle.Macos do
|
||||
@moduledoc """
|
||||
Build a self-contained, ad-hoc-signed `BDS2.app` macOS bundle.
|
||||
|
||||
mix bds.bundle.macos [--app-release PATH] [--output DIR] [--version V]
|
||||
[--regen-icon] [--register] [--no-codesign]
|
||||
|
||||
Assumes the `:bds` release has been built (`MIX_ENV=prod mix release bds`); pass
|
||||
`--app-release` to point elsewhere. Generates the red-pen icon on first run (or
|
||||
with `--regen-icon`), assembles the bundle, relocates the wxWidgets dylibs so it
|
||||
runs on a clean Mac, ad-hoc codesigns it, and (with `--register`) registers the
|
||||
`bds2://` URL scheme via LaunchServices.
|
||||
"""
|
||||
|
||||
use Mix.Task
|
||||
|
||||
alias BDS.MacBundle
|
||||
alias BDS.MacBundle.Icon
|
||||
|
||||
@shortdoc "Builds a self-contained macOS .app bundle (BDS2.app)"
|
||||
|
||||
@lsregister "/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister"
|
||||
|
||||
@impl Mix.Task
|
||||
def run(args) do
|
||||
unless match?({:unix, :darwin}, :os.type()) do
|
||||
Mix.raise("mix bds.bundle.macos only runs on macOS")
|
||||
end
|
||||
|
||||
{opts, _positional, _invalid} =
|
||||
OptionParser.parse(args,
|
||||
strict: [
|
||||
app_release: :string,
|
||||
output: :string,
|
||||
version: :string,
|
||||
regen_icon: :boolean,
|
||||
register: :boolean,
|
||||
codesign: :boolean
|
||||
]
|
||||
)
|
||||
|
||||
Mix.Task.run("app.config")
|
||||
|
||||
version = opts[:version] || Mix.Project.config()[:version]
|
||||
env_name = Atom.to_string(Mix.env())
|
||||
release_dir = opts[:app_release] || Path.expand("_build/#{env_name}/rel/bds", File.cwd!())
|
||||
output_dir = opts[:output] || Path.expand("dist/macos", File.cwd!())
|
||||
|
||||
unless File.dir?(release_dir) do
|
||||
Mix.raise("Release not found at #{release_dir}. Run `MIX_ENV=prod mix release bds` first.")
|
||||
end
|
||||
|
||||
icns = ensure_icon(opts[:regen_icon] == true)
|
||||
|
||||
Mix.shell().info("Assembling BDS2.app (version #{version})…")
|
||||
|
||||
case MacBundle.assemble(
|
||||
release_dir: release_dir,
|
||||
output_dir: output_dir,
|
||||
icns_path: icns,
|
||||
version: version,
|
||||
skip_codesign: opts[:codesign] == false
|
||||
) do
|
||||
{:ok, app} ->
|
||||
maybe_register(app, opts[:register] == true)
|
||||
Mix.shell().info("Built #{app}")
|
||||
|
||||
{:error, reason} ->
|
||||
Mix.raise("Bundle failed: #{inspect(reason)}")
|
||||
end
|
||||
end
|
||||
|
||||
defp ensure_icon(regen?) do
|
||||
icns = Icon.icns_path()
|
||||
|
||||
if regen? or not File.exists?(icns) do
|
||||
Mix.shell().info("Generating AppIcon.icns (blue→red)…")
|
||||
|
||||
case Icon.generate() do
|
||||
{:ok, path} -> path
|
||||
{:error, reason} -> Mix.raise("Icon generation failed: #{inspect(reason)}")
|
||||
end
|
||||
else
|
||||
icns
|
||||
end
|
||||
end
|
||||
|
||||
defp maybe_register(_app, false), do: :ok
|
||||
|
||||
defp maybe_register(app, true) do
|
||||
if File.exists?(@lsregister) do
|
||||
System.cmd(@lsregister, ["-f", app])
|
||||
Mix.shell().info("Registered bds2:// scheme via LaunchServices")
|
||||
else
|
||||
Mix.shell().info("lsregister not found; skip scheme registration")
|
||||
end
|
||||
end
|
||||
end
|
||||
BIN
priv/desktop/macos/AppIcon-1024.png
Normal file
BIN
priv/desktop/macos/AppIcon-1024.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 539 KiB |
BIN
priv/desktop/macos/AppIcon.icns
Normal file
BIN
priv/desktop/macos/AppIcon.icns
Normal file
Binary file not shown.
45
priv/desktop/macos/Info.plist.eex
Normal file
45
priv/desktop/macos/Info.plist.eex
Normal file
@@ -0,0 +1,45 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>en</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string><%= name %></string>
|
||||
<key>CFBundleName</key>
|
||||
<string><%= name %></string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string><%= identifier %></string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string><%= executable %></string>
|
||||
<key>CFBundleIconFile</key>
|
||||
<string><%= icon %></string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleSignature</key>
|
||||
<string>????</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string><%= version %></string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string><%= version %></string>
|
||||
<key>LSMinimumSystemVersion</key>
|
||||
<string><%= min_system %></string>
|
||||
<key>NSHighResolutionCapable</key>
|
||||
<true/>
|
||||
<key>LSApplicationCategoryType</key>
|
||||
<string>public.app-category.developer-tools</string>
|
||||
<key>CFBundleURLTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>CFBundleTypeRole</key>
|
||||
<string>Editor</string>
|
||||
<key>CFBundleURLName</key>
|
||||
<string>bDS2 Blogmark Links</string>
|
||||
<key>CFBundleURLSchemes</key>
|
||||
<array>
|
||||
<string><%= scheme %></string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
71
priv/desktop/macos/icon-source.svg
Normal file
71
priv/desktop/macos/icon-source.svg
Normal file
@@ -0,0 +1,71 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="1024" viewBox="0 0 1024 1024">
|
||||
<defs>
|
||||
<radialGradient id="goldBase" cx="36%" cy="24%" r="88%">
|
||||
<stop offset="0%" stop-color="#FCE9A4"/>
|
||||
<stop offset="22%" stop-color="#E8C96A"/>
|
||||
<stop offset="52%" stop-color="#C59A33"/>
|
||||
<stop offset="78%" stop-color="#9B741F"/>
|
||||
<stop offset="100%" stop-color="#6E4E14"/>
|
||||
</radialGradient>
|
||||
<linearGradient id="goldSheen" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#FFF8D8" stop-opacity="0.64"/>
|
||||
<stop offset="33%" stop-color="#F7E2A5" stop-opacity="0.08"/>
|
||||
<stop offset="62%" stop-color="#FFFFFF" stop-opacity="0.28"/>
|
||||
<stop offset="100%" stop-color="#7D5A1A" stop-opacity="0"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="penBody" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#6FA3FF"/>
|
||||
<stop offset="50%" stop-color="#2B61CE"/>
|
||||
<stop offset="100%" stop-color="#1D4294"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="penMetal" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||
<stop offset="0%" stop-color="#FBFCFF"/>
|
||||
<stop offset="45%" stop-color="#C6D0E2"/>
|
||||
<stop offset="100%" stop-color="#8E9AB1"/>
|
||||
</linearGradient>
|
||||
<filter id="scratch" x="-10%" y="-10%" width="120%" height="120%">
|
||||
<feTurbulence type="fractalNoise" baseFrequency="0.95" numOctaves="1" seed="7" result="noise"/>
|
||||
<feDisplacementMap in="SourceGraphic" in2="noise" scale="2.8" xChannelSelector="R" yChannelSelector="G"/>
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
<rect width="1024" height="1024" rx="224" fill="url(#goldBase)"/>
|
||||
<path d="M44 188 Q312 76 510 180 T980 164" fill="none" stroke="#FFF4C8" stroke-opacity="0.58" stroke-width="84" stroke-linecap="round"/>
|
||||
<rect width="1024" height="1024" rx="224" fill="url(#goldSheen)"/>
|
||||
|
||||
<g fill="none" stroke="#0D0D0D" stroke-linecap="round" stroke-linejoin="round" filter="url(#scratch)">
|
||||
<path d="M130 884 L512 118 L894 884 Z" stroke-width="34"/>
|
||||
<path d="M116 896 L518 108 L908 892" stroke-width="8" opacity="0.66"/>
|
||||
<path d="M142 876 L506 128 L882 876" stroke-width="7" opacity="0.54"/>
|
||||
|
||||
<path d="M176 884 Q354 868 560 860" stroke-width="24"/>
|
||||
<path d="M168 892 Q362 876 562 866" stroke-width="8" opacity="0.62"/>
|
||||
<path d="M568 862 Q704 862 848 882" stroke-width="7" opacity="0.3"/>
|
||||
|
||||
<path d="M322 646 Q512 610 706 646" stroke-width="20"/>
|
||||
<path d="M306 656 Q512 620 722 656" stroke-width="7" opacity="0.6"/>
|
||||
|
||||
<path d="M408 438 Q512 418 616 438" stroke-width="16"/>
|
||||
<path d="M396 448 Q512 426 628 448" stroke-width="6" opacity="0.64"/>
|
||||
|
||||
<path d="M372 530 Q512 446 652 530" stroke-width="18"/>
|
||||
<path d="M372 530 Q512 614 652 530" stroke-width="18"/>
|
||||
<path d="M392 530 Q512 462 632 530" stroke-width="6" opacity="0.64"/>
|
||||
<path d="M392 530 Q512 598 632 530" stroke-width="6" opacity="0.64"/>
|
||||
<circle cx="512" cy="530" r="34" stroke-width="18"/>
|
||||
<circle cx="512" cy="530" r="12" stroke-width="6" opacity="0.68"/>
|
||||
</g>
|
||||
|
||||
<g transform="rotate(-44 560 860)">
|
||||
<path d="M653.75 811.25 L1088.75 811.25 C1126.25 811.25 1156.25 833.75 1156.25 860 C1156.25 886.25 1126.25 908.75 1088.75 908.75 L653.75 908.75 Z" fill="url(#penBody)"/>
|
||||
<path d="M653.75 811.25 L747.5 811.25 C773.75 811.25 792.5 833.75 792.5 860 C792.5 886.25 773.75 908.75 747.5 908.75 L653.75 908.75 Z" fill="#7FB0FF"/>
|
||||
<rect x="788.75" y="811.25" width="37.5" height="97.5" rx="15" fill="#214A9D"/>
|
||||
<path d="M826.25 833.75 Q935 803.75 1040 833.75" fill="none" stroke="#9FC2FF" stroke-width="11.25"/>
|
||||
<path d="M560 860 L608.75 830 L653.75 860 L608.75 890 Z" fill="url(#penMetal)"/>
|
||||
<path d="M560 860 L537.5 845 L537.5 875 Z" fill="#101318"/>
|
||||
<line x1="560" y1="860" x2="638.75" y2="860" stroke="#4D5C78" stroke-width="5.6"/>
|
||||
<circle cx="601.25" cy="860" r="8.4" fill="#5A6780"/>
|
||||
</g>
|
||||
|
||||
<path d="M172 886 Q356 872 560 860" fill="none" stroke="#0C0C0C" stroke-width="10" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.9 KiB |
17
priv/desktop/macos/launcher.sh.eex
Normal file
17
priv/desktop/macos/launcher.sh.eex
Normal file
@@ -0,0 +1,17 @@
|
||||
#!/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
|
||||
238
test/bds/mac_bundle_test.exs
Normal file
238
test/bds/mac_bundle_test.exs
Normal file
@@ -0,0 +1,238 @@
|
||||
defmodule BDS.MacBundleTest do
|
||||
use ExUnit.Case, async: true
|
||||
|
||||
import Bitwise
|
||||
|
||||
alias BDS.MacBundle
|
||||
alias BDS.MacBundle.Dylibs
|
||||
alias BDS.MacBundle.Icon
|
||||
|
||||
# Parse "#RRGGBB" into {r, g, b} for property assertions in tests.
|
||||
defp rgb("#" <> hex) do
|
||||
{r, g, b} = {String.slice(hex, 0, 2), String.slice(hex, 2, 2), String.slice(hex, 4, 2)}
|
||||
{String.to_integer(r, 16), String.to_integer(g, 16), String.to_integer(b, 16)}
|
||||
end
|
||||
|
||||
describe "Icon.recolor_color/1" do
|
||||
test "turns the saturated blue pen colors red (red channel becomes dominant)" do
|
||||
for blue <- ~w(#1D4294 #214A9D #2B61CE #6FA3FF #7FB0FF #9FC2FF) do
|
||||
recolored = Icon.recolor_color(blue)
|
||||
{ir, _ig, ib} = rgb(blue)
|
||||
{r, _g, b} = rgb(recolored)
|
||||
|
||||
assert ib > ir, "expected #{blue} to start blue-dominant"
|
||||
assert r > b, "expected #{blue} -> #{recolored} to become red-dominant"
|
||||
end
|
||||
end
|
||||
|
||||
test "leaves gold / warm colors unchanged" do
|
||||
for gold <- ~w(#FCE9A4 #E8C96A #C59A33 #9B741F #6E4E14 #FFF4C8) do
|
||||
assert Icon.recolor_color(gold) == gold
|
||||
end
|
||||
end
|
||||
|
||||
test "leaves pure white and near-black unchanged" do
|
||||
assert Icon.recolor_color("#FFFFFF") == "#FFFFFF"
|
||||
assert Icon.recolor_color("#0D0D0D") == "#0D0D0D"
|
||||
end
|
||||
|
||||
test "is case-insensitive on input and returns uppercase hex" do
|
||||
assert Icon.recolor_color("#2b61ce") == Icon.recolor_color("#2B61CE")
|
||||
assert Icon.recolor_color("#2b61ce") =~ ~r/^#[0-9A-F]{6}$/
|
||||
end
|
||||
end
|
||||
|
||||
describe "Icon.recolor_svg/1" do
|
||||
test "replaces every blue hex in the source SVG and keeps gold stops" do
|
||||
svg = File.read!(Icon.source_svg_path())
|
||||
recolored = Icon.recolor_svg(svg)
|
||||
|
||||
refute recolored =~ "#2B61CE"
|
||||
refute recolored =~ "#1D4294"
|
||||
refute recolored =~ "#9FC2FF"
|
||||
# gold base gradient stops survive untouched
|
||||
assert recolored =~ "#E8C96A"
|
||||
assert recolored =~ "#C59A33"
|
||||
end
|
||||
end
|
||||
|
||||
describe "Icon.generate/1 (real libvips + sips + iconutil pipeline)" do
|
||||
@describetag :macos_tools
|
||||
|
||||
test "renders the recoloured SVG and packs a valid .icns" do
|
||||
tmp = Path.join(System.tmp_dir!(), "bds-icon-#{System.unique_integer([:positive])}")
|
||||
File.mkdir_p!(tmp)
|
||||
on_exit(fn -> File.rm_rf!(tmp) end)
|
||||
|
||||
icns = Path.join(tmp, "AppIcon.icns")
|
||||
png = Path.join(tmp, "AppIcon-1024.png")
|
||||
|
||||
assert {:ok, ^icns} = Icon.generate(icns_path: icns, png_path: png)
|
||||
assert File.exists?(icns)
|
||||
# .icns files begin with the "icns" magic.
|
||||
assert File.read!(icns) |> binary_part(0, 4) == "icns"
|
||||
# 1024² master PNG was produced.
|
||||
assert {:ok, img} = Image.open(png)
|
||||
assert Image.width(img) == 1024
|
||||
end
|
||||
end
|
||||
|
||||
describe "MacBundle.info_plist/1" do
|
||||
setup do
|
||||
%{plist: MacBundle.info_plist(version: "1.2.3")}
|
||||
end
|
||||
|
||||
test "registers the bds2 URL scheme", %{plist: plist} do
|
||||
assert plist =~ "CFBundleURLSchemes"
|
||||
assert plist =~ "<string>bds2</string>"
|
||||
end
|
||||
|
||||
test "carries the confirmed bundle identity", %{plist: plist} do
|
||||
assert plist =~ "<string>de.rfc1437.bds2</string>"
|
||||
assert plist =~ "<key>CFBundleExecutable</key>"
|
||||
assert plist =~ "<string>bds2</string>"
|
||||
assert plist =~ "<string>AppIcon</string>"
|
||||
assert plist =~ "<string>1.2.3</string>"
|
||||
end
|
||||
|
||||
test "is well-formed XML/plist" do
|
||||
assert plist = MacBundle.info_plist(version: "0.0.1")
|
||||
assert String.starts_with?(plist, "<?xml")
|
||||
assert plist =~ "<!DOCTYPE plist"
|
||||
assert plist =~ "</plist>"
|
||||
end
|
||||
end
|
||||
|
||||
describe "MacBundle.relative_up/2" do
|
||||
test "computes the ../-style hop from the wx NIF dir up to Frameworks" do
|
||||
nif_dir = "/x/BDS2.app/Contents/Resources/rel/lib/wx-2.5/priv"
|
||||
frameworks = "/x/BDS2.app/Contents/Frameworks"
|
||||
assert MacBundle.relative_up(nif_dir, frameworks) == "../../../../../Frameworks"
|
||||
end
|
||||
|
||||
test "siblings resolve with a single hop" do
|
||||
assert MacBundle.relative_up("/a/b/c", "/a/b/d") == "../d"
|
||||
end
|
||||
end
|
||||
|
||||
describe "Dylibs.parse_otool/1 and external?/1" do
|
||||
test "extracts the dependency paths, dropping the header line" do
|
||||
output =
|
||||
Enum.join(
|
||||
[
|
||||
"/abs/path/wxe_driver.so:",
|
||||
"\t/opt/homebrew/opt/wxwidgets/lib/libwx_baseu-3.2.dylib (compatibility version 0.0.0, current version 0.0.0)",
|
||||
"\t/usr/lib/libSystem.B.dylib (compatibility version 1.0.0, current version 1.0.0)",
|
||||
"\t@rpath/libwx_osx_cocoau_core-3.2.dylib (compatibility version 0.0.0, current version 0.0.0)"
|
||||
],
|
||||
"\n"
|
||||
)
|
||||
|
||||
assert Dylibs.parse_otool(output) == [
|
||||
"/opt/homebrew/opt/wxwidgets/lib/libwx_baseu-3.2.dylib",
|
||||
"/usr/lib/libSystem.B.dylib",
|
||||
"@rpath/libwx_osx_cocoau_core-3.2.dylib"
|
||||
]
|
||||
end
|
||||
|
||||
test "treats Homebrew prefixes as external and system/rpath as internal" do
|
||||
assert Dylibs.external?("/opt/homebrew/opt/wxwidgets/lib/libwx_baseu-3.2.dylib")
|
||||
assert Dylibs.external?("/usr/local/lib/libpng16.dylib")
|
||||
refute Dylibs.external?("/usr/lib/libSystem.B.dylib")
|
||||
refute Dylibs.external?("/System/Library/Frameworks/Cocoa.framework/Cocoa")
|
||||
refute Dylibs.external?("@rpath/libwx_osx_cocoau_core-3.2.dylib")
|
||||
refute Dylibs.external?("@executable_path/../Frameworks/libfoo.dylib")
|
||||
end
|
||||
end
|
||||
|
||||
describe "Dylibs install_name_tool arg builders" do
|
||||
test "id_args/2" do
|
||||
assert Dylibs.id_args("/Frameworks/libfoo.dylib", "@rpath/libfoo.dylib") ==
|
||||
["-id", "@rpath/libfoo.dylib", "/Frameworks/libfoo.dylib"]
|
||||
end
|
||||
|
||||
test "change_args/2 batches multiple -change pairs before the target binary" do
|
||||
changes = [
|
||||
{"/opt/homebrew/lib/a.dylib", "@executable_path/../Frameworks/a.dylib"},
|
||||
{"/opt/homebrew/lib/b.dylib", "@executable_path/../Frameworks/b.dylib"}
|
||||
]
|
||||
|
||||
assert Dylibs.change_args("/app/MacOS/bds2", changes) ==
|
||||
[
|
||||
"-change",
|
||||
"/opt/homebrew/lib/a.dylib",
|
||||
"@executable_path/../Frameworks/a.dylib",
|
||||
"-change",
|
||||
"/opt/homebrew/lib/b.dylib",
|
||||
"@executable_path/../Frameworks/b.dylib",
|
||||
"/app/MacOS/bds2"
|
||||
]
|
||||
end
|
||||
|
||||
test "change_args/2 with no changes is an empty list (no-op, do not invoke the tool)" do
|
||||
assert Dylibs.change_args("/app/MacOS/bds2", []) == []
|
||||
end
|
||||
end
|
||||
|
||||
describe "MacBundle.assemble/1" do
|
||||
setup do
|
||||
tmp = Path.join(System.tmp_dir!(), "bds-macbundle-#{System.unique_integer([:positive])}")
|
||||
release = Path.join(tmp, "rel")
|
||||
File.mkdir_p!(Path.join(release, "bin"))
|
||||
File.write!(Path.join(release, "bin/bds"), "#!/bin/sh\necho fake release\n")
|
||||
File.mkdir_p!(Path.join(release, "lib/wx-2.4/priv"))
|
||||
File.write!(Path.join(release, "lib/wx-2.4/priv/wxe_driver.so"), "fake-nif")
|
||||
|
||||
icns = Path.join(tmp, "AppIcon.icns")
|
||||
File.write!(icns, "fake-icns")
|
||||
|
||||
output = Path.join(tmp, "dist")
|
||||
|
||||
on_exit(fn -> File.rm_rf!(tmp) end)
|
||||
|
||||
{:ok, app} =
|
||||
MacBundle.assemble(
|
||||
release_dir: release,
|
||||
output_dir: output,
|
||||
icns_path: icns,
|
||||
version: "0.1.0",
|
||||
skip_dylibs: true,
|
||||
skip_codesign: true
|
||||
)
|
||||
|
||||
%{app: app}
|
||||
end
|
||||
|
||||
test "produces a BDS2.app with the standard Contents layout", %{app: app} do
|
||||
assert Path.basename(app) == "BDS2.app"
|
||||
assert File.dir?(Path.join(app, "Contents/MacOS"))
|
||||
assert File.dir?(Path.join(app, "Contents/Resources"))
|
||||
assert File.dir?(Path.join(app, "Contents/Frameworks"))
|
||||
end
|
||||
|
||||
test "writes Info.plist with the bds2 scheme", %{app: app} do
|
||||
plist = File.read!(Path.join(app, "Contents/Info.plist"))
|
||||
assert plist =~ "<string>bds2</string>"
|
||||
assert plist =~ "<string>de.rfc1437.bds2</string>"
|
||||
end
|
||||
|
||||
test "writes the PkgInfo file", %{app: app} do
|
||||
assert File.read!(Path.join(app, "Contents/PkgInfo")) == "APPL????"
|
||||
end
|
||||
|
||||
test "installs an executable launcher that execs the release", %{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"
|
||||
end
|
||||
|
||||
test "copies the release tree and the icon into Resources", %{app: app} do
|
||||
assert File.exists?(Path.join(app, "Contents/Resources/rel/bin/bds"))
|
||||
assert File.exists?(Path.join(app, "Contents/Resources/AppIcon.icns"))
|
||||
end
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user