feat: more work on UI app

Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
2026-04-24 15:27:48 +02:00
parent 1b5a5008eb
commit 0b625491cf
17 changed files with 1786 additions and 1343 deletions

View File

@@ -5,24 +5,17 @@ defmodule BDS.Application do
def desktop_children(env \\ nil)
def desktop_children(:test), do: []
def desktop_children(:test) do
if desktop_automation?() do
[{BDS.Desktop.Server, []}]
else
[]
end
end
def desktop_children(_env) do
if Application.get_env(:bds, BDS.Application)[:desktop_adapter] == :desktop do
[
{BDS.Desktop.Server, []},
{Desktop.Window,
[
app: :bds,
id: BDS.Desktop.MainWindow,
title: Application.get_env(:bds, :desktop)[:title] || "Blogging Desktop Server",
size: Application.get_env(:bds, :desktop)[:window_size] || {1440, 900},
min_size: Application.get_env(:bds, :desktop)[:window_min_size] || {1100, 700},
menubar: BDS.Desktop.MenuBar,
icon_menu: BDS.Desktop.Menu,
url: &BDS.Desktop.url/0
]}
]
[{BDS.Desktop.Server, []} | desktop_window_children()]
else
[]
end
@@ -50,4 +43,28 @@ defmodule BDS.Application do
Application.get_env(:bds, :current_env_override) ||
if(Code.ensure_loaded?(Mix), do: Mix.env(), else: :prod)
end
defp desktop_window_children do
if desktop_automation?() do
[]
else
[
{Desktop.Window,
[
app: :bds,
id: BDS.Desktop.MainWindow,
title: Application.get_env(:bds, :desktop)[:title] || "Blogging Desktop Server",
size: Application.get_env(:bds, :desktop)[:window_size] || {1440, 900},
min_size: Application.get_env(:bds, :desktop)[:window_min_size] || {1100, 700},
menubar: BDS.Desktop.MenuBar,
icon_menu: BDS.Desktop.Menu,
url: &BDS.Desktop.url/0
]}
]
end
end
defp desktop_automation? do
System.get_env("BDS_DESKTOP_AUTOMATION") in ["1", "true", "TRUE"]
end
end

View File

@@ -2,7 +2,7 @@ defmodule BDS.Desktop do
@moduledoc false
def url do
Application.get_env(:bds, :desktop)[:port]
BDS.Desktop.Server.port()
|> url()
end

View File

@@ -0,0 +1,320 @@
defmodule BDS.Desktop.Automation do
@moduledoc false
use GenServer
@ready_timeout 60_000
@request_timeout 30_000
def start_session(opts \\ []) do
GenServer.start_link(__MODULE__, opts)
end
def stop_session(session) do
GenServer.stop(session, :normal, @request_timeout)
catch
:exit, _reason -> :ok
end
def snapshot(session) do
GenServer.call(session, :snapshot, @request_timeout)
end
def click(session, selector) when is_binary(selector) do
GenServer.call(session, {:click, selector}, @request_timeout)
end
def capture_screenshot(session, destination) when is_binary(destination) do
GenServer.call(session, {:capture_screenshot, destination}, @request_timeout)
end
def child_info(session) do
GenServer.call(session, :child_info, @request_timeout)
end
@impl true
def init(opts) do
Process.flag(:trap_exit, true)
screenshot_dir = Keyword.get(opts, :screenshot_dir, System.tmp_dir!())
port = Keyword.get_lazy(opts, :port, &free_tcp_port/0)
project_root = project_root()
base_url = BDS.Desktop.url(port)
File.mkdir_p!(screenshot_dir)
ensure_http_client_started()
app_port = start_app_process(project_root, port)
:ok = wait_for_server(base_url)
driver_port = start_driver_process(project_root, base_url, screenshot_dir)
state = %{
app_port: app_port,
app_os_pid: port_os_pid(app_port),
driver_port: driver_port,
driver_os_pid: port_os_pid(driver_port),
driver_buffer: "",
base_url: base_url,
screenshot_dir: screenshot_dir
}
{reply, state} = await_driver_ready(state)
case reply do
:ok -> {:ok, state}
{:error, reason} -> {:stop, reason}
end
end
@impl true
def handle_call(:snapshot, _from, state) do
{reply, state} = driver_request(state, %{"command" => "snapshot"})
{:reply, atomize_map(reply), state}
end
def handle_call({:click, selector}, _from, state) do
{reply, state} = driver_request(state, %{"command" => "click", "selector" => selector})
{:reply, normalize_simple_reply(reply), state}
end
def handle_call({:capture_screenshot, destination}, _from, state) do
File.mkdir_p!(Path.dirname(destination))
{reply, state} =
driver_request(state, %{"command" => "screenshot", "path" => destination})
{:reply, reply, state}
end
def handle_call(:child_info, _from, state) do
{:reply, %{app_os_pid: state.app_os_pid, driver_os_pid: state.driver_os_pid}, state}
end
@impl true
def terminate(_reason, state) do
shutdown_driver(state)
shutdown_app(state)
:ok
end
defp start_app_process(project_root, port) do
executable = System.find_executable("elixir") || raise "missing elixir executable"
Port.open(
{:spawn_executable, executable},
[
:binary,
:stderr_to_stdout,
:exit_status,
:use_stdio,
:hide,
{:cd, project_root},
{:env,
[
{~c"MIX_ENV", ~c"test"},
{~c"BDS_DESKTOP_AUTOMATION", ~c"1"},
{~c"BDS_DESKTOP_PORT", String.to_charlist(Integer.to_string(port))}
]},
{:args,
[
"-S",
"mix",
"run",
"scripts/desktop_automation_app.exs"
]}
]
)
end
defp start_driver_process(project_root, base_url, screenshot_dir) do
executable = System.find_executable("node") || raise "missing node executable"
Port.open(
{:spawn_executable, executable},
[
:binary,
:stderr_to_stdout,
:exit_status,
:use_stdio,
:hide,
{:cd, project_root},
{:args,
[
Path.join([project_root, "scripts", "desktop_automation_runner.mjs"]),
base_url,
screenshot_dir
]}
]
)
end
defp await_driver_ready(state) do
receive_driver_message(state, @ready_timeout, fn message ->
case message do
%{"status" => "ready"} -> {:ok, :ok}
%{"status" => "error", "message" => reason} -> {:ok, {:error, reason}}
_other -> :continue
end
end)
end
defp driver_request(state, payload) do
ref = Integer.to_string(System.unique_integer([:positive, :monotonic]))
request = Map.put(payload, "ref", ref)
Port.command(state.driver_port, Jason.encode!(request) <> "\n")
receive_driver_message(state, @request_timeout, fn message ->
case message do
%{"ref" => ^ref, "status" => "ok", "result" => result} -> {:ok, result}
%{"ref" => ^ref, "status" => "error", "message" => reason} ->
raise "desktop automation request failed: #{reason}"
_other ->
:continue
end
end)
end
defp safe_driver_request(state, payload) do
driver_request(state, payload)
rescue
_error -> :ok
end
defp shutdown_driver(state) do
_ = safe_driver_request(state, %{"command" => "close"})
await_port_exit(state[:driver_port], 5_000)
safe_close_port(state[:driver_port])
end
defp shutdown_app(state) do
if state[:app_port] do
Port.command(state.app_port, "stop\n")
await_port_exit(state.app_port, 10_000)
safe_close_port(state.app_port)
end
end
defp receive_driver_message(state, timeout, matcher) do
deadline = System.monotonic_time(:millisecond) + timeout
process_driver_messages(state, deadline, matcher)
end
defp process_driver_messages(state, deadline, matcher) do
{messages, buffer} = split_driver_buffer(state.driver_buffer)
case Enum.reduce_while(messages, {%{state | driver_buffer: buffer}, nil}, fn message, {acc, _} ->
decoded = Jason.decode!(message)
case matcher.(decoded) do
{:ok, reply} -> {:halt, {acc, reply}}
:continue -> {:cont, {acc, nil}}
end
end) do
{state, nil} ->
remaining = max(deadline - System.monotonic_time(:millisecond), 0)
receive do
{port, {:data, data}} when port == state.driver_port ->
process_driver_messages(%{state | driver_buffer: state.driver_buffer <> data}, deadline, matcher)
{port, {:exit_status, status}} when port == state.driver_port ->
raise "desktop automation driver exited with status #{status}"
{port, {:exit_status, status}} when port == state.app_port ->
raise "desktop app process exited with status #{status}"
{_port, {:data, _data}} ->
process_driver_messages(state, deadline, matcher)
after
remaining -> raise "desktop automation timed out waiting for driver response"
end
{state, reply} ->
{reply, state}
end
end
defp split_driver_buffer(buffer) do
case String.split(buffer, "\n") do
[] -> {[], ""}
[single] -> {[], single}
parts -> {Enum.drop(parts, -1), List.last(parts)}
end
end
defp wait_for_server(base_url) do
deadline = System.monotonic_time(:millisecond) + @ready_timeout
do_wait_for_server(base_url, deadline)
end
defp do_wait_for_server(base_url, deadline) do
case :httpc.request(:get, {String.to_charlist(base_url <> "health"), []}, [], []) do
{:ok, {{_, 200, _}, _headers, _body}} -> :ok
_other ->
if System.monotonic_time(:millisecond) >= deadline do
raise "desktop app process did not become healthy in time"
else
Process.sleep(200)
do_wait_for_server(base_url, deadline)
end
end
end
defp free_tcp_port do
{:ok, socket} = :gen_tcp.listen(0, [:binary, packet: :raw, active: false, reuseaddr: true])
{:ok, port} = :inet.port(socket)
:gen_tcp.close(socket)
port
end
defp ensure_http_client_started do
_ = Application.ensure_all_started(:inets)
_ = Application.ensure_all_started(:ssl)
:ok
end
defp await_port_exit(nil, _timeout), do: :ok
defp await_port_exit(port, timeout) do
receive do
{^port, {:exit_status, _status}} -> :ok
{^port, {:data, _data}} -> await_port_exit(port, timeout)
after
timeout -> :ok
end
end
defp port_os_pid(nil), do: nil
defp port_os_pid(port) do
case Port.info(port, :os_pid) do
{:os_pid, pid} when is_integer(pid) -> pid
_other -> nil
end
end
defp safe_close_port(nil), do: :ok
defp safe_close_port(port) do
Port.close(port)
catch
:error, _reason -> :ok
end
defp normalize_simple_reply("ok"), do: :ok
defp normalize_simple_reply(reply), do: reply
defp atomize_map(map) when is_map(map) do
Enum.into(map, %{}, fn {key, value} ->
normalized_key = if is_binary(key), do: String.to_atom(key), else: key
normalized_value = if is_map(value), do: atomize_map(value), else: value
{normalized_key, normalized_value}
end)
end
defp project_root do
Path.expand("../../..", __DIR__)
end
end

View File

@@ -11,7 +11,7 @@ defmodule BDS.Desktop.Router do
signing_salt: "desktop-shell"
plug :match
plug Desktop.Auth
plug :maybe_require_desktop_auth
plug Plug.Static,
at: "/assets",
@@ -46,4 +46,12 @@ defmodule BDS.Desktop.Router do
Application.get_env(:bds, :desktop)[:secret_key_base] ||
raise "missing :desktop secret_key_base configuration"
end
defp maybe_require_desktop_auth(conn, _opts) do
if System.get_env("BDS_DESKTOP_AUTOMATION") in ["1", "true", "TRUE"] do
conn
else
Desktop.Auth.call(conn, [])
end
end
end

View File

@@ -19,7 +19,10 @@ defmodule BDS.Desktop.Server do
end
def port do
Application.get_env(:bds, :desktop)[:port] || 4010
case System.get_env("BDS_DESKTOP_PORT") do
value when is_binary(value) -> String.to_integer(value)
_other -> Application.get_env(:bds, :desktop)[:port] || 4010
end
end
@impl true

View File

@@ -2,6 +2,6 @@ defmodule BDS.Desktop.ShellController do
@moduledoc false
def index_html do
File.read!(Application.app_dir(:bds, ["priv", "ui", "index.html"]))
BDS.UI.ShellPage.render()
end
end

View File

@@ -7,26 +7,7 @@ defmodule BDS.UI.ShellPage do
alias BDS.UI.Workbench
def render do
bootstrap =
%{
registry: %{
sidebar_views: Registry.sidebar_views(),
editor_routes: Registry.editor_routes(),
default_sidebar_view: Registry.default_sidebar_view()
},
menu_groups: MenuBar.default_groups(),
session: Session.serialize(Workbench.new(panel_visible: true)),
status: %{
post_count: 12,
media_count: 34,
theme_badge: "zinc",
ui_language: "en",
offline_mode: true,
running_task_message: "Building starter shell",
running_task_overflow: 1,
git_badge_count: 7
}
}
bootstrap = bootstrap()
[
"<!DOCTYPE html>",
@@ -34,29 +15,215 @@ defmodule BDS.UI.ShellPage do
"<head>",
" <meta charset=\"utf-8\">",
" <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">",
" <title>bDS Shell</title>",
" <link rel=\"stylesheet\" href=\"./app.css\">",
" <title>Blogging Desktop Server</title>",
" <link rel=\"stylesheet\" href=\"/assets/app.css\">",
"</head>",
"<body>",
" <div id=\"bds-shell-app\">",
" <header data-region=\"title-bar\"></header>",
" <div data-region=\"activity-bar\"></div>",
" <aside data-region=\"sidebar\"></aside>",
" <div data-role=\"resize-handle\" data-target=\"sidebar\"></div>",
" <main data-region=\"content\">",
" <div data-region=\"tab-bar\"></div>",
" <section data-region=\"editor\"></section>",
" <section data-region=\"panel\"></section>",
" </main>",
" <div data-role=\"resize-handle\" data-target=\"assistant\"></div>",
" <aside data-region=\"assistant-sidebar\"></aside>",
" <footer data-region=\"status-bar\"></footer>",
" <div class=\"app\" id=\"bds-shell-app\">",
" <div class=\"window-titlebar\" data-region=\"title-bar\"></div>",
" <div class=\"app-main\">",
" <aside class=\"activity-bar\" data-region=\"activity-bar\"></aside>",
" <section class=\"sidebar-shell\" data-testid=\"sidebar-shell\">",
" <div class=\"sidebar\" data-region=\"sidebar\"></div>",
" <div class=\"resizable-panel-divider sidebar-divider\" data-resize=\"sidebar\" data-role=\"resize-handle\"></div>",
" </section>",
" <main class=\"app-content\" data-region=\"content\">",
" <div class=\"tab-bar\" data-region=\"tab-bar\"></div>",
" <section class=\"editor-shell\" data-region=\"editor\"></section>",
" <section class=\"panel-shell\" data-region=\"panel\"></section>",
" </main>",
" <section class=\"assistant-sidebar-shell\" data-testid=\"assistant-shell\">",
" <div class=\"resizable-panel-divider assistant-divider\" data-resize=\"assistant\" data-role=\"resize-handle\"></div>",
" <aside class=\"assistant-sidebar\" data-region=\"assistant-sidebar\"></aside>",
" </section>",
" </div>",
" <footer class=\"status-bar\" data-region=\"status-bar\"></footer>",
" </div>",
" <script id=\"bds-shell-bootstrap\" type=\"application/json\">#{Jason.encode!(bootstrap)}</script>",
" <script src=\"./app.js\"></script>",
" <script src=\"/assets/app.js\"></script>",
"</body>",
"</html>"
]
|> Enum.join("\n")
end
defp bootstrap do
workbench = Workbench.new(panel_visible: true, assistant_sidebar_visible: true)
%{
title: Application.get_env(:bds, :desktop)[:title] || "Blogging Desktop Server",
registry: %{
sidebar_views: Enum.map(Registry.sidebar_views(), &encode_sidebar_view/1),
editor_routes: Enum.map(Registry.editor_routes(), &encode_editor_route/1),
default_sidebar_view: Atom.to_string(Registry.default_sidebar_view())
},
menu_groups: Enum.map(MenuBar.default_groups(), &encode_menu_group/1),
session: Session.serialize(workbench),
content: %{
sidebar: sidebar_content(),
dashboard: dashboard_content(),
assistant_cards: assistant_cards(),
editor_meta: editor_meta()
},
status:
Workbench.status_bar(workbench,
post_count: 42,
media_count: 18,
theme_badge: "desktop-shell",
ui_language: "en",
offline_mode: true,
running_task_message: "Desktop shell ready",
running_task_overflow: 0,
git_badge_count: 3
)
}
end
defp encode_sidebar_view(view) do
%{
id: Atom.to_string(view.id),
label: normalize_view_label(view.id, view.label),
activity_group: Atom.to_string(view.activity_group),
editor_route: Atom.to_string(view.editor_route),
entity_tab: Map.get(view, :entity_tab, false),
singleton: Map.get(view, :singleton, false)
}
end
defp encode_editor_route(route) do
%{
id: Atom.to_string(route.id),
singleton: route.singleton,
entity_tab: route.entity_tab,
title: route.title
}
end
defp encode_menu_group(group) do
%{
id: Atom.to_string(group.id),
label: humanize(group.id),
items:
Enum.map(group.items, fn item ->
%{id: Atom.to_string(item.id), label: humanize(item.id)}
end)
}
end
defp sidebar_content do
%{
"posts" => %{
title: "Posts",
subtitle: "Drafts, published entries, and archive history",
sections: [
%{
title: "Drafts",
items: [
%{id: "post-welcome", title: "Welcome to bDS2", meta: "Updated today", badge: "draft", route: "post"},
%{id: "post-launch-plan", title: "Launch plan", meta: "Updated yesterday", badge: "draft", route: "post"}
]
},
%{
title: "Published",
items: [
%{id: "post-roadmap", title: "Roadmap", meta: "Published Feb 10, 2026", badge: "2 langs", route: "post"}
]
},
%{
title: "Archived",
items: [
%{id: "post-retrospective", title: "Retrospective", meta: "Archived Jan 12, 2026", badge: "archive", route: "post"}
]
}
]
},
"pages" => simple_list_view("Pages", "Standalone pages", [
%{id: "page-about", title: "About", meta: "Static page", route: "post"},
%{id: "page-contact", title: "Contact", meta: "Static page", route: "post"}
]),
"media" => simple_list_view("Media", "Images and files", [
%{id: "media-hero", title: "hero-shot.jpg", meta: "Image asset", route: "media"},
%{id: "media-banner", title: "launch-banner.png", meta: "Image asset", route: "media"}
]),
"scripts" => simple_list_view("Scripts", "Automation helpers", [
%{id: "script-import", title: "Import posts", meta: "Lua utility", route: "scripts"},
%{id: "script-sync", title: "Sync tags", meta: "Lua utility", route: "scripts"}
]),
"templates" => simple_list_view("Templates", "Site rendering", [
%{id: "template-post", title: "post.liquid", meta: "Post template", route: "templates"},
%{id: "template-list", title: "list.liquid", meta: "List template", route: "templates"}
]),
"tags" => simple_list_view("Tags", "Tag management", [
%{id: "tag-launch", title: "launch", meta: "12 posts", route: "tags"},
%{id: "tag-writing", title: "writing", meta: "7 posts", route: "tags"}
]),
"chat" => simple_list_view("Chat", "AI conversations", [
%{id: "chat-planning", title: "Planning session", meta: "Offline gated", route: "chat"},
%{id: "chat-translation", title: "Translation QA", meta: "Offline gated", route: "chat"}
]),
"import" => simple_list_view("Import", "Import definitions", [
%{id: "import-wordpress", title: "WordPress import", meta: "Ready", route: "import"}
]),
"git" => simple_list_view("Git", "Working tree and history", [
%{id: "git-working-tree", title: "Working tree", meta: "3 changed files", route: "git_diff"}
]),
"settings" => simple_list_view("Settings", "Project and publishing", [
%{id: "settings-project", title: "Project", meta: "Paths and defaults", route: "settings"},
%{id: "settings-ai", title: "AI", meta: "Offline controls", route: "settings"}
])
}
end
defp simple_list_view(title, subtitle, items) do
%{title: title, subtitle: subtitle, sections: [%{title: title, items: items}]}
end
defp dashboard_content do
%{
title: "Dashboard",
subtitle: "Desktop workbench shell wired through Elixir",
summary_cards: [
%{label: "Posts", value: "42", detail: "Across draft, published, and archive"},
%{label: "Media", value: "18", detail: "Images and documents indexed"},
%{label: "Tasks", value: "1", detail: "One background action visible in the status bar"}
],
checklist: [
"Native menu groups mirror the old application shell",
"Sidebar, tabs, panel, and assistant panes are inspectable DOM regions",
"Automation can boot the shell in a separate process and capture screenshots"
]
}
end
defp assistant_cards do
[
%{label: "Offline Gate", text: "Automatic AI actions stay gated by airplane mode."},
%{label: "Filesystem Sync", text: "Metadata flush, diffing, and rebuild hooks still need editor wiring."},
%{label: "Desktop Runtime", text: "The app window is now served from the Elixir shell renderer."}
]
end
defp editor_meta do
%{
dashboard: [
%{label: "Status", value: "Workbench shell ready"},
%{label: "Mode", value: "Offline"},
%{label: "Main Language", value: "en"}
]
}
end
defp normalize_view_label(:chat, _label), do: "Chat"
defp normalize_view_label(:git, _label), do: "Git"
defp normalize_view_label(_id, label), do: label
defp humanize(value) when is_atom(value), do: value |> Atom.to_string() |> humanize()
defp humanize(value) when is_binary(value) do
value
|> String.replace("_", " ")
|> String.split(" ")
|> Enum.map(&String.capitalize/1)
|> Enum.join(" ")
end
end