fix: fixed bds2: protocol handling
This commit is contained in:
@@ -22,6 +22,11 @@ defmodule BDS.Blogmark do
|
||||
@scheme "bds2"
|
||||
@new_post_action "new-post"
|
||||
|
||||
# Input-hardening limits mirrored from the legacy bDS app (shared/blogmark.ts).
|
||||
@max_title_length 200
|
||||
@max_url_length 2048
|
||||
@control_chars ~r/[\x00-\x1F\x7F]/
|
||||
|
||||
@type candidate :: %{required(String.t()) => term()}
|
||||
|
||||
@type receive_result :: %{
|
||||
@@ -34,7 +39,8 @@ defmodule BDS.Blogmark do
|
||||
Parses a `bds2://new-post` deep link into a post candidate map.
|
||||
|
||||
Returns `{:ok, candidate}` with string-keyed `title`, `url`, `content`,
|
||||
`tags` and `categories`, or an error when the scheme or action is unsupported.
|
||||
`tags`, `categories` and optional `project_id`, or an error when the scheme
|
||||
or action is unsupported.
|
||||
"""
|
||||
@spec parse_deep_link(String.t()) ::
|
||||
{:ok, candidate()} | {:error, :unsupported_scheme | :unsupported_action | :invalid_url}
|
||||
@@ -54,10 +60,27 @@ defmodule BDS.Blogmark do
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns the `project_id` a deep link targets, or `nil` when the link carries
|
||||
none or cannot be parsed. Used by the shell to switch to the link's project
|
||||
before importing.
|
||||
"""
|
||||
@spec deep_link_project_id(String.t()) :: String.t() | nil
|
||||
def deep_link_project_id(url) when is_binary(url) do
|
||||
case parse_deep_link(url) do
|
||||
{:ok, %{"project_id" => project_id}} -> project_id
|
||||
_other -> nil
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Receives a blogmark deep link for `project_id`: parses it, runs the transform
|
||||
pipeline, and creates a draft post from the resulting candidate.
|
||||
|
||||
When the deep link URL carries a `project_id` query parameter, it takes
|
||||
precedence over the passed `project_id` so the bookmarklet can target a
|
||||
specific project regardless of which project is currently active.
|
||||
|
||||
Returns `{:ok, %{post:, toasts:, errors:}}` where `toasts` are the
|
||||
budget-enforced transform messages and `errors` records any failed transforms.
|
||||
"""
|
||||
@@ -66,10 +89,12 @@ defmodule BDS.Blogmark do
|
||||
def receive_deep_link(project_id, url, opts \\ [])
|
||||
when is_binary(project_id) and is_binary(url) and is_list(opts) do
|
||||
with {:ok, candidate} <- parse_deep_link(url),
|
||||
target_project <- resolve_target_project(project_id, candidate),
|
||||
candidate <- apply_default_content(candidate),
|
||||
{:ok, %{data: data, toasts: toasts, errors: errors}} <-
|
||||
Transforms.run(project_id, candidate, opts),
|
||||
data <- apply_default_category(project_id, data),
|
||||
{:ok, post} <- create_draft(project_id, data) do
|
||||
Transforms.run(target_project, candidate, opts),
|
||||
data <- apply_default_category(target_project, data),
|
||||
{:ok, post} <- create_draft(target_project, data) do
|
||||
{:ok, %{post: post, toasts: toasts, errors: errors}}
|
||||
end
|
||||
end
|
||||
@@ -77,15 +102,102 @@ defmodule BDS.Blogmark do
|
||||
defp candidate_from_query(query) do
|
||||
params = URI.decode_query(query || "")
|
||||
|
||||
project_id =
|
||||
case Map.get(params, "project_id") do
|
||||
nil -> nil
|
||||
"" -> nil
|
||||
id -> id
|
||||
end
|
||||
|
||||
url = sanitize_http_url(Map.get(params, "url"))
|
||||
|
||||
%{
|
||||
"title" => Map.get(params, "title", "") |> to_string(),
|
||||
"url" => optional(params, "url"),
|
||||
"title" => sanitize_title(Map.get(params, "title"), url_host(url)),
|
||||
"url" => url,
|
||||
"content" => optional(params, "content"),
|
||||
"tags" => list_param(params, "tags"),
|
||||
"categories" => list_param(params, "categories")
|
||||
"categories" => list_param(params, "categories"),
|
||||
"project_id" => project_id
|
||||
}
|
||||
end
|
||||
|
||||
# Strip control characters, trim, cap length; fall back to the URL host when
|
||||
# blank — mirrors bDS sanitizeTitle. Title/url are single-line fields, so
|
||||
# control-character stripping is safe here (unlike the multi-line body).
|
||||
defp sanitize_title(raw, fallback_host) do
|
||||
title =
|
||||
raw
|
||||
|> to_string()
|
||||
|> strip_control()
|
||||
|> String.trim()
|
||||
|> String.slice(0, @max_title_length)
|
||||
|
||||
if title == "", do: fallback_host || "", else: title
|
||||
end
|
||||
|
||||
# Accept only http(s) URLs, strip credentials and fragment, enforce a length
|
||||
# cap; anything else collapses to nil so it can never reach the post body.
|
||||
defp sanitize_http_url(raw) when is_binary(raw) do
|
||||
trimmed = raw |> strip_control() |> String.trim()
|
||||
|
||||
case URI.parse(trimmed) do
|
||||
%URI{scheme: scheme, host: host} = uri
|
||||
when scheme in ["http", "https"] and is_binary(host) and host != "" ->
|
||||
normalized = URI.to_string(%URI{uri | userinfo: nil, fragment: nil})
|
||||
if String.length(normalized) <= @max_url_length, do: normalized, else: nil
|
||||
|
||||
_ ->
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
||||
defp sanitize_http_url(_other), do: nil
|
||||
|
||||
defp url_host(url) when is_binary(url), do: URI.parse(url).host
|
||||
defp url_host(_other), do: nil
|
||||
|
||||
defp strip_control(value), do: String.replace(value, @control_chars, "")
|
||||
|
||||
# If the deep link carries a project_id, route to that project; otherwise
|
||||
# use the currently active project passed by the caller. This lets the
|
||||
# bookmarklet target a specific project even when a different one is open.
|
||||
defp resolve_target_project(_caller_project_id, %{"project_id" => pid}) when is_binary(pid) do
|
||||
pid
|
||||
end
|
||||
|
||||
defp resolve_target_project(project_id, _candidate), do: project_id
|
||||
|
||||
# Match the legacy bDS app: when the bookmarklet supplies no body, seed the
|
||||
# post content with a markdown link to the bookmarked page so transforms run
|
||||
# against the same input. Explicit content always wins.
|
||||
defp apply_default_content(candidate) do
|
||||
with nil <- optional_string(Map.get(candidate, "content")),
|
||||
url when is_binary(url) <- optional_string(Map.get(candidate, "url")) do
|
||||
title = Map.get(candidate, "title", "")
|
||||
Map.put(candidate, "content", blogmark_markdown_link(title, url))
|
||||
else
|
||||
_ -> candidate
|
||||
end
|
||||
end
|
||||
|
||||
defp blogmark_markdown_link(title, url) do
|
||||
"[" <> escape_markdown_link_text(title) <> "](" <> url <> ")"
|
||||
end
|
||||
|
||||
# Mirrors bDS escapeMarkdownLinkText: backslash first, then brackets/parens,
|
||||
# then collapse newlines to spaces.
|
||||
defp escape_markdown_link_text(value) do
|
||||
value
|
||||
|> to_string()
|
||||
|> String.trim()
|
||||
|> String.replace("\\", "\\\\")
|
||||
|> String.replace("[", "\\[")
|
||||
|> String.replace("]", "\\]")
|
||||
|> String.replace("(", "\\(")
|
||||
|> String.replace(")", "\\)")
|
||||
|> String.replace(~r/\r?\n/, " ")
|
||||
end
|
||||
|
||||
defp optional(params, key) do
|
||||
case Map.get(params, key) do
|
||||
nil -> nil
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -20,34 +20,49 @@ defmodule BDS.Scripting.Capabilities.AppShell do
|
||||
if test_mode?() do
|
||||
true
|
||||
else
|
||||
command = string_or_nil(text)
|
||||
|
||||
case :os.type() do
|
||||
{:unix, :darwin} ->
|
||||
match?({_output, 0}, System.cmd("pbcopy", [], input: command, stderr_to_stdout: true))
|
||||
|
||||
{:unix, _other} ->
|
||||
match?(
|
||||
{_output, 0},
|
||||
System.cmd("xclip", ["-selection", "clipboard"],
|
||||
input: command,
|
||||
stderr_to_stdout: true
|
||||
)
|
||||
)
|
||||
|
||||
{:win32, _other} ->
|
||||
match?(
|
||||
{_output, 0},
|
||||
System.cmd("cmd", ["/c", "clip"], input: command, stderr_to_stdout: true)
|
||||
)
|
||||
end
|
||||
{executable, args} = clipboard_command()
|
||||
pipe_to_clipboard(executable, args, string_or_nil(text) || "")
|
||||
end
|
||||
rescue
|
||||
_error -> false
|
||||
end
|
||||
|
||||
def blogmark_bookmarklet do
|
||||
"javascript:(()=>{const t=encodeURIComponent(document.title||'');const u=encodeURIComponent(location.href||'');location.href='bds2://new-post?title='+t+'&url='+u;})();"
|
||||
defp clipboard_command do
|
||||
case :os.type() do
|
||||
{:unix, :darwin} -> {"pbcopy", []}
|
||||
{:win32, _other} -> {"cmd", ["/c", "clip"]}
|
||||
{:unix, _other} -> {"xclip", ["-selection", "clipboard"]}
|
||||
end
|
||||
end
|
||||
|
||||
# System.cmd has no stdin option, so feed the clipboard tool through a Port.
|
||||
# Closing the port signals EOF, which is what pbcopy/xclip/clip wait for.
|
||||
defp pipe_to_clipboard(executable, args, input) do
|
||||
case System.find_executable(executable) do
|
||||
nil ->
|
||||
false
|
||||
|
||||
path ->
|
||||
port = Port.open({:spawn_executable, path}, [:binary, :use_stdio, args: args])
|
||||
Port.command(port, input)
|
||||
Port.close(port)
|
||||
true
|
||||
end
|
||||
end
|
||||
|
||||
def blogmark_bookmarklet(opts \\ []) do
|
||||
project_id = Keyword.get(opts, :project_id)
|
||||
|
||||
# Concatenate as a JS string literal (+'&project_id=...'), not raw text, so
|
||||
# the generated bookmarklet stays valid JavaScript when a project is set.
|
||||
project_param =
|
||||
if is_binary(project_id) and project_id != "" do
|
||||
"+'&project_id=" <> URI.encode_www_form(project_id) <> "'"
|
||||
else
|
||||
""
|
||||
end
|
||||
|
||||
"javascript:(()=>{const t=encodeURIComponent(document.title||'');const u=encodeURIComponent(location.href||'');location.href='bds2://new-post?title='+t+'&url='+u#{project_param};})();"
|
||||
end
|
||||
|
||||
def title_bar_metrics(opts) do
|
||||
|
||||
Reference in New Issue
Block a user