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
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import type { RuntimeRequestHandler } from '../../../../shared/runtime/rpcPeer'
import { expect, it, vi } from 'vitest'
import { EXTENSION_CAPABILITIES_RPC, extensionCapabilitiesSchema } from '../../../../shared/extensions/extensionAuthoring'
import { EXTENSION_API_VERSION, extensionPlacementSchema } from '../../../../shared/extensions/extensionManifest'
import { registerExtensionAuthoringRpc } from '../registerExtensionAuthoringRpc'

vi.mock('../compileExtension', () => ({ compileExtension: () => {
throw new Error('Unexpected compilation')
} }))

it('discovers a compact host index and exact target contracts without loading plugins', async () => {
const handlers = new Map<string, RuntimeRequestHandler>()
const dispose = registerExtensionAuthoringRpc({ onRequest: (method, handler) => {
handlers.set(method, handler)
return () => handlers.delete(method)
} }, async () => { throw new Error('Unexpected inspection') })
try {
const query = (params: unknown) => Promise.resolve(handlers.get(EXTENSION_CAPABILITIES_RPC)!(params))
const index = extensionCapabilitiesSchema.parse(await query({}))
expect(index.apiVersion).toBe(EXTENSION_API_VERSION)
expect(index.targets.some(target => target.target === 'composer.accessory' && target.scope === 'composer')).toBe(true)
expect(index.targets.every(target => !target.description && !target.height)).toBe(true)
const detail = extensionCapabilitiesSchema.parse(await query({ target: 'composer.accessory' }))
expect(detail.targets).toEqual([expect.objectContaining({ kind: 'slot', selection: 'multiple', height: { min: 32, max: 240, default: 64 } })])
const placement = { id: 'tests.content.slot', view: 'tests.content.view', kind: 'slot', target: detail.targets[0]!.target }
expect(extensionPlacementSchema.parse(placement)).toMatchObject({ height: detail.targets[0]!.height!.default })
expect(extensionPlacementSchema.safeParse({ ...placement, height: detail.targets[0]!.height!.max + 1 }).success).toBe(false)
expect(extensionCapabilitiesSchema.parse(await query({ target: 'document.body' })).targets).toEqual([])
expect(extensionCapabilitiesSchema.parse(await query({ kind: 'runtime' })).targets.map(target => target.target)).toEqual(['commands', 'workbench.panes', 'workbench.interactions'])
await expect(query({ kind: 'arbitrary' })).rejects.toThrow()
}
finally { dispose() }
})
Original file line number Diff line number Diff line change
@@ -1,13 +1,20 @@
import type { ExtensionInspection } from '../../../shared/extensions/extensionAuthoring'
import type { RuntimeRpcPeerContract } from '../../../shared/runtime/rpcPeer'
import { buildExtensionPackage } from '../../../platform/extensions/buildExtensionPackage'
import { EXTENSION_BUILD_RPC, EXTENSION_INSPECT_RPC, extensionInspectionSchema, extensionInspectRequestSchema } from '../../../shared/extensions/extensionAuthoring'
import { EXTENSION_BUILD_RPC, EXTENSION_CAPABILITIES_RPC, EXTENSION_INSPECT_RPC, extensionCapabilitiesRequestSchema, extensionInspectionSchema, extensionInspectRequestSchema } from '../../../shared/extensions/extensionAuthoring'
import { EXTENSION_API_VERSION } from '../../../shared/extensions/extensionManifest'
import { queryWorkbenchCapabilities } from '../../../shared/workbench/workbenchUi'
import { compileExtension } from './compileExtension'

export function registerExtensionAuthoringRpc(peer: RuntimeRpcPeerContract, inspect: (id: string) => Promise<ExtensionInspection>): () => void {
export function registerExtensionAuthoringRpc(peer: Pick<RuntimeRpcPeerContract, 'onRequest'>, 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 stopCapabilities = peer.onRequest(EXTENSION_CAPABILITIES_RPC, async (input) => {
const query = extensionCapabilitiesRequestSchema.parse(input)
const targets = queryWorkbenchCapabilities(query)
return { apiVersion: EXTENSION_API_VERSION, targets: query.kind || query.target ? targets : targets.map(({ kind, target, title, scope }) => ({ kind, target, title, scope })) }
})
const stop = peer.onRequest(EXTENSION_BUILD_RPC, async (input, signal) => {
if (active >= 2)
return { ok: false, code: 'EXTENSION_COMPILER_BUSY', diagnostics: [] }
Expand All @@ -21,5 +28,6 @@ export function registerExtensionAuthoringRpc(peer: RuntimeRpcPeerContract, insp
lifetime.abort()
stop()
stopInspect()
stopCapabilities()
}
}
4 changes: 4 additions & 0 deletions apps/buddy/electron/main/extensions/registerExtensionIpc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,10 @@ export function registerExtensionIpc(options: {
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 'executeMenu': return await service.executeMenu(input.id, input.menu, input.invocation)
case 'executeSlash': return await service.executeSlash(input.id, input.command, input.arguments, input.instanceId)
case 'updatePanes': return service.updatePanes(input.panes)
case 'endInteraction': return service.endInteraction(input.id)
case 'openView': return await service.openView(input.view)
case 'closeView': return service.closeView(input.viewId, input.generation, input.token)
case 'viewRequest': return await service.viewRequest(input.viewId, input.generation, input.token, input.method, input.params)
Expand Down
56 changes: 51 additions & 5 deletions apps/buddy/electron/main/extensions/runtime/host.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,15 @@ const subscriptions = new Set()
let entry
let manifest
let context
let registrationError = null
let activated = false
let panes = Object.freeze([])
const paneListeners = new Set()
const interactions = new Map()
function applyPanes(next) {
panes = Object.freeze(next.map(pane => Object.freeze({ ...pane, rect: Object.freeze(pane.rect) })))
for (const listener of paneListeners) listener(panes)
}
const code = error => /^EXTENSION_[A-Z_]+$/.test(error?.message) ? error.message : 'EXTENSION_ACTIVATION_FAILED'
const request = (method, params = null) => bridge.request(method, params)
function disposable(cleanup) {
Expand All @@ -24,6 +32,7 @@ bridge.subscribe(async ({ id, method, params }) => {
let result = null
if (method === 'activate') {
manifest = params.manifest
applyPanes(params.panes ?? [])
entry = manifest.entry ? await import(`/__package/${manifest.entry}`) : {}
context = Object.freeze({
extension: Object.freeze({ id: manifest.id, version: manifest.version, apiVersion: manifest.apiVersion }),
Expand All @@ -32,13 +41,38 @@ bridge.subscribe(async ({ id, method, params }) => {
return value
} },
commands: { register(id, callback) {
if (!manifest.contributes.commands.some(command => command.id === id) || commands.has(id) || typeof callback !== 'function')
throw new Error('EXTENSION_COMMAND_INVALID')
if (activated || !manifest.contributes.commands.some(command => command.id === id) || commands.has(id) || typeof callback !== 'function') {
registrationError = new Error('EXTENSION_COMMAND_INVALID')
throw registrationError
}
commands.set(id, callback)
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 }) },
workbench: {
get panes() { return panes },
onPanesChange: (listener) => {
paneListeners.add(listener)
return disposable(() => paneListeners.delete(listener))
},
},
interactions: { start: async (title) => {
const id = crypto.randomUUID()
const controller = new AbortController()
interactions.set(id, controller)
try {
await request('interactions.start', { id, title })
if (controller.signal.aborted)
throw new Error('EXTENSION_INTERACTION_ENDED')
return Object.freeze({ id, signal: controller.signal, end: () => request('interactions.end', { id }) })
}
catch (error) {
controller.abort()
interactions.delete(id)
throw error
}
} },
views: { broadcast: message => request('views.broadcast', message), open: (type, options = {}) => request('views.open', { type, resource: options.resource ?? null, state: options.state ?? {} }) },
placements: { show: (id, options) => request('placements.show', { id, ...(typeof options === 'string' ? { instanceId: options } : options ?? {}) }), hide: (id, options) => request('placements.hide', { id, ...(typeof options === 'string' ? { instanceId: options } : options ?? {}) }) },
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 All @@ -61,16 +95,28 @@ bridge.subscribe(async ({ id, method, params }) => {
throw new Error('EXTENSION_ENTRY_INVALID')
await entry.activate(context)
}
if (registrationError)
throw registrationError
if (manifest.contributes.commands.some(command => !commands.has(command.id)))
throw new Error('EXTENSION_COMMAND_MISSING')
activated = true
}
else if (method === 'command') {
if (!activated || !commands.has(params.command))
throw new Error('EXTENSION_COMMAND_UNAVAILABLE')
result = await commands.get(params.command)(Object.freeze({ resource: params.resource, arguments: params.arguments ?? null })) ?? null
result = await commands.get(params.command)(Object.freeze({ resource: params.resource, arguments: params.arguments ?? null, invocation: params.invocation ?? null })) ?? null
}
else if (method === 'panes') {
applyPanes(params.panes)
}
else if (method === 'interactionEnded') {
interactions.get(params.id)?.abort()
interactions.delete(params.id)
}
else if (method === 'deactivate') {
for (const controller of interactions.values()) controller.abort()
interactions.clear()
paneListeners.clear()
activated = false
try {
await entry?.deactivate?.()
Expand Down
21 changes: 20 additions & 1 deletion apps/buddy/electron/main/extensions/runtime/view.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ let mount = null
let anchor = null
let control = null
let overlay = false
let interactionId = null
const activationListeners = new Set()
const messageListeners = new Set()
const environmentListeners = new Set()
const mountListeners = new Set()
const anchorListeners = new Set()
Expand Down Expand Up @@ -47,6 +50,14 @@ addEventListener('message', (event) => {
parent.postMessage({ channel: 'lexora-extension', token, pong: data.ping }, '*')
return
}
if (data.activation) {
for (const listener of activationListeners) listener(Object.freeze(data.activation))
return
}
if (Object.hasOwn(data, 'message')) {
for (const listener of messageListeners) listener(data.message)
return
}
if (data.workbench) {
applyWorkbench(data.workbench)
return
Expand Down Expand Up @@ -91,6 +102,10 @@ addEventListener('message', (event) => {
else item.reject(new Error(data.value))
})
addEventListener('keydown', (event) => {
if (interactionId && event.isTrusted && event.key === 'Escape') {
event.preventDefault()
parent.postMessage({ channel: 'lexora-extension', token, endInteraction: true }, '*')
}
if (control && event.isTrusted && event.key === 'Escape')
parent.postMessage({ channel: 'lexora-extension', token, dismiss: true }, '*')
})
Expand All @@ -104,11 +119,12 @@ addEventListener('pagehide', () => {
async function initialize() {
try {
const initial = await request('bootstrap')
interactionId = initial.interactionId
overlay = initial.presentation === 'decoration'
anchor = initial.anchor ?? anchor
mount = initial.mount ?? mount
control = initial.control
if (overlay || initial.presentation === 'control' || initial.location === 'mount') {
if (overlay || initial.presentation === 'control' || initial.presentation === 'slot' || initial.location === 'mount') {
const style = document.createElement('style')
style.textContent = 'html,body{height:100%;background:transparent}body>main{height:100%;box-sizing:border-box;padding:0}'
document.head.append(style)
Expand All @@ -131,6 +147,9 @@ async function initialize() {
}
const api = Object.freeze({
apiVersion: initial.apiVersion,
instanceId: initial.instanceId,
interaction: interactionId ? Object.freeze({ id: interactionId, setRegions: regions => request('interaction.setRegions', regions), onActivate: listener => subscribe(activationListeners, listener) }) : null,
onMessage: listener => subscribe(messageListeners, listener),
get workbench() { return workbench },
onWorkbenchChange: listener => subscribe(workbenchListeners, listener),
get visible() { return visible },
Expand Down
4 changes: 4 additions & 0 deletions apps/buddy/electron/preload/extensions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@ export function createExtensionApi(): ExtensionApi {
devtools: id => request({ action: 'devtools', id }),
revokeResources: id => request({ action: 'revokeResources', id }),
execute: (id, command, resource) => request({ action: 'execute', id, command, resource }),
executeMenu: (id, menu, invocation) => request({ action: 'executeMenu', id, menu, invocation }),
executeSlash: (id, command, argumentsText, instanceId) => request({ action: 'executeSlash', id, command, arguments: argumentsText, instanceId }),
updatePanes: panes => request({ action: 'updatePanes', panes }),
endInteraction: id => request({ action: 'endInteraction', id }),
openView: view => request({ action: 'openView', view }),
closeView: (viewId, generation, token) => request({ action: 'closeView', viewId, generation, token }),
viewRequest: (viewId, generation, token, method, params) => request({ action: 'viewRequest', viewId, generation, token, method, params }),
Expand Down
2 changes: 1 addition & 1 deletion apps/buddy/extensions/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,4 +32,4 @@ API 1 不开放 Node、shell 或任意 Desktop API。文件只读,仅能访问

需要独立开发窗口时,先构建 Electron,再运行 `node extensions/tools.mjs dev /path/to/plugin`。开发窗口使用单独的临时配置;修改后重新加载构建目录并重启扩展。SDK 快照可以通过 `node extensions/tools.mjs export-sdk /path/to/plugin-sdk/sdk` 导出。

API 2 通过 `contributes.placements` 将视图挂到输入框或侧边栏装饰、工作台顶部/底部面板,以及用户选择的思考等级控件。内容仍在隔离视图运行;宿主提供锚点几何、允许的控件值与生命周期。清单和调用示例见 [插件协议](../service/resources/skills/plugin-creator/references/protocol.md)。API 1 插件继续可用。
API 2 通过 `contributes.placements` 提供装饰、工作台面板与思考等级控件。API 3 增加可选择的内容区、操作菜单、独立分屏挂载、带插件命名空间的输入命令与可取消交互。创作时用 `lexora_plugin_capabilities` 按需查询当前宿主支持的位置。内容仍在隔离视图运行;清单和调用示例见 [插件协议](../service/resources/skills/plugin-creator/references/protocol.md)。旧版插件继续可用。
1 change: 1 addition & 0 deletions apps/buddy/extensions/sdk/authoring.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export { compileExtensionSource } from '../../platform/extensions/compileExtensionSource'
export { extensionIconUrl } from '../../platform/extensions/extensionIcon'
export { extensionManifestSchema, extensionPathSchema } from '../../shared/extensions/extensionManifest'
export { queryWorkbenchCapabilities, workbenchCapabilityQuerySchema } from '../../shared/workbench/workbenchUi'
Loading