fix: fixed bds2: protocol handling

This commit is contained in:
2026-06-30 21:07:26 +02:00
parent 49675a49d2
commit ba9634c478
29 changed files with 2876 additions and 2242 deletions

View File

@@ -1,15 +1,20 @@
defmodule BDS.Desktop.DeepLink do
@moduledoc """
Receives OS URL-scheme events for the `bds2://` scheme and routes them to the
shell (spec: script.allium `BlogmarkReceived`).
live shell (spec: script.allium `BlogmarkReceived`).
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.
relays erlang `:wx`'s `{:open_url, Url}` app event (where `Url` is the URL as a
charlist). This GenServer subscribes to those events and forwards recognised
`bds2://` links to the live shell.
Cold start matters here: when the bookmarklet *launches* the app, the deep
link arrives during startup, before the LiveView shell's websocket has
connected. Such links are queued and replayed the moment a shell registers via
`attach/2`; while a shell is connected, links are delivered to it directly.
The `bds2://` scheme is distinct from the legacy app's `bds://` so the two
installs do not contend for the same registration.
@@ -19,8 +24,6 @@ defmodule BDS.Desktop.DeepLink do
require Logger
alias BDS.CliSync.Watcher
@scheme "bds2://"
def child_spec(opts) do
@@ -31,32 +34,69 @@ defmodule BDS.Desktop.DeepLink do
GenServer.start_link(__MODULE__, opts, name: Keyword.get(opts, :name, __MODULE__))
end
@impl true
def init(opts) do
pubsub = Keyword.get(opts, :pubsub, BDS.PubSub)
topic = Keyword.get(opts, :topic, Watcher.topic())
subscribe_to_env()
{:ok, %{pubsub: pubsub, topic: topic}}
@doc """
Registers the live shell `pid` as the deep-link sink and immediately replays
any links that arrived before a shell was connected. Future links are sent to
this pid until it dies, after which links queue again.
"""
@spec attach(pid(), GenServer.server()) :: :ok
def attach(shell_pid, server \\ __MODULE__) when is_pid(shell_pid) do
GenServer.call(server, {:attach, shell_pid})
end
# Desktop.Env delivers OS events as {event_name, args} tuples.
@impl true
def handle_info({:open_url, [url | _rest]}, state) when is_binary(url) do
{:noreply, route(url, state)}
def init(_opts) do
subscribe_to_env()
{:ok, %{shell: nil, pending: []}}
end
@impl true
def handle_call({:attach, pid}, _from, state) do
Process.monitor(pid)
state.pending
|> Enum.reverse()
|> Enum.each(&send(pid, {:blogmark_deep_link, &1}))
{:reply, :ok, %{state | shell: pid, pending: []}}
end
# erlang's :wx delivers macOS app events as `{:open_url, Url}` where `Url` is
# the URL *string itself* — a charlist on current OTP, not a list of URLs
# (see `wx:subscribe_events/0`). Desktop.Env forwards it unchanged, so the
# payload must be normalised to a binary before routing.
@impl true
def handle_info({:open_url, payload}, state) do
{:noreply, route(to_url(payload), state)}
end
def handle_info({:DOWN, _ref, :process, pid, _reason}, %{shell: pid} = state) do
{:noreply, %{state | shell: nil}}
end
def handle_info(_message, state), do: {:noreply, state}
defp route(url, state) do
if String.starts_with?(url, @scheme) do
Phoenix.PubSub.broadcast(state.pubsub, state.topic, {:blogmark_deep_link, url})
else
Logger.debug("ignoring non-bds2 deep link: #{inspect(url)}")
end
# Accept the charlist wx actually sends, a bare binary, or a [binary | _] list
# (the shape Desktop.Env's docs imply), normalising all to a binary URL.
defp to_url(value) when is_binary(value), do: value
defp to_url([first | _]) when is_binary(first), do: first
defp to_url(value) when is_list(value), do: List.to_string(value)
defp to_url(_other), do: ""
state
defp route(url, state) do
cond do
not String.starts_with?(url, @scheme) ->
Logger.debug("ignoring non-bds2 deep link: #{inspect(url)}")
state
is_pid(state.shell) and Process.alive?(state.shell) ->
send(state.shell, {:blogmark_deep_link, url})
state
true ->
# No shell connected yet (cold start) — hold the link until one attaches.
%{state | pending: [url | state.pending]}
end
end
# Desktop.Env is only present when the wx desktop adapter is running. Guard the

View File

@@ -148,6 +148,7 @@ defmodule BDS.Desktop.ShellLive do
if connected do
Phoenix.PubSub.subscribe(BDS.PubSub, Watcher.topic())
attach_deep_link()
Process.send_after(self(), :refresh_task_status, @refresh_interval)
end
@@ -737,30 +738,45 @@ defmodule BDS.Desktop.ShellLive do
# create a draft post, open it in the editor, and surface transform toasts.
defp handle_blogmark_deep_link(socket, url) do
title = dgettext("ui", "Blogmark")
link_project_id = Blogmark.deep_link_project_id(url)
active = current_project_id(socket)
case current_project_id(socket) do
project_id when is_binary(project_id) ->
case Blogmark.receive_deep_link(project_id, url) do
{:ok, %{post: post, toasts: toasts, errors: errors}} ->
cond do
# A specific project was requested but does not exist in this install
# (e.g. a bookmarklet copied from another instance). Never guess another
# project — tell the user, import nothing.
is_binary(link_project_id) and Projects.get_project(link_project_id) == nil ->
append_output_entry(
socket,
title,
dgettext("ui", "The project this blogmark targets does not exist here."),
url,
"error"
)
# A specific, existing project that isn't active: switch to it first so
# the new post is shown in context, then import there.
is_binary(link_project_id) and link_project_id != active ->
case Projects.set_active_project(link_project_id) do
{:ok, _project} ->
socket
|> reload_shell(socket.assigns.workbench)
|> open_sidebar_item(
%{
"route" => "post",
"id" => post.id,
"title" => post.title,
"subtitle" => post.slug
},
:pin
)
|> append_blogmark_toasts(title, toasts)
|> append_blogmark_errors(title, errors)
|> assign(:project_menu_open, false)
|> assign(:sidebar_filters_by_view, %{})
|> reload_shell(Workbench.new())
|> UrlState.push()
|> import_blogmark(link_project_id, url, title)
{:error, reason} ->
append_output_entry(socket, title, inspect(reason), url, "error")
end
_ ->
# The link names the already-active project, or no project_id was given
# and a project is active: import into the active project.
is_binary(active) ->
import_blogmark(socket, active, url, title)
# No project_id and nothing active.
true ->
append_output_entry(
socket,
title,
@@ -771,6 +787,28 @@ defmodule BDS.Desktop.ShellLive do
end
end
defp import_blogmark(socket, project_id, url, title) do
case Blogmark.receive_deep_link(project_id, url) do
{:ok, %{post: post, toasts: toasts, errors: errors}} ->
socket
|> reload_shell(socket.assigns.workbench)
|> open_sidebar_item(
%{
"route" => "post",
"id" => post.id,
"title" => post.title,
"subtitle" => post.slug
},
:pin
)
|> append_blogmark_toasts(title, toasts)
|> append_blogmark_errors(title, errors)
{:error, reason} ->
append_output_entry(socket, title, inspect(reason), url, "error")
end
end
defp append_blogmark_toasts(socket, title, toasts) do
Enum.reduce(toasts, socket, fn message, acc ->
append_output_entry(acc, title, message, nil, "info")
@@ -838,6 +876,14 @@ defmodule BDS.Desktop.ShellLive do
defp current_project_id(socket), do: (socket.assigns[:projects] || %{})[:active_project_id]
# Register this shell with the deep-link router and drain any links queued
# before connect (cold start). The router is absent in headless/test runs.
defp attach_deep_link do
if Process.whereis(BDS.Desktop.DeepLink) do
BDS.Desktop.DeepLink.attach(self())
end
end
defp sidebar_create_action(view), do: SidebarCreate.action(view)
defp set_page_language(socket, language) do

View File

@@ -57,6 +57,18 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor do
@spec handle_event(String.t(), map(), Phoenix.LiveView.Socket.t()) ::
{:noreply, Phoenix.LiveView.Socket.t()}
@impl true
def handle_event("copy_blogmark_bookmarklet", _params, socket) do
project_id = socket.assigns.projects.active_project_id
bookmarklet = BDS.Scripting.Capabilities.AppShell.blogmark_bookmarklet(project_id: project_id)
case BDS.Scripting.Capabilities.AppShell.copy_to_clipboard(bookmarklet, []) do
true -> Notify.output(dgettext("ui", "Blogmark Bookmarklet"), dgettext("ui", "Bookmarklet copied to clipboard"), "info")
_ -> Notify.output(dgettext("ui", "Blogmark Bookmarklet"), dgettext("ui", "Failed to copy bookmarklet to clipboard"), "error")
end
{:noreply, socket}
end
def handle_event("change_settings_search", %{"query" => query}, socket) do
socket =
socket

View File

@@ -98,7 +98,10 @@
</div>
<div class="setting-row">
<div class="setting-info"><label class="setting-label"><%= dgettext("ui", "Blogmark Bookmarklet") %></label></div>
<div class="setting-control"><p class="setting-description"><%= dgettext("ui", "Bookmarklet copy support is wired through the desktop runtime and project public URL.") %></p></div>
<div class="setting-control">
<p class="setting-description"><%= dgettext("ui", "Bookmarklet copy support is wired through the desktop runtime and project public URL.") %></p>
<button class="secondary ui-button ui-button-secondary" type="button" phx-click="copy_blogmark_bookmarklet" phx-target={@myself}><%= dgettext("ui", "Copy Bookmarklet to Clipboard") %></button>
</div>
</div>
</form>
<div class="setting-actions"><button class="primary ui-button ui-button-primary" type="button" phx-click="save_settings_project" phx-target={@myself}><%= dgettext("ui", "Save") %></button></div>