Skip to content
Merged
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
4 changes: 4 additions & 0 deletions apps/buddy/electron/main/app/DesktopIntegrations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,10 @@ export class DesktopIntegrations {
readText: async (target, signal) => spaceTextDocumentSchema.parse(await service.request('spaceFiles.readDocument', target, { signal })).text,
})
this.#subscriptions.push(extensions.dispose)
runtime.inspectExtension = extensions.inspect
this.#subscriptions.push(() => {
runtime.inspectExtension = null
})
this.#subscriptions.push(registerWorkbenchIpc(new WorkbenchStateStore(paths.buddyHome), () => windows.window))
this.#subscriptions.push(registerContextPanelIpc(runtime.contextPanel, () => windows.window))
this.#subscriptions.push(registerStartupIpc(this.#environment.startup, () => windows.window, this.#environment.events))
Expand Down
8 changes: 7 additions & 1 deletion apps/buddy/electron/main/app/DesktopRuntimeHost.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { ExtensionInspection } from '../../../shared/extensions/extensionAuthoring'
import type { LexoraConfig } from '../../shared/desktopApi'
import type { BrowserIntegration } from '../browser/BrowserIntegration'
import type { DesktopFeature } from '../platform/desktopFeatures'
Expand Down Expand Up @@ -37,6 +38,7 @@ import { createCredentialVault } from '../secrets/CredentialVault'
import { registerCredentialHostRpc } from '../secrets/registerCredentialHostRpc'

export class DesktopRuntimeHost {
inspectExtension: ((id: string) => Promise<ExtensionInspection>) | null = null
readonly contextPanel: ContextPanelHost
readonly configStore: LexoraConfigStore
readonly #environment: DesktopEnvironment
Expand Down Expand Up @@ -160,7 +162,11 @@ export class DesktopRuntimeHost {
bindPeer: (peer) => {
const disposers = [
peer.onRequest(runtimePreferencesRpc.get, () => this.#config!.runtime),
registerExtensionAuthoringRpc(peer),
registerExtensionAuthoringRpc(peer, (id) => {
if (!this.inspectExtension)
throw new Error('EXTENSION_SERVICE_UNAVAILABLE')
return this.inspectExtension(id)
}),
peer.onRequest(contextPanelRpc.presentBrowser, (params) => {
const source = contextPanelSourceSchema.parse(params)
return this.contextPanel.execute({ action: 'open', target: { kind: 'browser', source } }, 'harness')
Expand Down
29 changes: 21 additions & 8 deletions apps/buddy/electron/main/extensions/ExtensionProtocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,19 @@ import type { Session } from 'electron'
import type { ExtensionPackage, ExtensionPackageStore } from '../../../platform/extensions/ExtensionPackageStore'
import { randomUUID } from 'node:crypto'
import { extname } from 'node:path'
import { extensionResourceRange } from '../../../platform/extensions/extensionResourceResponse'
import { EXTENSION_PROTOCOL } from '../../../shared/extensions/extensionManifest'
import { extensionResourceMimeType } from '../../../shared/extensions/extensionResources'
import hostSource from './runtime/host.js?raw'
import viewSource from './runtime/view.js?raw'

export const extensionSchemePrivileges: Electron.CustomScheme = {
scheme: EXTENSION_PROTOCOL,
privileges: { standard: true, secure: true, supportFetchAPI: true, corsEnabled: true },
privileges: { standard: true, secure: true, stream: true, supportFetchAPI: true, corsEnabled: true },
}

interface ExtensionEndpoint { token: string, kind: 'host' | 'view', package: ExtensionPackage }
const mime: Record<string, string> = { '.js': 'text/javascript', '.mjs': 'text/javascript', '.css': 'text/css', '.json': 'application/json', '.png': 'image/png', '.svg': 'image/svg+xml', '.jpg': 'image/jpeg', '.webp': 'image/webp', '.woff2': 'font/woff2' }
interface ExtensionEndpoint { token: string, kind: 'host' | 'view', package: ExtensionPackage, abort: AbortController }
const codeMime: Record<string, string> = { '.js': 'text/javascript', '.mjs': 'text/javascript', '.css': 'text/css' }

export class ExtensionProtocol {
readonly #store: ExtensionPackageStore
Expand All @@ -21,8 +23,12 @@ export class ExtensionProtocol {

register(pkg: ExtensionPackage, kind: 'host' | 'view') {
const token = randomUUID()
this.#endpoints.set(token, { token, kind, package: pkg })
return { token, url: `${EXTENSION_PROTOCOL}://${token}/__${kind}.html`, dispose: () => this.#endpoints.delete(token) }
const abort = new AbortController()
this.#endpoints.set(token, { token, kind, package: pkg, abort })
return { token, url: `${EXTENSION_PROTOCOL}://${token}/__${kind}.html`, dispose: () => {
abort.abort()
this.#endpoints.delete(token)
} }
}

install(session: Session, kind: 'host' | 'view', token?: string): () => void {
Expand All @@ -42,12 +48,17 @@ export class ExtensionProtocol {
try {
const url = new URL(request.url)
const endpoint = this.#endpoints.get(url.hostname)
if (!endpoint || endpoint.kind !== kind || (requiredToken && endpoint.token !== requiredToken) || request.method !== 'GET')
if (!endpoint || endpoint.kind !== kind || (requiredToken && endpoint.token !== requiredToken) || !['GET', 'HEAD'].includes(request.method))
return new Response(null, { status: 403 })
const path = decodeURIComponent(url.pathname)
const origin = `${EXTENSION_PROTOCOL}://${endpoint.token}`
const csp = `default-src 'none'; script-src ${origin}; style-src ${origin} 'unsafe-inline'; img-src ${origin} data:; font-src ${origin}; connect-src 'none'; object-src 'none'; base-uri 'none'; form-action 'none'; frame-src 'none'; worker-src 'none'; ${kind === 'view' ? 'sandbox allow-scripts allow-forms; frame-ancestors lexora-app://renderer http://localhost:1420 http://127.0.0.1:1420' : 'frame-ancestors \'none\''}`
const csp = `default-src 'none'; script-src ${origin}/__package/ ${origin}/__${kind}.js; style-src ${origin}/__package/ 'unsafe-inline'; img-src ${origin} data: blob:; font-src ${origin} data: blob:; media-src ${origin} blob:; connect-src ${origin}/__package/ ${origin}/__resource/; object-src 'none'; base-uri 'none'; form-action 'none'; frame-src 'none'; worker-src 'none'; ${kind === 'view' ? 'sandbox allow-scripts allow-forms; frame-ancestors lexora-app://renderer http://localhost:1420 http://127.0.0.1:1420' : 'frame-ancestors \'none\''}`
const headers = { 'content-security-policy': csp, 'access-control-allow-origin': '*', 'x-content-type-options': 'nosniff', 'cache-control': 'no-store' }
if (path.startsWith('/__resource/')) {
if (kind !== 'view' || !endpoint.package.manifest.permissions.localResources)
return new Response(null, { status: 403 })
return await this.#store.resources.response(endpoint.package.manifest.id, path.slice('/__resource/'.length), request, endpoint.abort.signal)
}
if (path === `/__${kind}.html`) {
return new Response(`<!doctype html><meta charset="UTF-8"><meta name="viewport" content="width=device-width"><style>html,body{margin:0;min-height:100%;font:14px system-ui;color-scheme:light dark}main{padding:16px;overflow-wrap:anywhere}button,input{font:inherit}pre{overflow:auto}</style><main></main><script type="module" src="/__${kind}.js"></script>`, { headers: { ...headers, 'content-type': 'text/html; charset=utf-8' } })
}
Expand All @@ -59,7 +70,9 @@ export class ExtensionProtocol {
const body = await this.#store.asset(endpoint.package, name)
if (!this.#endpoints.has(endpoint.token))
return new Response(null, { status: 410 })
return new Response(body as Uint8Array<ArrayBuffer>, { headers: { ...headers, 'content-type': mime[extname(name)] ?? 'application/octet-stream' } })
const type = codeMime[extname(name)] ?? extensionResourceMimeType(extname(name).slice(1))
const range = extensionResourceRange(body.byteLength, request.headers.get('range'))
return new Response(request.method === 'HEAD' || range.status === 416 ? null : body.subarray(range.start, range.end + 1) as Uint8Array<ArrayBuffer>, { status: range.status, headers: { ...headers, ...range.headers, 'content-type': type } })
}
catch { return new Response(null, { status: 403 }) }
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import type { Session } from 'electron'
import { rm } from 'node:fs/promises'
import { afterEach, expect, it } from 'vitest'
import { createStore, manifest, reviewPackage } from '../../../../platform/extensions/__tests__/fixtures'
import { ExtensionProtocol, extensionSchemePrivileges } from '../ExtensionProtocol'

const roots: string[] = []
afterEach(async () => {
for (const root of roots.splice(0)) await rm(root, { recursive: true, force: true })
})
it('serves verified package media with range semantics and invalidates disposed endpoints', async () => {
const { root, store } = await createStore()
roots.push(root)
await store.install((await reviewPackage(root, store, manifest(), { 'clip.mp3': '0123456789' })).token)
const protocol = new ExtensionProtocol(store)
const endpoint = protocol.register(store.installed['tests.reader']!.current, 'view')
let respond!: (request: Request) => Promise<Response>
protocol.install({ protocol: { handle: (_scheme: string, handler: typeof respond) => {
respond = handler
} } } as unknown as Session, 'view')
expect(extensionSchemePrivileges.privileges?.stream).toBe(true)
const url = new URL('/__package/clip.mp3', endpoint.url)
const part = await respond(new Request(url, { headers: { range: 'bytes=3-6' } }))
expect(part.status).toBe(206)
expect(part.headers.get('content-type')).toBe('audio/mpeg')
expect(part.headers.get('content-length')).toBe('4')
expect(await part.text()).toBe('3456')
expect((await respond(new Request(url, { headers: { range: 'bytes=99-' } }))).status).toBe(416)
const head = await respond(new Request(url, { method: 'HEAD' }))
expect(head.headers.get('content-length')).toBe('10')
expect(await head.text()).toBe('')
endpoint.dispose()
expect((await respond(new Request(url))).status).toBe(403)
})
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import type { ExtensionInspection } from '../../../shared/extensions/extensionAuthoring'
import type { RuntimeRpcPeerContract } from '../../../shared/runtime/rpcPeer'
import { buildExtensionPackage } from '../../../platform/extensions/buildExtensionPackage'
import { EXTENSION_BUILD_RPC } from '../../../shared/extensions/extensionAuthoring'
import { EXTENSION_BUILD_RPC, EXTENSION_INSPECT_RPC, extensionInspectionSchema, extensionInspectRequestSchema } from '../../../shared/extensions/extensionAuthoring'
import { compileExtension } from './compileExtension'

export function registerExtensionAuthoringRpc(peer: RuntimeRpcPeerContract): () => void {
export function registerExtensionAuthoringRpc(peer: RuntimeRpcPeerContract, inspect: (id: string) => Promise<ExtensionInspection>): () => void {
const lifetime = new AbortController()
let active = 0
const stopInspect = peer.onRequest(EXTENSION_INSPECT_RPC, async input => extensionInspectionSchema.parse(await inspect(extensionInspectRequestSchema.parse(input).id)))
const stop = peer.onRequest(EXTENSION_BUILD_RPC, async (input, signal) => {
if (active >= 2)
return { ok: false, code: 'EXTENSION_COMPILER_BUSY', diagnostics: [] }
Expand All @@ -18,5 +20,6 @@ export function registerExtensionAuthoringRpc(peer: RuntimeRpcPeerContract): ()
return () => {
lifetime.abort()
stop()
stopInspect()
}
}
49 changes: 47 additions & 2 deletions apps/buddy/electron/main/extensions/registerExtensionIpc.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { BrowserWindow, IpcMainEvent } from 'electron'
import type { ExtensionWorkbenchEvent } from '../../../shared/extensions/extensionApi'
import type { ExtensionInspection } from '../../../shared/extensions/extensionAuthoring'
import type { SpaceFileTarget } from '../../../shared/spaces/spaceFileApi'
import { join } from 'node:path'
import { dialog, ipcMain, Notification, powerMonitor, session } from 'electron'
Expand All @@ -20,13 +21,14 @@ export function registerExtensionIpc(options: {
get: (url: string, init: { signal: AbortSignal }) => Promise<Response>
developmentDirectory?: string
notificationsEnabled?: () => boolean
}): { dispose: () => Promise<void>, reviewPackage: (path: string) => Promise<void> } {
}): { dispose: () => Promise<void>, reviewPackage: (path: string) => Promise<void>, inspect: (id: string) => Promise<ExtensionInspection> } {
const store = new ExtensionPackageStore(join(options.home, 'extensions'), options.version)
const protocol = new ExtensionProtocol(store)
const stopProtocol = protocol.install(session.defaultSession, 'view')
const hosts = new Set<SandboxedExtensionHost>()
const replies = new Map<string, (value: string | null) => void>()
const bound = new Set<BrowserWindow>()
let resourcePickerOpen = false
const guardNavigation = (event: Electron.Event<Electron.WebContentsWillFrameNavigateEventParams>) => {
if (!event.isMainFrame && !protocol.validViewUrl(event.url))
event.preventDefault()
Expand All @@ -44,6 +46,36 @@ export function registerExtensionIpc(options: {
readText: options.readText,
get: options.get,
compile: compileExtension,
selectResources: async (name, selection, signal) => {
const owner = window()
if (!owner || owner.isDestroyed() || !owner.isVisible() || owner.isMinimized() || resourcePickerOpen)
throw new Error('EXTENSION_RESOURCE_PICKER_UNAVAILABLE')
signal.throwIfAborted()
resourcePickerOpen = true
try {
const result = await dialog.showOpenDialog(owner, {
title: name,
properties: selection.directory ? ['openDirectory'] : selection.multiple ? ['openFile', 'multiSelections'] : ['openFile'],
...(selection.directory || !selection.filters.length ? {} : { filters: selection.filters }),
})
signal.throwIfAborted()
return result.canceled ? [] : result.filePaths
}
finally { resourcePickerOpen = false }
},
selectSavePath: async (name, suggestedName, signal) => {
const owner = window()
if (!owner || owner.isDestroyed() || !owner.isVisible() || owner.isMinimized() || resourcePickerOpen)
throw new Error('EXTENSION_RESOURCE_PICKER_UNAVAILABLE')
signal.throwIfAborted()
resourcePickerOpen = true
try {
const result = await dialog.showSaveDialog(owner, { title: name, defaultPath: suggestedName, properties: ['showOverwriteConfirmation', 'createDirectory'] })
signal.throwIfAborted()
return result.canceled ? null : result.filePath ?? null
}
finally { resourcePickerOpen = false }
},
notify: (id, notification) => {
if (!Notification.isSupported() || options.notificationsEnabled?.() === false)
return false
Expand All @@ -65,9 +97,17 @@ export function registerExtensionIpc(options: {
resolve(null)
return
}
const cancel = () => finish(null)
let settled = false
const cancel = () => {
if (!current.isDestroyed())
current.webContents.send(EXTENSION_IPC.workbench, { kind: 'cancel', requestId: event.requestId } satisfies ExtensionWorkbenchEvent)
finish(null)
}
const timer = setTimeout(cancel, 10000)
function finish(value: string | null) {
if (settled)
return
settled = true
clearTimeout(timer)
signal.removeEventListener('abort', cancel)
replies.delete(event.requestId)
Expand Down Expand Up @@ -139,6 +179,7 @@ export function registerExtensionIpc(options: {
case 'restart': return await service.restart(input.id)
case 'uninstall': return await service.uninstall(input.id)
case 'devtools': return await service.devtools(input.id)
case 'revokeResources': return await service.revokeResources(input.id)
case 'execute': return await service.execute(input.id, input.command, input.resource)
case 'openView': return await service.openView(input.view)
case 'closeView': return service.closeView(input.viewId, input.generation, input.token)
Expand Down Expand Up @@ -191,6 +232,10 @@ export function registerExtensionIpc(options: {
}
return {
dispose,
inspect: async (id) => {
await prepared
return service.inspect(id)
},
async reviewPackage(path: string) {
await prepared
const review = await service.review(path, false)
Expand Down
1 change: 1 addition & 0 deletions apps/buddy/electron/main/extensions/runtime/host.js
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ bridge.subscribe(async ({ id, method, params }) => {
return disposable(() => commands.delete(id))
} },
views: { open: (type, options = {}) => request('views.open', { type, resource: options.resource ?? null, state: options.state ?? {} }) },
placements: { show: id => request('placements.show', { id }), hide: id => request('placements.hide', { id }) },
resources: { readText: resource => request('resources.readText', { id: resource.id }) },
storage: { get: () => request('storage.get'), set: value => request('storage.set', { value, version: manifest.dataVersion }) },
network: { get: url => request('network.get', { url }) },
Expand Down
Loading