Skip to content

feat(electron): upgrade to Electron 43 and restore plugin loading and HTTP - #50

Open
CR0CKER wants to merge 1 commit into
logseq:version/filefrom
CR0CKER:upstream-pr/electron-43
Open

feat(electron): upgrade to Electron 43 and restore plugin loading and HTTP#50
CR0CKER wants to merge 1 commit into
logseq:version/filefrom
CR0CKER:upstream-pr/electron-43

Conversation

@CR0CKER

@CR0CKER CR0CKER commented Aug 28, 2026

Copy link
Copy Markdown

Fixes #32.

What changed

Upgrades Electron 41.7.1 → 43.4.1 and restores plugin loading and plugin HTTP, both of which the
newer Chromium breaks. 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
    chore: bump electron 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
    (Bug (?) / Help With CORS Checks For Plugins 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. 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 fix: initiate logseq.Net HTTP requests over postMessage so they work from sandboxed plugin iframes logseq#12753. The abort path already
    went through the caller.

  5. 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.

  6. Path containment on the lsp:// handler (resolveWithin), plus a startup allowlist of
    legitimate external plugin roots.

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.Requestexper_requestnode-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.

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.

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

yarn install
yarn test:electron-js     # new; also chained into `yarn test`

46 cases under bare node --test — no Electron, no browser, no build step — covering
resolveWithin containment (traversal, absolute-looking paths, the sibling-directory startsWith
trap), the external-root allowlist, and which requests get relaxed (all three plugin frame forms;
the app frame explicitly not), the external-root allowlist and both the re-seed and
user-chosen-root paths into it, and the CORS header rewrite itself. Verified green on Node 22 and 24.

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.

Three of the 46 cases are guards over the compiled output, since neither release-build trap
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, and onBeforeRequest / onHeadersReceived / responseHeaders /
resourceType all survive unrenamed.

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.
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.

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.

… HTTP

41.x reached end of life on 2026-08-25; 43.x is supported until 2027-01-05.
Restores the two things the newer Chromium breaks, and changes nothing about
what a plugin is allowed to reach. 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.

better-sqlite3 12.10.0 no longer compiles against Electron 43's V8 -- its
SetNativeDataProperty call is ambiguous against the three overloads now
declared 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, and the root yarn.lock regenerated so it agrees with the bumped
devDependency (net -255 lines: @electron/get 5 drops transitives that version 2
pulled in). Note for packagers: from Electron 42 the npm package no longer
fetches its binary in a postinstall script -- it downloads on first use, so
offline or sandboxed builds need to prime the cache explicitly.

PLUGIN LOADING. Electron 40 tightened opaque-origin rules. Loading the
renderer from file:// makes the parent origin opaque, which breaks the
postMessage handshake plugin iframes rely on, so no plugin loads at all. The
renderer is served over the privileged lsp:// scheme instead. That means lsp://
can no longer serve static app files from one flat root, so the routes are
namespaced into /plugins/ (dot-root installs) and /external/<urlencoded-root>/
(plugins installed elsewhere), with everything else still 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.

PLUGIN HTTP. Serving the renderer over lsp:// gives plugin frames a real tuple
origin, so Chromium began enforcing CORS on requests they make; under file://
none was 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
like Zotero or Syncthing -- or omit the plugin's custom client header from
Access-Control-Allow-Headers, so the request fails before it is sent. Observed:
Readwise sync, url-title-tagger, and Syncthing/Zotero-backed plugins
(logseq/logseq#12736).

Two halves. The SDK installs a fetch bridge in plugin frames that routes
http(s) fetch through the main process, sidestepping the browser's CORS layer
rather than relaxing it; :httpRequest gains includeResponse so a real Response
can be rebuilt with its status, headers and url. Anything the bridge cannot
carry faithfully keeps the native implementation rather than being silently
altered: non-http(s) URLs, credentials:'include' (a main-process request has no
cookies), and a non-string body such as FormData/Blob/ArrayBuffer, which the
handler would JSON-serialise into "{}". AbortSignal is wired to the host's own
abort path, and an abort is never retried natively. A string body is also no
longer double-encoded by JSON.stringify, which broke the common
fetch(url, {body: JSON.stringify(x)}) shape.

The other half is a permissive CORS response for the requests the bridge cannot
see -- XHR, and anything issued before the bridge installs. It publishes a
WILDCARD origin, 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. Access-Control-Expose-Headers is
published too, 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. Otherwise this grants no new capability: any
plugin can already issue unrestricted HTTP via logseq.Request -> exper_request
-> node-fetch in the main process, with no CORS at all. Unlike
webSecurity:false, same-origin DOM access and mixed-content blocking are
untouched.

The relaxation is scoped and fails closed. The main renderer is ALSO
lsp://logseq.com (electron.html), so matching on scheme alone would relax the
app's own requests -- plugin paths are matched specifically, and only
xhr/other resource types. 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 its state 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. It is filtered to http(s): an unfiltered listener
also sees the renderer's own lsp:// asset loads and stalls them, and the app
never finishes starting.

logseq.Request also now initiates over the postMessage caller rather than
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 there. Ported from
logseq/logseq#12753.

PATH CONTAINMENT. The new /external/ route takes the directory to serve from
out of the URL, so containment alone cannot decide whether that directory may
be read at all: resolveWithin faithfully confirms that id_ed25519 is inside
~/.ssh. A URL may therefore only name a root the app already knows about --
preferences.json's "externals", plus <dot-root>/tmp, where the SDK generates
the entry document for a plugin whose package main is a .js file. That second
root is not optional: such a plugin addresses its generated entry with its own
directory as the root, which is never an "external", so leaving it out refuses
the entry and the plugin does not load at all. It 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 an unrecognised root (throttled to once a
second, so a stream of bogus roots cannot hammer the disk).

Re-reading is not enough for a FIRST install, though, 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. 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. From the next launch
preferences.json covers it. Every branch of the
handler additionally resolves through resolveWithin, which also closes the
pre-existing traversal in the plugin branch.

RELEASE-BUILD TRAPS, all invisible to a dev compile. 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 compile to mangled
keys that are undefined on Node's real path object, so a containment check
written with path.sep returns null for EVERYTHING -- including the app's own
electron.html, giving a blank window. Containment is done with plain string ops
instead. externs.js gains onBeforeRequest/resourceType/frame for the same
reason: without them the call fails with "$onBeforeRequest$ is not a function",
which aborts app setup before the 'main' IPC channel registers and leaves the
renderer dead on arrival. Registration is wrapped so a failure degrades to "no
attribution" rather than taking the app down.

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. Caught by running
the packaged app, not by the suite, so there is now 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.

Adds test/electron-js (46 cases, bare `node --test`, no Electron or build step)
covering containment, the external-root allowlist and its re-seed path, and
which requests are relaxed plus the header rewrite itself. It lives outside the
deps.edn :paths so it stays off the shadow-cljs compile surface, is chained
into `npm test`, and runs as its own step in the build workflow, whose test job
invokes yarn cljs:test directly rather than yarn test.

Two of those cases are guards over the COMPILED output, since neither trap
above is observable any other way. They read static/electron.js when it exists
and was built with pseudo-names (`--debug`); a plain release renames the
symbols away entirely, leaving nothing to match on, so that case is skipped
explicitly. When the build IS observable, a missing marker FAILS rather than
skipping -- a guard whose marker has gone stale silently protects nothing. The
marker anchors on resolveWithin's DEFINITION rather than its export line,
because Closure emits the export as a bare alias once the function has a second
caller inside the module.

Verified against a real advanced build (`clojure -M:cljs release electron
--debug`): resolveWithin compiles to plain string comparisons with
path.resolve/path.join intact and no path.sep, and onBeforeRequest /
onHeadersReceived / responseHeaders / resourceType all survive unrenamed.

Fixes logseq#32.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@TomaSajt

Copy link
Copy Markdown

I have pulled in the current state of this PR as a patch into NixOS/nixpkgs#516682, (minus the .js files, which I regenerate during the build)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

electron 40+ plugin loading not working

2 participants