diff --git a/README.md b/README.md index f5c06d1..a9c4680 100644 --- a/README.md +++ b/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//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 +``` diff --git a/lib/bds/desktop/deep_link.ex b/lib/bds/desktop/deep_link.ex index 886648e..94de96e 100644 --- a/lib/bds/desktop/deep_link.ex +++ b/lib/bds/desktop/deep_link.ex @@ -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. diff --git a/lib/bds/mac_bundle.ex b/lib/bds/mac_bundle.ex new file mode 100644 index 0000000..7baf34c --- /dev/null +++ b/lib/bds/mac_bundle.ex @@ -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 diff --git a/lib/bds/mac_bundle/dylibs.ex b/lib/bds/mac_bundle/dylibs.ex new file mode 100644 index 0000000..bedbed3 --- /dev/null +++ b/lib/bds/mac_bundle/dylibs.ex @@ -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 `/`, 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/`. + """ + @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 diff --git a/lib/bds/mac_bundle/icon.ex b/lib/bds/mac_bundle/icon.ex new file mode 100644 index 0000000..a029866 --- /dev/null +++ b/lib/bds/mac_bundle/icon.ex @@ -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 diff --git a/lib/mix/tasks/bds.bundle.macos.ex b/lib/mix/tasks/bds.bundle.macos.ex new file mode 100644 index 0000000..d0d6214 --- /dev/null +++ b/lib/mix/tasks/bds.bundle.macos.ex @@ -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 diff --git a/priv/desktop/macos/AppIcon-1024.png b/priv/desktop/macos/AppIcon-1024.png new file mode 100644 index 0000000..3711937 Binary files /dev/null and b/priv/desktop/macos/AppIcon-1024.png differ diff --git a/priv/desktop/macos/AppIcon.icns b/priv/desktop/macos/AppIcon.icns new file mode 100644 index 0000000..33b4ac1 Binary files /dev/null and b/priv/desktop/macos/AppIcon.icns differ diff --git a/priv/desktop/macos/Info.plist.eex b/priv/desktop/macos/Info.plist.eex new file mode 100644 index 0000000..b599e0f --- /dev/null +++ b/priv/desktop/macos/Info.plist.eex @@ -0,0 +1,45 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleDisplayName + <%= name %> + CFBundleName + <%= name %> + CFBundleIdentifier + <%= identifier %> + CFBundleExecutable + <%= executable %> + CFBundleIconFile + <%= icon %> + CFBundlePackageType + APPL + CFBundleSignature + ???? + CFBundleShortVersionString + <%= version %> + CFBundleVersion + <%= version %> + LSMinimumSystemVersion + <%= min_system %> + NSHighResolutionCapable + + LSApplicationCategoryType + public.app-category.developer-tools + CFBundleURLTypes + + + CFBundleTypeRole + Editor + CFBundleURLName + bDS2 Blogmark Links + CFBundleURLSchemes + + <%= scheme %> + + + + + diff --git a/priv/desktop/macos/icon-source.svg b/priv/desktop/macos/icon-source.svg new file mode 100644 index 0000000..fc7846f --- /dev/null +++ b/priv/desktop/macos/icon-source.svg @@ -0,0 +1,71 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/priv/desktop/macos/launcher.sh.eex b/priv/desktop/macos/launcher.sh.eex new file mode 100644 index 0000000..77483e4 --- /dev/null +++ b/priv/desktop/macos/launcher.sh.eex @@ -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 diff --git a/test/bds/mac_bundle_test.exs b/test/bds/mac_bundle_test.exs new file mode 100644 index 0000000..b6824e8 --- /dev/null +++ b/test/bds/mac_bundle_test.exs @@ -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 =~ "bds2" + end + + test "carries the confirmed bundle identity", %{plist: plist} do + assert plist =~ "de.rfc1437.bds2" + assert plist =~ "CFBundleExecutable" + assert plist =~ "bds2" + assert plist =~ "AppIcon" + assert plist =~ "1.2.3" + end + + test "is well-formed XML/plist" do + assert plist = MacBundle.info_plist(version: "0.0.1") + assert String.starts_with?(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 =~ "bds2" + assert plist =~ "de.rfc1437.bds2" + 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