feat: pipeline to create a full mac app

This commit is contained in:
2026-05-30 21:21:07 +02:00
parent 360a8d971a
commit 1d17b6e884
12 changed files with 1061 additions and 6 deletions

View File

@@ -162,3 +162,56 @@ Notes for developers:
- [DOCUMENTATION.md](/Users/gb/Projects/bDS2/DOCUMENTATION.md) is for end users, not implementation details. - [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. - [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. - 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
```

View File

@@ -3,12 +3,13 @@ defmodule BDS.Desktop.DeepLink do
Receives OS URL-scheme events for the `bds2://` scheme and routes them to the Receives OS URL-scheme events for the `bds2://` scheme and routes them to the
shell (spec: script.allium `BlogmarkReceived`). shell (spec: script.allium `BlogmarkReceived`).
On macOS the app bundle registers `bds2://` as a custom URL scheme (see the On macOS the `BDS2.app` bundle registers `bds2://` as a custom URL scheme via
`CFBundleURLTypes` entry in the packaged `Info.plist`). When the browser the `CFBundleURLTypes` entry in its `Info.plist` (built by `BDS.MacBundle` /
bookmarklet navigates to `bds2://new-post?title=&url=`, the OS launches/raises `mix bds.bundle.macos`). When the browser bookmarklet navigates to
the app and `Desktop.Env` delivers an `{:open_url, [url]}` event. This `bds2://new-post?title=&url=`, the OS launches/raises the app and `Desktop.Env`
GenServer subscribes to those events and forwards recognised `bds2://` links to delivers an `{:open_url, [url]}` event. This GenServer subscribes to those
the live shell over PubSub, where `BDS.Blogmark` turns them into draft posts. 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 The `bds2://` scheme is distinct from the legacy app's `bds://` so the two
installs do not contend for the same registration. installs do not contend for the same registration.

179
lib/bds/mac_bundle.ex Normal file
View 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

View 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
View 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 ~218221° blues onto ~3581° (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

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 539 KiB

Binary file not shown.

View 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>

View 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

View 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

View 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