diff --git a/apps/buddy/electron/main/app/DesktopIntegrations.ts b/apps/buddy/electron/main/app/DesktopIntegrations.ts index d4440e77..b21da088 100644 --- a/apps/buddy/electron/main/app/DesktopIntegrations.ts +++ b/apps/buddy/electron/main/app/DesktopIntegrations.ts @@ -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)) diff --git a/apps/buddy/electron/main/app/DesktopRuntimeHost.ts b/apps/buddy/electron/main/app/DesktopRuntimeHost.ts index 34120054..bef47918 100644 --- a/apps/buddy/electron/main/app/DesktopRuntimeHost.ts +++ b/apps/buddy/electron/main/app/DesktopRuntimeHost.ts @@ -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' @@ -37,6 +38,7 @@ import { createCredentialVault } from '../secrets/CredentialVault' import { registerCredentialHostRpc } from '../secrets/registerCredentialHostRpc' export class DesktopRuntimeHost { + inspectExtension: ((id: string) => Promise) | null = null readonly contextPanel: ContextPanelHost readonly configStore: LexoraConfigStore readonly #environment: DesktopEnvironment @@ -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') diff --git a/apps/buddy/electron/main/extensions/ExtensionProtocol.ts b/apps/buddy/electron/main/extensions/ExtensionProtocol.ts index 3412091c..52012bea 100644 --- a/apps/buddy/electron/main/extensions/ExtensionProtocol.ts +++ b/apps/buddy/electron/main/extensions/ExtensionProtocol.ts @@ -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 = { '.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 = { '.js': 'text/javascript', '.mjs': 'text/javascript', '.css': 'text/css' } export class ExtensionProtocol { readonly #store: ExtensionPackageStore @@ -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 { @@ -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(`
`, { headers: { ...headers, 'content-type': 'text/html; charset=utf-8' } }) } @@ -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, { 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, { status: range.status, headers: { ...headers, ...range.headers, 'content-type': type } }) } catch { return new Response(null, { status: 403 }) } } diff --git a/apps/buddy/electron/main/extensions/__tests__/ExtensionProtocol.spec.ts b/apps/buddy/electron/main/extensions/__tests__/ExtensionProtocol.spec.ts new file mode 100644 index 00000000..8440cbc2 --- /dev/null +++ b/apps/buddy/electron/main/extensions/__tests__/ExtensionProtocol.spec.ts @@ -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 + 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) +}) diff --git a/apps/buddy/electron/main/extensions/registerExtensionAuthoringRpc.ts b/apps/buddy/electron/main/extensions/registerExtensionAuthoringRpc.ts index 93b6fba5..78448c47 100644 --- a/apps/buddy/electron/main/extensions/registerExtensionAuthoringRpc.ts +++ b/apps/buddy/electron/main/extensions/registerExtensionAuthoringRpc.ts @@ -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): () => 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: [] } @@ -18,5 +20,6 @@ export function registerExtensionAuthoringRpc(peer: RuntimeRpcPeerContract): () return () => { lifetime.abort() stop() + stopInspect() } } diff --git a/apps/buddy/electron/main/extensions/registerExtensionIpc.ts b/apps/buddy/electron/main/extensions/registerExtensionIpc.ts index 1be7f36d..ac6d8677 100644 --- a/apps/buddy/electron/main/extensions/registerExtensionIpc.ts +++ b/apps/buddy/electron/main/extensions/registerExtensionIpc.ts @@ -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' @@ -20,13 +21,14 @@ export function registerExtensionIpc(options: { get: (url: string, init: { signal: AbortSignal }) => Promise developmentDirectory?: string notificationsEnabled?: () => boolean -}): { dispose: () => Promise, reviewPackage: (path: string) => Promise } { +}): { dispose: () => Promise, reviewPackage: (path: string) => Promise, inspect: (id: string) => Promise } { 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() const replies = new Map void>() const bound = new Set() + let resourcePickerOpen = false const guardNavigation = (event: Electron.Event) => { if (!event.isMainFrame && !protocol.validViewUrl(event.url)) event.preventDefault() @@ -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 @@ -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) @@ -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) @@ -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) diff --git a/apps/buddy/electron/main/extensions/runtime/host.js b/apps/buddy/electron/main/extensions/runtime/host.js index 5bb1229c..03a9aeff 100644 --- a/apps/buddy/electron/main/extensions/runtime/host.js +++ b/apps/buddy/electron/main/extensions/runtime/host.js @@ -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 }) }, diff --git a/apps/buddy/electron/main/extensions/runtime/view.js b/apps/buddy/electron/main/extensions/runtime/view.js index 96972be6..027075ef 100644 --- a/apps/buddy/electron/main/extensions/runtime/view.js +++ b/apps/buddy/electron/main/extensions/runtime/view.js @@ -1,9 +1,23 @@ const token = location.hostname const pending = new Map() let environment = { language: 'zh-CN', colorScheme: 'light', colors: {} } +let workbench = Object.freeze({ values: Object.freeze({}), pages: Object.freeze([]) }) +let visible = false +const workbenchListeners = new Set() +const visibilityListeners = new Set() +function applyWorkbench(next) { + workbench = Object.freeze({ values: Object.freeze(next.values), pages: Object.freeze(next.pages.map(page => Object.freeze(page))) }) + for (const listener of workbenchListeners) listener(workbench) +} +let mount = null +let anchor = null +let control = null +let overlay = false const environmentListeners = new Set() +const mountListeners = new Set() +const anchorListeners = new Set() +const controlListeners = new Set() const activityListeners = new Set() -let overlay = false function applyEnvironment(next) { environment = Object.freeze(next) document.documentElement.lang = next.language @@ -20,7 +34,7 @@ function request(method, params = null) { const timer = setTimeout(() => { pending.delete(id) reject(new Error('EXTENSION_REQUEST_TIMEOUT')) - }, 15000) + }, ['resources.pickFiles', 'resources.pickDirectory', 'resources.beginSave'].includes(method) ? 125000 : 15000) pending.set(id, { resolve, reject, timer }) parent.postMessage({ channel: 'lexora-extension', token, id, method, params }, '*') }) @@ -28,52 +42,116 @@ function request(method, params = null) { addEventListener('message', (event) => { if (event.source !== parent || event.data?.channel !== 'lexora-extension' || event.data.token !== token) return - if (event.data.environment) { - applyEnvironment(event.data.environment) + const data = event.data + if (typeof data.ping === 'string') { + parent.postMessage({ channel: 'lexora-extension', token, pong: data.ping }, '*') + return + } + if (data.workbench) { + applyWorkbench(data.workbench) + return + } + if (typeof data.visible === 'boolean') { + if (visible !== data.visible) { + visible = data.visible + for (const listener of visibilityListeners) listener(visible) + } + return + } + if (data.environment) { + applyEnvironment(data.environment) + return + } + if (data.mount) { + mount = Object.freeze(data.mount) + for (const listener of mountListeners) listener(mount) + return + } + if (data.anchor) { + anchor = Object.freeze(data.anchor) + for (const listener of anchorListeners) listener(anchor) return } - if (overlay && event.data.activity?.type === 'composer-input') { - for (const listener of activityListeners) listener(Object.freeze({ type: 'composer-input' })) + if (data.control) { + control = Object.freeze(data.control) + for (const listener of controlListeners) listener(control) return } - const request = pending.get(event.data.id) - if (!request) + if (overlay && data.activity?.type === 'composer-input') { + for (const listener of activityListeners) listener(Object.freeze(data.activity)) return - pending.delete(event.data.id) - clearTimeout(request.timer) - if (event.data.ok) - request.resolve(event.data.value) - else request.reject(new Error(event.data.value)) + } + const item = pending.get(data.id) + if (!item) + return + pending.delete(data.id) + clearTimeout(item.timer) + if (data.ok) + item.resolve(data.value) + else item.reject(new Error(data.value)) +}) +addEventListener('keydown', (event) => { + if (control && event.isTrusted && event.key === 'Escape') + parent.postMessage({ channel: 'lexora-extension', token, dismiss: true }, '*') }) addEventListener('pagehide', () => { - for (const request of pending.values()) { - clearTimeout(request.timer) - request.reject(new Error('EXTENSION_VIEW_CLOSED')) + for (const item of pending.values()) { + clearTimeout(item.timer) + item.reject(new Error('EXTENSION_VIEW_CLOSED')) } pending.clear() }) async function initialize() { try { const initial = await request('bootstrap') - overlay = initial.location === 'window-overlay' + overlay = initial.presentation === 'decoration' + anchor = initial.anchor ?? anchor + mount = initial.mount ?? mount + control = initial.control + if (overlay || initial.presentation === 'control' || 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) + } if (overlay) { - document.documentElement.style.background = 'transparent' - document.body.style.background = 'transparent' - document.querySelector('main').style.padding = '0' + document.documentElement.style.overflow = 'hidden' + document.body.style.overflow = 'hidden' } + applyWorkbench(initial.workbench) + visible = initial.visible applyEnvironment(initial.environment) const entry = await import(`/__package/${initial.entry}`) const controller = new AbortController() addEventListener('pagehide', () => controller.abort(), { once: true }) + const subscribe = (listeners, listener) => { + listeners.add(listener) + const dispose = () => listeners.delete(listener) + controller.signal.addEventListener('abort', dispose, { once: true }) + return { dispose } + } const api = Object.freeze({ - apiVersion: 1, + apiVersion: initial.apiVersion, + get workbench() { return workbench }, + onWorkbenchChange: listener => subscribe(workbenchListeners, listener), + get visible() { return visible }, + onVisibilityChange: listener => subscribe(visibilityListeners, listener), get environment() { return environment }, - onEnvironmentChange: (listener) => { - environmentListeners.add(listener) - const dispose = () => environmentListeners.delete(listener) - controller.signal.addEventListener('abort', dispose, { once: true }) - return { dispose } + onEnvironmentChange: listener => subscribe(environmentListeners, listener), + get mount() { return mount }, + onMountChange: listener => subscribe(mountListeners, listener), + get anchor() { return anchor }, + onAnchorChange: (listener) => { + if (!overlay) + throw new Error('EXTENSION_METHOD_DENIED') + return subscribe(anchorListeners, listener) }, + control: initial.presentation === 'control' + ? Object.freeze({ + get snapshot() { return control }, + onChange: listener => subscribe(controlListeners, listener), + propose: (value, revision = control?.revision) => request('control.propose', { value, revision }), + }) + : null, resource: initial.resource, state: initial.state, stateVersion: initial.stateVersion, @@ -82,19 +160,51 @@ async function initialize() { onActivity: (listener) => { if (!overlay) throw new Error('EXTENSION_METHOD_DENIED') - activityListeners.add(listener) - const dispose = () => activityListeners.delete(listener) - controller.signal.addEventListener('abort', dispose, { once: true }) - return { dispose } + return subscribe(activityListeners, listener) }, setState: state => request('view.setState', state), - resources: { readText: resource => request('resources.readText', { id: resource.id }) }, + setPresentation: presentation => request('view.setPresentation', presentation), + resources: { + readText: resource => request('resources.readText', { id: resource.id }), + readBytes: async (resource, options = {}) => { + const result = await request('resources.readBytes', { id: resource.id, offset: options.offset ?? 0, length: options.length ?? 65536 }) + return { data: Uint8Array.from(atob(result.base64), byte => byte.charCodeAt(0)), size: result.size, eof: result.eof } + }, + pickFiles: (options = {}) => request('resources.pickFiles', options), + listFiles: () => request('resources.listFiles'), + getUrl: resource => request('resources.getUrl', { id: resource.id }), + revokeFile: resource => request('resources.revokeFile', { id: resource.id }), + pickDirectory: () => request('resources.pickDirectory'), + listDirectories: () => request('resources.listDirectories'), + scanDirectory: (directory, options = {}) => request('resources.scanDirectory', { id: directory.id, ...options }), + revokeDirectory: directory => request('resources.revokeDirectory', { id: directory.id }), + saveFile: async ({ name, data }) => { + const blob = data instanceof Blob ? data : new Blob([data]) + const id = await request('resources.beginSave', { name, size: blob.size }) + if (!id) + return false + try { + for (let offset = 0; offset < blob.size; offset += 96 * 1024) { + const bytes = new Uint8Array(await blob.slice(offset, offset + 96 * 1024).arrayBuffer()) + const base64 = btoa(Array.from(bytes, byte => String.fromCharCode(byte)).join('')) + await request('resources.writeChunk', { id, offset, base64 }) + } + await request('resources.commitSave', { id }) + return true + } + catch (error) { + await request('resources.cancelSave', { id }).catch(() => {}) + throw error + } + }, + }, network: { get: url => request('network.get', { url }) }, commands: { execute: (command, args = null) => request('commands.execute', { command, arguments: args }) }, }) if (typeof entry.render !== 'function') throw new Error('EXTENSION_VIEW_ENTRY_INVALID') await entry.render(api, document.querySelector('main')) + await request('view.ready') } catch (error) { document.querySelector('main').textContent = overlay ? '' : 'This extension view could not be loaded.' diff --git a/apps/buddy/electron/preload/extensions.ts b/apps/buddy/electron/preload/extensions.ts index 2d59d62d..42808127 100644 --- a/apps/buddy/electron/preload/extensions.ts +++ b/apps/buddy/electron/preload/extensions.ts @@ -18,6 +18,7 @@ export function createExtensionApi(): ExtensionApi { restart: id => request({ action: 'restart', id }), uninstall: id => request({ action: 'uninstall', id }), devtools: id => request({ action: 'devtools', id }), + revokeResources: id => request({ action: 'revokeResources', id }), execute: (id, command, resource) => request({ action: 'execute', id, command, resource }), openView: view => request({ action: 'openView', view }), closeView: (viewId, generation, token) => request({ action: 'closeView', viewId, generation, token }), diff --git a/apps/buddy/extensions/README.md b/apps/buddy/extensions/README.md index 1628a0ee..3003ebc7 100644 --- a/apps/buddy/extensions/README.md +++ b/apps/buddy/extensions/README.md @@ -31,3 +31,5 @@ API 1 不开放 Node、shell 或任意 Desktop API。文件只读,仅能访问 更新安装后,当前版本继续运行,点击“重启扩展”应用更新。禁用停止插件,重新启用保留配置;卸载保留私有 JSON 与上下文视图位置,但移除代码和定时任务。 需要独立开发窗口时,先构建 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 插件继续可用。 diff --git a/apps/buddy/package.json b/apps/buddy/package.json index e631eb92..8c7f8dc7 100644 --- a/apps/buddy/package.json +++ b/apps/buddy/package.json @@ -89,7 +89,7 @@ "dayjs": "^1.11.21", "dompurify": "^3.4.15", "echarts": "^6.1.0", - "electron": "44.4.3", + "electron": "44.4.5", "electron-builder": "^26.16.1", "electron-vite": "^5.0.0", "marked": "^18.0.13", diff --git a/apps/buddy/platform/extensions/ExtensionInstallations.ts b/apps/buddy/platform/extensions/ExtensionInstallations.ts index 3d9c3dae..345ae321 100644 --- a/apps/buddy/platform/extensions/ExtensionInstallations.ts +++ b/apps/buddy/platform/extensions/ExtensionInstallations.ts @@ -49,6 +49,15 @@ export class ExtensionInstallations { return controller.signal } + identify(id: string, extensionId: string, version: string): void { + const record = this.#records.find(item => item.id === id) + if (record) { + record.extensionId = extensionId + record.version = version + this.#save() + } + } + log(id: string, stage: ExtensionInstallationStage, message: string): void { const record = this.#records.find(item => item.id === id) if (!record || (stage !== 'completed' && ['completed', 'failed', 'cancelled'].includes(record.status))) diff --git a/apps/buddy/platform/extensions/ExtensionPackageStore.ts b/apps/buddy/platform/extensions/ExtensionPackageStore.ts index 18abb4dc..96ededa2 100644 --- a/apps/buddy/platform/extensions/ExtensionPackageStore.ts +++ b/apps/buddy/platform/extensions/ExtensionPackageStore.ts @@ -13,6 +13,7 @@ import { addedExtensionPermissions, extensionCompatible, extensionIdSchema, exte import { spaceFileTargetSchema } from '../../shared/spaces/spaceFileApi' import { EXTENSION_PACKAGE_LIMIT, readExtensionDirectory, readExtensionFile, readExtensionJson, sha256, unpackExtension, validateExtensionFiles, verifiedExtensionAsset, writeExtensionJson } from './extensionFiles' import { extensionIconUrl } from './extensionIcon' +import { ExtensionResourceStore } from './ExtensionResourceStore' const revisionSchema = z.string().regex(/^[a-f0-9]{64}$/) const sourceSchema = z.object({ catalog: z.string().url(), artifact: z.string().url(), sha256: revisionSchema }).strict() @@ -27,6 +28,7 @@ export interface ExtensionCandidate { token: string, package: ExtensionPackage, export class ExtensionPackageStore { readonly root: string readonly appVersion: string + readonly resources: ExtensionResourceStore readonly #candidates = new Map() readonly #icons = new Map }>() #index: z.infer = { version: 1, installed: {} } @@ -37,6 +39,7 @@ export class ExtensionPackageStore { constructor(root: string, appVersion: string) { this.root = root this.appVersion = appVersion + this.resources = new ExtensionResourceStore(root) } async load(): Promise { diff --git a/apps/buddy/platform/extensions/ExtensionResourceStore.ts b/apps/buddy/platform/extensions/ExtensionResourceStore.ts new file mode 100644 index 00000000..e0e99315 --- /dev/null +++ b/apps/buddy/platform/extensions/ExtensionResourceStore.ts @@ -0,0 +1,329 @@ +import type { BigIntStats } from 'node:fs' +import type { FileHandle } from 'node:fs/promises' +import type { ExtensionDirectoryScan, ExtensionLocalDirectory, ExtensionLocalFile } from '../../shared/extensions/extensionResources' +import { Buffer } from 'node:buffer' +import { randomUUID } from 'node:crypto' +import { constants } from 'node:fs' +import { lstat, open, opendir, realpath } from 'node:fs/promises' +import { basename, extname, join, relative } from 'node:path' +import { z } from 'zod' +import { extensionIdSchema } from '../../shared/extensions/extensionManifest' +import { extensionLocalDirectorySchema, extensionLocalFileSchema, extensionResourceMimeType } from '../../shared/extensions/extensionResources' +import { containsCanonicalPath } from '../filesystem/filePaths' +import { readExtensionJson, writeExtensionJson } from './extensionFiles' +import { extensionResourceRange } from './extensionResourceResponse' + +const grantSchema = z.object({ + resource: extensionLocalFileSchema, + path: z.string().min(1).max(32768), + identity: z.object({ device: z.string(), inode: z.string(), size: z.string(), modified: z.string() }).strict(), + directoryId: z.string().uuid().optional(), +}).strict() +type Grant = z.infer +const grantsSchema = z.array(grantSchema).max(1000) +const directorySchema = z.object({ resource: extensionLocalDirectorySchema, path: z.string().min(1).max(32768), device: z.string(), inode: z.string() }).strict() +type DirectoryGrant = z.infer +const directoriesSchema = z.array(directorySchema).max(32) +function identity(stat: BigIntStats): Grant['identity'] { + return { device: String(stat.dev), inode: String(stat.ino), size: String(stat.size), modified: String(stat.mtimeNs) } +} + +export class ExtensionResourceStore { + readonly #root: string + readonly #writes = new Map>() + readonly #streams = new Map>() + constructor(root: string) { this.#root = root } + + async list(id: string): Promise { + const directories = await this.#directories(id) + return (await this.#read(id)).filter(grant => !grant.directoryId || directories.some(directory => directory.resource.id === grant.directoryId)).map(grant => grant.resource) + } + + grant(id: string, paths: string[], assertCurrent: () => void): Promise { + return this.#mutate(id, async () => { + if (paths.length > 100) + throw new Error('EXTENSION_RESOURCE_SELECTION_LIMIT') + const grants = await this.#read(id) + const selected = await this.#select(paths, grants, assertCurrent) + await writeExtensionJson(this.#path(id), grantsSchema.parse(grants), assertCurrent) + return selected + }) + } + + async #select(paths: string[], grants: Grant[], assertCurrent: () => void, directory?: DirectoryGrant): Promise { + const selected: ExtensionLocalFile[] = [] + for (const path of new Set(paths)) { + assertCurrent() + const canonical = await realpath(path) + if (directory && !containsCanonicalPath(directory.path, canonical)) + throw new Error('EXTENSION_RESOURCE_DIRECTORY_CHANGED') + const mimeType = extensionResourceMimeType(extname(canonical).slice(1)) + const handle = await open(canonical, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0)) + try { + const stat = await handle.stat({ bigint: true }) + if (!stat.isFile()) + throw new Error('EXTENSION_RESOURCE_UNSUPPORTED') + const fingerprint = identity(stat) + const existing = grants.find(grant => grant.path === canonical && grant.directoryId === directory?.resource.id && JSON.stringify(grant.identity) === JSON.stringify(fingerprint)) + const resource = existing?.resource ?? extensionLocalFileSchema.parse({ id: randomUUID(), name: basename(canonical), mimeType, size: Number(stat.size), ...(directory ? { relativePath: relative(directory.path, canonical).replaceAll('\\', '/') } : {}) }) + if (!existing) + grants.push({ resource, path: canonical, identity: fingerprint, ...(directory ? { directoryId: directory.resource.id } : {}) }) + selected.push(resource) + } + finally { await handle.close() } + } + return selected + } + + async directories(id: string): Promise { + return (await this.#directories(id)).map(directory => directory.resource) + } + + grantDirectory(id: string, path: string, assertCurrent: () => void): Promise { + return this.#mutate(id, async () => { + const canonical = await realpath(path) + const stat = await lstat(canonical, { bigint: true }) + if (!stat.isDirectory()) + throw new Error('EXTENSION_RESOURCE_DIRECTORY_UNAVAILABLE') + const directories = await this.#directories(id) + const existing = directories.find(directory => directory.path === canonical && directory.device === String(stat.dev) && directory.inode === String(stat.ino)) + const resource = existing?.resource ?? extensionLocalDirectorySchema.parse({ id: randomUUID(), name: basename(canonical) }) + if (!existing) + directories.push({ resource, path: canonical, device: String(stat.dev), inode: String(stat.ino) }) + await writeExtensionJson(this.#directoryPath(id), directoriesSchema.parse(directories), assertCurrent) + return resource + }) + } + + scanDirectory(id: string, options: ExtensionDirectoryScan, assertCurrent: () => void): Promise { + return this.#mutate(id, async () => { + const directoryId = options.id + const extensions = new Set(options.extensions.map(extension => extension.toLowerCase())) + const directory = (await this.#directories(id)).find(directory => directory.resource.id === directoryId) + await this.#assertDirectory(directory) + const paths: string[] = [] + let visited = 0 + const scan = async (path: string, depth: number) => { + assertCurrent() + if (depth > 16 || !containsCanonicalPath(directory!.path, await realpath(path))) + throw new Error('EXTENSION_RESOURCE_DIRECTORY_LIMIT') + for await (const entry of await opendir(path)) { + assertCurrent() + if (++visited > 10000 || paths.length > 1000) + throw new Error('EXTENSION_RESOURCE_DIRECTORY_LIMIT') + if (entry.isSymbolicLink()) + continue + const child = join(path, entry.name) + if (entry.isDirectory() && options.recursive) + await scan(child, depth + 1) + else if (entry.isFile() && (!extensions.size || extensions.has('*') || extensions.has(extname(entry.name).slice(1).toLowerCase()))) + paths.push(child) + } + } + await scan(directory!.path, 0) + const grants = await this.#read(id) + const selected = await this.#select(paths.sort((a, b) => a.localeCompare(b)), grants, assertCurrent, directory) + const selectedIds = new Set(selected.map(resource => resource.id)) + const retained = grants.filter((grant) => { + if (grant.directoryId !== directoryId || selectedIds.has(grant.resource.id)) + return true + const matchesExtension = !extensions.size || extensions.has('*') || extensions.has(extname(grant.path).slice(1).toLowerCase()) + const matchesDepth = options.recursive || !relative(directory!.path, grant.path).replaceAll('\\', '/').includes('/') + return !matchesExtension || !matchesDepth + }) + await this.#assertDirectory(directory) + await writeExtensionJson(this.#path(id), grantsSchema.parse(retained), assertCurrent) + for (const grant of grants) { + if (!retained.includes(grant)) + this.#abort(id, grant.resource.id) + } + return selected + }) + } + + revokeDirectory(id: string, directoryId: string, assertCurrent: () => void): Promise { + return this.#mutate(id, async () => { + const directories = await this.#directories(id) + await writeExtensionJson(this.#directoryPath(id), directories.filter(directory => directory.resource.id !== directoryId), assertCurrent) + const grants = await this.#read(id) + for (const grant of grants) { + if (grant.directoryId === directoryId) + this.#abort(id, grant.resource.id) + } + await writeExtensionJson(this.#path(id), grants.filter(grant => grant.directoryId !== directoryId), assertCurrent) + }) + } + + revoke(id: string, resourceId?: string, assertCurrent: () => void = () => {}): Promise { + return this.#mutate(id, async () => { + if (resourceId) + z.string().uuid().parse(resourceId) + const grants = await this.#read(id) + if (!resourceId) + await writeExtensionJson(this.#directoryPath(id), [], assertCurrent) + await writeExtensionJson(this.#path(id), grants.filter(grant => resourceId && grant.resource.id !== resourceId), assertCurrent) + for (const grant of grants) { + if (!resourceId || grant.resource.id === resourceId) { + this.#abort(id, grant.resource.id) + } + } + }) + } + + async readBytes(id: string, resourceId: string, offset: number, length: number, signal: AbortSignal): Promise<{ base64: string, size: number, eof: boolean }> { + const url = 'https://resource.invalid/' + const metadata = await this.response(id, resourceId, new Request(url, { method: 'HEAD' }), signal) + const size = Number(metadata.headers.get('content-length')) + if (offset >= size) + return { base64: '', size, eof: true } + const response = await this.response(id, resourceId, new Request(url, { headers: { range: `bytes=${offset}-${offset + length - 1}` } }), signal) + const bytes = Buffer.from(await response.arrayBuffer()) + return { base64: bytes.toString('base64'), size, eof: offset + bytes.length >= size } + } + + async readText(id: string, resourceId: string, signal: AbortSignal): Promise { + const size = (await this.list(id)).find(resource => resource.id === resourceId)?.size + if (size === undefined || size > 1024 * 1024) + throw new Error('EXTENSION_RESOURCE_TEXT_LIMIT') + const response = await this.response(id, resourceId, new Request('https://resource.invalid/'), signal) + return response.text() + } + + async response(id: string, resourceId: string, request: Request, lifetime: AbortSignal): Promise { + z.string().uuid().parse(resourceId) + const controller = new AbortController() + const key = `${id}:${resourceId}` + const streams = this.#streams.get(key) ?? new Set() + streams.add(controller) + this.#streams.set(key, streams) + const signal = AbortSignal.any([request.signal, lifetime, controller.signal]) + let handle: FileHandle | undefined + let closed = false + let body: ReadableStreamDefaultController | undefined + const close = async () => { + if (closed) + return + closed = true + signal.removeEventListener('abort', abort) + streams.delete(controller) + if (!streams.size) + this.#streams.delete(key) + await handle?.close() + } + function abort() { + body?.error(new Error('EXTENSION_RESOURCE_REVOKED')) + void close() + } + try { + signal.throwIfAborted() + const grant = (await this.#read(id)).find(grant => grant.resource.id === resourceId) + if (!grant || await realpath(grant.path) !== grant.path) + throw new Error('EXTENSION_RESOURCE_UNAVAILABLE') + if (grant.directoryId) { + const directory = (await this.#directories(id)).find(directory => directory.resource.id === grant.directoryId) + await this.#assertDirectory(directory) + if (!containsCanonicalPath(directory!.path, grant.path)) + throw new Error('EXTENSION_RESOURCE_DIRECTORY_CHANGED') + } + handle = await open(grant.path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0)) + const stat = await handle.stat({ bigint: true }) + if (!stat.isFile() || JSON.stringify(identity(stat)) !== JSON.stringify(grant.identity)) + throw new Error('EXTENSION_RESOURCE_CHANGED') + signal.throwIfAborted() + const range = extensionResourceRange(grant.resource.size, request.headers.get('range')) + const headers = { ...range.headers, 'content-type': grant.resource.mimeType, 'content-security-policy': 'default-src \'none\'; sandbox', 'cache-control': 'no-store', 'x-content-type-options': 'nosniff', 'access-control-allow-origin': '*' } + if (range.status === 416 || grant.resource.size === 0 || request.method === 'HEAD') { + await close() + return new Response(null, { status: range.status, headers }) + } + let position = range.start + const stream = new ReadableStream({ + start(next) { + body = next + signal.addEventListener('abort', abort, { once: true }) + }, + async pull(next) { + try { + signal.throwIfAborted() + const bytes = new Uint8Array(Math.min(64 * 1024, range.end - position + 1)) + const read = await handle!.read(bytes, 0, bytes.length, position) + signal.throwIfAborted() + if (!read.bytesRead) + throw new Error('EXTENSION_RESOURCE_CHANGED') + position += read.bytesRead + next.enqueue(bytes.subarray(0, read.bytesRead)) + if (position > range.end) { + next.close() + await close() + } + } + catch (error) { + if (!closed) + next.error(error) + await close() + } + }, + cancel: close, + }) + return new Response(stream, { status: range.status, headers }) + } + catch (error) { + await close() + throw error + } + } + + #path(id: string): string { + return join(this.#root, 'data', extensionIdSchema.parse(id), 'local-resources.json') + } + + #directoryPath(id: string): string { + return join(this.#root, 'data', extensionIdSchema.parse(id), 'local-directories.json') + } + + async #directories(id: string): Promise { + try { + return directoriesSchema.parse(await readExtensionJson(this.#directoryPath(id))) + } + catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') + return [] + throw new Error('EXTENSION_RESOURCE_REGISTRY_UNREADABLE') + } + } + + async #assertDirectory(directory: DirectoryGrant | undefined): Promise { + if (!directory || await realpath(directory.path) !== directory.path) + throw new Error('EXTENSION_RESOURCE_DIRECTORY_UNAVAILABLE') + const stat = await lstat(directory.path, { bigint: true }) + if (!stat.isDirectory() || directory.device !== String(stat.dev) || directory.inode !== String(stat.ino)) + throw new Error('EXTENSION_RESOURCE_DIRECTORY_CHANGED') + } + + #abort(id: string, resourceId: string): void { + for (const controller of this.#streams.get(`${id}:${resourceId}`) ?? []) controller.abort() + } + + async #read(id: string): Promise { + try { + return grantsSchema.parse(await readExtensionJson(this.#path(id), 4 * 1024 * 1024)) + } + catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') + return [] + throw new Error('EXTENSION_RESOURCE_REGISTRY_UNREADABLE') + } + } + + async #mutate(id: string, operation: () => Promise): Promise { + const task = (this.#writes.get(id) ?? Promise.resolve()).catch(() => {}).then(operation) + this.#writes.set(id, task) + try { + return await task + } + finally { + if (this.#writes.get(id) === task) + this.#writes.delete(id) + } + } +} diff --git a/apps/buddy/platform/extensions/ExtensionResourceWriter.ts b/apps/buddy/platform/extensions/ExtensionResourceWriter.ts new file mode 100644 index 00000000..dbcd60ca --- /dev/null +++ b/apps/buddy/platform/extensions/ExtensionResourceWriter.ts @@ -0,0 +1,114 @@ +import type { FileHandle } from 'node:fs/promises' +import { Buffer } from 'node:buffer' +import { randomUUID } from 'node:crypto' +import { lstat, open, realpath, rm } from 'node:fs/promises' +import { basename, dirname, join } from 'node:path' +import { fileStorage } from '../filesystem/fileStorage' + +async function fileIdentity(path: string): Promise { + try { + const stat = await lstat(path, { bigint: true }) + if (!stat.isFile()) + throw new Error('EXTENSION_RESOURCE_SAVE_TARGET_INVALID') + return `${stat.dev}:${stat.ino}:${stat.size}:${stat.mtimeNs}` + } + catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') + return null + throw error + } +} + +export class ExtensionResourceWriter { + readonly id = randomUUID() + readonly #path: string + readonly #temporary: string + readonly #handle: FileHandle + readonly #size: number + readonly #identity: string | null + readonly #parentIdentity: string + readonly #assertCurrent: () => void + #written = 0 + #closed = false + #cancelled = false + #committed = false + #tail: Promise = Promise.resolve() + + private constructor(path: string, temporary: string, handle: FileHandle, size: number, identity: string | null, parentIdentity: string, assertCurrent: () => void) { + this.#path = path + this.#temporary = temporary + this.#handle = handle + this.#size = size + this.#identity = identity + this.#parentIdentity = parentIdentity + this.#assertCurrent = assertCurrent + } + + static async create(path: string, size: number, assertCurrent: () => void): Promise { + const parent = await realpath(dirname(path)) + const canonical = join(parent, basename(path)) + const stat = await lstat(parent, { bigint: true }) + const identity = await fileIdentity(canonical) + const temporary = join(parent, `.lexora-${randomUUID()}.tmp`) + assertCurrent() + const handle = await open(temporary, 'wx', 0o600) + return new ExtensionResourceWriter(canonical, temporary, handle, size, identity, `${stat.dev}:${stat.ino}`, assertCurrent) + } + + append(offset: number, base64: string): Promise { + return this.#enqueue(async () => { + this.#assert() + const data = Buffer.from(base64, 'base64') + if (offset !== this.#written || !data.length || data.length > 96 * 1024 || this.#written + data.length > this.#size) + throw new Error('EXTENSION_RESOURCE_WRITE_RANGE') + await this.#handle.writeFile(data) + this.#written += data.length + }) + } + + commit(): Promise { + return this.#enqueue(async () => { + this.#assert() + if (this.#written !== this.#size) + throw new Error('EXTENSION_RESOURCE_WRITE_INCOMPLETE') + await this.#handle.sync() + await this.#close() + const parent = dirname(this.#path) + const stat = await lstat(parent, { bigint: true }) + if (await realpath(parent) !== parent || `${stat.dev}:${stat.ino}` !== this.#parentIdentity || await fileIdentity(this.#path) !== this.#identity) + throw new Error('EXTENSION_RESOURCE_SAVE_TARGET_CHANGED') + this.#assert() + await fileStorage.replace(this.#temporary, this.#path) + this.#committed = true + await fileStorage.syncDirectory(parent) + }) + } + + dispose(): Promise { + this.#cancelled = true + return this.#enqueue(async () => { + await this.#close() + if (!this.#committed) + await rm(this.#temporary, { force: true }) + }) + } + + #assert(): void { + if (this.#cancelled || this.#committed) + throw new Error('EXTENSION_RESOURCE_WRITE_EXPIRED') + this.#assertCurrent() + } + + async #close(): Promise { + if (!this.#closed) { + this.#closed = true + await this.#handle.close() + } + } + + #enqueue(operation: () => Promise): Promise { + const task = this.#tail.catch(() => {}).then(operation) + this.#tail = task + return task + } +} diff --git a/apps/buddy/platform/extensions/ExtensionService.ts b/apps/buddy/platform/extensions/ExtensionService.ts index 2e7b6bfb..88b8e6f5 100644 --- a/apps/buddy/platform/extensions/ExtensionService.ts +++ b/apps/buddy/platform/extensions/ExtensionService.ts @@ -1,4 +1,6 @@ import type { ExtensionResource, ExtensionStatus, ExtensionViewInput, ExtensionViewSession, ExtensionWorkbenchEvent } from '../../shared/extensions/extensionApi' +import type { ExtensionInspection } from '../../shared/extensions/extensionAuthoring' +import type { ExtensionResourceSelection } from '../../shared/extensions/extensionResources' import type { SpaceFileTarget } from '../../shared/spaces/spaceFileApi' import type { JsonValue } from '../../shared/workbench/workbenchState' import type { ExtensionCompiler } from './compileExtensionSource' @@ -9,12 +11,15 @@ import { z } from 'zod' import { extensionError, extensionJsonSchema, extensionResourceSchema } from '../../shared/extensions/extensionApi' import { EXTENSION_CATALOG_URL } from '../../shared/extensions/extensionCatalog' import { extensionCompatible, extensionManifestSchema } from '../../shared/extensions/extensionManifest' +import { extensionDirectoryScanSchema, extensionResourceSelectionSchema } from '../../shared/extensions/extensionResources' import { extensionNotificationSchema, extensionScheduleIdSchema, extensionScheduleInputSchema } from '../../shared/extensions/extensionSchedule' +import { controlProposalSchema, extensionPresentationRequestSchema } from '../../shared/workbench/workbenchUi' import { publicWebUrl, readResponseBytes } from '../network/publicWebTransport' import { ExtensionCatalogService } from './ExtensionCatalogService' import { unpackExtension } from './extensionFiles' import { ExtensionInstallations } from './ExtensionInstallations' import { extensionActivationOrder } from './ExtensionPackageStore' +import { ExtensionResourceWriter } from './ExtensionResourceWriter' import { ExtensionScheduler } from './ExtensionScheduler' export interface ExtensionHost { @@ -31,6 +36,8 @@ export interface ExtensionServicePorts { changed: () => void notify?: (id: string, notification: { title: string, body: string }) => boolean compile?: ExtensionCompiler + selectResources?: (name: string, selection: ExtensionResourceSelection & { directory?: boolean }, signal: AbortSignal) => Promise + selectSavePath?: (name: string, suggestedName: string, signal: AbortSignal) => Promise } interface RunningExtension { package: ExtensionPackage @@ -46,6 +53,10 @@ interface RunningView { session: ExtensionViewSession running: RunningExtension dispose: () => void + abort: AbortController + ready: boolean + error: string | null + writers: Map } export class ExtensionService { @@ -56,6 +67,7 @@ export class ExtensionService { readonly #reviews = new Map() readonly #ports: ExtensionServicePorts readonly #running = new Map() + readonly #viewOpenings = new Map() readonly #views = new Map() readonly #diagnostics = new Map>() readonly #notificationTimes = new Map() @@ -101,21 +113,42 @@ export class ExtensionService { const diagnostics = this.#diagnostics.get(id) ?? { error: null, activationMs: null, logs: [] } const running = this.#running.get(id) let blocked: string | null = null - try { - this.#order(id) - } - catch (error) { - blocked = extensionError(error) + if (record.enabled) { + try { + this.#order(id) + } + catch (error) { + blocked = extensionError(error) + } } return { manifest: record.current.manifest, iconUrl: await this.store.icon(record.current), revision: record.current.revision, enabled: record.enabled, development: record.development, compatible: extensionCompatible(record.current.manifest, this.store.appVersion), pending: record.pending ? { manifest: record.pending.manifest, revision: record.pending.revision } : null, ...diagnostics, error: blocked ?? diagnostics.error, generation: running?.generation ?? null, state: !record.enabled ? 'disabled' : blocked ? 'blocked' : running ? running.active ? 'active' : 'activating' : diagnostics.error ? 'failed' : 'inactive' } })) } + async inspect(id: string): Promise { + const item = (await this.list()).find(item => item.manifest.id === id) + return { + id, + installed: !!item, + version: item?.manifest.version ?? null, + pendingVersion: item?.pending?.manifest.version ?? null, + enabled: item?.enabled ?? false, + state: item?.state ?? 'not-installed', + error: item?.error ?? null, + commands: item?.manifest.contributes.commands.filter(command => !command.hidden).map(({ id, title }) => ({ id, title })) ?? [], + navigation: item?.manifest.contributes.navigation?.title ?? null, + views: [...this.#views.values()].filter(view => view.input.extensionId === id).map(view => ({ type: view.input.viewType, placement: view.input.placementId ?? null, ready: view.ready, error: view.error })), + installations: this.installations.list().filter(record => record.extensionId === id).slice(0, 5).map(record => ({ version: record.version!, status: record.status, stage: record.stage, error: record.error })), + logs: item?.logs ?? [], + } + } + async review(path: string, development = false) { await this.initialize() const job = this.installations.begin(basename(path), 'validate') try { const review = await this.store.review(path, development) + this.installations.identify(job.id, review.manifest.id, review.manifest.version) job.signal.throwIfAborted() this.#reviews.set(review.token, job.id) this.installations.log(job.id, 'review', 'permissions') @@ -148,6 +181,7 @@ export class ExtensionService { job.signal.throwIfAborted() const source = { catalog: EXTENSION_CATALOG_URL, artifact: entry.artifact.url, sha256: entry.artifact.sha256 } const review = await this.store.reviewFiles(files, false, source) + this.installations.identify(job.id, review.manifest.id, review.manifest.version) this.#reviews.set(review.token, job.id) this.installations.log(job.id, 'review', 'permissions') return { ...review, installationId: job.id, source } @@ -209,10 +243,19 @@ export class ExtensionService { }) } + revokeResources(id: string): Promise { + return this.#mutate(async () => { + await this.store.resources.revoke(id) + await this.#stopClosure(id) + this.#log(id, 'resources.revoked') + }) + } + uninstall(id: string): Promise { return this.#mutate(async () => { await this.store.uninstall(id) await this.#stopClosure(id) + await this.store.resources.revoke(id) await this.scheduler.remove(id) await this.store.removePackages(id) this.#diagnostics.delete(id) @@ -242,21 +285,40 @@ export class ExtensionService { } async openView(input: ExtensionViewInput): Promise { - const running = await this.#activate(input.extensionId) - const view = running.package.manifest.contributes.views.find(item => item.id === input.viewType) - if (!view) - throw new Error('EXTENSION_VIEW_UNAVAILABLE') - if (view.resource === 'selected-file' && !input.resource) - throw new Error('EXTENSION_RESOURCE_REQUIRED') - if (input.resource) - await this.#resource(running, input.resource.id) - this.#assertCurrent(running) - const old = this.#views.get(input.viewId) - old?.dispose() - const endpoint = this.#ports.createView(running.package) - const session = { id: input.viewId, extensionId: input.extensionId, generation: running.generation, token: endpoint.token, url: endpoint.url } - this.#views.set(input.viewId, { input, session, running, dispose: endpoint.dispose }) - return session + const request = Symbol('view-open') + this.#viewOpenings.set(input.viewId, request) + try { + const running = await this.#activate(input.extensionId) + const view = running.package.manifest.contributes.views.find(item => item.id === input.viewType) + if (!view) + throw new Error('EXTENSION_VIEW_UNAVAILABLE') + if (input.placementId && !running.package.manifest.contributes.placements.some(placement => placement.id === input.placementId && placement.view === view.id)) + throw new Error('EXTENSION_PLACEMENT_UNAVAILABLE') + if (view.resource === 'selected-file' && !input.resource) + throw new Error('EXTENSION_RESOURCE_REQUIRED') + if (input.resource) + await this.#resource(running, input.resource.id) + this.#assertCurrent(running) + if (this.#viewOpenings.get(input.viewId) !== request) + throw new Error('EXTENSION_VIEW_EXPIRED') + const old = this.#views.get(input.viewId) + old?.dispose() + const endpoint = this.#ports.createView(running.package) + const abort = new AbortController() + const writers = new Map() + const session = { id: input.viewId, extensionId: input.extensionId, generation: running.generation, token: endpoint.token, url: endpoint.url } + this.#views.set(input.viewId, { input, session, running, abort, ready: false, error: null, writers, dispose: () => { + abort.abort() + endpoint.dispose() + for (const writer of writers.values()) void writer.dispose().catch(() => {}) + writers.clear() + } }) + return session + } + finally { + if (this.#viewOpenings.get(input.viewId) === request) + this.#viewOpenings.delete(input.viewId) + } } closeView(id: string, generation: string, token: string): void { @@ -273,24 +335,166 @@ export class ExtensionService { throw new Error('EXTENSION_VIEW_EXPIRED') this.#assertCurrent(view.running) const contribution = view.running.package.manifest.contributes.views.find(item => item.id === view.input.viewType)! + const placement = view.running.package.manifest.contributes.placements.find(placement => placement.id === view.input.placementId) let result: JsonValue if (method === 'bootstrap') { - result = { entry: contribution.entry, location: contribution.location, resource: view.input.resource, state: view.input.state, stateVersion: view.input.stateVersion, expectedStateVersion: contribution.stateVersion } + result = { apiVersion: view.running.package.manifest.apiVersion, entry: contribution.entry, location: placement?.kind === 'view' ? 'mount' : contribution.location, presentation: placement?.kind ?? (contribution.location === 'window-overlay' ? 'decoration' : 'view'), resource: view.input.resource, state: view.input.state, stateVersion: view.input.stateVersion, expectedStateVersion: contribution.stateVersion } } else if (method === 'view.setState') { - if (contribution.location !== 'context') + if (contribution.location !== 'context' || (placement && placement.kind !== 'view')) throw new Error('EXTENSION_METHOD_DENIED') const state = extensionJsonSchema.parse(params) - const saved = await this.#ports.workbench({ kind: 'state', requestId: randomUUID(), viewId: id, generation, state, stateVersion: contribution.stateVersion }, view.running.abort.signal) + const saved = await this.#ports.workbench({ kind: 'state', requestId: randomUUID(), viewId: id, generation, token, state, stateVersion: contribution.stateVersion }, view.running.abort.signal) if (saved !== id) throw new Error('EXTENSION_STATE_SAVE_FAILED') view.input = { ...view.input, state, stateVersion: contribution.stateVersion } result = null } + else if (method === 'control.propose') { + if (placement?.kind !== 'control' || !view.running.package.manifest.permissions.controls.includes(placement.target)) + throw new Error('EXTENSION_METHOD_DENIED') + const proposal = controlProposalSchema.parse(params) + const accepted = await this.#ports.workbench({ kind: 'control', requestId: randomUUID(), viewId: id, generation, token, proposal }, view.running.abort.signal) + if (accepted !== id) + throw new Error('EXTENSION_CONTROL_STALE') + result = null + } + else if (method === 'view.setPresentation') { + if (placement?.kind !== 'view') + throw new Error('EXTENSION_METHOD_DENIED') + const presentation = extensionPresentationRequestSchema.parse(params) + const saved = await this.#ports.workbench({ kind: 'presentation', requestId: randomUUID(), viewId: id, generation, token, presentation }, view.running.abort.signal) + if (saved !== id) + throw new Error('EXTENSION_PRESENTATION_FAILED') + result = null + } + else if (method === 'view.ready') { + view.ready = true + result = null + } else if (method === 'view.failed') { - this.#log(view.input.extensionId, 'view.failed', 'EXTENSION_VIEW_FAILED') + const failure = z.object({ code: z.enum(['EXTENSION_VIEW_FAILED', 'EXTENSION_VIEW_TIMEOUT']) }).safeParse(params) + view.error = failure.success ? failure.data.code : 'EXTENSION_VIEW_FAILED' + this.#log(view.input.extensionId, 'view.failed', failure.success ? failure.data.code : 'EXTENSION_VIEW_FAILED') result = null } + else if (['resources.beginSave', 'resources.writeChunk', 'resources.commitSave', 'resources.cancelSave'].includes(method)) { + if (!view.running.package.manifest.permissions.resourceExport || placement?.kind === 'decoration' || contribution.location === 'window-overlay') + throw new Error('EXTENSION_RESOURCE_EXPORT_DENIED') + const assertView = () => { + this.#assertCurrent(view.running) + if (view.abort.signal.aborted || this.#views.get(id) !== view) + throw new Error('EXTENSION_VIEW_EXPIRED') + } + if (method === 'resources.beginSave') { + if (!this.#ports.selectSavePath || view.writers.size >= 2) + throw new Error('EXTENSION_RESOURCE_SAVE_UNAVAILABLE') + const input = z.object({ name: z.string().min(1).max(255).regex(/^[^/\\\0]+$/), size: z.number().int().min(0).max(2 * 1024 ** 3) }).strict().parse(params) + const signal = AbortSignal.any([view.abort.signal, view.running.abort.signal, AbortSignal.timeout(120000)]) + const path = await this.#ports.selectSavePath(view.running.package.manifest.name, input.name, signal) + signal.throwIfAborted() + assertView() + if (path) { + const writer = await ExtensionResourceWriter.create(path, input.size, assertView) + try { + assertView() + } + catch (error) { + await writer.dispose() + throw error + } + view.writers.set(writer.id, writer) + result = writer.id + } + else { result = null } + } + else { + const input = z.object({ id: z.string().uuid(), offset: z.number().int().min(0).optional(), base64: z.string().max(128 * 1024).regex(/^(?:[A-Z0-9+/]{4})*(?:[A-Z0-9+/]{2}==|[A-Z0-9+/]{3}=)?$/i).optional() }).strict().parse(params) + const writer = view.writers.get(input.id) + if (!writer) + throw new Error('EXTENSION_RESOURCE_WRITE_EXPIRED') + if (method === 'resources.writeChunk') { + if (input.offset === undefined || input.base64 === undefined) + throw new Error('EXTENSION_RESOURCE_WRITE_RANGE') + await writer.append(input.offset, input.base64) + } + else { + try { + if (method === 'resources.commitSave') + await writer.commit() + } + finally { + view.writers.delete(input.id) + await writer.dispose() + } + } + result = null + } + } + else if (method.startsWith('resources.') && (method !== 'resources.readText' || !view.input.resource || !params || typeof params !== 'object' || Array.isArray(params) || params.id !== view.input.resource.id)) { + if (!view.running.package.manifest.permissions.localResources || placement?.kind === 'decoration' || contribution.location === 'window-overlay') + throw new Error('EXTENSION_RESOURCE_DENIED') + const extensionId = view.input.extensionId + const assertView = () => { + this.#assertCurrent(view.running) + if (view.abort.signal.aborted || this.#views.get(id) !== view) + throw new Error('EXTENSION_VIEW_EXPIRED') + } + if (method === 'resources.pickFiles' || method === 'resources.pickDirectory') { + if (!this.#ports.selectResources) + throw new Error('EXTENSION_RESOURCE_UNAVAILABLE') + const selection = method === 'resources.pickDirectory' ? { filters: [], multiple: false, directory: true } : extensionResourceSelectionSchema.parse(params) + const signal = AbortSignal.any([view.abort.signal, view.running.abort.signal, AbortSignal.timeout(120000)]) + const paths = await this.#ports.selectResources(view.running.package.manifest.name, selection, signal) + signal.throwIfAborted() + assertView() + const assertSelection = () => { + signal.throwIfAborted() + assertView() + } + result = method === 'resources.pickDirectory' + ? paths[0] ? await this.store.resources.grantDirectory(extensionId, paths[0], assertSelection) : null + : await this.store.resources.grant(extensionId, paths, assertSelection) + } + else if (method === 'resources.listFiles') { + result = await this.store.resources.list(extensionId) + } + else if (method === 'resources.listDirectories') { + result = await this.store.resources.directories(extensionId) + } + else if (method === 'resources.readBytes') { + const input = z.object({ id: z.string().uuid(), offset: z.number().int().min(0).max(2 * 1024 ** 3), length: z.number().int().min(1).max(128 * 1024) }).strict().parse(params) + result = await this.store.resources.readBytes(extensionId, input.id, input.offset, input.length, view.abort.signal) + } + else if (method === 'resources.readText') { + const input = z.object({ id: z.string().uuid() }).strict().parse(params) + result = await this.store.resources.readText(extensionId, input.id, view.abort.signal) + } + else if (method === 'resources.scanDirectory') { + const signal = AbortSignal.any([view.abort.signal, view.running.abort.signal, AbortSignal.timeout(10000)]) + result = await this.store.resources.scanDirectory(extensionId, extensionDirectoryScanSchema.parse(params), () => { + signal.throwIfAborted() + assertView() + }) + } + else { + const input = z.object({ id: z.string().uuid() }).strict().parse(params) + if (method === 'resources.revokeDirectory') { + await this.store.resources.revokeDirectory(extensionId, input.id, assertView) + result = null + } + else if (method === 'resources.getUrl') { + if (!(await this.store.resources.list(extensionId)).some(resource => resource.id === input.id)) + throw new Error('EXTENSION_RESOURCE_UNAVAILABLE') + result = new URL(`/__resource/${input.id}`, view.session.url).href + } + else if (method === 'resources.revokeFile') { + await this.store.resources.revoke(extensionId, input.id, assertView) + result = null + } + else { throw new Error('EXTENSION_METHOD_DENIED') } + } + } else if (method === 'commands.execute') { const input = z.object({ command: z.string().max(180), arguments: extensionJsonSchema }).strict().parse(params) if (!view.running.package.manifest.contributes.commands.some(item => item.id === input.command)) @@ -443,6 +647,15 @@ export class ExtensionService { const { id: resource } = z.object({ id: z.string().uuid() }).strict().parse(params) result = await this.#ports.readText(await this.#resource(running, resource), running.abort.signal) } + else if (method === 'placements.show' || method === 'placements.hide') { + const input = z.object({ id: z.string().max(180) }).strict().parse(params) + const placement = running.package.manifest.contributes.placements.find(placement => placement.id === input.id) + if (placement?.kind !== 'view') + throw new Error('EXTENSION_PLACEMENT_UNAVAILABLE') + result = await this.#ports.workbench({ kind: 'placement', requestId: randomUUID(), extensionId: id, generation: running.generation, placementId: placement.id, visible: method === 'placements.show' }, running.abort.signal) + if (method === 'placements.show' && !result) + throw new Error('EXTENSION_VIEW_UNAVAILABLE') + } else if (method === 'views.open') { const input = z.object({ type: z.string().max(180), resource: extensionResourceSchema.nullable(), state: extensionJsonSchema }).strict().parse(params) const view = running.package.manifest.contributes.views.find(view => view.id === input.type) @@ -455,7 +668,7 @@ export class ExtensionService { if (input.resource) await this.#resource(running, input.resource.id) this.#assertCurrent(running) - result = await this.#ports.workbench({ kind: 'open', requestId: randomUUID(), extensionId: id, viewType: view.id, resource: input.resource, state: input.state, stateVersion: view.stateVersion }, running.abort.signal) + result = await this.#ports.workbench({ kind: 'open', requestId: randomUUID(), extensionId: id, generation: running.generation, viewType: view.id, resource: input.resource, state: input.state, stateVersion: view.stateVersion }, running.abort.signal) if (!result) throw new Error('EXTENSION_VIEW_UNAVAILABLE') } diff --git a/apps/buddy/platform/extensions/__tests__/ExtensionPackageStore.spec.ts b/apps/buddy/platform/extensions/__tests__/ExtensionPackageStore.spec.ts index c9615eb4..08dd7e11 100644 --- a/apps/buddy/platform/extensions/__tests__/ExtensionPackageStore.spec.ts +++ b/apps/buddy/platform/extensions/__tests__/ExtensionPackageStore.spec.ts @@ -31,7 +31,7 @@ describe('extension package contract', () => { }) it('rejects foreign contribution ownership, duplicate IDs, and unsupported API versions', () => { const value = manifest() - expect(extensionManifestSchema.safeParse({ ...value, apiVersion: 2 }).success).toBe(false) + expect(extensionManifestSchema.safeParse({ ...value, apiVersion: 3 }).success).toBe(false) expect(extensionManifestSchema.safeParse({ ...value, contributes: { ...value.contributes, commands: [{ id: 'another.reader.open', title: 'Open' }] } }).success).toBe(false) expect(extensionManifestSchema.safeParse({ ...value, contributes: { ...value.contributes, commands: [value.contributes.commands[0], value.contributes.commands[0]] } }).success).toBe(false) }) @@ -148,3 +148,57 @@ describe('extension package contract', () => { expect(() => extensionActivationOrder(installed, a.id)).toThrow('EXTENSION_DEPENDENCY_VERSION') }) }) + +it.each(['composer.input', 'workbench.pane'] as const)('preserves the declared decoration scope and requires permission: %s', (anchor) => { + const input = manifest({ apiVersion: 2, permissions: { windowEffects: true }, contributes: { + views: [{ id: 'tests.reader.effect', title: 'Effect', entry: 'view.js', resource: 'none' }], + placements: [{ id: 'tests.reader.scope', view: 'tests.reader.effect', kind: 'decoration', anchor }], + } }) + expect(extensionManifestSchema.parse(input).contributes.placements[0]).toEqual({ id: 'tests.reader.scope', view: 'tests.reader.effect', kind: 'decoration', anchor }) + expect(extensionManifestSchema.safeParse({ ...input, permissions: {} }).success).toBe(false) +}) + +it.each([1, 2] as const)('accepts window overlays in API %s without adding local placements', (apiVersion) => { + const input = manifest({ apiVersion, permissions: { windowEffects: true }, contributes: { + views: [{ id: 'tests.reader.effect', title: 'Effect', entry: 'view.js', resource: 'none', location: 'window-overlay' }], + } }) + expect(extensionManifestSchema.parse(input).contributes.views[0]?.location).toBe('window-overlay') + expect(extensionManifestSchema.safeParse({ ...input, permissions: {} }).success).toBe(false) +}) + +it('keeps API 1 compatible and validates UI placements and permission upgrades in API 2', async () => { + const { root, store } = await fixture() + const old = manifest() + await store.install((await reviewPackage(root, store, old)).token) + const value = manifest({ + apiVersion: 2, + version: '1.1.0', + permissions: { windowEffects: true, controls: ['model.reasoning'] }, + contributes: { + views: [{ id: 'tests.reader.ui', title: 'UI', entry: 'view.js', resource: 'none' }], + navigation: { view: 'tests.reader.ui', title: 'Reader' }, + placements: [ + { id: 'tests.reader.top', view: 'tests.reader.ui', kind: 'view', location: 'workbench.top' }, + { id: 'tests.reader.effect', view: 'tests.reader.ui', kind: 'decoration', anchor: 'composer.input' }, + { id: 'tests.reader.reasoning', view: 'tests.reader.ui', kind: 'control', target: 'model.reasoning' }, + ], + }, + }) + const review = await reviewPackage(root, store, value) + expect(review.addedPermissions).toEqual(['windowEffects', 'controls:model.reasoning']) + expect(extensionManifestSchema.safeParse({ ...value, apiVersion: 1 }).success).toBe(false) + expect(extensionManifestSchema.safeParse({ ...value, contributes: { ...value.contributes, placements: [], views: [{ ...value.contributes.views[0], location: 'window-overlay' }] } }).success).toBe(false) + expect(extensionManifestSchema.safeParse({ ...value, permissions: { windowEffects: true } }).success).toBe(false) + expect(extensionManifestSchema.safeParse({ ...value, permissions: { controls: ['model.reasoning'] } }).success).toBe(false) + for (const placement of [ + { ...value.contributes.placements[0], view: 'tests.reader.missing' }, + { ...value.contributes.placements[0], id: 'other.plugin.top' }, + { ...value.contributes.placements[0], location: 'arbitrary.selector' }, + { ...value.contributes.placements[0], height: 9000 }, + { ...value.contributes.placements[1], anchor: 'document.body' }, + { ...value.contributes.placements[2], target: 'permissions.approval' }, + ]) { + expect(extensionManifestSchema.safeParse({ ...value, contributes: { ...value.contributes, placements: [placement] } }).success).toBe(false) + } + expect(extensionManifestSchema.safeParse({ ...value, contributes: { ...value.contributes, views: [{ ...value.contributes.views[0], resource: 'selected-file' }] }, permissions: { ...value.permissions, selectedResource: 'read' } }).success).toBe(false) +}) diff --git a/apps/buddy/platform/extensions/__tests__/ExtensionResourceStore.spec.ts b/apps/buddy/platform/extensions/__tests__/ExtensionResourceStore.spec.ts new file mode 100644 index 00000000..c23153f8 --- /dev/null +++ b/apps/buddy/platform/extensions/__tests__/ExtensionResourceStore.spec.ts @@ -0,0 +1,166 @@ +import { Buffer } from 'node:buffer' +import { randomUUID } from 'node:crypto' +import { mkdir, mkdtemp, readFile, rename, rm, symlink, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, expect, it } from 'vitest' +import { extensionResourceRange } from '../extensionResourceResponse' +import { ExtensionResourceStore } from '../ExtensionResourceStore' + +const roots: string[] = [] +afterEach(async () => { + for (const root of roots.splice(0)) await rm(root, { recursive: true, force: true }) +}) +async function fixture() { + const root = await mkdtemp(join(tmpdir(), 'lexora-extension-resource-')) + roots.push(root) + const path = join(root, 'selected.mp3') + await writeFile(path, new Uint8Array(256 * 1024).map((_, index) => index % 251)) + const store = new ExtensionResourceStore(join(root, 'extensions')) + const [resource] = await store.grant('tests.resources', [path], () => {}) + const lifetime = new AbortController() + const request = (id = resource!.id, range?: string) => store.response('tests.resources', id, new Request('https://resource.invalid/file', { headers: range ? { range } : {} }), lifetime.signal) + return { root, path, store, resource: resource!, lifetime, request } +} + +it('persists only scoped handles publicly and supports real partial reads', async () => { + const f = await fixture() + expect(f.resource).toMatchObject({ name: 'selected.mp3', mimeType: 'audio/mpeg', size: 256 * 1024 }) + expect(JSON.stringify(f.resource)).not.toContain(f.root) + const reopened = new ExtensionResourceStore(join(f.root, 'extensions')) + expect(await reopened.list('tests.resources')).toEqual([f.resource]) + const part = await f.request(f.resource.id, 'bytes=100-199') + expect(part.status).toBe(206) + expect(part.headers.get('content-range')).toBe('bytes 100-199/262144') + expect(new Uint8Array(await part.arrayBuffer())).toEqual(new Uint8Array((await readFile(f.path)).subarray(100, 200))) + await expect(f.request(randomUUID())).rejects.toThrow('EXTENSION_RESOURCE_UNAVAILABLE') + await expect(reopened.response('tests.other', f.resource.id, new Request('https://resource.invalid'), f.lifetime.signal)).rejects.toThrow('EXTENSION_RESOURCE_UNAVAILABLE') +}) + +it('rejects replacement and symlink retargeting without changing source files', async () => { + const f = await fixture() + const original = await readFile(f.path) + await rename(f.path, `${f.path}.old`) + await writeFile(f.path, original) + await expect(f.request()).rejects.toThrow('EXTENSION_RESOURCE_CHANGED') + await rm(f.path) + await symlink(`${f.path}.old`, f.path) + await expect(f.request()).rejects.toThrow('EXTENSION_RESOURCE_UNAVAILABLE') + expect(await readFile(`${f.path}.old`)).toEqual(original) +}) + +it('cancels streams and future access on revoke or expired view lifetime', async () => { + const f = await fixture() + const reader = (await f.request()).body!.getReader() + await reader.read() + await f.store.revoke('tests.resources', f.resource.id) + await expect(reader.read()).rejects.toThrow('EXTENSION_RESOURCE_REVOKED') + await expect(f.request()).rejects.toThrow('EXTENSION_RESOURCE_UNAVAILABLE') + expect(await f.store.list('tests.resources')).toEqual([]) + const [again] = await f.store.grant('tests.resources', [f.path], () => {}) + const next = (await f.request(again!.id)).body!.getReader() + await next.read() + f.lifetime.abort() + await expect(next.read()).rejects.toThrow('EXTENSION_RESOURCE_REVOKED') + expect((await readFile(f.path)).length).toBe(256 * 1024) +}) + +it('does not commit an expired selection and handles concurrent grants without losing records', async () => { + const f = await fixture() + const second = join(f.root, 'second.ogg') + await writeFile(second, 'media bytes') + await expect(f.store.grant('tests.resources', [second], () => { + throw new Error('expired') + })).rejects.toThrow('expired') + expect(await f.store.list('tests.resources')).toEqual([f.resource]) + await Promise.all([f.store.grant('tests.resources', [second], () => {}), f.store.grant('tests.resources', [f.path], () => {})]) + expect((await f.store.list('tests.resources')).map(item => item.name).sort()).toEqual(['second.ogg', 'selected.mp3']) +}) + +it('bounds open, suffix, invalid and unsatisfiable byte ranges', () => { + expect(extensionResourceRange(100, null)).toMatchObject({ start: 0, end: 99, status: 200 }) + expect(extensionResourceRange(100, 'bytes=50-')).toMatchObject({ start: 50, end: 99, status: 206 }) + expect(extensionResourceRange(100, 'bytes=-10')).toMatchObject({ start: 90, end: 99, status: 206 }) + expect(extensionResourceRange(100, 'bytes=90-999')).toMatchObject({ start: 90, end: 99, status: 206 }) + for (const range of ['bytes=100-', 'bytes=20-10', 'bytes=-0', 'bytes=0-1,3-4', 'invalid']) + expect(extensionResourceRange(100, range)).toMatchObject({ status: 416, headers: { 'content-range': 'bytes */100' } }) +}) + +it('applies plugin filters inside a directory, refreshes files and revokes the directory independently', async () => { + const f = await fixture() + const folder = join(f.root, 'library') + await mkdir(join(folder, 'album'), { recursive: true }) + await writeFile(join(folder, 'album', 'song.mp3'), 'song bytes') + await writeFile(join(folder, 'private.txt'), 'not media') + await symlink(f.path, join(folder, 'escape.mp3')) + await symlink(f.root, join(folder, 'loop')) + const directory = await f.store.grantDirectory('tests.resources', folder, () => {}) + expect(Object.keys(directory).sort()).toEqual(['id', 'name']) + const [song] = await f.store.scanDirectory('tests.resources', { id: directory.id, extensions: ['mp3', 'wav'], recursive: true }, () => {}) + expect(song?.name).toBe('song.mp3') + expect(await f.store.scanDirectory('tests.resources', { id: directory.id, extensions: ['mp3', 'wav'], recursive: true }, () => {})).toEqual([song]) + await writeFile(join(folder, 'new.wav'), 'new media') + await rm(join(folder, 'album', 'song.mp3')) + const [added] = await f.store.scanDirectory('tests.resources', { id: directory.id, extensions: ['mp3', 'wav'], recursive: true }, () => {}) + expect(added?.name).toBe('new.wav') + expect(await f.store.list('tests.resources')).toEqual([f.resource, added]) + const restored = new ExtensionResourceStore(join(f.root, 'extensions')) + expect(await restored.directories('tests.resources')).toEqual([directory]) + await restored.revokeDirectory('tests.resources', directory.id, () => {}) + await expect(f.request(added!.id)).rejects.toThrow('EXTENSION_RESOURCE_UNAVAILABLE') + expect(await restored.list('tests.resources')).toEqual([f.resource]) + expect(await readFile(join(folder, 'new.wav'), 'utf8')).toBe('new media') +}) + +it('rejects replaced directory roots and stale authorization during scanning', async () => { + const f = await fixture() + const folder = join(f.root, 'library') + await mkdir(folder) + await writeFile(join(folder, 'song.mp3'), 'song') + const directory = await f.store.grantDirectory('tests.resources', folder, () => {}) + await expect(f.store.scanDirectory('tests.resources', { id: directory.id, extensions: ['mp3', 'wav'], recursive: true }, () => { + throw new Error('expired') + })).rejects.toThrow('expired') + expect(await f.store.list('tests.resources')).toEqual([f.resource]) + await rename(folder, `${folder}-old`) + await mkdir(folder) + await expect(f.store.scanDirectory('tests.resources', { id: directory.id, extensions: ['mp3', 'wav'], recursive: true }, () => {})).rejects.toThrow('EXTENSION_RESOURCE_DIRECTORY_CHANGED') +}) + +it('reads arbitrary formats as text or bounded bytes without a host format whitelist', async () => { + const f = await fixture() + const novel = join(f.root, 'novel.txt') + const design = join(f.root, 'shape.vendorcad') + const empty = join(f.root, 'empty') + await writeFile(novel, '第一章\n海风吹过窗台。') + await writeFile(design, new Uint8Array([0, 255, 7, 100, 42])) + await writeFile(empty, '') + const [text, cad, blank] = await f.store.grant('tests.resources', [novel, design, empty], () => {}) + expect(await f.store.readText('tests.resources', text!.id, f.lifetime.signal)).toBe('第一章\n海风吹过窗台。') + expect(cad!.mimeType).toBe('application/octet-stream') + const part = await f.store.readBytes('tests.resources', cad!.id, 1, 3, f.lifetime.signal) + expect(part).toMatchObject({ size: 5, eof: false }) + expect([...Buffer.from(part.base64, 'base64')]).toEqual([255, 7, 100]) + expect(await f.store.readBytes('tests.resources', cad!.id, 4, 10, f.lifetime.signal)).toMatchObject({ base64: 'Kg==', eof: true }) + expect(await f.store.readBytes('tests.resources', cad!.id, 5, 10, f.lifetime.signal)).toMatchObject({ base64: '', eof: true }) + expect(await f.store.readText('tests.resources', blank!.id, f.lifetime.signal)).toBe('') +}) + +it('keeps independent directory scopes and other scan filters while refreshing one subset', async () => { + const f = await fixture() + const folder = join(f.root, 'project') + await mkdir(join(folder, 'nested'), { recursive: true }) + const text = join(folder, 'chapter.txt') + await writeFile(text, 'chapter') + await writeFile(join(folder, 'nested', 'image.png'), 'image') + const dir = await f.store.grantDirectory('tests.resources', folder, () => {}) + const files = await f.store.scanDirectory('tests.resources', { id: dir.id, extensions: [], recursive: true }, () => {}) + expect(files.map(file => file.relativePath).sort()).toEqual(['chapter.txt', 'nested/image.png']) + const [selected] = await f.store.grant('tests.resources', [text], () => {}) + expect(selected!.id).not.toBe(files.find(file => file.name === 'chapter.txt')!.id) + await f.store.scanDirectory('tests.resources', { id: dir.id, extensions: ['txt'], recursive: false }, () => {}) + expect(await f.store.list('tests.resources')).toHaveLength(4) + await f.store.revokeDirectory('tests.resources', dir.id, () => {}) + expect(await f.store.list('tests.resources')).toEqual([f.resource, selected]) + expect(await f.store.readText('tests.resources', selected!.id, f.lifetime.signal)).toBe('chapter') +}) diff --git a/apps/buddy/platform/extensions/__tests__/ExtensionResourceWriter.spec.ts b/apps/buddy/platform/extensions/__tests__/ExtensionResourceWriter.spec.ts new file mode 100644 index 00000000..8389aea1 --- /dev/null +++ b/apps/buddy/platform/extensions/__tests__/ExtensionResourceWriter.spec.ts @@ -0,0 +1,70 @@ +import { Buffer } from 'node:buffer' +import { mkdtemp, readdir, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, expect, it } from 'vitest' +import { ExtensionResourceWriter } from '../ExtensionResourceWriter' + +const roots: string[] = [] +afterEach(async () => { + for (const root of roots.splice(0)) await rm(root, { force: true, recursive: true }) +}) +async function fixture() { + const root = await mkdtemp(join(tmpdir(), 'lexora-resource-export-')) + roots.push(root) + const target = join(root, 'design.unknown') + await writeFile(target, 'original') + return { root, target } +} + +it('commits complete binary output atomically and preserves the source until commit', async () => { + const { root, target } = await fixture() + const bytes = Buffer.from([0, 255, 17, 90, 2]) + const writer = await ExtensionResourceWriter.create(target, bytes.length, () => {}) + await writer.append(0, bytes.subarray(0, 2).toString('base64')) + expect(await readFile(target, 'utf8')).toBe('original') + await expect(writer.commit()).rejects.toThrow('EXTENSION_RESOURCE_WRITE_INCOMPLETE') + await writer.append(2, bytes.subarray(2).toString('base64')) + await writer.commit() + await writer.dispose() + expect(await readFile(target)).toEqual(bytes) + expect(await readdir(root)).toEqual(['design.unknown']) +}) + +it('discards cancelled and expired writes without overwriting user data', async () => { + const { root, target } = await fixture() + let current = true + const writer = await ExtensionResourceWriter.create(target, 4, () => { + if (!current) + throw new Error('expired') + }) + await writer.append(0, Buffer.from('new!').toString('base64')) + current = false + await expect(writer.commit()).rejects.toThrow('expired') + await writer.dispose() + await expect(writer.append(4, 'YQ==')).rejects.toThrow('EXTENSION_RESOURCE_WRITE_EXPIRED') + expect(await readFile(target, 'utf8')).toBe('original') + expect(await readdir(root)).toEqual(['design.unknown']) +}) + +it('refuses to overwrite a target changed after selection and rejects invalid chunks', async () => { + const { root, target } = await fixture() + const writer = await ExtensionResourceWriter.create(target, 4, () => {}) + await expect(writer.append(1, 'YQ==')).rejects.toThrow('EXTENSION_RESOURCE_WRITE_RANGE') + await expect(writer.append(0, Buffer.alloc(97 * 1024).toString('base64'))).rejects.toThrow('EXTENSION_RESOURCE_WRITE_RANGE') + await writer.append(0, Buffer.from('new!').toString('base64')) + await writeFile(target, 'external change') + await expect(writer.commit()).rejects.toThrow('EXTENSION_RESOURCE_SAVE_TARGET_CHANGED') + await writer.dispose() + expect(await readFile(target, 'utf8')).toBe('external change') + expect(await readdir(root)).toEqual(['design.unknown']) +}) + +it('supports empty exports without requiring a data chunk', async () => { + const { root } = await fixture() + const target = join(root, 'empty.txt') + const writer = await ExtensionResourceWriter.create(target, 0, () => {}) + await writer.commit() + await writer.dispose() + expect(await readFile(target, 'utf8')).toBe('') +}) diff --git a/apps/buddy/platform/extensions/__tests__/ExtensionService.spec.ts b/apps/buddy/platform/extensions/__tests__/ExtensionService.spec.ts index 39b74eee..63d54916 100644 --- a/apps/buddy/platform/extensions/__tests__/ExtensionService.spec.ts +++ b/apps/buddy/platform/extensions/__tests__/ExtensionService.spec.ts @@ -1,7 +1,9 @@ import type { JsonValue } from '../../../shared/workbench/workbenchState' import type { ExtensionServicePorts } from '../ExtensionService' +import { Buffer } from 'node:buffer' import { randomUUID } from 'node:crypto' -import { rm } from 'node:fs/promises' +import { readFile, rm, writeFile } from 'node:fs/promises' +import { join } from 'node:path' import { afterEach, expect, it, vi } from 'vitest' import { ExtensionService } from '../ExtensionService' import { createStore, manifest, reviewPackage } from './fixtures' @@ -124,7 +126,7 @@ it('binds every view to its token and resource, and acknowledges only persisted await expect(service.viewRequest(second.id, second.generation, second.token, 'resources.readText', { id: randomUUID() })).rejects.toThrow('EXTENSION_RESOURCE_DENIED') await expect(service.viewRequest(second.id, second.generation, second.token, 'storage.get', null)).rejects.toThrow('EXTENSION_METHOD_DENIED') await service.viewRequest(second.id, second.generation, second.token, 'view.setState', { position: 9 }) - expect(events).toContainEqual(expect.objectContaining({ kind: 'state', viewId: second.id, generation: second.generation, state: { position: 9 } })) + expect(events).toContainEqual(expect.objectContaining({ kind: 'state', viewId: second.id, generation: second.generation, token: second.token, state: { position: 9 } })) await service.enable('tests.reader', false) await expect(service.viewRequest(second.id, second.generation, second.token, 'bootstrap', null)).rejects.toThrow('EXTENSION_VIEW_EXPIRED') }) @@ -161,5 +163,152 @@ it('stops dependent hosts when their dependency is disabled', async () => { expect(hosts).toHaveLength(2) await service.enable('tests.reader', false) expect(hosts.every(host => host.disposed)).toBe(true) + expect((await service.list()).find(item => item.manifest.id === 'tests.reader')).toMatchObject({ enabled: false, state: 'disabled', error: null, generation: null }) expect((await service.list()).find(item => item.manifest.id === 'tests.dependent')).toMatchObject({ state: 'blocked', error: 'EXTENSION_DEPENDENCY_UNAVAILABLE' }) + await service.enable('tests.dependent', false) + expect((await service.list()).every(item => !item.enabled && item.state === 'disabled' && item.error === null)).toBe(true) + await service.enable('tests.dependent', true) + expect((await service.list()).find(item => item.manifest.id === 'tests.dependent')).toMatchObject({ state: 'blocked', error: 'EXTENSION_DEPENDENCY_UNAVAILABLE' }) + await service.enable('tests.reader', true) + expect((await service.list()).every(item => item.state === 'inactive' && item.error === null)).toBe(true) +}) + +it('authorizes each placement and control request against its own live view session', async () => { + const { root, store, service, hosts, events } = await fixture({ workbench: async (event) => { + events.push(event) + return event.kind === 'control' ? event.viewId : randomUUID() + } }) + const value = manifest({ apiVersion: 2, id: 'tests.controls', permissions: { controls: ['model.reasoning'] }, contributes: { + views: [{ id: 'tests.controls.ui', title: 'Controls', entry: 'view.js', resource: 'none' }], + placements: [ + { id: 'tests.controls.reasoning', view: 'tests.controls.ui', kind: 'control', target: 'model.reasoning' }, + { id: 'tests.controls.top', view: 'tests.controls.ui', kind: 'view', location: 'workbench.top' }, + ], + } }) + await service.install((await reviewPackage(root, store, value)).token) + const input = { viewId: randomUUID(), extensionId: value.id, viewType: 'tests.controls.ui', resource: null, state: null, stateVersion: 1 } + await expect(service.openView({ ...input, placementId: 'tests.reader.top' })).rejects.toThrow('EXTENSION_PLACEMENT_UNAVAILABLE') + const session = await service.openView({ ...input, placementId: 'tests.controls.reasoning' }) + const proposal = { revision: randomUUID(), value: 'high' } + await service.viewRequest(session.id, session.generation, session.token, 'control.propose', proposal) + expect(events).toContainEqual(expect.objectContaining({ kind: 'control', viewId: session.id, token: session.token, generation: session.generation, proposal })) + await expect(service.viewRequest(session.id, session.generation, randomUUID(), 'control.propose', proposal)).rejects.toThrow('EXTENSION_VIEW_EXPIRED') + await expect(service.viewRequest(session.id, session.generation, session.token, 'view.setState', {})).rejects.toThrow('EXTENSION_METHOD_DENIED') + const ordinary = await service.openView({ ...input, viewId: randomUUID() }) + await expect(service.viewRequest(ordinary.id, ordinary.generation, ordinary.token, 'control.propose', proposal)).rejects.toThrow('EXTENSION_METHOD_DENIED') + await hosts[0]!.broker('placements.show', { id: 'tests.controls.top' }) + expect(events).toContainEqual(expect.objectContaining({ kind: 'placement', extensionId: value.id, placementId: 'tests.controls.top', visible: true })) + await expect(hosts[0]!.broker('placements.show', { id: 'tests.controls.reasoning' })).rejects.toThrow('EXTENSION_PLACEMENT_UNAVAILABLE') + await expect(hosts[0]!.broker('placements.show', { id: 'another.plugin.top' })).rejects.toThrow('EXTENSION_PLACEMENT_UNAVAILABLE') + await service.enable(value.id, false) + await expect(service.viewRequest(session.id, session.generation, session.token, 'control.propose', proposal)).rejects.toThrow('EXTENSION_VIEW_EXPIRED') +}) + +it('does not let an older concurrent open replace a newer view endpoint', async () => { + const { store, service } = await fixture() + const resource = await store.grant('tests.reader', target) + const original = store.resolveGrant.bind(store) + const pending = deferred() + const entered = deferred() + let requests = 0 + vi.spyOn(store, 'resolveGrant').mockImplementation(async (...args) => { + if (++requests === 1) { + entered.resolve() + return pending.promise + } + return original(...args) + }) + const input = { viewId: randomUUID(), extensionId: 'tests.reader', viewType: 'tests.reader.reader', resource, state: {}, stateVersion: 1 } + const first = service.openView(input) + const rejected = expect(first).rejects.toThrow('EXTENSION_VIEW_EXPIRED') + await entered.promise + const current = await service.openView({ ...input, state: { restored: true } }) + pending.resolve(target) + await rejected + expect(await service.viewRequest(current.id, current.generation, current.token, 'bootstrap', null)).toMatchObject({ state: { restored: true } }) +}) + +it('records a failed view without failing its host or other views', async () => { + const { store, service } = await fixture() + const resource = await store.grant('tests.reader', target) + const input = { viewId: randomUUID(), extensionId: 'tests.reader', viewType: 'tests.reader.reader', resource, state: {}, stateVersion: 1 } + const failed = await service.openView(input) + const retained = await service.openView({ ...input, viewId: randomUUID() }) + await service.viewRequest(failed.id, failed.generation, failed.token, 'view.failed', { code: 'EXTENSION_VIEW_TIMEOUT' }) + service.closeView(failed.id, failed.generation, failed.token) + const status = (await service.list())[0]! + expect(status).toMatchObject({ state: 'active', error: null }) + expect(status.logs.at(-1)).toMatchObject({ event: 'view.failed', code: 'EXTENSION_VIEW_TIMEOUT' }) + expect(await service.viewRequest(retained.id, retained.generation, retained.token, 'bootstrap', null)).toMatchObject({ state: {} }) +}) + +it('rejects unauthorized media and late picker selections without retaining grants', async () => { + const selection = deferred() + const entered = deferred() + const f = await fixture({ selectResources: async () => { + entered.resolve() + return selection.promise + } }) + const value = manifest({ apiVersion: 2, id: 'tests.media', permissions: { localResources: true }, contributes: { views: [{ id: 'tests.media.page', title: 'Media', entry: 'view.js', resource: 'none' }] } }) + const path = join(f.root, 'track.mp3') + await writeFile(path, 'media bytes') + await f.service.install((await reviewPackage(f.root, f.store, value)).token) + const input = { viewId: randomUUID(), extensionId: value.id, viewType: 'tests.media.page', resource: null, state: {}, stateVersion: 1 } + const view = await f.service.openView(input) + await expect(f.hosts.at(-1)!.broker('resources.pickFiles', {})).rejects.toThrow('EXTENSION_METHOD_DENIED') + const picking = f.service.viewRequest(view.id, view.generation, view.token, 'resources.pickFiles', {}) + const rejected = expect(picking).rejects.toThrow() + await entered.promise + f.service.closeView(view.id, view.generation, view.token) + selection.resolve([path]) + await rejected + expect(await f.store.resources.list(value.id)).toEqual([]) + const resource = await f.store.grant('tests.reader', target) + const denied = await f.service.openView({ ...input, viewId: randomUUID(), extensionId: 'tests.reader', viewType: 'tests.reader.reader', resource }) + await expect(f.service.viewRequest(denied.id, denied.generation, denied.token, 'resources.listFiles', null)).rejects.toThrow('EXTENSION_RESOURCE_DENIED') +}) + +it('binds presentation changes to a current declared placement and reports real installation state', async () => { + const f = await fixture({ workbench: async event => event.kind === 'presentation' ? event.viewId : null }) + const value = manifest({ apiVersion: 2, id: 'tests.panel', contributes: { views: [{ id: 'tests.panel.ui', title: 'Panel', entry: 'view.js', resource: 'none' }], placements: [{ id: 'tests.panel.float', kind: 'view', location: 'workbench.floating', view: 'tests.panel.ui' }] } }) + await f.service.install((await reviewPackage(f.root, f.store, value)).token) + const input = { viewId: randomUUID(), extensionId: value.id, viewType: 'tests.panel.ui', resource: null, state: { secret: 'private-state' }, stateVersion: 1 } + const view = await f.service.openView({ ...input, placementId: 'tests.panel.float' }) + const request = (method: string, params: JsonValue) => f.service.viewRequest(view.id, view.generation, view.token, method, params) + await request('view.setPresentation', { height: 260, width: 360, position: 'absolute', right: 0 }) + await expect(request('view.setPresentation', { height: 99999 })).rejects.toThrow() + await request('view.ready', null) + const status = await f.service.inspect(value.id) + expect(status).toMatchObject({ installed: true, version: '1.0.0', state: 'active', views: [{ type: 'tests.panel.ui', placement: 'tests.panel.float', ready: true, error: null }] }) + expect(JSON.stringify(status)).not.toContain('private-state') + const page = await f.service.openView({ ...input, viewId: randomUUID() }) + await expect(f.service.viewRequest(page.id, page.generation, page.token, 'view.setPresentation', { height: 260 })).rejects.toThrow('EXTENSION_METHOD_DENIED') + await f.service.enable(value.id, false) + expect(await f.service.inspect(value.id)).toMatchObject({ state: 'disabled', views: [] }) + await expect(request('view.setPresentation', { height: 100 })).rejects.toThrow('EXTENSION_VIEW_EXPIRED') +}) + +it('keeps export permission separate from reads and expires writers with their owning view', async () => { + let destination = '' + const f = await fixture({ selectSavePath: async () => destination }) + destination = join(f.root, 'export.binary') + await writeFile(destination, 'original') + const value = manifest({ apiVersion: 2, id: 'tests.export', permissions: { localResources: true }, contributes: { views: [{ id: 'tests.export.page', title: 'Export', entry: 'view.js', resource: 'none' }] } }) + await f.service.install((await reviewPackage(f.root, f.store, value)).token) + const input = { viewId: randomUUID(), extensionId: value.id, viewType: 'tests.export.page', resource: null, state: null, stateVersion: 1 } + const readOnly = await f.service.openView(input) + const payload = { name: 'file.unknown', size: 4 } + await expect(f.service.viewRequest(readOnly.id, readOnly.generation, readOnly.token, 'resources.beginSave', payload)).rejects.toThrow('EXTENSION_RESOURCE_EXPORT_DENIED') + await f.service.install((await reviewPackage(f.root, f.store, manifest({ ...value, version: '1.1.0', permissions: { resourceExport: true } }))).token) + await f.service.restart(value.id) + const owner = await f.service.openView(input) + const other = await f.service.openView({ ...input, viewId: randomUUID() }) + const request = (method: string, params: JsonValue) => f.service.viewRequest(owner.id, owner.generation, owner.token, method, params) + await expect(request('resources.listFiles', null)).rejects.toThrow('EXTENSION_RESOURCE_DENIED') + const id = await request('resources.beginSave', payload) + await expect(f.service.viewRequest(other.id, other.generation, other.token, 'resources.commitSave', { id })).rejects.toThrow('EXTENSION_RESOURCE_WRITE_EXPIRED') + await request('resources.writeChunk', { id, offset: 0, base64: Buffer.from('new!').toString('base64') }) + f.service.closeView(owner.id, owner.generation, owner.token) + await expect(request('resources.commitSave', { id })).rejects.toThrow('EXTENSION_VIEW_EXPIRED') + expect(await readFile(destination, 'utf8')).toBe('original') }) diff --git a/apps/buddy/platform/extensions/extensionResourceResponse.ts b/apps/buddy/platform/extensions/extensionResourceResponse.ts new file mode 100644 index 00000000..b3f5759f --- /dev/null +++ b/apps/buddy/platform/extensions/extensionResourceResponse.ts @@ -0,0 +1,15 @@ +export function extensionResourceRange(size: number, raw: string | null): { start: number, end: number, status: number, headers: Record } { + const headers: Record = { 'accept-ranges': 'bytes', 'content-length': String(size) } + if (!raw) + return { start: 0, end: size - 1, status: 200, headers } + const match = /^bytes=(\d*)-(\d*)$/.exec(raw) + let start = Number.NaN + let end = size - 1 + if (match && (match[1] || match[2])) { + start = match[1] ? Number(match[1]) : Math.max(0, size - Number(match[2])) + end = match[1] && match[2] ? Math.min(Number(match[2]), size - 1) : size - 1 + } + if (!Number.isSafeInteger(start) || !Number.isSafeInteger(end) || start < 0 || start > end || start >= size) + return { start: 0, end: -1, status: 416, headers: { 'accept-ranges': 'bytes', 'content-range': `bytes */${size}`, 'content-length': '0' } } + return { start, end, status: 206, headers: { ...headers, 'content-length': String(end - start + 1), 'content-range': `bytes ${start}-${end}/${size}` } } +} diff --git a/apps/buddy/service/resources/skills/plugin-creator/SKILL.md b/apps/buddy/service/resources/skills/plugin-creator/SKILL.md index d62b96ea..1d67984d 100644 --- a/apps/buddy/service/resources/skills/plugin-creator/SKILL.md +++ b/apps/buddy/service/resources/skills/plugin-creator/SKILL.md @@ -1,6 +1,6 @@ --- name: plugin-creator -description: 创建、修改并验证 Lexora 桌面插件。当用户想通过对话制作插件、增加窗口特效、插件页面、通知或后台提醒,或修复已有插件时使用。也响应 $plugin-creator。普通任务执行、使用现有插件、其他产品的插件开发不属于此技能。 +description: 创建、修改并验证可安装的 Lexora 桌面插件,包括工作台装饰、导航与面板、文件阅读工具、控件样式、网络查询、通知和定时命令。也响应 $plugin-creator。普通任务执行、使用现有插件、其他产品的插件开发不属于此技能。 --- # 创建 Lexora 插件 @@ -9,16 +9,24 @@ description: 创建、修改并验证 Lexora 桌面插件。当用户想通过 ## 理解需求 -先利用当前对话确定触发时机、可见效果、设置和停止方式。描述充分时直接实施;只询问会改变实现的缺失信息。短需求选择轻量默认值并说明,不要求用户先填技术问卷。没有设置需求时优先复用插件管理中的启用、禁用,不额外增加导航和配置页面。 +先利用当前对话确定输入、触发时机、呈现位置与生效范围、需要保留的数据和停止方式。描述充分时直接实施;只询问会改变实现的缺失信息。短需求选择轻量默认值并说明,不要求用户先填技术问卷。没有设置需求时优先复用插件管理中的启用、禁用,不额外增加导航和配置页面。 -“打字时窗口出现礼花”默认指在 Lexora 对话输入时产生短暂效果,不是读取全局键盘。窗口效果使用插件自身绘制的隔离视图,由宿主提供不含文字和按键的输入活动。 +分别确定事件来自哪里、内容画在哪里、何时可见。事件来自某个控件,不意味着内容只能画在该控件内;保留用户指定的窗口、分屏或局部区域,不擅自缩小范围。未指定的视觉细节可以选择默认值;指定范围缺少正式能力时明确说明,不能用相近位置代替交付。 + +用户提供外部参考时先阅读公开说明或代码,提取需要复现的用户行为,再映射到 Lexora 的正式能力。参考项目的内部接口不能直接移植;读取失败时说明缺少的依据,不把推测写成已经核对的实现。 + +## 按需求组合能力 + +读取 [能力选择与组合](references/capabilities.md),将需求对应到输入来源、呈现方式、运行入口与状态归属,再确定最小清单。一个插件可以组合多种正式扩展点,也可以只提供命令或一个文件视图;现有示例不是插件类别或必须复制的模板。 + +先说明所选组合能完成的用户行为,再创作。文件阅读工具需要按文件打开的独立视图和各自的阅读状态;常驻信息条可能需要底部挂载、宿主查询命令与私有设置。不要因为都“有界面”就统一做导航页,也不要为了演示全部 API 给插件添加无关权限。 ## 阅读协议并创作 1. 读取 [协议与清单](references/protocol.md) 和 [完整 API 类型](references/api.d.ts)。这些文件与当前 Lexora 一起分发;不要根据其他产品的插件格式猜测接口。 -2. 在当前可写工作区创建独立源码目录,例如 `plugins/typing-confetti/`。通过文件工具写入真实文件,保留源码以便后续对话修改。不需要用户另外安装 Node、Bun 或 SDK。 +2. 在当前可写工作区创建独立源码目录,例如 `plugins/<插件名>/`。通过文件工具写入真实文件,保留源码以便后续对话修改。不需要用户另外安装 Node、Bun 或 SDK。 3. 使用稳定的 `发布者.插件名` ID。未指定发布者时使用 `local`;不要冒用官方发布者。修改已有插件保持原 ID,并提升版本。 -4. 根据能力选择页面、资源视图或窗口效果。仅声明实际需要的权限;没有对应能力时解释限制,不绕过隔离访问宿主 DOM、内部接口或文件。 +4. 按选定组合声明视图、命令、导航和挂载点,明确每项能力由宿主入口还是视图入口调用;限定页面时使用公开上下文和声明条件。仅声明实际需要的权限;没有对应能力时解释限制,不绕过隔离访问宿主 DOM、内部接口或文件。明确用户第一次如何启动、关闭后如何再次打开;仅声明视图或挂载点不会显示它。现有入口足够时不额外增加设置页。 5. 使用自包含 TS/JS 和包内资源。复制 `references/api.d.ts` 为源码目录的 `lexora.d.ts`,仅通过 `import type` 引用。源码清单设置 `format: "source"`。视图导出 `render(context, container)`,需要命令、存储或调度时再添加宿主入口 `activate(context)`。 6. 事件、定时器、动画在关闭时清理,动画结束回收节点、空闲时停止循环。页面使用主题变量;窗口效果保持透明、不阻挡输入,并尊重减少动态效果偏好。 @@ -29,17 +37,23 @@ description: 创建、修改并验证 Lexora 桌面插件。当用户想通过 如果当前明确是在独立 Lexora Plugin SDK 仓库中开发、没有 Lexora 内置工具,使用 [SDK 工作流](references/sdk-workflow.md)。普通 Lexora 对话不需要这条分支。 ```json -{ "source": "plugins/typing-confetti", "output": "typing-confetti-1.0.0.lexora-extension", "review": true } +{ "source": "plugins/my-plugin", "output": "my-plugin-1.0.0.lexora-extension", "review": true } ``` `output` 是已存在父目录中的新文件,放在源码目录之外;现有文件不会被覆盖。工具使用内置隔离编译器,不执行 npm 安装脚本或插件代码。错误结果包含 `code` 与有限的 `diagnostics`:修正对应问题后重新构建,避免重复提交相同错误。需要重新生成包时使用新的输出文件名。 -`review: true` 请求显示安装确认,用户确认后才会安装。只要求源码或打包时设为 `false`。不要绕过确认或通过内部配置安装。工具返回 `installed: false`,不代表安装完成。 +`review: true` 请求显示安装确认,用户确认后才会安装。只要求源码或打包时设为 `false`。不要绕过确认或通过内部配置安装。构建结果中的 `installation` 只表示是否发起审核,不代表当前安装状态;旧版本返回的 `installed: false` 也不能用于断言用户尚未安装。 + +需要判断安装、待应用更新或加载失败时,通过 `lexora_tool_search` 查找 `lexora_plugin_inspect`,以插件 `id` 读取当前宿主状态。比较当前版本与 `pendingVersion`,检查可见命令、导航入口、视图 ready 和错误。仍待用户审核时交付结果即可,不循环轮询;工具不可用时明确安装状态未核实。ready 只证明入口完成加载,不能代替实际功能验收。 通过 `lexora_output_present` 交付源码目录与安装包。不要只在回答中粘贴代码,也不要下载 SDK 或建立另一套构建工程。 ## 验证与迭代 -选择 2–3 个与需求直接相关的检查:主要动作、停止或禁用、配置保存或关闭页面后的行为。编译通过只证明包可构建,不证明界面和运行效果通过。Lexora 的浏览器工具只能控制资源浏览器,不能操作插件安装弹窗或窗口效果;不要为此创建另一个 HTML 模拟宿主来代替安装验收。有能操作真实插件界面的工具时执行检查;否则交付编译后的包和待验证项,让用户安装后反馈,不在等待安装期间反复调用无关工具。不要编造截图、通知送达、运行成功或性能数据。 +按所选能力检查主要动作,以及真正影响它的边界:文件视图验证两份资源的状态互不覆盖;组合面板验证导航切换和设置刷新;控件验证宿主允许值、失败回退与明确重试;后台命令验证关闭页面后仍按约定运行。再验证禁用后的停止行为,避免机械套用某个示例的测试步骤。 + +有持久设置时,检查修改后的实际行为、离开页面再返回及重启后的恢复;开关需要验证关闭与重新开启,不能只确认表单显示成功提示。 + +编译通过只证明包可构建,不证明界面和运行效果通过。Lexora 的浏览器工具只能控制资源浏览器,不能操作插件安装弹窗或窗口效果;不要为此创建另一个 HTML 模拟宿主来代替安装验收。有能操作真实插件界面的工具时执行检查;否则交付编译后的包和待验证项,让用户安装后反馈,不在等待安装期间反复调用无关工具。不要编造截图、通知送达、运行成功或性能数据。 交付简短说明:完成了什么、源码与包的位置、如何触发、已检查和仍待体验的行为。用户反馈后读取现有源码,修正实现、重新编译并再次验证相同场景。保持 ID 与数据含义稳定。发布市场或同步远端单独遵循用户指示。 diff --git a/apps/buddy/service/resources/skills/plugin-creator/references/api.d.ts b/apps/buddy/service/resources/skills/plugin-creator/references/api.d.ts index 3eeafffc..ae0bcbe4 100644 --- a/apps/buddy/service/resources/skills/plugin-creator/references/api.d.ts +++ b/apps/buddy/service/resources/skills/plugin-creator/references/api.d.ts @@ -2,6 +2,20 @@ export type Json = null | boolean | number | string | Json[] | { [key: string]: export interface Disposable { dispose: () => void | Promise } export interface Resource { readonly id: string, readonly name: string } export interface ResourceApi { readText: (resource: Resource) => Promise } +export interface LocalFile extends Resource { readonly mimeType: string, readonly size: number, readonly relativePath?: string } +export interface LocalDirectory extends Resource {} +export interface LocalResourceApi extends ResourceApi { + pickFiles: (options?: { filters?: { name: string, extensions: string[] }[], multiple?: boolean }) => Promise + listFiles: () => Promise + getUrl: (resource: Pick) => Promise + readBytes: (resource: Pick, options?: { offset?: number, length?: number }) => Promise<{ data: Uint8Array, size: number, eof: boolean }> + revokeFile: (resource: Pick) => Promise + pickDirectory: () => Promise + listDirectories: () => Promise + scanDirectory: (directory: Pick, options?: { extensions?: string[], recursive?: boolean }) => Promise + revokeDirectory: (directory: Pick) => Promise + saveFile: (options: { name: string, data: Blob | ArrayBuffer | Uint8Array }) => Promise +} export interface NetworkApi { get: (url: string) => Promise<{ status: number, text: string }> } export interface ScheduleInput { id: string, command: string, enabled: boolean, intervalMinutes: number } export interface Schedule extends ScheduleInput { nextRunAt: number | null } @@ -11,9 +25,10 @@ export interface ScheduleApi { remove: (id: string) => Promise } export interface ExtensionContext { - readonly extension: { readonly id: string, readonly version: string, readonly apiVersion: 1 } + readonly extension: { readonly id: string, readonly version: string, readonly apiVersion: 1 | 2 } readonly subscriptions: { add: (disposable: T) => T } readonly commands: { register: (id: string, execute: (context: { resource: Resource | null, arguments: Json }) => Json | void | Promise) => Disposable } + readonly placements: { show: (id: string) => Promise, hide: (id: string) => Promise } readonly views: { open: (type: string, options?: { resource?: Resource | null, state?: Json }) => Promise } readonly resources: ResourceApi readonly network: NetworkApi @@ -21,19 +36,72 @@ export interface ExtensionContext { readonly notifications: { show: (notification: { title: string, body: string }) => Promise } readonly schedules: ScheduleApi } +export interface AnchorGeometry { + readonly kind: 'app.sidebar' | 'workbench.sidebar' | 'workbench.pane' | 'composer.input' + readonly visible: boolean + readonly width: number + readonly height: number +} +export interface ControlSnapshot { + readonly revision: string + readonly value: string | null + readonly options: readonly { readonly value: string, readonly label: string }[] + readonly disabled: boolean +} +export type MountTarget = 'workbench' | 'app.sidebar' | 'workbench.sidebar' +export type MountLength = number | `${number}%` | null +export interface ViewPresentation { + target?: MountTarget + position?: 'static' | 'absolute' + order?: number + zIndex?: number + width?: MountLength + height?: MountLength + top?: MountLength + right?: MountLength + bottom?: MountLength + left?: MountLength +} +export interface MountGeometry { + readonly target: MountTarget + readonly visible: boolean + readonly width: number + readonly height: number + readonly rect: { readonly x: number, readonly y: number, readonly width: number, readonly height: number } +} +export type WorkbenchContextValues = Readonly> +export type WorkbenchCondition = Record +export interface WorkbenchContextSnapshot { + readonly values: WorkbenchContextValues + readonly pages: readonly { readonly id: string, readonly title: string }[] +} export interface ViewContext { + readonly workbench: WorkbenchContextSnapshot + onWorkbenchChange: (listener: (snapshot: WorkbenchContextSnapshot) => void) => Disposable + readonly visible: boolean + onVisibilityChange: (listener: (visible: boolean) => void) => Disposable + readonly mount: MountGeometry | null + onMountChange: (listener: (mount: MountGeometry) => void) => Disposable + readonly anchor: AnchorGeometry | null + onAnchorChange: (listener: (anchor: AnchorGeometry) => void) => Disposable + readonly control: { + readonly snapshot: ControlSnapshot + onChange: (listener: (snapshot: ControlSnapshot) => void) => Disposable + propose: (value: string, revision?: string) => Promise + } | null readonly environment: { language: string, colorScheme: 'light' | 'dark', colors: Record } onEnvironmentChange: (listener: (environment: ViewContext['environment']) => void) => Disposable - readonly apiVersion: 1 + readonly apiVersion: 1 | 2 readonly resource: Resource | null readonly state: Json readonly stateVersion: number readonly expectedStateVersion: number readonly signal: AbortSignal - onActivity: (listener: (event: { readonly type: 'composer-input' }) => void) => Disposable - readonly resources: ResourceApi + onActivity: (listener: (event: { readonly type: 'composer-input', readonly caret?: { readonly x: number, readonly y: number, readonly width: number, readonly height: number } | null }) => void) => Disposable + readonly resources: LocalResourceApi readonly network: NetworkApi readonly commands: { execute: (command: string, args?: Json) => Promise } + setPresentation: (presentation: ViewPresentation) => Promise setState: (state: Json) => Promise } export interface ExtensionModule { diff --git a/apps/buddy/service/resources/skills/plugin-creator/references/capabilities.md b/apps/buddy/service/resources/skills/plugin-creator/references/capabilities.md new file mode 100644 index 00000000..fa88c89b --- /dev/null +++ b/apps/buddy/service/resources/skills/plugin-creator/references/capabilities.md @@ -0,0 +1,36 @@ +# 按需求选择插件能力 + +先决定数据从哪里来、用户在哪里操作、关闭页面后是否继续运行、哪些状态需要恢复。按下表选择能力,再查 [协议](protocol.md) 的清单字段和 [API 类型](api.d.ts) 的准确签名。能力可组合,但不必全部使用。 + +| 用户需要 | 清单与入口 | 状态和边界 | +| --- | --- | --- | +| 对当前文件提供阅读、统计或专用展示 | `selectedResource: "read"`;`resource: "selected-file"` 的视图;宿主可见命令将收到的 `resource` 传给 `views.open`,视图用 `resources.readText(context.resource)` 读取 | 用户先打开文件,再执行插件命令。句柄不包含任意路径,每次打开是独立实例;不能枚举目录、读其他视图的文件或写回文件 | +| 执行一次动作,无需常驻页面 | 宿主 `activate` 注册 `contributes.commands` 中的命令 | 可见命令进入命令面板和已安装插件卡片的“运行命令”菜单;命令收到的 `resource` 可能为空。只供自己视图调用的命令设为 `hidden: true` | +| 目录入口、较完整的页面或设置表单 | `contributes.navigation` 引用 `resource: "none"` 的视图 | API 2 可省略视图 `location`;页面由 `render` 绘制。需要持久配置时通过自身隐藏命令调用宿主私有存储 | +| 将自定义内容挂到页面区域 | `kind: "view"`、`target` 与 `presentation`;宿主命令调用 `placements.show/hide` | 区域包括 `workbench`、`app.sidebar`、`workbench.sidebar`。插件渲染隔离内容,通过通用外框布局接口声明定位和尺寸。挂载口没有面板 UI 或交互 | +| 限定生效页面或响应页面变化 | 贡献的 `when`;视图的 `workbench/onWorkbenchChange` 与 `visible/onVisibilityChange` | 按稳定页面 ID 与公开上下文匹配;离开范围隐藏而保留实例,业务暂停或继续由插件决定。未知上下文不匹配,不读取父页面 DOM | +| 自定义浮动内容或运行时换区域 | `presentation.position: "absolute"`;`setPresentation` 更新目标及外框 CSS 布局;`mount/onMountChange` 获取局部几何 | 拖动、折叠、恢复入口等由插件自行实现。宿主不理解这些行为,只维持独立实例、投影和边界;换位置保留会话,关闭销毁 | +| 用户选择的文件或目录 | `localResources: true`;交互视图的 `resources.pickFiles/pickDirectory` | 返回不透明句柄;任意文件格式都可读取。以 `scanDirectory` 按需筛选授权目录,`readText/readBytes/getUrl` 分别用于文本、分段二进制和受控 URL;只保存 ID,URL 按视图重新获取 | +| 将编辑结果或生成内容保存到本机 | `resourceExport: true`;交互视图调用 `resources.saveFile({name,data})` | 每次弹出系统另存为窗口,用户决定目标;取消返回 false。文本先编码为 Blob 或字节。读取授权不授予原文件覆写权限;保存成功后才报告完成 | +| 局部装饰、输入位置附近的短暂动画 | `windowEffects: true`;`kind: "decoration"` 与正式 `anchor` | 使用 `anchor/onAnchorChange` 的局部尺寸;输入活动经 `onActivity` 提供,可带局部 caret。不获取字符或全局键盘,不接收指针或焦点 | +| 在输入所在的整个分屏内绘制装饰 | `windowEffects: true`;`kind: "decoration"`、`anchor: "workbench.pane"` | 每个分屏独立实例,画布覆盖该分屏;只接收该分屏内的对话输入活动,caret 相对分屏。绘制位置由插件决定,不受输入框边界限制;不接收指针或焦点 | +| 整个 Lexora 窗口上的透明效果 | `windowEffects: true`;`resource: "none"`、`location: "window-overlay"` | API 1 和 API 2 均可用,每插件一个全窗口实例;收到无坐标的对话输入活动,不区分分屏。插件按窗口尺寸绘制,不接收指针或焦点 | +| 改变宿主控件的操作方式 | `controls: ["model.reasoning"]`;`kind: "control"`、`target: "model.reasoning"` | 安装后由用户选择样式。读取 `control.snapshot/onChange`,以当前快照 revision 提交 `propose`;值和允许选项归宿主管理,不能自建一份业务真源 | +| 读取远端公开信息 | 声明实际需要的 HTTPS origins,以宿主或视图的 `network.get` 获取 | 只有 GET,不携带浏览器登录态;直接 fetch、远程图片或媒体不替代网络代理。需要其他方法、凭据或实时连接时先说明能力缺口 | +| 关闭页面后仍定期执行插件动作 | `schedules: true`、宿主入口与已声明命令;`schedules.get/set/remove` | 定时任务归应用,最短一分钟;应用退出时暂停,错过的不补发。页面计时器只适合页面自身交互,不承担后台任务 | +| 系统提示 | `notifications: true`;宿主 `notifications.show` | 通知受用户全局开关和限速约束,返回 false 不表示插件崩溃。按真实需要与命令、调度组合 | + +## 状态与模块 + +- `activate(context)` 管理命令、私有设置、通知和定时任务;`render(context, container)` 管理自己的隔离 UI。只需要视图时不创建空宿主;需要命令时必须同时声明宿主入口和命令 ID。 +- 资源页签及普通挂载面板用 `ViewContext.state/setState` 保存该实例的筛选、阅读位置等 JSON。升级比较 `stateVersion` 与 `expectedStateVersion`,转换后再保存。导航页面、装饰和替换控件不支持这条持久化路径。 +- 挂载目标和外框布局通过 `setPresentation` 更新;内容样式、交互及业务状态完全由插件实现,业务状态使用 `setState` 或宿主私有存储。宿主不会依据插件数量提供面板切换或自动避让。不能用父页面 CSS 选择器或 Vue Teleport 穿透隔离。 +- 扩展级配置用宿主 `storage.get/set`。视图通过自身命令读写,不访问宿主内存;更改数据结构时增加 `dataVersion` 并提供 `migrate`。不要把一个文件的阅读位置存成覆盖所有文件的全局值。 +- 每个视图具有独立 JS 环境。没有通用跨视图广播;需要设置同步时设计明确刷新入口或有界的读取周期,并在销毁时取消,不能承诺不存在的订阅事件。源码中的纯函数可通过包内相对导入复用。 +- 视图通过 `signal` 清理监听、定时器、音频和异步回写;销毁时不要再异步保存状态,正常交互中及时保存,并处理保存失败;宿主通过 `subscriptions` 释放注册。恢复视图不代表自动播放音频或重放上次用户提交。 + +## 组合验收 + +验收从用户行为出发,不以采用某个样例的文件名或界面作为成功依据。例如文件阅读工具应验证两份不同文件、空内容、各自筛选状态及重开恢复;网络信息条应验证允许来源、请求失败后的状态、关闭与再次显示;自定义控件应验证当前快照、禁用或隐藏时提交被拒绝,以及故障后内置控件可用。 + +对每项需求明确已有 API 是否覆盖。没有任意插槽、父页面 DOM、静默覆写用户文件、通用 IPC 或 Agent 调用权限;核心要求超出已开放能力时先明确缺口与可实现范围,不能把样例资源或相近布局当作已经满足核心要求,也不用内部接口模拟完整实现。 diff --git a/apps/buddy/service/resources/skills/plugin-creator/references/protocol.md b/apps/buddy/service/resources/skills/plugin-creator/references/protocol.md index 4578c6dc..b930b1a6 100644 --- a/apps/buddy/service/resources/skills/plugin-creator/references/protocol.md +++ b/apps/buddy/service/resources/skills/plugin-creator/references/protocol.md @@ -1,35 +1,38 @@ -# Lexora 插件 API 1 +# Lexora 插件 API 2 一个包包含根 `extension.json` 和包内文件。源码包支持自包含 `.ts`、`.mts`、`.js`、`.mjs`,以及静态 CSS、图片等资源;原始 HTML、Vue 单文件组件、npm 裸导入不属于当前编译协议。第三方库需作者预先打包。不要生成 `package.json`、锁文件、构建脚本、`node_modules` 或远程 CDN 依赖。 ## 清单 -窗口效果的最小清单(名称、ID、入口按实际需求修改): +清单结构如下;按需求补齐 `contributes`、入口和权限,空清单只是结构说明,不能作为成品交付。能力组合见 [能力选择](capabilities.md)。 ```json { "schemaVersion": 1, "format": "source", - "id": "local.typing-confetti", - "name": "打字礼花", - "description": "在对话输入时显示短暂礼花。", + "id": "local.my-plugin", + "name": "插件名称", + "description": "插件完成的用户行为。", "version": "1.0.0", - "apiVersion": 1, - "engines": { "lexora": ">=0.7.3 <1.0.0" }, - "permissions": { "windowEffects": true }, - "contributes": { - "views": [{ - "id": "local.typing-confetti.effect", - "title": "打字礼花", - "entry": "effect.ts", - "location": "window-overlay", - "resource": "none" - }] - } + "apiVersion": 2, + "engines": { "lexora": ">=0.8.7 <1.0.0" }, + "permissions": {}, + "contributes": {} } ``` -可选字段:`icon`(包内 SVG/PNG/JPEG/WebP,≤64 KiB)、`categories`、`tags`、宿主 `entry`、`dataVersion`。不要添加未定义的字段。命令和视图 ID 以插件 ID 加 `.` 开头且不重复。文件路径相对包根,不能包含 `..` 或符号链接;512 文件、单文件 4 MiB、合计 16 MiB 上限。 +可选字段:`icon`(包内 SVG/PNG/JPEG/WebP,≤64 KiB)、`categories`、`tags`、宿主 `entry`、`dataVersion`。不要添加未定义的字段。命令、视图和挂载声明的 ID 以插件 ID 加 `.` 开头且不重复。文件路径相对包根,不能包含 `..` 或符号链接;512 文件、单文件 4 MiB、合计 16 MiB 上限。 + +`contributes` 内的声明结构如下。省略不用的项;每项权限和运行方式见后文,无需查找应用实现或复制某个示例清单。 + +| 字段 | 结构 | +| --- | --- | +| `commands` | 数组,每项必填 `id`、`title`;`hidden` 可选,默认 false。存在命令时必须设置清单顶层的宿主 `entry` | +| `views` | 数组,每项必填 `id`、`title`、`entry`;`resource` 为 `selected-file`(默认)或 `none`;`location` 默认 `context`;`stateVersion` 为正整数,默认 1 | +| `placements` | 数组,每项必填 `id`、`view`、`kind`,并按 kind 添加后文规定的 `anchor`、`target` 和 `presentation`;不要把它们写入视图定义 | +| `navigation` | 单个对象,必填 `title`、`view`,引用自己的无资源视图 | + +插件 ID 使用小写字母开头的两段名称,中间以 `.` 分隔,每段可含小写字母、数字与 `-`;贡献 ID 在插件 ID 后追加由小写字母、数字、`.`、`-` 组成的后缀。声明文件路径使用包内相对路径,`.ts` 文件名直接指向源码,编译工具负责产出运行文件。 ## 视图类型 @@ -39,35 +42,108 @@ | `context` | `selected-file` 或 `none` | 工作区资源页签,由用户打开 | | `window-overlay` | `none` | 启用后自动挂载的透明窗口效果,每插件最多一个;无法点击或聚焦 | -页面与小视图由插件绘制,均使用 `render(context, container)`,共用隔离和通信 API。窗口效果是当前已支持的独立挂载场景;没有看板宿主或任意宿主 DOM 插槽。 +页面与小视图由插件绘制,均使用 `render(context, container)`,共用隔离和通信 API。API 2 将视图内容与挂载位置分开,通过 `contributes.placements` 选择正式扩展点;`page` 和 `window-overlay` 在 API 1、API 2 中均可使用。插件不能查询或修改宿主 DOM。 + +装饰、替换控件和普通挂载面板的 `container` 默认填满隔离视图,无内边距,支持子元素使用百分比高度。装饰的绘制范围仍受锚点及祖先裁剪约束;页面保持普通文档流,可按内容滚动。 -导航入口位于自动化下方,声明 `contributes.navigation: { "title": "插件名称", "view": "local.plugin.settings" }`,引用一个 `page` 视图。不需要设置页的效果插件可以没有导航。 +导航入口位于自动化下方,声明 `contributes.navigation: { "title": "插件名称", "view": "local.plugin.settings" }`,引用一个 `resource: "none"` 的视图(API 1 仍需 `location: "page"`)。不需要设置页的效果插件可以没有导航。 -页面与窗口效果不能使用 `context.setState()`;需要持久设置时声明宿主入口和隐藏命令,以 `context.storage.get/set` 保存。多个视图使用同一设置时明确刷新时机,插件局部开关必须能在不重载视图的情况下关闭并重新启用。资源页签的 `setState` 用于保存该页签的 JSON 状态。 +导航页面、装饰和替换控件不能使用 `context.setState()`;需要持久设置时声明宿主入口和隐藏命令,以 `context.storage.get/set` 保存。多个视图使用同一设置时明确刷新时机,插件局部开关必须能在不重载视图的情况下关闭并重新启用。资源页签的 `setState` 用于保存该页签的 JSON 状态。 ## 权限 | permissions 字段 | 能力 | | --- | --- | -| `windowEffects: true` | 窗口叠加绘制以及无内容的对话输入活动 | +| `windowEffects: true` | 锚点装饰、窗口效果与无内容的对话输入活动 | +| `controls: ["model.reasoning"]` | 提供可由用户选用的思考等级控件 | | `notifications: true` | 宿主 `context.notifications.show({title,body})` | | `schedules: true` | 宿主持久间隔任务,需要宿主入口与已声明命令 | | `selectedResource: "read"` | 读取明确分配给视图的文件资源 | +| `localResources: true` | 用户通过系统窗口选择文件或目录,视图读取文本、二进制或受控 URL | +| `resourceExport: true` | 用户通过系统另存为窗口确认目标后,保存插件提供的字节 | | `network: ["https://example.com"]` | 通过宿主向列出的 HTTPS origin 发 GET 请求 | 未声明权限默认为关闭。视图无法访问 Node、宿主 DOM、全局键盘、网络 fetch 或任意本地文件。资源和网络调用通过 API;用户在安装确认中查看权限。没有一般性消息订阅、系统剪贴板或屏幕截图接口。 ## 窗口效果 -视图入口形式为 `export function render(context: ViewContext, container: HTMLElement)`,类型使用 `import type { ViewContext } from './lexora'`。在 `render` 内调用 `context.onActivity(listener)`,监听 `{type:"composer-input"}` 并在自己的 `container` 内绘制实际效果。 +视图入口形式为 `export function render(context: ViewContext, container: HTMLElement)`,类型使用 `import type { ViewContext } from './lexora'`。在 `render` 内调用 `context.onActivity(listener)`,在自己的 `container` 内绘制实际效果。输入锚点事件为 `{type:"composer-input", caret:{x,y,width,height}|null}`,坐标相对当前锚点。 -`onActivity` 仅用于 `window-overlay`。事件没有字符、按键、文本长度、坐标或任务 ID,最多约每秒 20 次,只来自前台 Lexora 对话输入框,包括输入法输入。宿主过滤程序性修改、其他输入框和减少动态效果模式。不要注册父窗口键盘事件。 +`context.anchor` 和 `onAnchorChange` 提供 `{kind,visible,width,height}`,不包含屏幕坐标。呈现范围与事件来源按下表选择,不能因输入触发就默认选输入框装饰: + +| 呈现范围 | 声明 | 活动与坐标 | +| --- | --- | --- | +| 输入框内部 | `kind: "decoration"`、`anchor: "composer.input"` | 只接收该输入框活动,caret 相对输入框,绘制裁剪在输入框内 | +| 整个输入所在分屏 | `kind: "decoration"`、`anchor: "workbench.pane"` | 每个分屏独立实例,只接收自己内部的对话输入活动,caret 相对该分屏;可以覆盖对话区域,不能越过分屏边界 | +| 整个 Lexora 窗口 | 视图 `location: "window-overlay"` | API 1、API 2 均只收到 `{type:"composer-input"}`,不含来源分屏与坐标;插件自行选择窗口内的位置 | + +分屏装饰随分屏创建、改变尺寸和关闭;页面隐藏时保留实例并更新可见性。事件绑定输入发生的分屏,不随之后的焦点切换转交其他分屏;插件无需读取任务或分屏 ID。多个分屏及多个插件的实例各自隔离。此锚点提供透明装饰区域,不提供交互面板外壳。 + +事件没有字符、按键、文本长度或任务 ID,最多约每秒 20 次,只来自前台 Lexora 对话输入框,包括输入法输入。宿主过滤程序性修改、其他输入框和减少动态效果模式。不要注册父窗口键盘事件。 宿主保持 iframe 透明且不拦截鼠标或焦点。插件使用 DOM、Canvas、Web Animations 或 requestAnimationFrame 绘制。保持 `html/body` 透明,不铺不透明全屏背景;无操作时不持续刷新。禁用、卸载和窗口销毁会关闭视图。通过 `context.signal` 清理事件、计时器与动画;粒子和同时运行的动画必须有上限。 +## 正式挂载点 + +`placements` 中的 `view` 引用本插件的 `resource: "none"` 视图定义,省略 `location`(默认 `context`)。每种挂载均使用独立的隔离视图实例。 + +| kind | 必填字段 | 行为 | +| --- | --- | --- | +| `decoration` | `anchor` | 自动挂载至 `app.sidebar`、`workbench.sidebar`、`workbench.pane` 或 `composer.input`;每个锚点实例分别挂载,只在该区域内绘制,不接收指针和焦点 | +| `view` | `target` | `workbench`、`app.sidebar` 或 `workbench.sidebar`;`presentation` 描述区域内布局;宿主入口调用 `context.placements.show(id)` / `hide(id)` 显示或关闭 | +| `control` | `target` | 当前支持 `model.reasoning`;用户在控件样式选择中启用后才替换内置控件 | + +挂载口只提供容器,不提供面板外壳或交互。声明示意:`{id,view,kind:"view",target:"workbench",presentation:{position:"absolute",width:420,height:180,right:16,bottom:16}}`。三个目标分别属于持续存在的工作台、应用侧栏和任务侧栏。挂载口暂时不可用时保留实例,恢复后继续投影。插件自行渲染全部内容、样式与交互;插件之间保留独立实例,宿主不生成切换器、不自动避让或折叠。 + +`presentation` 是隔离视图外框的布局声明:`position` 为 `static`(默认,正常布局)或 `absolute`(相对挂载口);`order` 为 -1000 至 1000(默认 1,与核心内容的 0 比较);`zIndex` 为 0–20 的扩展层内顺序,不覆盖核心弹窗。`width/height/top/right/bottom/left` 使用 0–8192 的像素数或 0%–100% 字符串;`null` 清除该 CSS 值。未指定尺寸时宽度 100%、高度 56px,最大尺寸及可见内容受目标容器边界约束。内容内部自由使用 DOM/CSS、Canvas 等;不访问父页面 DOM。 + +`context.setPresentation({...})` 合并更新上述字段,传 `target` 可转移挂载口。实例、会话与状态保留;插件的定时器、媒体等不会因为换位置而重建。布局值由宿主持久化,业务状态由插件自己保存。`context.mount` / `onMountChange` 提供目标容器宽高、可见性和当前视图的局部矩形,供插件响应尺寸变化或实现拖动等交互,不包含屏幕坐标、其他插件信息或父 DOM。 + +宿主不认识“折叠”“展开”“贴边”或“播放器”。需要这些效果时,插件自己渲染触发入口、控制布局并保存自己的状态。缩小视图外框不销毁内容;调用宿主命令中的 `placements.hide` 才关闭实例。插件负责再次打开的入口,可以使用自己的可见 UI、已声明命令或导航。不要依赖宿主替插件提供关闭栏或恢复按钮。 + +旧版 `location: workbench.top/workbench.bottom` 与 `height` 在读取时转换为工作台的 static/-1 或 static/1,旧悬浮声明转换为 absolute。新源码使用 target/presentation,不增加业务专用 location。`control.height` 仍为 32–160,默认 64。 + +挂载点 `id` 是升级和恢复的稳定身份,更新时不要随意改名。同一 `id` 调整挂载位置或更换视图定义后,宿主保留实例及旧状态;需要转换状态时,比较 `stateVersion` 与 `expectedStateVersion` 并调用 `setState` 保存转换结果。移除贡献或禁用插件不会清空已保存的视图状态。 + +控件通过 `context.control.snapshot` 读取 `{revision,value,options,disabled}`,`onChange` 订阅变化。`options` 是宿主允许的值及标签;插件不获取模型或任务身份,也不直接写入业务状态。提交用户选择使用 `control.propose(value, renderedSnapshot.revision)`;宿主拒绝旧版本、不可用值、禁用或隐藏控件的请求,窗口隐藏时也不能提交。请求失败后读取最新快照重新呈现,不重试旧选择。加载失败、失去响应、禁用或缺失时恢复内置控件,保留用户样式选择。关闭再打开弹层不会重试失败控件;用户重新选择样式或重启插件后才恢复加载。 + +组合视图可用导航页管理设置、挂载面板展示持续内容、隐藏命令连接私有存储。设置更新后的刷新时机由插件明确处理,页面之间没有隐式共享 JS 状态。 + +## 页面上下文与生效条件 + +视图通过 `context.workbench` 读取只读快照:`values` 是公开语义上下文,`pages` 列出已登记页面的稳定 `id/title`。`onWorkbenchChange` 订阅变化。当前 `page.id` 使用 `lexora.tasks`、`lexora.automations`、`lexora.extensions`、`lexora.settings` 或 `extension:<插件ID>`;自动化和设置通过 `page.section` 提供子页标识,如 `plans`、`history`、`general`、`models`。其他页面不保留上一个页面的 section。未来页面通过登记扩展,不以名称、URL 或 DOM 选择器识别。这里不提供任务正文、输入内容、文件路径、路由参数或 Provider/模型身份。 + +`contributes.views`、`placements`、`commands` 和 `navigation` 可声明可选的 `when`。例如 `"when": {"page.id": ["lexora.tasks", "lexora.automations"]}`;对象中的不同 key 为 AND,数组内为 OR,标量严格相等,缺失 key 不匹配。空对象或省略表示不限定,不执行表达式代码。最多 32 个 key,每个数组最多 32 项,值限字符串(256 字符)、布尔和有限数字。视图定义与挂载的条件同时满足才显示。只限定某个挂载实例时优先在 placement 声明;view 的条件影响该定义的所有实例,navigation 的条件只限定入口。 + +条件只控制 UI 的可用性,不授予权限、不启动插件,也不阻止插件自身调用命令或后台调度。命令菜单与导航入口随上下文更新。首次不满足条件的视图不激活;已打开视图离开范围时隐藏,挂载面板退出布局占位,实例、状态与会话保留,返回后恢复。控件不匹配时使用内置控件并拒绝旧提交。`placements.hide` 仍表示关闭实例,与条件隐藏不同。 + +`context.visible/onVisibilityChange` 表达当前视图实际可见性(包含页面范围、区域隐藏和窗口隐藏)。插件自行决定隐藏时暂停动画、查询或媒体,宿主不推断业务行为。订阅随视图销毁自动释放;插件也可提前调用返回对象的 `dispose()`。受范围限制的功能应验收首次不匹配、进入、离开、返回、子页切换和重启后的条件恢复。 + +## 本地资源与导出 + +资源授权与文件格式无关。宿主不限制业务格式,不解析文档或工程文件;插件负责解析、显示和编辑。`mimeType` 是根据扩展名给出的提示,未知格式为 `application/octet-stream`,不是授权白名单。文件选择的 `filters: [{name,extensions}]` 与目录扫描的 `extensions` 由插件按需求提供;省略筛选即接受所有格式。 + +声明 `localResources: true` 后,在可见的交互视图中响应用户操作调用 `resources.pickFiles({filters?,multiple?})` 或 `resources.pickDirectory()`。取消分别返回 `[]` 和 `null`。宿主只接受系统窗口确认的路径,插件不能提交或取得绝对路径。文件句柄为 `{id,name,mimeType,size,relativePath?}`,目录句柄为 `{id,name}`;单次最多选择 100 个文件,单文件不超过 2 GiB,每插件最多 1000 个文件句柄和 32 个目录。 + +`resources.listFiles/listDirectories` 返回已有授权。`scanDirectory(directory,{extensions?,recursive?})` 返回授权根内的文件句柄及相对路径,默认递归,跳过符号链接,限制 10000 项与 16 层;超限明确报错,不静默截断。扫描负责刷新相同筛选范围,保留其他筛选范围的句柄;它不是自动监听。文件删除或变化后,刷新撤销旧句柄,变化的文件返回新 ID。独立选择和不同目录的授权分别管理。目录内引用可以根据返回的 `relativePath` 解析,但不能越过授权根。 + +按内容选择读取方式: + +- `resources.readText(file)`:UTF-8 文本,最多 1 MiB;更大文本或其他编码使用分段读取与插件自己的解码器。 +- `resources.readBytes(file,{offset?,length?})`:返回 `{data:Uint8Array,size,eof}`;默认 64 KiB,单次最多 128 KiB,适合二进制解析、增量加载和自定义格式。 +- `resources.getUrl(file)`:当前视图专用 URL,可供图片、音视频等元素或视图内 fetch 读取;支持 HEAD、Range 和 seek。只保存资源 ID,每次视图创建后重新取得 URL。不可作为脚本、样式或新页面执行。 + +授权跨重启保留;文件被移动、替换或修改后旧句柄读取失败,可重新选择或刷新已授权目录。`revokeFile(file)` 撤销单个句柄,保留的目录授权仍允许下次扫描重新发现它;`revokeDirectory(directory)` 撤销该目录及其派生句柄,其他独立授权保留。用户可从插件管理撤销全部本地资源授权,卸载也撤销。撤销、禁用及视图关闭会使相关 URL 和未完成的读取失效,不删除用户原文件。 + +保存另行声明 `resourceExport: true`。`resources.saveFile({name,data})` 接收建议文件名和 `Blob | ArrayBuffer | Uint8Array`,弹出系统另存为窗口;取消返回 false,完整写入后返回 true,失败抛错。目标由用户决定,插件得不到路径。写入先暂存,提交时验证目标未被其他程序修改,再替换;关闭、禁用、取消或未完整提交不会覆盖原文件。读取授权不包含静默写回或目录批量写入。 + +包内静态资源使用 `new URL('./assets/file.ext', import.meta.url).href`,遵循包大小上限。音视频支持流式读取和 seek,具体解码能力取决于运行平台;复杂格式可使用包内自包含 JS 解析器。通用文件通道不等于内置任意格式解码器、原生模块或外部可执行程序支持。 + +验收实际读取、解析、保存结果和取消路径。涉及音视频时验证 metadata、实际进度与 seek;关闭时停止工作、清除 URL 和定时器。交互期间保存进度,销毁后不得再异步回写状态。 + ## 页面、宿主与设置 -完整类型见同目录 `api.d.ts`。每个视图是独立沙箱。`render` 可异步;没有 `mount`、全局 `lexora` 或 Vue 运行时。 +完整类型见同目录 `api.d.ts`。每个视图是独立沙箱。`render` 可异步,前台加载须在 10 秒内完成;耗时内容先绘制加载状态,再异步填充。加载失败会结束该视图并提供重启入口,其他视图与宿主继续运行。没有 `mount`、全局 `lexora` 或 Vue 运行时。 宿主入口示意: @@ -81,6 +157,18 @@ export function activate(context: ExtensionContext) { 该命令须出现在 `contributes.commands` 中,例如 `{ "id":"local.example.read-settings", "title":"读取设置", "hidden":true }`。视图通过 `context.commands.execute(command, arguments)` 调用自身命令,返回 JSON。私有数据使用 JSON;更改结构时递增 `dataVersion` 并导出 `migrate(previous, from, to)`。 +文件工具需要一个可见命令作为打开入口。用户先在文件面板打开文件,再从命令面板执行它;宿主将收到的资源句柄传入视图: + +```ts +context.subscriptions.add(context.commands.register('local.example.open', async ({ resource }) => { + if (!resource) + throw new Error('请先打开一个文件') + await context.views.open('local.example.reader', { resource, state: {} }) +})) +``` + +该视图声明 `resource: "selected-file"`,插件声明 `selectedResource: "read"`;视图从 `context.resource` 取得同一资源,通过 `resources.readText` 读取。句柄不是文件路径。只声明视图不会自动增加文件菜单或命令入口。 + 后台提醒使用 `context.schedules.set({ id, command, enabled, intervalMinutes })`,间隔最少 1 分钟;不要用页面 setInterval 代替跨页面提醒。Lexora 退出后暂停,重启继续,错过的提醒不会补发。 ## 主题与样式 diff --git a/apps/buddy/service/src/plugins/__tests__/pluginAuthoringCapability.spec.ts b/apps/buddy/service/src/plugins/__tests__/pluginAuthoringCapability.spec.ts index a2b9856b..878d5e2e 100644 --- a/apps/buddy/service/src/plugins/__tests__/pluginAuthoringCapability.spec.ts +++ b/apps/buddy/service/src/plugins/__tests__/pluginAuthoringCapability.spec.ts @@ -45,7 +45,7 @@ it('writes an authorized installable output and requests review without installi const f = await fixture() const output = join(f.root, 'example.lexora-extension') const result = await f.execute({ source: 'source', output: 'example.lexora-extension', review: true }) - expect(result.details).toMatchObject({ ok: true, packagePath: output, installed: false, runtimeTested: false }) + expect(result.details).toMatchObject({ ok: true, packagePath: output, installation: 'review_requested', runtimeTested: false }) expect(unpackExtension(await readFile(output)).has('view.js')).toBe(true) expect(f.notifications).toEqual([{ path: output }]) }) diff --git a/apps/buddy/service/src/plugins/pluginAuthoringCapability.ts b/apps/buddy/service/src/plugins/pluginAuthoringCapability.ts index 120bb4b4..63b57a7f 100644 --- a/apps/buddy/service/src/plugins/pluginAuthoringCapability.ts +++ b/apps/buddy/service/src/plugins/pluginAuthoringCapability.ts @@ -9,10 +9,12 @@ import { Type } from 'typebox' import { Check } from 'typebox/value' import { readExtensionDirectory } from '../../../platform/extensions/extensionFiles' import { containsCanonicalPath } from '../../../platform/filesystem/filePaths' -import { EXTENSION_BUILD_RPC, EXTENSION_REVIEW_REQUEST, extensionBuildResultSchema } from '../../../shared/extensions/extensionAuthoring' +import { EXTENSION_BUILD_RPC, EXTENSION_INSPECT_RPC, EXTENSION_REVIEW_REQUEST, extensionBuildResultSchema, extensionInspectionSchema } from '../../../shared/extensions/extensionAuthoring' import { resolveGrantedPath } from '../directories/resolveGrantedPath' const name = 'lexora_plugin_build' +const inspectName = 'lexora_plugin_inspect' +const inspectParameters = Type.Object({ id: Type.String({ maxLength: 120, pattern: '^[a-z][a-z0-9-]*\\.[a-z][a-z0-9-]*$', description: 'Plugin ID to inspect. This only reads host status; it does not start or install the plugin.' }) }, { additionalProperties: false }) const parameters = Type.Object({ source: Type.String({ minLength: 1, maxLength: 4096, description: 'Plugin source directory containing extension.json, relative to the workspace or absolute.' }), output: Type.String({ minLength: 1, maxLength: 4096, description: 'New .lexora-extension file outside the source directory. Existing files are never overwritten.' }), @@ -22,16 +24,32 @@ const parameters = Type.Object({ export function createPluginAuthoringCapability(context: BuddyCapabilityContext, peer: Pick): BuddyCapability { return { classify(event) { + if (event.toolName === inspectName) + return Check(inspectParameters, event.input) ? { access: 'read', paths: [] } : { blocked: true, reason: 'VALIDATION_FAILED' } if (event.toolName !== name) return null if (!Check(parameters, event.input)) return { blocked: true, reason: 'VALIDATION_FAILED' } return { access: 'write', paths: [{ path: event.input.source, mode: 'existing' }, { path: event.input.output, mode: 'create' }] } }, - disclosure: { group: 'plugins', keywords: 'plugin extension build create 插件 创建 编译 校验 安装', toolNames: [name] }, + disclosure: { group: 'plugins', keywords: 'plugin extension build create inspect 插件 创建 编译 校验 安装 诊断', toolNames: [name, inspectName] }, extension: { name: 'lexora-plugin-authoring', factory(pi) { + pi.registerTool(defineTool({ + name: inspectName, + label: 'Inspect plugin', + parameters: inspectParameters, + description: 'Read a Lexora plugin installation, pending update, runtime views, errors and available entry commands from the actual desktop host. Does not install, activate, execute commands, read private plugin data or prove visual and media behavior.', + async execute(_toolCallId, input, signal) { + try { + const abort = signal ? AbortSignal.any([signal, context.signal]) : context.signal + const status = extensionInspectionSchema.parse(await peer.request(EXTENSION_INSPECT_RPC, input, 10000, abort)) + return response({ ok: true, ...status }) + } + catch { return response({ ok: false, code: 'EXTENSION_INSPECTION_FAILED' }) } + }, + })) pi.registerTool(defineTool({ name, label: 'Build plugin', @@ -74,7 +92,7 @@ export function createPluginAuthoringCapability(context: BuddyCapabilityContext, finally { await handle.close() } if (input.review) peer.notify(EXTENSION_REVIEW_REQUEST, { path: output.canonicalPath }) - return response({ ok: true, id: result.id, version: result.version, packagePath: output.canonicalPath, diagnostics, reviewRequested: input.review === true, installed: false, runtimeTested: false }) + return response({ ok: true, id: result.id, version: result.version, packagePath: output.canonicalPath, diagnostics, reviewRequested: input.review === true, installation: input.review ? 'review_requested' : 'not_requested', runtimeTested: false }) } catch (error) { const code = (error as { code?: string }).code ?? (error instanceof Error ? error.message : '') @@ -83,7 +101,7 @@ export function createPluginAuthoringCapability(context: BuddyCapabilityContext, }, })) pi.on('tool_result', (event) => { - if (event.toolName === name && event.details && typeof event.details === 'object' && 'ok' in event.details && event.details.ok === false) + if ([name, inspectName].includes(event.toolName) && event.details && typeof event.details === 'object' && 'ok' in event.details && event.details.ok === false) return { isError: true } }) }, diff --git a/apps/buddy/shared/extensions/extensionApi.ts b/apps/buddy/shared/extensions/extensionApi.ts index d508544f..d2d798a8 100644 --- a/apps/buddy/shared/extensions/extensionApi.ts +++ b/apps/buddy/shared/extensions/extensionApi.ts @@ -1,4 +1,5 @@ import type { JsonValue } from '../workbench/workbenchState' +import type { ControlProposal, WorkbenchPresentation } from '../workbench/workbenchUi' import type { ExtensionCatalogSnapshot } from './extensionCatalog' import type { ExtensionInstallation } from './extensionInstallation' import type { ExtensionManifest } from './extensionManifest' @@ -22,6 +23,7 @@ export const extensionViewInputSchema = z.object({ viewId: z.string().uuid(), extensionId: extensionIdSchema, viewType: z.string().max(180), + placementId: z.string().min(1).max(180).optional(), resource: extensionResourceSchema.nullable(), state: extensionJsonSchema, stateVersion: z.number().int().min(0).max(10000), @@ -67,8 +69,12 @@ export interface ExtensionReview { addedPermissions: string[] } export type ExtensionWorkbenchEvent - = | { kind: 'open', requestId: string, extensionId: string, viewType: string, resource: ExtensionResource | null, state: JsonValue, stateVersion: number } - | { kind: 'state', requestId: string, viewId: string, generation: string, state: JsonValue, stateVersion: number } + = | { kind: 'cancel', requestId: string } + | { kind: 'open', requestId: string, extensionId: string, generation: string, viewType: string, resource: ExtensionResource | null, state: JsonValue, stateVersion: number } + | { kind: 'state', requestId: string, viewId: string, generation: string, token: string, state: JsonValue, stateVersion: number } + | { kind: 'placement', requestId: string, extensionId: string, generation: string, placementId: string, visible: boolean } + | { kind: 'control', requestId: string, viewId: string, generation: string, token: string, proposal: ControlProposal } + | { kind: 'presentation', requestId: string, viewId: string, generation: string, token: string, presentation: WorkbenchPresentation } export const extensionManagementSchema = z.discriminatedUnion('action', [ z.object({ action: z.literal('list') }).strict(), @@ -83,6 +89,7 @@ export const extensionManagementSchema = z.discriminatedUnion('action', [ z.object({ action: z.literal('restart'), id: extensionIdSchema }).strict(), z.object({ action: z.literal('uninstall'), id: extensionIdSchema }).strict(), z.object({ action: z.literal('devtools'), id: extensionIdSchema }).strict(), + z.object({ action: z.literal('revokeResources'), id: extensionIdSchema }).strict(), z.object({ action: z.literal('execute'), id: extensionIdSchema, command: z.string().max(180), resource: spaceFileTargetSchema.nullable() }).strict(), z.object({ action: z.literal('openView'), view: extensionViewInputSchema }).strict(), z.object({ action: z.literal('closeView'), viewId: z.string().uuid(), generation: z.string().uuid(), token: z.string().uuid() }).strict(), @@ -102,6 +109,7 @@ export interface ExtensionApi { restart: (id: string) => Promise uninstall: (id: string) => Promise devtools: (id: string) => Promise + revokeResources: (id: string) => Promise execute: (id: string, command: string, resource: import('../spaces/spaceFileApi').SpaceFileTarget | null) => Promise openView: (view: ExtensionViewInput) => Promise closeView: (viewId: string, generation: string, token: string) => Promise diff --git a/apps/buddy/shared/extensions/extensionAuthoring.ts b/apps/buddy/shared/extensions/extensionAuthoring.ts index 32effe66..60c25ab5 100644 --- a/apps/buddy/shared/extensions/extensionAuthoring.ts +++ b/apps/buddy/shared/extensions/extensionAuthoring.ts @@ -1,7 +1,25 @@ import { z } from 'zod' +import { extensionIdSchema, extensionVersionSchema } from './extensionManifest' export const EXTENSION_BUILD_RPC = 'extensions.build' export const EXTENSION_REVIEW_REQUEST = 'extensions.reviewPackage' +export const EXTENSION_INSPECT_RPC = 'extensions.inspect' +export const extensionInspectRequestSchema = z.object({ id: extensionIdSchema }).strict() +export const extensionInspectionSchema = z.object({ + id: extensionIdSchema, + installed: z.boolean(), + version: extensionVersionSchema.nullable(), + pendingVersion: extensionVersionSchema.nullable(), + enabled: z.boolean(), + state: z.enum(['not-installed', 'disabled', 'inactive', 'activating', 'active', 'failed', 'blocked']), + error: z.string().nullable(), + commands: z.array(z.object({ id: z.string(), title: z.string() }).strict()).max(64), + navigation: z.string().nullable(), + views: z.array(z.object({ type: z.string(), placement: z.string().nullable(), ready: z.boolean(), error: z.string().nullable() }).strict()).max(512), + installations: z.array(z.object({ version: z.string(), status: z.string(), stage: z.string(), error: z.string().nullable() }).strict()).max(5), + logs: z.array(z.object({ time: z.string(), event: z.string(), code: z.string().optional(), durationMs: z.number().optional() }).strict()).max(50), +}).strict() +export type ExtensionInspection = z.infer export const extensionArchiveSchema = z.string().max(24 * 1024 * 1024).regex(/^[A-Z0-9+/]+={0,2}$/i) export const extensionBuildRequestSchema = z.object({ archive: extensionArchiveSchema }).strict() export const extensionBuildResultSchema = z.discriminatedUnion('ok', [ diff --git a/apps/buddy/shared/extensions/extensionInstallation.ts b/apps/buddy/shared/extensions/extensionInstallation.ts index 80a6e360..56c92a60 100644 --- a/apps/buddy/shared/extensions/extensionInstallation.ts +++ b/apps/buddy/shared/extensions/extensionInstallation.ts @@ -3,6 +3,8 @@ import { z } from 'zod' export const extensionInstallationSchema = z.object({ id: z.string().uuid(), name: z.string().max(120), + extensionId: z.string().max(120).optional(), + version: z.string().max(80).optional(), startedAt: z.string(), status: z.enum(['running', 'review', 'completed', 'failed', 'cancelled']), stage: z.enum(['download', 'validate', 'review', 'compile', 'install', 'completed']), diff --git a/apps/buddy/shared/extensions/extensionManifest.ts b/apps/buddy/shared/extensions/extensionManifest.ts index 86c84d68..0619d37b 100644 --- a/apps/buddy/shared/extensions/extensionManifest.ts +++ b/apps/buddy/shared/extensions/extensionManifest.ts @@ -1,7 +1,9 @@ import { satisfies, valid, validRange } from 'semver' import { z } from 'zod' +import { workbenchConditionSchema } from '../workbench/workbenchContext' +import { workbenchAnchorSchema, workbenchControlSchema, workbenchMountTargetSchema, workbenchPresentationSchema } from '../workbench/workbenchUi' -export const EXTENSION_API_VERSION = 1 +export const EXTENSION_API_VERSION = 2 export const EXTENSION_PROTOCOL = 'lexora-extension' export const EXTENSION_ICON_LIMIT = 64 * 1024 export const extensionIconUrlSchema = z.string().max(Math.ceil(EXTENSION_ICON_LIMIT / 3) * 4 + 40).regex(/^data:image\/(?:svg\+xml|png|jpeg|webp);base64,[A-Za-z0-9+/]+={0,2}$/) @@ -12,11 +14,39 @@ export const extensionPathSchema = z.string().min(1).max(256).refine((value) => return /^[\w./-]+$/.test(value) && !value.startsWith('__') && parts.every(part => part && part !== '.' && part !== '..' && !part.endsWith('.') && !/^(?:con|prn|aux|nul|com\d|lpt\d)(?:\.|$)/i.test(part)) }, 'Invalid package path') const contributionId = z.string().min(1).max(180).regex(/^[a-z][a-z0-9.-]+$/) +const placementBase = { id: contributionId, view: contributionId, when: workbenchConditionSchema.optional() } +export const extensionPlacementSchema = z.discriminatedUnion('kind', [ + z.object({ + ...placementBase, + kind: z.literal('view'), + target: workbenchMountTargetSchema.optional(), + presentation: workbenchPresentationSchema.omit({ target: true }).default({}), + location: z.enum(['workbench.top', 'workbench.bottom', 'workbench.floating']).optional(), + height: z.number().int().min(32).max(640).optional(), + width: z.number().int().min(160).max(960).optional(), + }).strict().superRefine((value, context) => { + if (!!value.target === !!value.location || (value.target && (value.height !== undefined || value.width !== undefined))) + context.addIssue({ code: 'custom', message: 'Declare a mount target and presentation, or a legacy location' }) + }).transform(({ id, view, kind, when, target, presentation, location, height, width }) => ({ + id, + view, + kind, + ...(when ? { when } : {}), + target: target ?? 'workbench' as const, + presentation: location ? { position: location === 'workbench.floating' ? 'absolute' as const : 'static' as const, order: location === 'workbench.top' ? -1 : 1, height: height ?? 56, ...(width === undefined ? {} : { width }), ...presentation } : presentation, + })), + z.object({ ...placementBase, kind: z.literal('decoration'), anchor: workbenchAnchorSchema }).strict(), + z.object({ ...placementBase, kind: z.literal('control'), target: workbenchControlSchema, height: z.number().int().min(32).max(160).default(64) }).strict(), +]) +export type ExtensionPlacement = z.infer export const extensionPermissionsSchema = z.object({ windowEffects: z.boolean().default(false), + controls: z.array(workbenchControlSchema).max(1).default([]), notifications: z.boolean().default(false), schedules: z.boolean().default(false), selectedResource: z.enum(['none', 'read']).default('none'), + localResources: z.boolean().default(false), + resourceExport: z.boolean().default(false), network: z.array(z.string().url().max(512).refine((value) => { const url = new URL(value) return url.protocol === 'https:' && url.origin === value && !url.username && !url.password @@ -32,20 +62,21 @@ export const extensionManifestSchema = z.object({ categories: z.array(z.string().min(1).max(40)).max(8).default([]), tags: z.array(z.string().min(1).max(40)).max(16).default([]), version: extensionVersionSchema, - apiVersion: z.literal(EXTENSION_API_VERSION), + apiVersion: z.union([z.literal(1), z.literal(EXTENSION_API_VERSION)]), engines: z.object({ lexora: z.string().max(100).refine(value => validRange(value) !== null) }).strict(), entry: extensionPathSchema.optional(), dataVersion: z.number().int().min(1).max(10000).default(1), dependencies: z.record(extensionIdSchema, z.string().max(100).refine(value => validRange(value) !== null)).default({}), permissions: extensionPermissionsSchema.prefault({}), contributes: z.object({ - commands: z.array(z.object({ id: contributionId, title: z.string().min(1).max(100), hidden: z.boolean().default(false) }).strict()).max(64).default([]), - views: z.array(z.object({ id: contributionId, title: z.string().min(1).max(100), entry: extensionPathSchema, stateVersion: z.number().int().min(1).max(10000).default(1), resource: z.enum(['selected-file', 'none']).default('selected-file'), location: z.enum(['context', 'page', 'window-overlay']).default('context') }).strict()).max(16).default([]), - navigation: z.object({ title: z.string().min(1).max(40), view: contributionId }).strict().optional(), + commands: z.array(z.object({ id: contributionId, title: z.string().min(1).max(100), hidden: z.boolean().default(false), when: workbenchConditionSchema.optional() }).strict()).max(64).default([]), + views: z.array(z.object({ id: contributionId, title: z.string().min(1).max(100), entry: extensionPathSchema, stateVersion: z.number().int().min(1).max(10000).default(1), resource: z.enum(['selected-file', 'none']).default('selected-file'), location: z.enum(['context', 'page', 'window-overlay']).default('context'), when: workbenchConditionSchema.optional() }).strict()).max(16).default([]), + placements: z.array(extensionPlacementSchema).max(16).default([]), + navigation: z.object({ title: z.string().min(1).max(40), view: contributionId, when: workbenchConditionSchema.optional() }).strict().optional(), }).strict(), }).strict().superRefine((manifest, context) => { const ids = new Set() - for (const contribution of [...manifest.contributes.commands, ...manifest.contributes.views]) { + for (const contribution of [...manifest.contributes.commands, ...manifest.contributes.views, ...manifest.contributes.placements]) { if (!contribution.id.startsWith(`${manifest.id}.`) || ids.has(contribution.id)) context.addIssue({ code: 'custom', message: 'Contribution IDs must be unique and owned by the extension' }) ids.add(contribution.id) @@ -62,8 +93,19 @@ export const extensionManifestSchema = z.object({ context.addIssue({ code: 'custom', message: 'Window overlays require windowEffects permission' }) if (manifest.contributes.views.filter(view => view.location === 'window-overlay').length > 1) context.addIssue({ code: 'custom', message: 'An extension can provide one window overlay' }) - if (manifest.contributes.navigation && !manifest.contributes.views.some(view => view.id === manifest.contributes.navigation?.view && view.location === 'page')) + if (manifest.contributes.navigation && !manifest.contributes.views.some(view => view.id === manifest.contributes.navigation?.view && view.resource === 'none' && (view.location === 'page' || (manifest.apiVersion === 2 && view.location === 'context')))) context.addIssue({ code: 'custom', message: 'Navigation must reference a page view' }) + if (manifest.apiVersion === 1 && (manifest.contributes.placements.length || manifest.permissions.controls.length)) + context.addIssue({ code: 'custom', message: 'UI placements and controls require API 2' }) + for (const placement of manifest.contributes.placements) { + const view = manifest.contributes.views.find(view => view.id === placement.view) + if (!view || view.resource !== 'none' || view.location !== 'context') + context.addIssue({ code: 'custom', message: 'Placements require a resource-free view definition' }) + if (placement.kind === 'decoration' && !manifest.permissions.windowEffects) + context.addIssue({ code: 'custom', message: 'Decorations require windowEffects permission' }) + if (placement.kind === 'control' && !manifest.permissions.controls.includes(placement.target)) + context.addIssue({ code: 'custom', message: 'Control replacement requires permission for its target' }) + } if (manifest.permissions.schedules && !manifest.entry) context.addIssue({ code: 'custom', message: 'Schedules require an extension entry' }) }) @@ -71,15 +113,18 @@ export type ExtensionManifest = z.infer export type ExtensionPermissions = z.infer export function extensionCompatible(manifest: ExtensionManifest, version: string): boolean { - return manifest.apiVersion === EXTENSION_API_VERSION && satisfies(version, manifest.engines.lexora, { includePrerelease: true }) + return manifest.apiVersion <= EXTENSION_API_VERSION && satisfies(version, manifest.engines.lexora, { includePrerelease: true }) } export function addedExtensionPermissions(previous: ExtensionPermissions | undefined, next: ExtensionPermissions): string[] { return [ ...(next.windowEffects && !previous?.windowEffects ? ['windowEffects'] : []), + ...next.controls.filter(target => !previous?.controls?.includes(target)).map(target => `controls:${target}`), ...(next.notifications && !previous?.notifications ? ['notifications'] : []), ...(next.schedules && !previous?.schedules ? ['schedules'] : []), ...(next.selectedResource === 'read' && previous?.selectedResource !== 'read' ? ['selectedResource:read'] : []), + ...(next.localResources && !previous?.localResources ? ['localResources'] : []), + ...(next.resourceExport && !previous?.resourceExport ? ['resourceExport'] : []), ...next.network.filter(origin => !previous?.network.includes(origin)).map(origin => `network:${origin}`), ] } diff --git a/apps/buddy/shared/extensions/extensionResources.ts b/apps/buddy/shared/extensions/extensionResources.ts new file mode 100644 index 00000000..f47b749f --- /dev/null +++ b/apps/buddy/shared/extensions/extensionResources.ts @@ -0,0 +1,56 @@ +import { z } from 'zod' + +export const extensionLocalFileSchema = z.object({ + id: z.string().uuid(), + name: z.string().max(512), + mimeType: z.string().max(80), + size: z.number().int().min(0).max(2 * 1024 ** 3), + relativePath: z.string().max(32768).optional(), +}).strict() +export const extensionFileExtensionsSchema = z.array(z.string().min(1).max(32).regex(/^(?:[a-z0-9][\w-]*|\*)$/i)).max(64) +export const extensionResourceSelectionSchema = z.object({ + filters: z.array(z.object({ name: z.string().min(1).max(80), extensions: extensionFileExtensionsSchema.min(1) }).strict()).max(12).default([]), + multiple: z.boolean().default(false), +}).strict() +export const extensionDirectoryScanSchema = z.object({ + id: z.string().uuid(), + extensions: extensionFileExtensionsSchema.default([]), + recursive: z.boolean().default(true), +}).strict() +export type ExtensionDirectoryScan = z.infer +export type ExtensionLocalFile = z.infer +export type ExtensionResourceSelection = z.infer +export const extensionLocalDirectorySchema = z.object({ id: z.string().uuid(), name: z.string().max(512) }).strict() +export type ExtensionLocalDirectory = z.infer + +const mimeTypes: Readonly> = { + wav: 'audio/wav', + mp3: 'audio/mpeg', + ogg: 'audio/ogg', + opus: 'audio/ogg', + flac: 'audio/flac', + m4a: 'audio/mp4', + mp4: 'video/mp4', + webm: 'video/webm', + png: 'image/png', + jpg: 'image/jpeg', + jpeg: 'image/jpeg', + webp: 'image/webp', + gif: 'image/gif', + avif: 'image/avif', + svg: 'image/svg+xml', + ico: 'image/x-icon', + pdf: 'application/pdf', + txt: 'text/plain', + md: 'text/plain', + csv: 'text/csv', + json: 'application/json', + epub: 'application/epub+zip', + woff: 'font/woff', + woff2: 'font/woff2', + ttf: 'font/ttf', + otf: 'font/otf', +} +export function extensionResourceMimeType(extension: string): string { + return mimeTypes[extension.toLowerCase()] ?? 'application/octet-stream' +} diff --git a/apps/buddy/shared/workbench/__tests__/workbenchContext.spec.ts b/apps/buddy/shared/workbench/__tests__/workbenchContext.spec.ts new file mode 100644 index 00000000..35fdb33b --- /dev/null +++ b/apps/buddy/shared/workbench/__tests__/workbenchContext.spec.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from 'vitest' +import { extensionManifestSchema } from '../../extensions/extensionManifest' +import { matchesWorkbenchContext, workbenchConditionSchema } from '../workbenchContext' + +describe('contribution context conditions', () => { + it('combines keys with AND and alternatives with strict equality', () => { + const condition = { 'page.id': ['lexora.tasks', 'lexora.settings'], 'feature.enabled': true } + expect(matchesWorkbenchContext(condition, { 'page.id': 'lexora.tasks', 'feature.enabled': true })).toBe(true) + expect(matchesWorkbenchContext(condition, { 'page.id': 'lexora.settings', 'feature.enabled': 'true' })).toBe(false) + expect(matchesWorkbenchContext(condition, { 'page.id': 'lexora.automations', 'feature.enabled': true })).toBe(false) + expect(matchesWorkbenchContext({ missing: false }, {})).toBe(false) + expect(matchesWorkbenchContext({ constructor: 'Object' }, {})).toBe(false) + expect(matchesWorkbenchContext(undefined, {})).toBe(true) + expect(matchesWorkbenchContext({}, {})).toBe(true) + }) + + it('bounds conditions without restricting future registered page identities', () => { + expect(workbenchConditionSchema.parse({ 'page.id': 'future.canvas', 'page.mode': ['preview', 'edit'] })).toEqual({ 'page.id': 'future.canvas', 'page.mode': ['preview', 'edit'] }) + for (const value of [{ 'page.id': [] }, { 'page.id': Array.from({ length: 33 }).fill('tasks') }, { 'page.id': { equals: 'tasks' } }, { 'page.id': 'x'.repeat(257) }, { size: Infinity }, Object.fromEntries(Array.from({ length: 33 }, (_, index) => [`key${index}`, true]))]) + expect(workbenchConditionSchema.safeParse(value).success).toBe(false) + }) + + it('preserves conditions through legacy mount normalization and manifest round trips', () => { + const when = { 'page.id': ['lexora.tasks', 'future.canvas'] } + const manifest = extensionManifestSchema.parse({ schemaVersion: 1, id: 'tests.context', name: 'Context', version: '1.0.0', apiVersion: 2, engines: { lexora: '*' }, entry: 'host.js', contributes: { + commands: [{ id: 'tests.context.open', title: 'Open', when }], + views: [{ id: 'tests.context.ui', title: 'UI', entry: 'ui.js', resource: 'none', when }], + placements: [{ id: 'tests.context.surface', kind: 'view', location: 'workbench.bottom', view: 'tests.context.ui', when }], + navigation: { title: 'Context', view: 'tests.context.ui', when }, + } }) + expect(manifest.contributes.placements[0]).toMatchObject({ target: 'workbench', when, presentation: { position: 'static', order: 1 } }) + expect(extensionManifestSchema.parse(JSON.parse(JSON.stringify(manifest)))).toEqual(manifest) + }) +}) diff --git a/apps/buddy/shared/workbench/workbenchContext.ts b/apps/buddy/shared/workbench/workbenchContext.ts new file mode 100644 index 00000000..6a9c0e65 --- /dev/null +++ b/apps/buddy/shared/workbench/workbenchContext.ts @@ -0,0 +1,15 @@ +import { z } from 'zod' + +const contextValueSchema = z.union([z.string().max(256), z.boolean(), z.number().finite()]) +const contextKeySchema = z.string().max(100).regex(/^[a-z][a-zA-Z0-9]*(?:[.-][a-zA-Z0-9]+)*$/) +export const workbenchConditionSchema = z.record(contextKeySchema, z.union([contextValueSchema, z.array(contextValueSchema).min(1).max(32)])).refine(value => Object.keys(value).length <= 32, 'Too many context conditions') +export type WorkbenchCondition = z.infer +export type WorkbenchContextValues = Readonly> +export interface WorkbenchContextSnapshot { + readonly values: WorkbenchContextValues + readonly pages: readonly { readonly id: string, readonly title: string }[] +} + +export function matchesWorkbenchContext(condition: WorkbenchCondition | undefined, values: WorkbenchContextValues): boolean { + return !condition || Object.entries(condition).every(([key, expected]) => Object.hasOwn(values, key) && (Array.isArray(expected) ? expected.includes(values[key]!) : values[key] === expected)) +} diff --git a/apps/buddy/shared/workbench/workbenchUi.ts b/apps/buddy/shared/workbench/workbenchUi.ts new file mode 100644 index 00000000..dcf165ce --- /dev/null +++ b/apps/buddy/shared/workbench/workbenchUi.ts @@ -0,0 +1,49 @@ +import { z } from 'zod' + +export const workbenchAnchorSchema = z.enum(['app.sidebar', 'workbench.sidebar', 'workbench.pane', 'composer.input']) +export const workbenchControlSchema = z.enum(['model.reasoning']) +export const workbenchMountTargetSchema = z.enum(['workbench', 'app.sidebar', 'workbench.sidebar']) +const mountLengthSchema = z.union([z.number().min(0).max(8192), z.string().regex(/^(?:100|\d{1,2})(?:\.\d{1,3})?%$/).refine(value => Number.parseFloat(value) <= 100)]) +export const workbenchPresentationSchema = z.object({ + target: workbenchMountTargetSchema.optional(), + position: z.enum(['static', 'absolute']).optional(), + order: z.number().int().min(-1000).max(1000).optional(), + zIndex: z.number().int().min(0).max(20).optional(), + width: mountLengthSchema.nullable().optional(), + height: mountLengthSchema.nullable().optional(), + top: mountLengthSchema.nullable().optional(), + right: mountLengthSchema.nullable().optional(), + bottom: mountLengthSchema.nullable().optional(), + left: mountLengthSchema.nullable().optional(), +}).strict() +export const extensionPresentationRequestSchema = workbenchPresentationSchema +export type WorkbenchPresentation = z.infer +export type WorkbenchMountTarget = z.infer +export type WorkbenchAnchor = z.infer +export type WorkbenchControl = z.infer + +export interface MountGeometry { + target: WorkbenchMountTarget + visible: boolean + width: number + height: number + rect: { x: number, y: number, width: number, height: number } +} +export interface ControlSnapshot { + revision: string + value: string | null + options: readonly { value: string, label: string }[] + disabled: boolean +} +export const controlProposalSchema = z.object({ revision: z.string().uuid(), value: z.string().max(80) }).strict() +export type ControlProposal = z.infer +export interface AnchorGeometry { + kind: WorkbenchAnchor + visible: boolean + width: number + height: number +} +export interface ComposerActivity { + type: 'composer-input' + caret: { x: number, y: number, width: number, height: number } | null +} diff --git a/apps/buddy/src/app/bootstrap/DesktopAppProvider.vue b/apps/buddy/src/app/bootstrap/DesktopAppProvider.vue index a7b6d013..c626af13 100644 --- a/apps/buddy/src/app/bootstrap/DesktopAppProvider.vue +++ b/apps/buddy/src/app/bootstrap/DesktopAppProvider.vue @@ -4,11 +4,11 @@ import type { DesktopBrowserGuestSurfaceHost } from '@/platform/browser/browserG import { DEFAULT_DESKTOP_CHAT_PREFERENCES } from '@buddy-electron/shared/desktopApi' import { useMessage } from 'naive-ui' import { computed, nextTick, onScopeDispose, provide, toRef, useTemplateRef, watch } from 'vue' -import { useRoute, useRouter } from 'vue-router' +import { useRouter } from 'vue-router' import { resolveBuddyLocale, translateBuddy } from '@/i18n/buddyI18n' import { useProvideAutomationContext } from '@/modules/automations' import { useExtensionState, useExtensionViews, useProvideExtensionContext } from '@/modules/extensions' -import { DesktopExtensionFrameHost, DesktopExtensionOverlays, DesktopExtensionReviewHost } from '@/modules/extensions/ui' +import { DesktopExtensionControl, DesktopExtensionFrameHost, DesktopExtensionOverlays, DesktopExtensionReviewHost } from '@/modules/extensions/ui' import { useProvideSettingsContext } from '@/modules/settings' import { useProvideSkillsContext } from '@/modules/skills' import { useProvideTaskEnvironment, useTaskIndex, useTaskResourcePanel } from '@/modules/tasks' @@ -16,7 +16,11 @@ import DesktopBrowserGuestHost from '@/platform/browser/DesktopBrowserGuestHost. import { useBrowserGuestHost } from '@/platform/browser/useBrowserGuestHost' import { requireDesktopApi } from '@/platform/desktop/desktopApi' import { runtimeAvailabilityKey } from '@/platform/runtime/runtimeAvailability' +import { useProvideWorkbenchUi } from '@/shared/ui/contributions/workbenchUiContext' import { useProvideDesktopUi } from '@/shared/ui/desktopUiContext' +import { SemanticAnchorRegistry } from '@/workbench/browser/surfaces/SemanticAnchorRegistry' +import WorkbenchSurfaceHost from '@/workbench/browser/surfaces/WorkbenchSurfaceHost.vue' +import { useDesktopPages } from '../router/useDesktopPages' import { useDesktopShellState } from '../shell/useDesktopShellState' import { desktopWorkbenchKey } from '../workbench/desktopWorkbenchContext' import { useDesktopKeybindings } from '../workbench/useDesktopKeybindings' @@ -36,7 +40,6 @@ defineSlots<{ default: (props: { shell: DesktopShellBindings }) => unknown }>() const api = requireDesktopApi() const router = useRouter() -const route = useRoute() const message = useMessage() const appState = useDesktopAppState({ api }) const { stores } = appState @@ -51,11 +54,20 @@ const taskIndex = useTaskIndex({ const workbench = useDesktopWorkbench({ api, stores, taskIndex, router, resources: getResources, onError: () => message.error(translateBuddy(stores.applicationSettings.language.value, 'desktop.command.failed')) }) provide(desktopWorkbenchKey, workbench) const extensions = useExtensionState(api.extensions) -const extensionViews = useExtensionViews(api.extensions, extensions.installed) -useProvideExtensionContext({ state: extensions, views: extensionViews, language: stores.applicationSettings.language, isDark: toRef(() => props.isDark), focusView: (id) => { +const pages = useDesktopPages(router, extensions.installed, stores.applicationSettings.language) +const extensionViews = useExtensionViews(api.extensions, extensions.installed, computed(() => pages.context.value.values)) +watch(() => pages.context.value.values, (values, _, cleanup) => { + const leases = Object.entries(values).map(([key, value]) => workbench.controller.contextKeys.set(key, value)) + cleanup(() => leases.forEach(dispose => dispose())) + workbench.controller.changed() +}, { immediate: true, flush: 'sync' }) +const anchors = new SemanticAnchorRegistry() +onScopeDispose(() => anchors.dispose()) +useProvideWorkbenchUi({ anchors, controlRenderer: DesktopExtensionControl }) +const controls = useExtensionContributions({ controller: workbench.controller, renderers: workbench.renderers, persistence: workbench.persistence, installed: extensions.installed, api: api.extensions, views: extensionViews, ready: () => workbench.initialized }) +useProvideExtensionContext({ state: extensions, views: extensionViews, anchors, controls, workbench: pages.context, language: stores.applicationSettings.language, isDark: toRef(() => props.isDark), startCreation: prompt => workbench.startTaskWithSkill('plugin-creator', prompt), focusView: (id) => { workbench.controller.focus(id) } }) -useExtensionContributions(workbench.controller, workbench.persistence, extensions.installed, api.extensions, id => extensionViews.surfaces.get(id)?.session?.generation ?? null) onScopeDispose(workbench.controller.subscribe(() => void nextTick(extensionViews.layout))) const selectedTask = workbench.activeTask const resources = useTaskResourcePanel({ @@ -68,7 +80,7 @@ const resources = useTaskResourcePanel({ activeSpace: computed(() => selectedTask.value?.session.activeSpace.value ?? null), spaces: taskIndex.index.spaces, mode: computed(() => stores.applicationSettings.config.value?.desktop.contextPanelMode ?? 'task'), - taskVisible: computed(() => route.meta.desktopView === 'tasks' || !!stores.applicationSettings.config.value?.desktop.contextPanelGlobal), + taskVisible: computed(() => pages.current.value === 'lexora.tasks' || !!stores.applicationSettings.config.value?.desktop.contextPanelGlobal), control: api.contextPanel, browser: api.browser, closeView: id => workbench.controller.close(id), @@ -124,7 +136,7 @@ const lifecycle = useDesktopLifecycle({ taskIndex, prepareSurface: async () => { await router.isReady() - await extensions.refresh() + void extensions.refresh() await workbench.initialize() }, flushSurface: workbench.flush, @@ -162,7 +174,7 @@ onScopeDispose(workbench.controller.subscribe(() => void nextTick(() => browserG const toggleAppSidebar = () => void shell.setAppSidebarCollapsed(!shell.appSidebarCollapsed.value) const shellBindings: DesktopShellBindings = { - extensionNavigation: extensions.navigation, + pages, shortcuts, contextPanelGlobal: computed(() => stores.applicationSettings.config.value?.desktop.contextPanelGlobal ?? false), workbench, @@ -244,8 +256,10 @@ watch(() => stores.applicationSettings.config.value?.desktop.theme, (theme) => {