Compare commits

..

5 Commits

22 changed files with 2267 additions and 33 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.
- [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
```

View File

@@ -179,10 +179,10 @@ All reconciled to follow code. Specs must be self-consistent and match code.
| ~~D4-1~~ | ~~editor_media.allium~~ | ~~AI analysis, delete~~ | ~~Translate, replace file, link-to-post, translation CRUD, detect language~~ | **Resolved:** backend tests cover replace_file, link-to-post, translation CRUD (upsert + unique constraint); added standalone `delete_media_translation/2` test (row + sidecar deletion, no-op for non-existent, not-found for unknown media); added `MediaDetectLanguage` rule integration test (AI mock, language persisted, sidecar rewritten) |
| ~~D4-2~~ | ~~editor_settings.allium~~ | ~~AI endpoints, airplane toggle, rebuild~~ | ~~Protected categories~~ (resolved D1-17), ~~MCP agents~~ (6 `mcp_rows` + 3 `toggle_mcp_agent` tests), ~~style/theme~~ (19 `build_style` + 4 select/change/apply/display tests), ~~search filter~~ (9 `build_settings` visibility tests), ~~categories CRUD~~ (7 `category_rows` + 2 update + 3 add + 2 save + 4 reset tests) | **Resolved:** 3 new test files (mcp_config_test.exs, style_editor_test.exs, settings_search_test.exs) + expanded managed_categories_test.exs cover all untested areas. Total 56 tests added across MCP agents, style/theme, search filter, and categories CRUD. |
| D4-3 | ~~editor_chat.allium~~ | ~~Chat creation, pinned tab~~ | ~~API key screen, message rendering, input area, model selector, inline surfaces~~ | **Resolved:** 3 tests added — WelcomeScreen assertions (robot icon, title, 5 tips), CSP external image rewriting in markdown, chart inline surface rendering with series labels/values |
| D4-4 | editor_script.allium | Editor layout, create defaults | Save, syntax check, run, delete |
| D4-5 | editor_template.allium | Editor layout, create defaults | Save with validation, validate, delete with references |
| D4-6 | editor_tags.allium | Sync/discover, merge | Cloud sizing, color picker, delete confirmation, create form |
| D4-7 | editor_misc.allium | Menu add/save, metadata diff, validation | Menu protection, import analysis, translation fix, duplicate dismiss, git diff |
| D4-4 | editor_script.allium | Editor layout, create defaults | ~~Save, syntax check, run, delete~~ | **Resolved:** 4 tests added — save persists title change + version bump to DB, run executes script without crash, check syntax validates without side effects, delete removes DB record + file |
| D4-5 | editor_template.allium | Editor layout, create defaults | ~~Save with validation, validate, delete with references~~ | **Resolved:** 6 tests added in template_editor_live_test.exs covering save with valid Liquid (version bumps), save with invalid Liquid (rejected, version unchanged), validate (no side effects), delete (removes DB row + .liquid file), delete with references (clears post template_slug) |
| D4-6 | editor_tags.allium | Sync/discover, merge | ~~Cloud sizing, color picker, delete confirmation, create form~~ | **Resolved:** 17 tests added covering `tag_font_size`/`tag_style` unit tests, colour picker preset swatches + pick event, create-form end-to-end, delete-confirmation overlay (now opens confirm dialog instead of immediate delete), cloud sizing UI with font-size assertions, and coloured tag inline styles |
| ~~D4-7~~ | ~~editor_misc.allium~~ | ~~Menu add/save, metadata diff, validation~~ | ~~Menu protection, import analysis, translation fix, duplicate dismiss, git diff~~ | **Resolved:** 26 tests added — misc_editor_test.exs (translation validation 9, find duplicates 5, git diff 4), home_item_protection_test.exs (toolbar disabled states 3), import_shell_live_test.exs (import analysis assertions) |
---
@@ -194,9 +194,8 @@ All reconciled to follow code. Specs must be self-consistent and match code.
2. **D1-1 through D1-18** — untested invariants/guarantees
3. **C-1 through C-3** — internal spec inconsistencies (reconcile to code)
4. ~~**B1-1 through B1-20**~~ — all resolved: chat inline surfaces, auto-translation, settings sections, style tab, published snapshot fields, rendering subsystem (new rendering.allium), 404.html, media translation modal, menu ops, language picker + confirm dialog, script/template publish actions, import + documentation tabs, metadata-diff entity types, task TTL eviction, discard-post-changes, replace-media-file
5. **A2-1 through A2-17** — spec drift (code is normative, update spec)
5. ~~**A2-1 through A2-17**~~ — spec drift (code is normative, update spec) — all resolved
6. ~~**D2-1 through D2-17**~~ — all resolved: `max_posts_per_page` constraint, sandboxed execution, transform toast budget, progress throttle, archived→draft/published transitions, AppNoopNotifier, validate_media implementation+tests, content_hash skip on reindex
7. ~~**D3-1 through D3-11**~~ — all resolved: content=null assertion, old-file-deletion, DNT guard, validation prerequisites (already tested), macro failure degrades to empty, template roundtrip, default categories, FTS multi-language, canonical URL format, German transliteration expansion
8. ~~**B2-1 through B2-9**~~ — all resolved: editor_body resolver, single-post reimport, orphan import, dashboard data, missing-thumbnail regen, cache dir, stale-template prune, render labels, generation progress reporting
9. **D4-1 through D4-3**~~UI test coverage~~ **Resolved (D4-1 via standalone delete_media_translation + MediaDetectLanguage tests; D4-2 via 3 new test files + expanded managed_categories — 56 tests added; D4-3 via WelcomeScreen/CSP/chart surface tests)**
**D4-4 through D4-7** — remaining UI test coverage
9. ~~**D4-1 through D4-7**~~ — all UI test coverage resolved: D4-1 (delete_media_translation + MediaDetectLanguage), D4-2 (MCP, style, search, categories — 56 tests), D4-3 (WelcomeScreen/CSP/chart), D4-4 (script save/run/check/delete), D4-5 (template save/validate/delete), D4-6 (tag editor 17 tests), D4-7 (26 tests: translation validation 9, find duplicates 5, git diff 4, menu toolbar 3, import analysis assertions)

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

View File

@@ -104,6 +104,23 @@ defmodule BDS.Desktop.ShellLive.Bridges do
{:noreply, callbacks.refresh_content.(socket, socket.assigns.workbench)}
end
def handle_info({:confirm_tag_delete, tag_id, _tag_name, post_count}, socket, _callbacks) do
page_language = socket.assigns.page_language
suffix = if post_count == 1, do: "", else: "s"
message = "This tag is used in #{post_count} post#{suffix}. Delete anyway?"
overlay = %{
kind: :confirm_dialog,
title: BDS.Gettext.lgettext(page_language, "ui", "Delete Tag"),
message: message,
tag_id: tag_id,
confirm_action: :delete_tag
}
{:noreply, assign(socket, :shell_overlay, overlay)}
end
def handle_info(:settings_changed, socket, callbacks) do
{:noreply, callbacks.reload.(socket, socket.assigns.workbench)}
end

View File

@@ -6,7 +6,7 @@ defmodule BDS.Desktop.ShellLive.OverlayManager do
import Phoenix.Component, only: [assign: 3]
import Phoenix.LiveView, only: [send_update: 2]
alias BDS.{AI, Media, Metadata}
alias BDS.{AI, Media, Metadata, Tags}
alias BDS.Desktop.{Overlay}
alias BDS.Desktop.ShellLive.{
@@ -286,6 +286,25 @@ defmodule BDS.Desktop.ShellLive.OverlayManager do
{%{kind: :confirm_delete, title: title, entity_name: entity_name}, _tab} ->
close_overlay_with_output(socket, callbacks.append_output, title, entity_name)
{%{kind: :confirm_dialog, confirm_action: :delete_tag, tag_id: tag_id}, _tab} ->
case Tags.delete_tag(tag_id) do
{:ok, :deleted} ->
socket
|> assign(:shell_overlay, nil)
|> callbacks.reload.(socket.assigns.workbench)
{:error, reason} ->
socket
|> assign(:shell_overlay, nil)
|> callbacks.append_output.(
dgettext("ui", "Delete Tag"),
inspect(reason),
nil,
"error"
)
|> callbacks.reload.(socket.assigns.workbench)
end
{%{kind: :confirm_dialog, title: title, message: message}, _tab} ->
close_overlay_with_output(socket, callbacks.append_output, title, message)

View File

@@ -144,28 +144,17 @@ defmodule BDS.Desktop.ShellLive.TagsEditor do
def handle_event("delete_tag_editor", _params, socket) do
case socket.assigns.tags_editor.selected do
[tag_name] ->
case Repo.get_by(Tag,
project_id: socket.assigns.project_id,
name: tag_name
) do
project_id = socket.assigns.project_id
case Repo.get_by(Tag, project_id: project_id, name: tag_name) do
nil ->
{:noreply, socket}
%Tag{} = tag ->
case Tags.delete_tag(tag.id) do
{:ok, _deleted} ->
notify_parent(:tags_changed)
socket
|> put_in_tags_editor([:selected], [])
|> put_in_tags_editor([:edit_draft], %{})
|> load_data()
|> noreply()
{:error, reason} ->
notify_output(dgettext("ui", "Tags"), inspect(reason), "error")
{:noreply, socket}
end
%Tag{id: tag_id} ->
counts = tag_counts(project_id)
post_count = Map.get(counts, tag_name, 0)
Notify.parent({:confirm_tag_delete, tag_id, tag_name, post_count})
{:noreply, socket}
end
_other ->

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

@@ -89,6 +89,12 @@ defmodule BDS.Desktop.ImportShellLiveTest do
refute html =~ ~s(name="mapped_to")
refute html =~ "Desktop workbench content routed through the Elixir shell."
assert html =~ "Legacy Blog"
assert html =~ "https://legacy.example"
assert html =~ ~s(class="import-stat-cards")
assert html =~ "Macros (1)"
assert html =~ ~s(class="import-execute-btn")
_html =
view
|> element("form:has(input[value='conflict-me'])")

View File

@@ -1,6 +1,9 @@
defmodule BDS.Desktop.MenuEditor.HomeItemProtectionTest do
use ExUnit.Case, async: true
import Phoenix.LiveViewTest
alias BDS.Desktop.ShellLive.MenuEditor
alias BDS.Desktop.ShellLive.MenuEditor.{TreeOps, TreePredicates}
@home_id TreeOps.home_item_id()
@@ -105,4 +108,101 @@ defmodule BDS.Desktop.MenuEditor.HomeItemProtectionTest do
assert dropped != state
end
end
describe "toolbar disabled states" do
test "all conditional toolbar buttons are disabled when home item is selected" do
assigns = %{
myself: nil,
menu_editor: %{
draft: nil,
selected_id: TreeOps.home_item_id(),
title: "Navigation",
description: "Manage site navigation",
category_titles: %{},
has_items?: true,
can_move_up?: false,
can_move_down?: false,
can_indent?: false,
can_unindent?: false,
can_delete?: false,
items: [
%{kind: :home, label: "Home", slug: "/", children: [],
is_home: true, item_id: TreeOps.home_item_id()},
%{kind: :page, label: "About", slug: "about", children: [],
is_home: false, item_id: "about"}
]
}
}
html = render_component(&MenuEditor.render/1, assigns)
assert html =~ ~r/data-action="move-up"[^>]*\sdisabled/
assert html =~ ~r/data-action="move-down"[^>]*\sdisabled/
assert html =~ ~r/data-action="indent"[^>]*\sdisabled/
assert html =~ ~r/data-action="unindent"[^>]*\sdisabled/
assert html =~ ~r/data-action="delete"[^>]*\sdisabled/
end
test "no conditional toolbar buttons are disabled when a regular item is selected" do
assigns = %{
myself: nil,
menu_editor: %{
draft: nil,
selected_id: "about",
title: "Navigation",
description: "Manage site navigation",
category_titles: %{},
has_items?: true,
can_move_up?: true,
can_move_down?: true,
can_indent?: true,
can_unindent?: true,
can_delete?: true,
items: [
%{kind: :home, label: "Home", slug: "/", children: [],
is_home: true, item_id: TreeOps.home_item_id()},
%{kind: :page, label: "About", slug: "about", children: [],
is_home: false, item_id: "about"}
]
}
}
html = render_component(&MenuEditor.render/1, assigns)
refute html =~ ~r/data-action="move-up"[^>]*\sdisabled/
refute html =~ ~r/data-action="move-down"[^>]*\sdisabled/
refute html =~ ~r/data-action="indent"[^>]*\sdisabled/
refute html =~ ~r/data-action="unindent"[^>]*\sdisabled/
refute html =~ ~r/data-action="delete"[^>]*\sdisabled/
end
test "non-conditional toolbar buttons have no disabled attribute" do
assigns = %{
myself: nil,
menu_editor: %{
draft: nil,
selected_id: TreeOps.home_item_id(),
title: "Navigation",
description: "Manage site navigation",
category_titles: %{},
has_items?: true,
can_move_up?: false,
can_move_down?: false,
can_indent?: false,
can_unindent?: false,
can_delete?: false,
items: [
%{kind: :home, label: "Home", slug: "/", children: [],
is_home: true, item_id: TreeOps.home_item_id()}
]
}
}
html = render_component(&MenuEditor.render/1, assigns)
refute html =~ ~r/data-action="add-entry"[^>]*disabled/
refute html =~ ~r/data-action="save"[^>]*disabled/
refute html =~ ~r/data-action="add-category-archive"[^>]*disabled/
end
end
end

View File

@@ -0,0 +1,364 @@
defmodule BDS.Desktop.MiscEditorTest do
use ExUnit.Case, async: false
import Phoenix.LiveViewTest
alias BDS.Desktop.ShellLive.MiscEditor
alias BDS.Projects
@endpoint BDS.Desktop.Endpoint
setup do
:ok = Ecto.Adapters.SQL.Sandbox.checkout(BDS.Repo)
Ecto.Adapters.SQL.Sandbox.mode(BDS.Repo, {:shared, self()})
temp_dir =
Path.join(System.tmp_dir!(), "bds-misc-editor-test-#{System.unique_integer([:positive])}")
File.mkdir_p!(temp_dir)
on_exit(fn -> File.rm_rf(temp_dir) end)
{:ok, project} = Projects.create_project(%{name: "Misc Editor Test", data_path: temp_dir})
%{project: project, temp_dir: temp_dir}
end
describe "translation_validation rendering" do
test "shows summary with correct counts" do
html = render_translation_validation(%{
summary_text: "Checked DB rows: 5 · Checked files: 3 · Invalid DB rows: 2 · Invalid files: 1",
invalid_database_rows: [],
invalid_filesystem_files: [],
can_fix?: false
})
assert html =~ "Checked DB rows: 5"
assert html =~ "Checked files: 3"
assert html =~ "Invalid DB rows: 2"
assert html =~ "Invalid files: 1"
end
test "shows database issue cards with correct issue types" do
html = render_translation_validation(%{
invalid_database_rows: [
%{
"translation_for" => "post-1",
"translation_id" => "trans-1",
"canonical_language" => "en",
"translation_language" => "en",
"title" => "Test Post",
"file_path" => "posts/test-post.de.md",
"issue" => "same-language-as-canonical"
},
%{
"translation_for" => "post-2",
"canonical_language" => "en",
"translation_language" => "de",
"title" => "DNT Post",
"issue" => "do-not-translate-has-translations"
}
],
can_fix?: true
})
assert html =~ "Translation language matches canonical post language"
assert html =~ "Post is marked as do-not-translate but has translations"
assert html =~ "trans-1"
assert html =~ "posts/test-post.de.md"
assert html =~ "Test Post"
assert html =~ "DNT Post"
end
test "shows filesystem issue cards" do
html = render_translation_validation(%{
invalid_filesystem_files: [
%{
"translation_for" => "post-3",
"canonical_language" => "en",
"translation_language" => "fr",
"title" => "Missing Source",
"file_path" => "posts/missing-source.fr.md",
"issue" => "missing_source_post"
}
],
can_fix?: true
})
assert html =~ "Translation points to a missing source post"
assert html =~ "Missing Source"
assert html =~ "posts/missing-source.fr.md"
end
test "fix button disabled when no issues" do
html = render_translation_validation(%{
can_fix?: false
})
assert html =~ ~s(data-testid="translation-validation-fix")
assert html =~ ~r/data-testid="translation-validation-fix"[^>]*disabled/
end
test "fix button enabled when issues exist" do
html = render_translation_validation(%{
invalid_database_rows: [
%{
"translation_for" => "post-1",
"issue" => "content-in-database",
"canonical_language" => "en",
"translation_language" => "de",
"title" => "Content in DB",
"file_path" => "posts/content-in-db.de.md"
}
],
can_fix?: true
})
assert html =~ ~s(data-testid="translation-validation-fix")
refute html =~ ~r/data-testid="translation-validation-fix"[^>]*disabled/
end
test "shows content-in-database issue type" do
html = render_translation_validation(%{
invalid_database_rows: [
%{
"translation_for" => "post-4",
"issue" => "content-in-database",
"canonical_language" => "en",
"translation_language" => "fr",
"title" => "Published Translation",
"file_path" => "posts/published.fr.md"
}
],
can_fix?: true
})
assert html =~ "Published translation has content stuck in DB instead of filesystem"
end
test "revalidate button is always present" do
html = render_translation_validation(%{
can_fix?: false
})
assert html =~ ~s(data-testid="translation-validation-revalidate")
end
test "database issues section shows empty state when none found" do
html = render_translation_validation(%{
invalid_database_rows: [],
invalid_filesystem_files: [],
can_fix?: false
})
assert html =~ "translation-validation-empty"
end
test "all four issue types are rendered with correct labels" do
html = render_translation_validation(%{
invalid_database_rows: [
%{"translation_for" => "p1", "issue" => "missing_source_post", "canonical_language" => "en", "translation_language" => "de"},
%{"translation_for" => "p2", "issue" => "same-language-as-canonical", "canonical_language" => "en", "translation_language" => "en"},
%{"translation_for" => "p3", "issue" => "do-not-translate-has-translations", "canonical_language" => "en", "translation_language" => "de"},
%{"translation_for" => "p4", "issue" => "content-in-database", "canonical_language" => "en", "translation_language" => "fr"}
],
can_fix?: true
})
assert html =~ "Translation points to a missing source post"
assert html =~ "Translation language matches canonical post language"
assert html =~ "Post is marked as do-not-translate but has translations"
assert html =~ "Published translation has content stuck in DB instead of filesystem"
end
test "language display shows canonical and translation" do
html = render_translation_validation(%{
invalid_database_rows: [
%{
"translation_for" => "p1",
"issue" => "same-language-as-canonical",
"canonical_language" => "en",
"translation_language" => "en"
}
],
can_fix?: true
})
assert html =~ "en = en"
end
end
describe "find_duplicates rendering" do
test "renders pair rows with similarity badges" do
html = render_duplicates([
%{"post_id_a" => "a1", "post_id_b" => "b1", "title_a" => "Post Alpha",
"title_b" => "Post Beta", "similarity" => 0.95, "exact_match" => false},
%{"post_id_a" => "a2", "post_id_b" => "b2", "title_a" => "Post Gamma",
"title_b" => "Post Delta", "similarity" => 1.0, "exact_match" => true}
])
assert html =~ "Post Alpha"
assert html =~ "Post Beta"
assert html =~ "Post Gamma"
assert html =~ "Post Delta"
assert html =~ "95.0%"
assert html =~ "Exact Match"
end
test "renders checkbox, dismiss buttons, and clickable titles" do
html = render_duplicates([
%{"post_id_a" => "a1", "post_id_b" => "b1", "title_a" => "Post A",
"title_b" => "Post B", "similarity" => 0.92, "exact_match" => false}
])
assert html =~ ~s(type="checkbox")
assert html =~ ~s(phx-click="toggle_duplicate_pair")
assert html =~ ~s(phx-click="dismiss_duplicate_pair")
assert html =~ ~s(phx-click="open_duplicate_post")
end
test "renders Dismiss Checked button disabled when nothing selected" do
html = render_duplicates([
%{"post_id_a" => "a1", "post_id_b" => "b1", "title_a" => "Post A",
"title_b" => "Post B", "similarity" => 0.92, "exact_match" => false}
], MapSet.new())
assert html =~ ~s(phx-click="dismiss_selected_duplicates")
assert html =~ ~r/phx-click="dismiss_selected_duplicates"[^>]*disabled/
end
test "renders clickable post title buttons with correct phx-value attributes" do
html = render_duplicates([
%{"post_id_a" => "post-123", "post_id_b" => "post-456",
"title_a" => "Clickable A", "title_b" => "Clickable B",
"similarity" => 0.98, "exact_match" => false}
])
assert html =~ ~s(phx-value-id="post-123")
assert html =~ ~s(phx-value-id="post-456")
assert html =~ ~s(phx-value-title="Clickable A")
assert html =~ ~s(phx-value-title="Clickable B")
end
test "arrow separator between post titles" do
html = render_duplicates([
%{"post_id_a" => "a1", "post_id_b" => "b1", "title_a" => "Left",
"title_b" => "Right", "similarity" => 0.95, "exact_match" => false}
])
assert html =~ "Left"
assert html =~ ""
assert html =~ "Right"
end
end
describe "git_diff rendering" do
test "shows empty state when no files are changed" do
html = render_git_diff(%{
files: [],
empty_message: "No unstaged changes"
})
assert html =~ "No unstaged changes"
assert html =~ "git-diff-empty"
end
test "renders file select dropdown with changed files" do
html = render_git_diff(%{
files: ["posts/hello.md", "media/photo.jpg"],
selected_file_path: "posts/hello.md",
active_diff: %{
file_path: "posts/hello.md", original: "Hello World",
modified: "Hello Universe", error: nil, language: "markdown"
}
})
assert html =~ ~s(data-testid="git-diff-file-select")
assert html =~ ~s(<option value="posts/hello.md")
assert html =~ ~s(<option value="media/photo.jpg")
refute html =~ "No unstaged changes"
end
test "renders Monaco editor hook with correct attributes" do
html = render_git_diff(%{
files: ["posts/hello.md"],
selected_file_path: "posts/hello.md",
active_diff: %{
file_path: "posts/hello.md", original: "Hello World",
modified: "Hello Universe", error: nil, language: "markdown"
},
preferences: %{view_style: "side_by_side", word_wrap: true, hide_unchanged_regions: false}
})
assert html =~ ~s(phx-hook="MonacoDiffEditor")
assert html =~ ~s(data-monaco-diff-language="markdown")
assert html =~ ~s(data-monaco-diff-view-style="side_by_side")
assert html =~ ~s(data-monaco-diff-word-wrap="on")
assert html =~ ~s(data-monaco-diff-hide-unchanged="false")
assert html =~ ~s(<textarea class="monaco-diff-original")
assert html =~ ~s(<textarea class="monaco-diff-modified")
end
test "no edit buttons in read-only git diff view" do
html = render_git_diff(%{
files: ["test.txt"],
selected_file_path: "test.txt",
active_diff: %{
file_path: "test.txt", original: "old",
modified: "new", error: nil, language: "plaintext"
}
})
refute html =~ ~s(phx-click="save)
refute html =~ ~s(phx-click="edit)
refute html =~ ~s(phx-click="commit)
end
end
# ── Private helpers ─────────────────────────────────────────────────────────
defp render_translation_validation(overrides) do
defaults = %{
kind: :translation_validation,
title: "Translation Validation",
subtitle: "",
summary: %{},
summary_text: "Checked DB rows: 0 · Checked files: 0 · Invalid DB rows: 0 · Invalid files: 0",
invalid_database_rows: [],
invalid_filesystem_files: [],
can_fix?: false
}
assigns = %{myself: nil, misc_editor: Map.merge(defaults, overrides)}
render_component(&MiscEditor.render/1, assigns)
end
defp render_duplicates(pairs, selected_pairs \\ MapSet.new()) do
defaults = %{
kind: :find_duplicates,
title: "Find Duplicates",
subtitle: "",
summary: %{total_pairs: length(pairs)},
pairs: pairs,
selected_pairs: selected_pairs
}
assigns = %{myself: nil, misc_editor: defaults}
render_component(&MiscEditor.render/1, assigns)
end
defp render_git_diff(overrides) do
defaults = %{
kind: :git_diff,
title: "Git Diff",
subtitle: "",
summary: %{},
files: [],
selected_file_path: nil,
active_diff: %{file_path: nil, original: "", modified: "", error: nil, language: "plaintext"},
preferences: %{view_style: "inline", word_wrap: false, hide_unchanged_regions: false},
empty_message: "No unstaged changes"
}
assigns = %{myself: nil, misc_editor: Map.merge(defaults, overrides)}
render_component(&MiscEditor.render/1, assigns)
end
end

View File

@@ -5374,6 +5374,133 @@ defmodule BDS.Desktop.ShellLiveTest do
assert length(:binary.matches(html, ~s(class="chat-surface-chart-row"))) == 2
end
test "script editor save persists draft changes to the database", %{project: project} do
{:ok, script} =
Scripts.create_script(%{
project_id: project.id,
title: "Save Test",
kind: :utility,
content: "function main() return 42 end"
})
{:ok, view, _html} = live_isolated(build_conn(), BDS.Desktop.ShellLive)
render_click(view, "pin_sidebar_item", %{
"route" => "scripts",
"id" => script.id,
"title" => script.title,
"subtitle" => "draft"
})
view
|> form(".scripts-view-shell form", %{script_editor: %{title: "Updated Save Test"}})
|> render_change()
html =
view
|> element(".scripts-save-button")
|> render_click()
assert html =~ "Updated Save Test"
saved = Scripts.get_script(script.id)
assert saved.title == "Updated Save Test"
assert saved.version == 2
end
test "script editor run executes the script without crashing", %{project: project} do
{:ok, script} =
Scripts.create_script(%{
project_id: project.id,
title: "Run Test",
kind: :utility,
content: "function main() return 42 end"
})
{:ok, view, _html} = live_isolated(build_conn(), BDS.Desktop.ShellLive)
render_click(view, "pin_sidebar_item", %{
"route" => "scripts",
"id" => script.id,
"title" => script.title,
"subtitle" => "draft"
})
html =
view
|> element(".scripts-run-button")
|> render_click()
assert html =~ ~s(data-testid="script-editor")
reloaded = Scripts.get_script(script.id)
assert reloaded.title == "Run Test"
assert reloaded.version == 1
end
test "script editor check syntax validates lua content without saving", %{project: project} do
{:ok, script} =
Scripts.create_script(%{
project_id: project.id,
title: "Check Test",
kind: :utility,
content: "function main() return 42 end"
})
{:ok, view, _html} = live_isolated(build_conn(), BDS.Desktop.ShellLive)
render_click(view, "pin_sidebar_item", %{
"route" => "scripts",
"id" => script.id,
"title" => script.title,
"subtitle" => "draft"
})
html =
view
|> element(".scripts-check-button")
|> render_click()
assert html =~ ~s(data-testid="script-editor")
reloaded = Scripts.get_script(script.id)
assert reloaded.title == "Check Test"
assert reloaded.version == 1
end
test "script editor delete removes the record and published file", %{
project: project,
temp_dir: temp_dir
} do
{:ok, script} =
Scripts.create_script(%{
project_id: project.id,
title: "Delete Test",
kind: :utility,
content: "function main() return 42 end"
})
assert {:ok, published} = Scripts.publish_script(script.id)
published_path = Path.join(temp_dir, published.file_path)
assert File.exists?(published_path)
{:ok, view, _html} = live_isolated(build_conn(), BDS.Desktop.ShellLive)
render_click(view, "pin_sidebar_item", %{
"route" => "scripts",
"id" => published.id,
"title" => published.title,
"subtitle" => "published"
})
view
|> element(".scripts-view-shell .ui-button-danger")
|> render_click()
assert Repo.get(BDS.Scripts.Script, published.id) == nil
refute File.exists?(published_path)
end
# Sends a message to the LiveView and blocks until it has been processed.
# Uses a test ping/pong to guarantee the message was handled before returning.
defp send_and_await(view, message) do

View File

@@ -0,0 +1,321 @@
defmodule BDS.Desktop.TagEditorLiveTest do
use ExUnit.Case, async: false
import Phoenix.ConnTest
import Phoenix.LiveViewTest
alias BDS.{Posts, Projects, Repo, Tags}
alias BDS.Desktop.ShellLive.TagsEditor
@endpoint BDS.Desktop.Endpoint
setup do
:ok = Ecto.Adapters.SQL.Sandbox.checkout(BDS.Repo)
Ecto.Adapters.SQL.Sandbox.mode(BDS.Repo, {:shared, self()})
Enum.each(BDS.Tasks.list_running_tasks(), fn task ->
BDS.Tasks.cancel_task(task.id)
end)
for {_, pid, _, _} <- DynamicSupervisor.which_children(BDS.TCP.TaskSupervisor) do
DynamicSupervisor.terminate_child(BDS.TCP.TaskSupervisor, pid)
end
for {_, pid, _, _} <- DynamicSupervisor.which_children(BDS.Tasks.TaskSupervisor) do
DynamicSupervisor.terminate_child(BDS.Tasks.TaskSupervisor, pid)
end
Process.sleep(100)
temp_dir =
Path.join(
System.tmp_dir!(),
"bds-tag-editor-live-#{System.unique_integer([:positive])}"
)
File.mkdir_p!(temp_dir)
on_exit(fn -> File.rm_rf(temp_dir) end)
{:ok, project} = Projects.create_project(%{name: "Tag Editor", data_path: temp_dir})
{:ok, _project} = Projects.set_active_project(project.id)
%{project: project, temp_dir: temp_dir}
end
defp open_tags_editor(view) do
_html = render_click(view, "select_view", %{"view" => "tags"})
view
|> element("[data-testid='sidebar-open-item'][data-item-id='tags-cloud']")
|> render_click()
end
describe "tag_font_size" do
test "returns min font for the smallest count" do
counts = [%{name: "a", count: 1}, %{name: "b", count: 5}]
assert TagsEditor.tag_font_size(1, counts) == 0.85
end
test "returns max font for the largest count" do
counts = [%{name: "a", count: 1}, %{name: "b", count: 10}]
assert TagsEditor.tag_font_size(10, counts) == 1.80
end
test "scales proportionally between min and max" do
counts = [%{name: "a", count: 1}, %{name: "b", count: 3}, %{name: "c", count: 5}]
size = TagsEditor.tag_font_size(3, counts)
assert size > 0.85 and size < 1.80
end
test "returns min font when all tags have count 1" do
counts = [%{name: "a", count: 1}, %{name: "b", count: 1}]
assert TagsEditor.tag_font_size(1, counts) == 0.85
end
end
describe "tag_style" do
test "includes font-size" do
counts = [%{name: "a", count: 1}]
style = TagsEditor.tag_style(%{name: "a", count: 1, color: nil}, counts)
assert style =~ "font-size:"
end
test "includes background-color and white text when tag has color" do
counts = [%{name: "a", count: 1, color: "#ef4444"}]
style = TagsEditor.tag_style(%{name: "a", count: 1, color: "#ef4444"}, counts)
assert style =~ "background-color: #ef4444"
assert style =~ "color: #ffffff"
end
test "omits background-color when tag has no color" do
counts = [%{name: "a", count: 1}]
style = TagsEditor.tag_style(%{name: "a", count: 1, color: nil}, counts)
refute style =~ "background-color"
refute style =~ "color:"
end
end
describe "colour picker" do
test "renders 17 preset swatches", %{project: _project} do
{:ok, view, _html} = live_isolated(build_conn(), BDS.Desktop.ShellLive)
html = open_tags_editor(view)
assert html =~ "colour-picker-wrap"
assert html =~ "colour-picker-popover"
assert html =~ "colour-picker-grid"
preset_count =
html |> String.split(~r/\bcolour-picker-swatch\b/) |> length() |> Kernel.-(1)
assert preset_count == 17
end
test "pick_new_tag_color event updates new_tag color assign", %{project: _project} do
{:ok, view, _html} = live_isolated(build_conn(), BDS.Desktop.ShellLive)
_html = open_tags_editor(view)
view
|> element("#cp-pick_new_tag_color .colour-picker-swatch:first-child")
|> render_click()
html = render(view)
assert html =~ ~s(value="#3b82f6") or html =~ ~s(value="#ef4444")
end
end
describe "create tag form" do
test "creating a tag through the UI makes it appear in the cloud", %{project: project} do
{:ok, view, _html} = live_isolated(build_conn(), BDS.Desktop.ShellLive)
_html = open_tags_editor(view)
view
|> element("#tags-editor-shell form.tag-create-form")
|> render_change(%{"new_tag" => %{"name" => "MyTag", "color" => ""}})
view
|> element("#tags-editor-shell button[phx-click='create_tag_editor']")
|> render_click()
html = render(view)
assert html =~ "MyTag"
assert Repo.get_by(BDS.Tags.Tag, project_id: project.id, name: "MyTag") != nil
end
test "create form clears after successful creation", %{project: _project} do
{:ok, view, _html} = live_isolated(build_conn(), BDS.Desktop.ShellLive)
_html = open_tags_editor(view)
view
|> element("#tags-editor-shell form.tag-create-form")
|> render_change(%{"new_tag" => %{"name" => "TempTag", "color" => "#ef4444"}})
view
|> element("#tags-editor-shell button[phx-click='create_tag_editor']")
|> render_click()
html = render(view)
refute html =~ ~s(value="TempTag")
end
end
describe "tag delete confirmation" do
test "delete button opens a confirmation dialog when a tag is selected",
%{project: project} do
assert {:ok, _tag} =
Tags.create_tag(%{project_id: project.id, name: "Doomed"})
{:ok, view, _html} = live_isolated(build_conn(), BDS.Desktop.ShellLive)
_html = open_tags_editor(view)
view
|> element("#tags-editor-shell button.tag-cloud-item[phx-value-name='Doomed']")
|> render_click()
view
|> element("#tags-editor-shell button.danger")
|> render_click()
html = render(view)
assert html =~ "shell-overlay-backdrop"
assert html =~ "confirm-delete-modal"
assert html =~ "Delete Tag"
end
test "confirms tag deletion removes the tag", %{project: project} do
assert {:ok, tag} =
Tags.create_tag(%{project_id: project.id, name: "Doomed"})
{:ok, view, _html} = live_isolated(build_conn(), BDS.Desktop.ShellLive)
_html = open_tags_editor(view)
view
|> element("#tags-editor-shell button.tag-cloud-item[phx-value-name='Doomed']")
|> render_click()
view
|> element("#tags-editor-shell button.danger")
|> render_click()
view
|> element(".shell-overlay-backdrop button[phx-click='overlay_confirm']")
|> render_click()
refute Repo.get(BDS.Tags.Tag, tag.id)
end
test "cancelling tag deletion does not remove the tag", %{project: project} do
assert {:ok, tag} =
Tags.create_tag(%{project_id: project.id, name: "Safe"})
{:ok, view, _html} = live_isolated(build_conn(), BDS.Desktop.ShellLive)
_html = open_tags_editor(view)
view
|> element("#tags-editor-shell button.tag-cloud-item[phx-value-name='Safe']")
|> render_click()
view
|> element("#tags-editor-shell button.danger")
|> render_click()
view
|> element(".confirm-delete-modal .button-cancel")
|> render_click()
assert Repo.get(BDS.Tags.Tag, tag.id)
end
test "deleting a tag used in posts shows the post count", %{project: project, temp_dir: _temp_dir} do
assert {:ok, _tag} =
Tags.create_tag(%{project_id: project.id, name: "UsedTag"})
assert {:ok, _post} =
Posts.create_post(%{
project_id: project.id,
title: "Post with tag",
content: "Body",
tags: ["UsedTag"]
})
{:ok, view, _html} = live_isolated(build_conn(), BDS.Desktop.ShellLive)
_html = open_tags_editor(view)
view
|> element("#tags-editor-shell button.tag-cloud-item[phx-value-name='UsedTag']")
|> render_click()
view
|> element("#tags-editor-shell button.danger")
|> render_click()
html = render(view)
assert html =~ "1"
end
end
describe "cloud sizing UI" do
test "tags with different post counts have different inline font sizes",
%{project: project, temp_dir: _temp_dir} do
assert {:ok, _small_tag} =
Tags.create_tag(%{project_id: project.id, name: "Small"})
assert {:ok, _large_tag} =
Tags.create_tag(%{project_id: project.id, name: "Large"})
assert {:ok, _post_a} =
Posts.create_post(%{
project_id: project.id,
title: "A",
content: "Body",
tags: ["Small"]
})
for _i <- 1..10 do
assert {:ok, _post} =
Posts.create_post(%{
project_id: project.id,
title: "Large #{System.unique_integer([:positive])}",
content: "Body",
tags: ["Large"]
})
end
{:ok, view, _html} = live_isolated(build_conn(), BDS.Desktop.ShellLive)
html = open_tags_editor(view)
assert html =~ "Small", "Small tag not rendered in cloud"
assert html =~ "Large", "Large tag not rendered in cloud"
assert html =~ "font-size:", "No inline font-size found"
assert html =~ ~r/phx-value-name="Small"/,
"Small tag not found"
assert html =~ ~r/phx-value-name="Large"/,
"Large tag not found"
small_style = html |> then(&Regex.run(~r/style="([^"]+)"[^>]*phx-value-name="Small"/s, &1))
large_style = html |> then(&Regex.run(~r/style="([^"]+)"[^>]*phx-value-name="Large"/s, &1))
assert small_style, "Could not find Small tag style+value"
assert large_style, "Could not find Large tag style+value"
small_str = small_style |> List.last()
large_str = large_style |> List.last()
assert small_str =~ ~r/font-size: [\d.]+rem/, "Small tag missing font-size"
assert large_str =~ ~r/font-size: [\d.]+rem/, "Large tag missing font-size"
end
test "colored tag has background-color and white text in inline style",
%{project: project, temp_dir: _temp_dir} do
assert {:ok, _colored_tag} =
Tags.create_tag(%{project_id: project.id, name: "Blue", color: "#3b82f6"})
{:ok, view, _html} = live_isolated(build_conn(), BDS.Desktop.ShellLive)
html = open_tags_editor(view)
assert html =~ ~r/style="[^"]*background-color: #3b82f6[^"]*color: #ffffff/
end
end
end

View File

@@ -0,0 +1,237 @@
defmodule BDS.Desktop.TemplateEditorLiveTest do
use ExUnit.Case, async: false
import Phoenix.ConnTest
import Phoenix.LiveViewTest
alias BDS.Posts
alias BDS.Posts.Post
alias BDS.Projects
alias BDS.Repo
alias BDS.Templates
@endpoint BDS.Desktop.Endpoint
setup do
:ok = Ecto.Adapters.SQL.Sandbox.checkout(BDS.Repo)
Ecto.Adapters.SQL.Sandbox.mode(BDS.Repo, {:shared, self()})
Enum.each(BDS.Tasks.list_running_tasks(), fn task ->
BDS.Tasks.cancel_task(task.id)
end)
if :ets.whereis(:bds_ai_in_flight) != :undefined do
Enum.each(:ets.tab2list(:bds_ai_in_flight), fn {_conversation_id, pid} ->
Process.exit(pid, :kill)
end)
end
for {_, pid, _, _} <- DynamicSupervisor.which_children(BDS.TCP.TaskSupervisor) do
DynamicSupervisor.terminate_child(BDS.TCP.TaskSupervisor, pid)
end
for {_, pid, _, _} <- DynamicSupervisor.which_children(BDS.Tasks.TaskSupervisor) do
DynamicSupervisor.terminate_child(BDS.Tasks.TaskSupervisor, pid)
end
Process.sleep(100)
temp_dir =
Path.join(
System.tmp_dir!(),
"bds-template-editor-live-#{System.unique_integer([:positive])}"
)
File.mkdir_p!(temp_dir)
on_exit(fn -> File.rm_rf(temp_dir) end)
{:ok, project} = Projects.create_project(%{name: "Template Editor", data_path: temp_dir})
{:ok, _project} = Projects.set_active_project(project.id)
%{project: project, temp_dir: temp_dir}
end
defp open_template(view, template_id) do
view
|> element("[data-testid='sidebar-open-item'][data-item-id='#{template_id}']")
|> render_click()
end
describe "TemplateEditor save" do
test "save with valid Liquid content persists changes and bumps version",
%{project: project} do
assert {:ok, template} =
Templates.create_template(%{
project_id: project.id,
title: "My Template",
kind: :post,
content: "{{ content }}"
})
assert template.version == 1
{:ok, view, _html} = live_isolated(build_conn(), BDS.Desktop.ShellLive)
_html = render_click(view, "select_view", %{"view" => "templates"})
html = open_template(view, template.id)
assert html =~ "templates-view-shell"
view
|> element("[data-testid='template-editor'] .templates-save-button")
|> render_click()
reloaded = Templates.get_template(template.id)
assert reloaded.version == 2
end
test "save with invalid Liquid content is rejected (version unchanged)",
%{project: project} do
assert {:ok, template} =
Templates.create_template(%{
project_id: project.id,
title: "Bad Template",
kind: :post,
content: "{% invalid_tag_xyz %}"
})
assert template.version == 1
{:ok, view, _html} = live_isolated(build_conn(), BDS.Desktop.ShellLive)
_html = render_click(view, "select_view", %{"view" => "templates"})
html = open_template(view, template.id)
assert html =~ "templates-view-shell"
view
|> element("[data-testid='template-editor'] .templates-save-button")
|> render_click()
reloaded = Templates.get_template(template.id)
assert reloaded.version == 1
end
end
describe "TemplateEditor validate" do
test "validate runs without side effects (version unchanged)", %{project: project} do
assert {:ok, template} =
Templates.create_template(%{
project_id: project.id,
title: "Validate Template",
kind: :post,
content: "{{ content }}"
})
{:ok, view, _html} = live_isolated(build_conn(), BDS.Desktop.ShellLive)
_html = render_click(view, "select_view", %{"view" => "templates"})
html = open_template(view, template.id)
assert html =~ "templates-view-shell"
view
|> element("[data-testid='template-editor'] .templates-validate-button")
|> render_click()
reloaded = Templates.get_template(template.id)
assert reloaded.version == 1
assert reloaded.content == "{{ content }}"
end
end
describe "TemplateEditor delete" do
test "delete removes template from DB", %{project: project} do
assert {:ok, template} =
Templates.create_template(%{
project_id: project.id,
title: "Delete Template",
kind: :post,
content: "{{ content }}"
})
assert {:ok, _published} = Templates.publish_template(template.id)
{:ok, view, _html} = live_isolated(build_conn(), BDS.Desktop.ShellLive)
_html = render_click(view, "select_view", %{"view" => "templates"})
html = open_template(view, template.id)
assert html =~ "templates-view-shell"
view
|> element("[data-testid='template-editor'] .danger")
|> render_click()
refute Repo.get(BDS.Templates.Template, template.id)
end
test "delete with references clears them (force delete), removes template",
%{project: project} do
assert {:ok, template} =
Templates.create_template(%{
project_id: project.id,
title: "Referenced Template",
kind: :post,
content: "{{ content }}"
})
assert {:ok, published} = Templates.publish_template(template.id)
assert {:ok, post} =
Posts.create_post(%{
project_id: project.id,
title: "Ref Post",
content: "Body",
template_slug: published.slug
})
assert {:ok, _published_post} = Posts.publish_post(post.id)
{:ok, view, _html} = live_isolated(build_conn(), BDS.Desktop.ShellLive)
_html = render_click(view, "select_view", %{"view" => "templates"})
html = open_template(view, template.id)
assert html =~ "templates-view-shell"
view
|> element("[data-testid='template-editor'] .danger")
|> render_click()
refute Repo.get(BDS.Templates.Template, template.id)
reloaded_post = Repo.get(Post, post.id)
assert reloaded_post.template_slug == nil
end
test "delete removes the .liquid file from disk", %{project: project, temp_dir: temp_dir} do
assert {:ok, template} =
Templates.create_template(%{
project_id: project.id,
title: "File Template",
kind: :post,
content: "{{ content }}"
})
assert {:ok, published} = Templates.publish_template(template.id)
file_path = Path.join(temp_dir, published.file_path)
assert File.exists?(file_path)
{:ok, view, _html} = live_isolated(build_conn(), BDS.Desktop.ShellLive)
_html = render_click(view, "select_view", %{"view" => "templates"})
html = open_template(view, template.id)
assert html =~ "templates-view-shell"
view
|> element("[data-testid='template-editor'] .danger")
|> render_click()
refute File.exists?(file_path)
end
end
end

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