Fixes #32.
## What changed
Upgrades Electron 41.7.1 → 43.4.1 and restores plug…in loading, plugin HTTP, and local asset
loading — all three broken by the newer Chromium. No change to what a plugin is allowed to reach.
1. **Electron 41.7.1 → 43.4.1.** 41.x reached end of life on 2026-08-25; 43.x is supported to
2027-01-05. `better-sqlite3` 12.10.0 no longer compiles against Electron 43's V8
(`SetNativeDataProperty` is ambiguous against the three overloads now in `v8-template.h`);
13.0.3 fixes that and ships N-API prebuilds, so it no longer needs an `electron-rebuild` pass
per Electron major. `node-abi` bumped to 4.33.0. 43 rather than 44 because 44.0.0 was two days
old when this was prepared and 43.4.1 is a matured patch line; 44 is a straightforward
follow-up once it has settled.
2. **Renderer served over `lsp://` instead of `file://`.** Electron 40 tightened opaque-origin
rules: a `file://` renderer makes the parent origin opaque, which breaks the `postMessage`
handshake plugin iframes rely on, so **no plugin loads at all**. `lsp://` can no longer serve
static app files from one flat root, so routes are namespaced into `/plugins/` (dot-root
installs) and `/external/<urlencoded-root>/` (plugins installed elsewhere), with everything
else resolving against `__dirname`. The bare legacy form `lsp://logseq.io/<pid>/...` is still
accepted — themes register under it and their URLs are persisted in `preferences.json` and
`localStorage`, so rejecting it silently breaks every installed theme on upgrade. Ported from
logseq/logseq#12741.
3. **Plugin HTTP restored, two ways.** Serving the renderer over `lsp://` gives plugin frames a
real tuple origin, so Chromium began enforcing CORS on requests they make; under `file://`
none applied and the same calls worked. Most endpoints a plugin talks to send no
`Access-Control-Allow-Origin` at all (ordinary web pages, local APIs such as Zotero or
Syncthing) or omit the plugin's custom client header from `Access-Control-Allow-Headers`.
Observed breakage: Readwise sync, url-title-tagger, and Syncthing/Zotero-backed plugins
(logseq/logseq#12736).
- **A `fetch` bridge in the SDK** (`LSPlugin.user.ts`) routes http(s) `fetch` from a plugin
frame through the main process, which sidesteps the browser's CORS layer rather than
relaxing it. The `:httpRequest` handler gains `includeResponse` so the bridge can rebuild a
real `Response` with its status, headers and URL. Anything the bridge cannot carry
faithfully keeps the native implementation instead of being silently altered: non-http(s)
URLs (`lsp://` assets, `data:`, `blob:`, relative paths), `credentials: 'include'` (a
main-process request has no cookies), and a non-string body such as `FormData`, `Blob` or
`ArrayBuffer` (the handler would JSON-serialise it into `{}`). `AbortSignal` is wired
through to the host's own abort path, and an abort is never retried on the native path.
- **A permissive CORS response for plugin frames** covers what the bridge cannot: XHR, and
anything issued before the bridge installs. It publishes
`Access-Control-Expose-Headers: *` alongside the wildcard origin, since under `file://` a
plugin could read every response header and the CORS-safelisted six are not enough for the
`Link`/`ETag`/`X-RateLimit` headers real APIs answer with.
4. **Local assets served over `assets://` instead of `file://`.** The third thing the origin
move breaks, and the one that is hardest to read from its symptom. Chromium refuses a
`file://` subresource from the non-file `lsp://logseq.com` origin, but
`frontend.handler.assets/normalize-asset-resource-url` still built `file://` URLs — a
builder that had always been correct and stayed correct-*looking*. Three faces:
- **PDFs fail with a message that blames the file.** pdf.js maps a request that failed at the
network layer to `Missing PDF "file:///…pdf"` whenever the URL is `file:`
(`status === 0 && url.startsWith("file:")`), so a *blocked read* and a *deleted file* are
indistinguishable to the user. The file is on disk and readable the whole time.
- **Alias-path images fail silently** — same builder, `<img>` `naturalWidth` 0, no error a
user ever sees.
- **Plugin themes fail, via a rewrite that used to be the fix.** `assets-theme-to-file`
deliberately rewrote an `assets://` theme URL (SDK `_loadConfigThemes`) to `file://` before
`injectTheme` added the `<link rel=stylesheet>`; the link now errors and the theme silently
never applies. Over `assets://` the stylesheet loads, applies, **and** resolves its own
relative `url()` references, so the rewrite has no purpose left and is removed rather than
left as an identity function.
Images inside the graph kept working throughout, which is why this was easy to miss: the app
has **two** asset-URL builders and only `editor/make-asset-url` already produced `assets://`.
Fixed in the builder rather than at the PDF viewer, so all three faces are covered at once.
`assets://` gains `supportFetchAPI` + `corsEnabled` — it is a different origin from
`lsp://logseq.com`, so the renderer reading it is a cross-origin request Chromium otherwise
refuses outright. `standard` deliberately **stays false**: under a standard scheme Chromium
reparses the URL and an absolute path's leading slash becomes the **hostname**
(`assets:///home/x` arrives as host `home`, path `/x`; on Windows the drive letter becomes the
host), which resolves to a relative path that only works by accident.
5. **`logseq.Request` initiates over `postMessage`.** It used
`Experiments.invokeExperMethod`, which does a synchronous `window.top.logseq` read — that
throws for a plugin iframe on a different origin than the host, i.e. every `:effect false`
plugin, so requests never started. Ported from logseq/logseq#12753. The abort path already
went through the caller.
6. **A string request body is no longer double-encoded.** `handler.cljs` ran `JSON.stringify`
over a body that was already a string, breaking the common
`fetch(url, {body: JSON.stringify(x)})` shape.
7. **Path containment on the `lsp://` handler** (`resolveWithin`), plus a startup allowlist of
legitimate external plugin roots — and the same containment for `assets://` (below).
## Why the CORS relaxation is not a new capability
A **wildcard** origin is published, deliberately, not the echoed request origin. The browser
rejects `*` for credentialed requests, so cookie-bearing cross-origin reads stay blocked. Echoing
the origin would let a plugin read another site's authenticated responses — a real escalation.
Beyond that it grants nothing new: any plugin can already issue unrestricted HTTP via
`logseq.Request` → `exper_request` → `node-fetch` in the main process, with no CORS at all.
Browser CORS was never a boundary against a hostile plugin here, only a tax on honest ones using
plain `fetch`. Unlike `webSecurity: false`, the same-origin policy for DOM access and
mixed-content blocking are untouched.
The relaxation is scoped and fails closed:
- Only plugin frames — the main renderer is **also** `lsp://logseq.com` (`electron.html`), so
matching on the scheme alone would relax the app's own requests. Plugin paths are matched
specifically.
- Only `xhr`/`other` resource types, which are the ones that read a cross-origin body.
- Identity is resolved in `onBeforeRequest`, where the frame is still alive, and recorded against
the webRequest id. Electron documents `details.frame` as nullable once a frame has navigated or
been destroyed, which is exactly the state it can be in at `onHeadersReceived` time. Anything
not positively attributed is left untouched.
- The `onBeforeRequest` listener **never cancels a request**; it only records attribution, so
plugin network reach is exactly what it was before.
## Why path containment is part of this PR
The `/external/<urlencoded-root>/` route is new here, and it takes the directory to serve from
**out of the URL**. `resolveWithin` verifies that the file stays inside the root it was given —
but the URL chose that root, so a request naming `~/.ssh` and the file `id_ed25519` passes
containment cleanly. Containment answers "is the file inside the directory?", never "may you read
that directory at all?".
So a URL may only name a root the app already knows about: `preferences.json`'s `externals` — the
SDK's own record of installed external plugins — plus `<dot-root>/tmp`, where the SDK generates
the entry document for a plugin whose package `main` is a `.js` file. That second one is not
optional: such a plugin addresses its generated entry with *its own* directory as the root, which
is never an "external", so without it the entry is refused and the plugin does not load. The tmp
dir is app-controlled rather than named by the URL, so trusting it adds no reach.
The list is seeded at startup and re-read when the handler meets a root it does not recognise
(throttled to once a second, so a stream of bogus roots cannot hammer the disk). A root still
unknown after that re-read is refused.
Re-reading is not enough for a **first** install, and load order is why: `PluginLocal#load()`
mounts the plugin's frame — which fetches its entry document and then the plugin's own scripts
over `lsp://` — *before* `LSPluginCore` registers it and writes `preferences.json`. At that
moment the file is not yet a record of anything, so nothing on disk can authorise the plugin
being installed. The authority there is the directory the user picked, so the
load-unpacked-plugin flow gets its own IPC (`:openPluginDirDialog`) that opens the same dialog
and allows the chosen directory for the rest of the session; from the next launch
`preferences.json` covers it. It is deliberately not a renderer-callable "trust this path" API —
the only path it ever accepts is one the main process watched the user choose in a file dialog.
Without any of this the PR would introduce a wider traversal than the one that exists today; the
pre-existing traversal in the plugin branch is closed by the same `resolveWithin` call.
## Why `assets://` containment is part of this PR
Turning on `corsEnabled` is what makes the scheme *readable* by `fetch`/XHR, and the handler
serves a path named in the URL with no containment at all before this. The boundary that matters
is **plugin frames**: a plugin frame receives no preload bridge, so it cannot reach the app's IPC,
which makes `assets://` its only route to a local file. Measured in the suite — a plugin frame
could read an arbitrary file outside the graph and see the bytes.
So requests are refused unless the path lands inside a registered root: the graph directories,
which the main process learns first-hand in `set-current-graph!`, plus the asset alias
directories the user picked in Settings. The alias list lives in renderer storage, so it arrives
over `:setAssetsAliasDirs` and is mirrored into main's `configs.edn` — that mirror is what makes
the roots known at startup, before the renderer gets a chance to push them. A refusal returns
`ERR_FILE_NOT_FOUND`, exactly as the `lsp://` handler does, so it is not a probe oracle.
Net effect is **stricter** than before this PR: the scheme previously served any absolute path on
disk to any frame as a subresource.
## Release-build trap worth knowing
`src/electron/electron/utils.js` is Closure-compiled under `:advanced` in a **release** build,
which renames any `path.*` **property** it has no extern for. `path.sep` and `path.relative` both
compile to mangled keys that are `undefined` on Node's real `path` object, so a containment check
written with `path.sep` silently returns null for *everything* — including the app's own
`electron.html`, giving a blank window. Containment is therefore done with plain string ops. A dev
`cljs compile` does **not** rename properties and cannot catch this; only a `release` build will.
The same renaming bites a property read on **parsed data**, where there is nothing to write an
extern against: `preferences.json`'s `externals`, read as `prefs.externals`, compiled to a mangled
key that is `undefined` on the real object — so only the tmp root was seeded and every plugin
installed outside the dot-root was refused. It is read as `prefs['externals']`; a string literal
cannot be renamed. This one was caught by running the packaged app, not by the suite, so there is
a guard over the compiled output for it too. Note the trap inside the trap: a pseudo-named build
spells the *broken* form `.$externals$`, which still contains the word, so grepping the bundle for
`externals` proves nothing — the guard asserts the absence of the mangled form.
The scheme **privilege tables** are the same trap once more, in a third shape. They are plain JS
object literals in `utils.js` handed straight to `registerSchemesAsPrivileged`, and Electron reads
those keys by their real names — so a renamed key does not error, it registers the scheme with
**default** privileges. `assets://` would quietly lose `supportFetchAPI` + `corsEnabled` and every
asset read would start failing again, in release builds only. The keys are quoted, and a guard
over the compiled output asserts they survive.
`externs.js` gains `onBeforeRequest` / `resourceType` / `frame` for the same reason — without them
the call fails at runtime with `$onBeforeRequest$ is not a function`, which aborts app setup
*before* the `main` IPC channel registers, leaving the renderer dead on arrival. Registration is
additionally wrapped so a failure degrades to "no attribution" rather than taking the app down.
## Testing instructions
```bash
yarn install
yarn test:electron-js # new; also chained into `yarn test`
```
76 cases, of which 68 run under bare `node --test` — no Electron, no browser, no build step.
The plugin half (46 cases) covers `resolveWithin` containment (traversal, absolute-looking paths,
the sibling-directory `startsWith` trap), the external-root allowlist and both the re-seed and
user-chosen-root paths into it, which requests get relaxed (all three plugin frame forms; the app
frame explicitly not), and the CORS header rewrite itself. Verified green on Node 22 and 24.
The asset half (30 cases, Node 24) covers the `assets://` resolver — traversal, encoded
traversal, sibling-prefix roots, malformed escapes, the `/C:/…` Windows form — and the scheme
privileges, including that `standard` is false and the keys survive `:advanced`.
**8 of those cases boot a real Electron** (`asset-protocol.test.mjs`), because the bug they guard
is not a wrong value a pure function could catch: the builder was *correct* and stayed
correct-looking after the origin moved underneath it. Only a real renderer at a real origin can
assert "this asset is actually fetchable". They assert both halves of the contract — an in-root
asset **is** fetchable from the app's own origin, and a file outside every root is **not**,
including from a plugin frame. The out-of-root file is created on disk on purpose, so a refusal
proves *containment* rather than *absence* — the exact confusion the original bug turned on.
There is also a canary asserting the renderer origin really is `lsp://logseq.com`, since the
`file://` assertion means nothing otherwise.
Those 8 need the Electron binary. With none present they **skip** rather than fail, and never
trigger a download from inside a test (`require('electron')` would download, which a test must
not do). Point them at an unpacked dist with `ELECTRON_OVERRIDE_DIST_PATH`.
**Be aware they therefore SKIP in CI today, and I have not claimed otherwise.** From Electron 42
the npm package downloads the binary on first use rather than in a postinstall script, so
`yarn install` does not prime it, and the `Run Electron main-process tests` step has no display
either. The skip is visible in the log —
`﹣ assets:// scheme contract # no Electron binary available` — rather than silent, and the 68
pure cases do run. Making the other 8 run in CI needs the binary primed plus `xvfb-run`; I have
left that out rather than push an unverified workflow change, since I have no way to test a CI
runner from here. Happy to add it if a maintainer wants it, or to take a pointer on how this repo
prefers to handle GUI-dependent tests.
The suite was mutation-checked: replacing the containment check with a naive `startsWith`, and
widening frame matching to the whole `lsp://` scheme, each turn it red.
Four of the cases are guards over the **compiled** output, since none of the release-build traps
above is observable any other way. They read `static/electron.js` when it exists and was built
with pseudo-names (`clojure -M:cljs release electron --debug`); with no such build present there
is nothing to inspect and they no-op, so they protect a developer who has just run a debug release
build and nobody else. When the build *is* observable, a missing marker **fails** rather than
skipping — an earlier version of this guard used a marker that matched no build at all and so
passed unconditionally.
The suite is wired into `.github/workflows/build.yml` as its own step, since that job runs
`yarn cljs:test` directly rather than `yarn test`.
Also run: `clojure -M:clj-kondo --lint src/electron` — 0 errors (one pre-existing unused-referral
warning in `utils.cljs`, unchanged by this PR).
**A release build has been run and verified**: `resolveWithin` compiles to plain string
comparisons with `path.resolve`/`path.join` intact and no `path.sep`, the `externals` read
survives unmangled, `onBeforeRequest` / `onHeadersReceived` / `responseHeaders` / `resourceType`
all survive unrenamed, and the scheme privilege tables compile to
`{standard:!1, secure:!1, bypassCSP:!1, supportFetchAPI:!0, corsEnabled:!0}` with the key names
intact.
Exercised manually on a packaged Linux/aarch64 build: 15 dot-root plugins across both the
`logseq.com/plugins/` and `logseq.io/plugins/` routes; themes rendering correctly (checked
against 0.10.15 with an A/B of computed styles over CDP); Readwise sync, i.e. plugin HTTP, working
again; both external plugins loading over the `/external/` route — one with an HTML entry and one
with a `.js` entry, freshly installed through *Load unpacked plugin* and again after a restart, so
both the dialog and the `preferences.json` paths into the root list are covered; and a
FTS5/trigger/`MATCH` smoke test of `better-sqlite3` 13 under Electron 43.
For the asset fix specifically, a packaged Linux/aarch64 build was installed and used: a PDF asset
in a journal page, which reproduced `Missing PDF` beforehand, now opens. The alias-image and
plugin-theme faces were verified in the Electron harness rather than by hand — I have no
alias-directory or `assets://`-theme plugin set up to click through.
**macOS and Windows packaging are untested** — I don't have the hardware.
## Upgrade note for release notes
The renderer origin moves from `file://` to `lsp://logseq.com`, and `localStorage` is keyed by
origin, so UI preferences silently reset to defaults on first launch after upgrading. The visible
one is `radix-color` returning to `logseq`, which activates the built-in Solarized background and
overrides theme plugins; sidebar widths reset the same way. Nothing is lost — the graph and
`~/.logseq*` are untouched — but it looks alarming without a note.
## Known limitations
- A bridged response is rebuilt rather than streamed: the body is buffered and base64
round-tripped, and `redirected`/`type` are not reconstructed (`status`, `statusText`, headers
and `url` are). Requests the bridge declines — `credentials: 'include'`, non-string bodies —
take the native path and are therefore subject to CORS as any web page would be.
- A server that does not answer `OPTIONS` still fails preflight for a non-simple XHR. Bridged
`fetch` is unaffected, so this is confined to plugins issuing XHR directly.
- `assets://` containment is path resolution, not `realpath`, so a symlink *inside* a registered
root that points outside it is followed. Shared with `resolveWithin`, and not a new exposure:
anything under a graph root is already readable through the app's own file IPC. Tightening it
needs an async `realpath` per request and would still race the filesystem.
- An asset link a user authored as an explicit `file:///…` URL in their markdown still passes
through untouched and still will not load. Rewriting those would route arbitrary user-named
paths through the asset scheme, which is a separate decision from fixing URL *construction*.
## Scope
Deliberately excluded: the per-plugin network permission model this work was originally built
alongside. It restricts plugins **more** than today's behaviour and is not required by the
upgrade — it belongs in its own PR with its own discussion. This PR restores parity and nothing
else.