Fase 3: make the remote transport config-driven, not Electron-only

Every PersistentDB dispatch site in persist_db.cljs branched on
electron-runtime?, so a web or mobile client could never take the remote
path -- only Electron's IPC-managed local daemon could. The remote client
itself (persist_db/remote.cljs) was already platform-agnostic (plain
js/fetch and js/EventSource), so this is a transport-selection fix, not a
new architecture.

- frontend.persist-db/remote-runtime?: true for Electron OR a non-Electron
  client with config/get-server-base-url configured. Replaces every
  electron-runtime? check at the seven PersistentDB call sites
  (<start-runtime!, <list-db, <unsafe-delete, <close-db, <export-db,
  <import-db, <open-and-fetch-schema, <new).
- Fixes the known <export-db bug: it always went through the local OPFS
  impl regardless of transport, so exporting a remote graph silently
  exported nothing. It now follows the same remote-runtime? dispatch as
  every sibling function.
- <resolve-remote-runtime!/<release-remote-runtime-slot! split the
  Electron-IPC path from a new web path that just reads the locally
  configured base-url/token -- there's no daemon to spawn from a browser,
  only one to log into.
- config.cljs: get/set-server-base-url!, get/set-server-token!,
  clear-server-config!, mirroring the existing custom-sync-server-url
  localStorage pattern.
- remote.cljs: login! POSTs to the daemon's /v1/auth/login (added in Fase
  1) and returns a bearer token.
- A minimal settings panel (components/settings.cljs, non-Electron only):
  server URL + username + password, calls login!, saves the result, and
  reloads. Functional but not visually verified -- no browser available in
  this environment; the request/response wiring is confirmed below against
  a real daemon, not the rendered UI itself.

Verified by running a real db-worker-node daemon and exercising the exact
sequence the new settings panel and remote.cljs drive: unauthenticated
invoke -> 401, login with correct/wrong credentials -> token/401, healthz
unauthenticated with :pid/:root-dir absent, authenticated invoke (list-db)
-> 200, per-origin CORS reflection, bad Host -> 403, and SSE both without a
token (401) and with one as a query param (200). Plus clojure -M:cljs
compile app db-worker-node electron (0 warnings) and the full
logseq.db-worker.daemon-test, logseq.cli.server-test,
frontend.worker.db-worker-node-test, electron.db-worker-manager-test,
frontend.state-test, frontend.persist-db.remote-test suites (134 tests,
541 assertions, 0 failures).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-25 19:08:54 +02:00
parent 49d9f7da84
commit be7abd52a3
5 changed files with 247 additions and 12 deletions

View File

@@ -21,6 +21,7 @@
[frontend.mobile.util :as mobile-util]
[frontend.modules.instrumentation.core :as instrument]
[frontend.modules.shortcut.data-helper :as shortcut-helper]
[frontend.persist-db.remote :as remote]
[frontend.rfx :as rfx]
[frontend.spec.storage :as storage-spec]
[frontend.state :as state]
@@ -689,6 +690,104 @@
:on-click (fn [] (reset-url!))}
(t :settings.sync-server/reset)))]]]))
(hsx/defc db-worker-server-settings-container
"Connects a web/mobile client to a self-hosted db-worker-node server (see
frontend.persist-db/remote-runtime?). Electron manages its own local
daemon instead and doesn't need this. Reconnecting or disconnecting
requires a reload since frontend.persist-db/<start-runtime! only decides
the transport once at boot."
[]
(let [current-url (config/get-server-base-url)
connected? (and (seq current-url) (seq (config/get-server-token)))
[url set-url!] (hooks/use-state (or current-url ""))
[username set-username!] (hooks/use-state "")
[password set-password!] (hooks/use-state "")
[connecting? set-connecting!] (hooks/use-state false)
disconnect! (fn []
(config/clear-server-config!)
(notification/show! "Disconnected. Reloading..." :success)
(js/setTimeout #(js/location.reload) 500))
connect! (fn []
(let [trimmed-url (string/trim url)]
(cond
(string/blank? trimmed-url)
(notification/show! "Server URL is required." :error)
(not (config/valid-sync-server-url? trimmed-url))
(notification/show! "Enter a valid http(s) URL." :error)
(or (string/blank? username) (string/blank? password))
(notification/show! "Username and password are required." :error)
:else
(do
(set-connecting! true)
(-> (remote/login! {:base-url trimmed-url
:username username
:password password
:device-label (str (.-platform js/navigator))})
(p/then (fn [{:keys [token]}]
(config/set-server-base-url! trimmed-url)
(config/set-server-token! token)
(notification/show! "Connected. Reloading..." :success)
(js/setTimeout #(js/location.reload) 500)))
(p/catch (fn [error]
(set-connecting! false)
(notification/show!
(str "Failed to connect: " (or (.-message error) error))
:error))))))))]
[:div.cp__settings-db-worker-server-cnt
[:h1.mb-2.text-2xl.font-bold "Server"]
[:div.p-2
[:p.text-sm.opacity-70.mb-4
"Connect this client to a self-hosted Logseq server (db-worker-node) instead of storing graphs locally in the browser."]
(if connected?
[:div
[:p [:strong "Connected to: "] current-url]
[:p.pt-2
(shui/button
{:size :sm :variant :outline :on-click disconnect!}
"Disconnect")]]
[:div
[:p
[:label [:strong "Server URL"]
[:input.form-input.is-small
{:value url
:placeholder "https://your-server.example:PORT"
:style {:width "100%"}
:on-change #(set-url! (util/evalue %))}]]]
[:p.pt-2
[:label [:strong "Username"]
[:input.form-input.is-small
{:value username
:style {:width "100%"}
:on-change #(set-username! (util/evalue %))}]]]
[:p.pt-2
[:label [:strong "Password"]
[:input.form-input.is-small
{:type "password"
:value password
:style {:width "100%"}
:on-change #(set-password! (util/evalue %))}]]]
[:p.pt-2
(shui/button
{:size :sm :disabled connecting? :on-click connect!}
(if connecting? "Connecting..." "Connect"))]])]]))
(hsx/defc db-worker-server-button
[]
(let [current-url (config/get-server-base-url)]
(ui/button [:span.flex.items-center
[:span.pr-1 (if (seq current-url) current-url "Connect to server")]
(ui/icon "edit")]
:class "text-sm"
:on-click #(state/pub-event! [:go/db-worker-server-settings]))))
(defn db-worker-server-row []
(row-with-button-action
{:left-label "Server"
:action (db-worker-server-button)}))
(hsx/defc sync-server-url-button
[]
(let [current-url (config/get-custom-sync-server-url)]
@@ -924,6 +1023,7 @@
(when (and (or util/mac? util/win32?) (util/electron?)) (app-auto-update-row t))
(usage-diagnostics-row t instrument-disabled?)
(when-not (mobile-util/native-platform?) (developer-mode-row t developer-mode?))
(when-not (util/electron?) (db-worker-server-row))
(sync-server-url-row)
(publish-server-url-row)
(when (util/electron?) (https-user-agent-row https-agent-opts))

View File

@@ -99,6 +99,53 @@
(custom-url->http-base custom)
default-db-sync-http-base))
;; Web/mobile clients connect to a self-hosted db-worker-node daemon by
;; base URL + bearer token, instead of Electron's IPC-managed local runtime
;; (see frontend.persist-db/remote-runtime?). Stored client-side only; the
;; daemon holds the authoritative credential (see
;; frontend.worker.db-worker-node-auth).
(defn get-server-base-url
"Read the configured db-worker-node server base URL from localStorage.
Returns nil when not set or empty."
[]
(when-not util/node-test?
(let [v (.getItem js/localStorage "db-worker-server-url")]
(when (and (string? v) (not (string/blank? v)))
v))))
(defn set-server-base-url!
"Persist the db-worker-node server base URL to localStorage. Pass nil or
empty string to clear."
[url]
(when-not util/node-test?
(if (or (nil? url) (string/blank? url))
(.removeItem js/localStorage "db-worker-server-url")
(.setItem js/localStorage "db-worker-server-url" (string/trim url)))))
(defn get-server-token
"Read the current db-worker-node session bearer token from localStorage.
Returns nil when not set or empty."
[]
(when-not util/node-test?
(let [v (.getItem js/localStorage "db-worker-server-token")]
(when (and (string? v) (not (string/blank? v)))
v))))
(defn set-server-token!
"Persist the db-worker-node session bearer token to localStorage. Pass
nil or empty string to clear (e.g. on logout or a 401)."
[token]
(when-not util/node-test?
(if (or (nil? token) (string/blank? token))
(.removeItem js/localStorage "db-worker-server-token")
(.setItem js/localStorage "db-worker-server-token" (string/trim token)))))
(defn clear-server-config!
"Clear both the configured server URL and session token, e.g. on logout."
[]
(set-server-base-url! nil)
(set-server-token! nil))
(defn get-custom-publish-server-url
"Read the user-configured custom publish server URL from localStorage.
Returns nil when not set or empty."

View File

@@ -105,6 +105,11 @@
(settings/sync-server-url-settings-container)
{:id :sync-server-panel :center? true :class "lg:max-w-2xl"}))
(defevent! :go/db-worker-server-settings [[_]]
(shui/dialog-open!
(settings/db-worker-server-settings-container)
{:id :db-worker-server-panel :center? true :class "lg:max-w-2xl"}))
(defevent! :go/publish-server-settings [[_]]
(shui/dialog-open!
(settings/publish-server-url-settings-container)

View File

@@ -25,7 +25,7 @@
(defonce remote-repo (atom nil))
(defonce remote-runtime-state (atom nil))
(declare <ensure-remote!)
(declare <ensure-remote! <release-remote-runtime-slot!)
(defn- clear-remote-runtime!
[]
@@ -105,7 +105,7 @@
(if (active-runtime-client? @remote-runtime-state repo session-id remote-client)
(p/let [_ (do
(clear-remote-runtime!)
(ipc/ipc "releaseDbWorkerRuntime" repo))]
(<release-remote-runtime-slot! repo))]
true)
(do
(log/info :event :db-worker-runtime-recovery-skipped
@@ -178,6 +178,48 @@
(and (not (node-runtime?))
(util/electron?)))
(defn- web-remote-configured?
"True when running as a non-Electron, non-Node client (browser or
Capacitor) with a db-worker-node server configured -- the counterpart to
Electron's IPC-managed local runtime, driven by config/get-server-base-url
instead."
[]
(and (not (node-runtime?))
(not (util/electron?))
(some? (config/get-server-base-url))))
(defn- remote-runtime?
"True whenever this client talks to a remote db-worker-node daemon over
HTTP/SSE rather than an in-process worker (frontend.persist-db.browser).
Replaces the old electron-runtime? checks at every PersistentDB dispatch
site below, so the same remote path serves Electron and a configured web
or mobile client alike."
[]
(or (electron-runtime?)
(web-remote-configured?)))
(defn- <resolve-remote-runtime!
"Resolves {:base-url :auth-token} for the active remote transport.
Electron gets it from the main process, which owns spawning/reusing the
local daemon (see logseq.cli.server/ensure-server!); a web/mobile client
reads its own locally configured server instead -- there is no daemon to
spawn, only one to log into."
[repo]
(if (electron-runtime?)
(ipc/ipc "db-worker-runtime" repo)
(p/resolved {:base-url (config/get-server-base-url)
:auth-token (config/get-server-token)})))
(defn- <release-remote-runtime-slot!
"Releases the Electron-owned runtime slot for repo, if any. No-op for a
web/mobile client: there's no per-window runtime manager on the other
end to release, just the daemon's own session, which the client simply
stops using."
[repo]
(if (electron-runtime?)
(ipc/ipc "releaseDbWorkerRuntime" repo)
(p/resolved nil)))
(defn- current-db-sync-config
[]
{:enabled? true
@@ -243,7 +285,7 @@
(log/warn :event :db-worker-ensure-remote-stale
:repo repo :phase :before-runtime)
nil)
(p/let [runtime (ipc/ipc "db-worker-runtime" repo)
(p/let [runtime (<resolve-remote-runtime! repo)
client (remote/start! (assoc runtime
:repo repo
:event-handler worker-handler/handle
@@ -278,7 +320,7 @@
(log/info :event :db-worker-stale-release-skipped
:repo repo
:reason :runtime-changed)
(ipc/ipc "releaseDbWorkerRuntime" repo))]
(<release-remote-runtime-slot! repo))]
nil)))
(p/catch (fn [e]
(log/warn :event :db-worker-stale-release-error
@@ -302,7 +344,7 @@
(defn <start-runtime!
[]
(cond
(electron-runtime?)
(remote-runtime?)
(if-let [repo (state/get-current-repo)]
(<ensure-remote! repo)
(p/resolved nil))
@@ -316,7 +358,7 @@
opfs-db)
(defn <list-db []
(if (electron-runtime?)
(if (remote-runtime?)
(if-let [repo (or @remote-repo (state/get-current-repo))]
(p/let [client (<ensure-remote! repo)]
(protocol/<list-db client))
@@ -325,14 +367,14 @@
(defn <unsafe-delete [repo]
(when repo
(if (electron-runtime?)
(if (remote-runtime?)
(p/let [client (<ensure-remote! repo)]
(protocol/<unsafe-delete client repo))
(protocol/<unsafe-delete (get-impl) repo))))
(defn <close-db [repo]
(when repo
(if (electron-runtime?)
(if (remote-runtime?)
(if (same-remote-repo? repo @remote-repo)
(if-let [remote-client @remote-db]
(p/let [_ (-> (remote/invoke! (:client remote-client) "thread-api/close-db" [repo])
@@ -346,12 +388,15 @@
(defn <export-db
[repo opts]
(when repo
(protocol/<export-db (get-impl) repo opts)))
(if (remote-runtime?)
(p/let [client (<ensure-remote! repo)]
(protocol/<export-db client repo opts))
(protocol/<export-db (get-impl) repo opts))))
(defn <import-db
[repo data]
(when repo
(if (electron-runtime?)
(if (remote-runtime?)
(p/let [client (<ensure-remote! repo)]
(protocol/<import-db client repo data))
(protocol/<import-db (get-impl) repo data))))
@@ -361,7 +406,7 @@
(<open-and-fetch-schema repo {}))
([repo opts]
(when repo
(if (electron-runtime?)
(if (remote-runtime?)
(p/let [client (<ensure-remote! repo)]
(protocol/<open-and-fetch-schema client repo opts))
(protocol/<open-and-fetch-schema (get-impl) repo opts)))))
@@ -370,7 +415,7 @@
;; @shuyu Do we still need this?
(defn <new [repo opts]
{:pre [(<= (count repo) 128)]}
(p/let [impl (if (electron-runtime?)
(p/let [impl (if (remote-runtime?)
(<ensure-remote! repo)
(p/resolved (get-impl)))
_ (protocol/<new impl repo opts)]

View File

@@ -57,6 +57,44 @@
(string? code) (keyword code)
:else :invoke-failed))
(defn- login-url
[base-url]
(str (normalize-base-url base-url) "/v1/auth/login"))
(defn- default-fetch-json!
[{:keys [method url headers body]}]
(p/let [^js res (js/fetch url (clj->js (cond-> {:method method
:headers (or headers {})}
body (assoc :body body))))
text (.text res)]
{:status (.-status res)
:body text}))
(defn login!
"Logs into a db-worker-node daemon's HTTP auth (see
frontend.worker.db-worker-node-auth) and returns {:token :session-id} on
success. Used by the web/mobile server-connection settings UI; Electron
gets its token from the main process instead (see
frontend.persist-db/<resolve-remote-runtime!)."
[{:keys [base-url username password device-label fetch-fn]}]
(p/let [payload (js/JSON.stringify
(clj->js {:username username
:password password
:deviceLabel device-label}))
{:keys [status body]} ((or fetch-fn default-fetch-json!)
{:method "POST"
:url (login-url base-url)
:headers {"Content-Type" "application/json"}
:body payload})
parsed (parse-response-body body)]
(if (<= 200 status 299)
{:token (:token parsed) :session-id (:sessionId parsed)}
(let [error (:error parsed)]
(throw (ex-info (or (:message error) "login failed")
(cond-> {:status status
:code (normalize-code (:code error))}
error (assoc :error error))))))))
(defn- decode-event
[{:keys [type payload]}]
(let [decoded (when (some? payload)