Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,9 @@ jobs:
yarn cljs:test
node static/tests.js

- name: Run Electron main-process tests
run: yarn test:electron-js

lint:
runs-on: ubuntu-22.04

Expand Down
7 changes: 7 additions & 0 deletions externs.js
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,13 @@ dummy.commit = function() {};
dummy.raw = function() {};
dummy.onHeadersReceived = function() {};
dummy.responseHeaders = function() {};
// Electron webRequest names used by the plugin CORS policy in
// src/electron/electron/utils.js. Without these, advanced-mode property renaming
// mangles them and the call fails at runtime with "$onBeforeRequest$ is not a
// function" -- which aborts app setup before the 'main' IPC handler registers.
dummy.onBeforeRequest = function() {};
dummy.resourceType = function() {};
dummy.frame = function() {};
dummy.velocityDecay = function() {};
dummy.velocityDecay = function() {};
dummy.updatePosition = function() {};
Expand Down
78 changes: 66 additions & 12 deletions libs/src/LSPlugin.core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ import {
getSDKPathRoot,
PROTOCOL_FILE,
URL_LSP,
URL_LSP_EXTERNAL,
URL_LSP_HOST,
URL_LSP_HOST_EXTERNAL,
safetyPathJoin,
path,
safetyPathNormalize,
Expand Down Expand Up @@ -371,16 +374,43 @@ function initApiProxyHandlers(pluginLocal: PluginLocal) {
})
}

function convertToLSPResource(fullUrl: string, dotPluginRoot: string) {
if (dotPluginRoot && fullUrl.startsWith(PROTOCOL_FILE + dotPluginRoot)) {
function convertToLSPResource(
fullUrl: string,
localRoot: string,
lspRoot = URL_LSP
) {
if (localRoot && fullUrl.startsWith(PROTOCOL_FILE + localRoot)) {
fullUrl = safetyPathJoin(
URL_LSP,
fullUrl.substr(PROTOCOL_FILE.length + dotPluginRoot.length)
lspRoot,
fullUrl.substr(PROTOCOL_FILE.length + localRoot.length)
)
}
return fullUrl
}

function getPluginLSPRoot(effect?: boolean) {
return effect ? URL_LSP_HOST : URL_LSP
}

function getExternalLSPRoot(localRoot: string, effect?: boolean) {
return safetyPathJoin(
effect === false ? URL_LSP_EXTERNAL : URL_LSP_HOST_EXTERNAL,
encodeURIComponent(localRoot)
)
}

function convertToExternalLSPResource(
fullUrl: string,
localRoot: string,
effect?: boolean
) {
return convertToLSPResource(
fullUrl,
localRoot,
getExternalLSPRoot(localRoot, effect)
)
}

class IllegalPluginPackageError extends Error {
constructor(message: string) {
super(message)
Expand Down Expand Up @@ -507,9 +537,19 @@ class PluginLocal extends EventEmitter<
const url = path.join(localRoot, filePath)
filePath = reg.test(url) ? url : PROTOCOL_FILE + url
}
return !this.options.effect && this.isInstalledInDotRoot
? convertToLSPResource(filePath, this.dotPluginsRoot)
: filePath
if (this.isInstalledInDotRoot) {
return convertToLSPResource(
filePath,
this.dotPluginsRoot,
getPluginLSPRoot(this.options.effect)
)
}

return convertToExternalLSPResource(
filePath,
localRoot,
this.options.effect
)
}

async _preparePackageConfigs() {
Expand Down Expand Up @@ -619,7 +659,10 @@ class PluginLocal extends EventEmitter<
devEntry = devEntry || settings?.get('_devEntry')

if (devEntry) {
this._options.entry = devEntry
this._options.entry = this._resolveResourceFullUrl(
devEntry,
this._localRoot
)
return
}

Expand Down Expand Up @@ -657,10 +700,21 @@ class PluginLocal extends EventEmitter<
dirPathInstalled
)

entry = convertToLSPResource(
withFileProtocol(path.normalize(entryPath)),
this.dotPluginsRoot
)
entry = withFileProtocol(path.normalize(entryPath))

if (this.isInstalledInDotRoot) {
entry = convertToLSPResource(
entry,
this.dotPluginsRoot,
getPluginLSPRoot(this.options.effect)
)
} else {
entry = convertToExternalLSPResource(
entry,
path.dirname(entryPath),
this.options.effect
)
}

this._options.entry = entry
}
Expand Down
148 changes: 148 additions & 0 deletions libs/src/LSPlugin.user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -526,6 +526,154 @@ export class LSPluginUser
actor?.reject(e)
}
})

this._installFetchBridge()
}

/**
* Route plugin `fetch` calls for http(s) URLs through the host process.
*
* Plugin iframes are served over the privileged `lsp://` scheme, which is a real
* origin, so Chromium enforces CORS on requests they make. Most endpoints a plugin
* talks to either 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`, so the request fails before it is sent. Under
* the old `file://` renderer Chromium applied no CORS and the same calls worked.
*
* Performing the request in the main process sidesteps the browser's CORS layer
* instead of disabling it: nothing in the renderer is relaxed, and the scope is
* exactly plugin frames. Non-http(s) URLs -- the plugin's own `lsp://` assets,
* `data:`, `blob:`, relative paths -- keep the native implementation.
*/
private _installFetchBridge () {
const g = globalThis as any
if (g.__lspFetchBridged) return

const nativeFetch = typeof g.fetch === 'function' ? g.fetch.bind(g) : null
if (!nativeFetch) return

const toHeaderRecord = (h: any): Record<string, string> => {
const out: Record<string, string> = {}
if (!h) return out
if (typeof h.forEach === 'function') h.forEach((v: any, k: any) => { out[String(k)] = String(v) })
else if (Array.isArray(h)) for (const [k, v] of h) out[String(k)] = String(v)
else Object.assign(out, h)
return out
}

const abortError = () =>
typeof DOMException !== 'undefined'
? new DOMException('The operation was aborted.', 'AbortError')
: Object.assign(new Error('The operation was aborted.'), { name: 'AbortError' })

g.fetch = async (input: any, init?: any) => {
const url = typeof input === 'string'
? input
: (typeof URL !== 'undefined' && input instanceof URL ? input.href : input?.url)

if (typeof url !== 'string' || !/^https?:\/\//i.test(url)) {
return nativeFetch(input, init)
}

const req = input && typeof input === 'object' && 'url' in input ? input : null
const signal: AbortSignal | undefined = init?.signal ?? req?.signal
const credentials = init?.credentials ?? req?.credentials
let body = init?.body ?? undefined

// The host performs the request outside the browser, so it carries no
// cookies. A caller that explicitly asked for them is better served by the
// native path, where the browser attaches them, than by a bridged request
// that silently goes out unauthenticated.
if (credentials === 'include') {
return nativeFetch(input, init)
}

if (typeof URLSearchParams !== 'undefined' && body instanceof URLSearchParams) {
body = body.toString()
}

// The host serialises a non-string body with JSON.stringify, which turns a
// FormData/Blob/ArrayBuffer upload into "{}" and sends it without
// complaint. Silent corruption is worse than CORS, so those keep the
// native path.
if (body != null && typeof body !== 'string') {
return nativeFetch(input, init)
}

if (signal?.aborted) throw abortError()

try {
const options: any = {
url,
method: String(init?.method ?? req?.method ?? 'GET').toUpperCase(),
headers: { ...toHeaderRecord(req?.headers), ...toHeaderRecord(init?.headers) },
data: body,
returnType: 'base64',
includeResponse: true
}

let res: any
if (signal) {
// `abortable` makes _request resolve a task rather than the payload,
// which is the only handle the host gives us onto an in-flight request.
const task: any = await this.Request._request({ ...options, abortable: true })
const onAbort = () => task.abort?.()
signal.addEventListener('abort', onAbort, { once: true })
try {
res = await Promise.race([
task.promise,
new Promise((_, reject) => {
signal.addEventListener('abort', () => reject(abortError()), { once: true })
})
])
} finally {
signal.removeEventListener('abort', onAbort)
}
} else {
res = await this.Request._request(options)
}

// A host without includeResponse support resolves the bare body; there is
// no status or headers to rebuild from, so let the native path handle it.
if (!res || typeof res !== 'object' || typeof res.status !== 'number') {
return nativeFetch(input, init)
}

let payload: Uint8Array | null = null
if (res.body) {
const bin = atob(res.body)
payload = Uint8Array.from(bin, (c: string) => c.charCodeAt(0))
}

// 204/205/304 are forbidden from carrying a body
const nullBody = res.status === 204 || res.status === 205 || res.status === 304

const response = new Response(nullBody ? null : payload, {
status: res.status,
statusText: res.statusText || '',
headers: res.headers || {}
})

// Response.url is read-only and empty on a constructed Response; plugins
// read it after a redirect, so publish the URL we actually requested.
try {
Object.defineProperty(response, 'url', { value: url, configurable: true })
} catch (e) {
// non-fatal: the response is still usable without it
}

return response
} catch (e) {
// An abort is the caller's own decision -- never retry it on the native
// path, which would send the request a second time.
if (signal?.aborted || (e as any)?.name === 'AbortError') throw e
// Otherwise never turn a request the native path could have served into a
// hard failure -- fall back rather than propagating a bridge-side error.
return nativeFetch(input, init)
}
}

g.__lspFetchBridged = true
}

// Life related
Expand Down
5 changes: 4 additions & 1 deletion libs/src/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,10 @@ export const path =
export const IS_DEV = process.env.NODE_ENV === 'development'
export const PROTOCOL_FILE = 'file://'
export const PROTOCOL_LSP = 'lsp://'
export const URL_LSP = PROTOCOL_LSP + 'logseq.io/'
export const URL_LSP = PROTOCOL_LSP + 'logseq.io/plugins/'
export const URL_LSP_EXTERNAL = PROTOCOL_LSP + 'logseq.io/external/'
export const URL_LSP_HOST = PROTOCOL_LSP + 'logseq.com/plugins/'
export const URL_LSP_HOST_EXTERNAL = PROTOCOL_LSP + 'logseq.com/external/'

let _appPathRoot

Expand Down
9 changes: 7 additions & 2 deletions libs/src/modules/LSPlugin.Request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,8 +118,13 @@ export class LSPluginRequest extends EventEmitter {
> {
const pid = this.ctx.baseInfo.id
const { success, fail, final, ...requestOptions } = options
const reqID = this.ctx.Experiments.invokeExperMethod(
'request',
// Initiate over the postMessage caller rather than Experiments.invokeExperMethod.
// The latter does a synchronous `window.top.logseq` read, which throws for a
// plugin iframe on a different origin than the host (any `:effect false`
// plugin, served from lsp://logseq.io), so requests never start there.
// The abort path already goes through the caller. Ported from logseq/logseq#12753.
const reqID = await this.ctx._execCallableAPIAsync(
'exper_request',
pid,
requestOptions
)
Expand Down
5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,8 @@
"run-android-release": "yarn clean && yarn release-app && rm -rf ./public/static && rm -rf ./static/js/*.map && mv static ./public && npx cap sync android && npx cap run android",
"run-ios-release": "yarn clean && yarn release-app && rm -rf ./public/static && rm -rf ./static/js/*.map && mv static ./public && npx cap sync ios && npx cap run ios",
"clean": "gulp clean",
"test": "run-s cljs:test cljs:run-test",
"test": "run-s cljs:test cljs:run-test test:electron-js",
"test:electron-js": "node --test \"test/electron-js/**/*.test.mjs\"",
"report": "run-s cljs:report",
"style:lint": "stylelint \"src/**/*.css\"",
"gulp:watch": "gulp watch",
Expand Down Expand Up @@ -113,7 +114,7 @@
"d3-force": "3.0.0",
"diff": "5.0.0",
"dompurify": "2.4.0",
"electron": "41.7.1",
"electron": "43.4.1",
"electron-dl": "^4.0.0",
"fs": "0.0.1-security",
"fs-extra": "9.1.0",
Expand Down
2 changes: 1 addition & 1 deletion resources/js/lsplugin.core.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion resources/js/lsplugin.user.js

Large diffs are not rendered by default.

12 changes: 6 additions & 6 deletions resources/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
"electron:make-linux-arm64": "electron-forge make --platform=linux --arch=arm64",
"electron:make-macos-arm64": "electron-forge make --platform=darwin --arch=arm64",
"electron:publish:github": "electron-forge publish",
"rebuild:all": "electron-rebuild -v 41.7.1 -f",
"rebuild:all": "electron-rebuild -v 43.4.1 -f",
"postinstall": "install-app-deps"
},
"config": {
Expand All @@ -25,7 +25,7 @@
"@logseq/rsapi": "0.0.92",
"@sentry/electron": "2.5.1",
"abort-controller": "3.0.0",
"better-sqlite3": "12.10.0",
"better-sqlite3": "13.0.3",
"chokidar": "^3.5.1",
"command-exists": "1.2.9",
"diff-match-patch": "1.0.5",
Expand Down Expand Up @@ -54,14 +54,14 @@
"@electron-forge/maker-squirrel": "^7.8.3",
"@electron-forge/maker-wix": "^7.8.3",
"@electron-forge/maker-zip": "^7.8.3",
"@electron/rebuild": "4.0.1",
"electron": "41.7.1",
"@electron/rebuild": "4.2.0",
"electron": "43.4.1",
"electron-builder": "26.0.12",
"electron-forge-maker-appimage": "https://github.com/logseq/electron-forge-maker-appimage.git"
},
"resolutions": {
"**/electron": "41.7.1",
"**/node-abi": "4.31.0",
"**/electron": "43.4.1",
"**/node-abi": "4.33.0",
"**/node-gyp": "12.0.0",
"string-width": "4.2.0",
"wrap-ansi": "^7.0.0",
Expand Down
Loading
Loading