diff --git a/apps/desktop/src/main/__tests__/runtime-host-ui-extensions-ipc-main.test.ts b/apps/desktop/src/main/__tests__/runtime-host-ui-extensions-ipc-main.test.ts new file mode 100644 index 0000000000..b8426992c5 --- /dev/null +++ b/apps/desktop/src/main/__tests__/runtime-host-ui-extensions-ipc-main.test.ts @@ -0,0 +1,97 @@ +import assert from 'node:assert/strict'; +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { test } from 'node:test'; +import type { IpcHandler } from '../ipc-reconnect-policy.js'; +import type { DesktopRuntimeHostClient } from '../runtime-host-client.js'; +import { registerRuntimeHostUiExtensionsIpc } from '../runtime-host-ui-extensions-ipc-main.js'; + +test('user import previews, confirms, installs, and enables one trusted UI and Event package', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-ui-import-')); + try { + await mkdir(join(root, 'documents')); + await mkdir(join(root, 'host')); + await mkdir(join(root, 'dist')); + await writeFile( + join(root, 'maka.extension.json'), + JSON.stringify({ + schemaVersion: 1, + id: 'dev.maka.user.ui', + runtime: { + entry: 'dist/index.mjs', + tools: [], + events: [ + { + name: 'dev.maka.user.ui.changed', + description: 'UI changed.', + payloadSchema: { type: 'object' }, + }, + ], + listeners: [ + { id: 'changed', event: 'dev.maka.user.ui.changed', handler: 'changed' }, + ], + services: [], + timers: [], + permissions: { workspace: 'none', network: false }, + }, + ui: { + contributions: [ + { id: 'root', surface: 'app.root', priority: 1, document: 'documents/root.html' }, + ], + host: { entry: 'host/service.mjs', methods: [{ name: 'hello', handler: 'hello' }] }, + permissions: { network: false, hostState: true, sessionAccess: true }, + }, + }), + ); + await writeFile(join(root, 'documents', 'root.html'), '
hello
'); + await writeFile(join(root, 'host', 'service.mjs'), 'export default { hello: () => "world" };'); + await writeFile( + join(root, 'dist', 'index.mjs'), + 'export default { changed: () => undefined };', + ); + const handlers = new Map(); + const requests: Array<{ operation: string; input: unknown }> = []; + const client = { + request: async (operation: string, input: unknown) => { + requests.push({ operation, input }); + if (operation === 'extension.package.install') { + return { extensionId: 'dev.maka.user.ui', toolNames: [], uiContributionIds: ['root'], eventContributionIds: ['event:dev.maka.user.ui.changed', 'listener:dev.maka.user.ui.changed:changed'] }; + } + if (operation === 'extension.composition.query') return { extensions: [], entries: [] }; + if (operation === 'extension.composition.mutate') return { entry: null }; + throw new Error(`unexpected ${operation}`); + }, + } as unknown as DesktopRuntimeHostClient; + registerRuntimeHostUiExtensionsIpc({ + ipcMain: { + handle: (channel, listener) => handlers.set(channel, listener), + handleReconnectableRead: (channel, listener) => handlers.set(channel, listener), + }, + client, + mainWindowController: { + showOpenDialog: async () => ({ canceled: false, filePaths: [root] }), + showMessageBox: async () => ({ response: 0, checkboxChecked: false }), + } as never, + allowLocalPaths: true, + }); + const handler = handlers.get('ui-extensions:importLocal'); + assert.ok(handler); + assert.deepEqual(await handler({} as never), { ok: true, extensionId: 'dev.maka.user.ui' }); + assert.equal(requests[0]?.operation, 'extension.package.install'); + const mutations = requests.filter(({ operation }) => operation === 'extension.composition.mutate'); + assert.equal(mutations.length, 2); + assert.deepEqual(mutations[0], { + operation: 'extension.composition.mutate', + input: { + kind: 'enable', + entryId: mutations[0] && (mutations[0].input as { entryId: string }).entryId, + scopeId: 'desktop-ui', + extensionId: 'dev.maka.user.ui', + }, + }); + assert.equal((mutations[1]?.input as { scopeId?: string } | undefined)?.scopeId, 'profile'); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); diff --git a/apps/desktop/src/main/__tests__/ui-extension-host.test.ts b/apps/desktop/src/main/__tests__/ui-extension-host.test.ts new file mode 100644 index 0000000000..2ecf53b368 --- /dev/null +++ b/apps/desktop/src/main/__tests__/ui-extension-host.test.ts @@ -0,0 +1,194 @@ +import assert from 'node:assert/strict'; +import { afterEach, describe, test } from 'node:test'; +import { parseHTML } from 'linkedom'; +import { act, createElement, useEffect } from 'react'; +import { createRoot } from 'react-dom/client'; +import type { ExtensionUiContributionProjection } from '@maka/runtime-host/protocol'; +import { selectUiSnapshots, UiExtensionSlot, UiExtensionSlotProvider } from '../../renderer/ui-extension-host.js'; +import { withUiSandboxPolicy } from '../ui-extension-frame-document.js'; +import { createUiExtensionFrameRequestHandler } from '../ui-extension-frame-protocol.js'; +import { uiExtensionFrameUrl } from '../../renderer/ui-extension-frame-url.js'; + +const originalGlobals = { + document: globalThis.document, + window: globalThis.window, + HTMLElement: globalThis.HTMLElement, + HTMLIFrameElement: globalThis.HTMLIFrameElement, + IS_REACT_ACT_ENVIRONMENT: (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }) + .IS_REACT_ACT_ENVIRONMENT, +}; + +afterEach(() => { + Object.assign(globalThis, originalGlobals); +}); + +describe('Desktop UI extension shell', () => { + test('selects one deterministic root and ordered independent overlays', () => { + const selected = selectUiSnapshots(null, [ + item('low', 'app.root', 1), + item('overlay-b', 'app.overlay', 20), + item('high', 'app.root', 100), + item('overlay-a', 'app.overlay', 20), + item('settings', 'app.slot', 30, 'settings.content'), + item('conversation', 'app.slot', 40, 'conversation.header'), + ]); + assert.equal(selected.root.id, 'high'); + assert.deepEqual(selected.overlays.map(({ id }) => id), ['overlay-a', 'overlay-b']); + assert.deepEqual(selected.slots.map(({ id }) => id), ['conversation', 'settings']); + }); + + test('updates one slot without remounting the official root', async () => { + const { document, window } = parseHTML('
'); + Object.assign(globalThis, { + document, + window, + HTMLElement: window.HTMLElement, + HTMLIFrameElement: window.HTMLIFrameElement ?? class HTMLIFrameElement {}, + IS_REACT_ACT_ENVIRONMENT: true, + }); + const container = document.querySelector('#root'); + assert.ok(container); + const root = createRoot(container); + let mounts = 0; + let unmounts = 0; + function OfficialRootProbe() { + useEffect(() => { + mounts += 1; + return () => { + unmounts += 1; + }; + }, []); + return createElement('main', { 'data-official-root': true }); + } + const render = async (generation: number) => { + await act(async () => { + root.render( + createElement( + UiExtensionSlotProvider, + { + contributions: [ + { ...item('status', 'app.slot', 10, 'conversation.header'), generation }, + ], + onSafeMode: () => undefined, + }, + createElement(OfficialRootProbe), + createElement(UiExtensionSlot, { name: 'conversation.header' }), + ), + ); + await Promise.resolve(); + }); + }; + + await render(1); + await render(2); + assert.equal(mounts, 1); + assert.equal(unmounts, 0); + assert.equal(container.querySelectorAll('iframe').length, 1); + await act(async () => root.unmount()); + assert.equal(unmounts, 1); + }); + + test('injects an offline CSP by default and only opens declared network lanes', () => { + const offline = withUiSandboxPolicy('Hello', false); + assert.match(offline, /connect-src 'none'/); + assert.match(offline, /frame-src 'none'/); + assert.ok(offline.indexOf('Content-Security-Policy') < offline.indexOf('')); + const online = withUiSandboxPolicy('
Hello
', true); + assert.match(online, /connect-src https: wss:/); + assert.match(online, /form-action 'none'/); + }); + + test('injects the narrow Host SDK only for an admitted frame token', () => { + const plain = withUiSandboxPolicy('
Hello
', false); + assert.doesNotMatch(plain, /makaUI/); + const bridged = withUiSandboxPolicy('
Hello
', false, 'test-token', [ + 'workspace.body', + ]); + assert.match(bridged, /maka-ui-bridge\/v1/); + assert.match(bridged, /maka-ui-bridge-ready\/v1/); + assert.match(bridged, /maka-ui-host-ready\/v1/); + assert.match(bridged, /queued\.push/); + assert.match(bridged, /setInterval\(announce,50\)/); + assert.match(bridged, /clearInterval\(retry\)/); + assert.match(bridged, /getState/); + assert.match(bridged, /setState/); + assert.match(bridged, /deleteState/); + assert.match(bridged, /invoke/); + assert.match(bridged, /session_list/); + assert.match(bridged, /session_send/); + assert.match(bridged, /session_stop/); + assert.match(bridged, /safe_mode/); + assert.match(bridged, /maka-ui-slot-layout\/v1/); + assert.match(bridged, /data-maka-slot/); + assert.match(bridged, /workspace\.body/); + assert.match(bridged, /getConfig/); + assert.match(bridged, /test-token/); + }); + + test('serves active UI bytes from an isolated scheme instead of srcdoc CSP inheritance', async () => { + const token = '12345678-1234-4123-8123-123456789abc'; + const contribution = item('root', 'app.root', 1); + const url = uiExtensionFrameUrl({ + scopeId: 'desktop-ui', + entryId: contribution.entryId, + extensionId: contribution.extensionId, + generation: contribution.generation, + contributionId: contribution.id, + token, + }); + const handler = createUiExtensionFrameRequestHandler(() => ({ + request: async () => ({ + scopeId: 'desktop-ui', + digest: 'sha256-test', + contributions: [{ ...contribution, hostState: true }], + }), + })); + const response = await handler(new Request(url)); + assert.equal(response.status, 200); + assert.match(response.headers.get('content-security-policy') ?? '', /script-src 'unsafe-inline'/); + assert.match(await response.text(), /makaUI/); + }); + + test('injects the emergency recovery bridge even without extension permissions', async () => { + const token = '12345678-1234-4123-8123-123456789abc'; + const contribution = item('root', 'app.root', 1); + const handler = createUiExtensionFrameRequestHandler(() => ({ + request: async () => ({ + scopeId: 'desktop-ui', + digest: 'sha256-test', + contributions: [contribution], + }), + })); + const response = await handler(new Request(uiExtensionFrameUrl({ + scopeId: 'desktop-ui', + entryId: contribution.entryId, + extensionId: contribution.extensionId, + generation: contribution.generation, + contributionId: contribution.id, + token, + }))); + const document = await response.text(); + assert.match(document, /safe_mode/); + assert.match(document, /makaUI/); + }); +}); + +function item( + id: string, + surface: 'app.root' | 'app.overlay' | 'app.slot', + priority: number, + slot?: string, +): ExtensionUiContributionProjection { + return { + entryId: `entry-${id}`, + extensionId: 'demo', + generation: 1, + id, + surface, + ...(slot ? { slot } : {}), + priority, + document: '

demo

', + documentSha256: 'sha256', + network: false, + }; +} diff --git a/apps/desktop/src/main/__tests__/ui-plugin-runtime.test.ts b/apps/desktop/src/main/__tests__/ui-plugin-runtime.test.ts new file mode 100644 index 0000000000..7bb00e7c21 --- /dev/null +++ b/apps/desktop/src/main/__tests__/ui-plugin-runtime.test.ts @@ -0,0 +1,41 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import type { ExtensionUiContributionProjection } from '@maka/runtime-host/protocol'; +import { UiPluginRuntime } from '../../renderer/ui-plugin-runtime.js'; + +test('Client Cordis tree updates one UI entry without remounting siblings', async () => { + const runtime = new UiPluginRuntime(); + const first = contribution('first', 1, 'one'); + const sibling = contribution('sibling', 1, 'sibling'); + await runtime.reconcile([first, sibling]); + assert.deepEqual(runtime.inspect().map(({ entryId, generation }) => [entryId, generation]), [ + ['first', 1], + ['sibling', 1], + ]); + await runtime.reconcile([contribution('first', 2, 'two'), sibling]); + assert.deepEqual(runtime.inspect().map(({ entryId, generation }) => [entryId, generation]), [ + ['first', 2], + ['sibling', 1], + ]); + await runtime.reconcile([sibling]); + assert.deepEqual(runtime.inspect().map(({ entryId }) => entryId), ['sibling']); + await runtime.close(); +}); + +function contribution( + entryId: string, + generation: number, + id: string, +): ExtensionUiContributionProjection { + return Object.freeze({ + entryId, + extensionId: `fixture.${entryId}`, + generation, + id, + surface: 'app.overlay', + priority: 0, + document: '', + documentSha256: `${id}-${generation}`, + network: false, + }); +} diff --git a/apps/desktop/src/main/main-window.ts b/apps/desktop/src/main/main-window.ts index dd217d9471..884a528463 100644 --- a/apps/desktop/src/main/main-window.ts +++ b/apps/desktop/src/main/main-window.ts @@ -32,6 +32,7 @@ export interface MainWindowController { setTitleBarOverlayTheme(sender: Electron.WebContents, theme: unknown): void; showOpenDialog(options: Electron.OpenDialogOptions): Promise; showSaveDialog(options: Electron.SaveDialogOptions): Promise; + showMessageBox(options: Electron.MessageBoxOptions): Promise; getBrowserViews(): BrowserViewManager; disposeBrowserViews(): Promise; hasOpenWindows(): boolean; @@ -501,6 +502,11 @@ export function createMainWindowController(deps: MainWindowControllerDeps): Main ? dialog.showSaveDialog(mainWindow, options) : dialog.showSaveDialog(options); }, + showMessageBox(options) { + return mainWindow + ? dialog.showMessageBox(mainWindow, options) + : dialog.showMessageBox(options); + }, getBrowserViews, disposeBrowserViews, hasOpenWindows() { diff --git a/apps/desktop/src/main/main.ts b/apps/desktop/src/main/main.ts index f9a13e5b23..e6772e437e 100644 --- a/apps/desktop/src/main/main.ts +++ b/apps/desktop/src/main/main.ts @@ -1,9 +1,22 @@ -import { app, dialog } from 'electron'; +import { app, dialog, protocol } from 'electron'; import { installMainProcessLogCapture } from './main-process-diagnostics.js'; import { isIsolatedE2e } from './startup-context.js'; installMainProcessLogCapture(); +protocol.registerSchemesAsPrivileged([ + { + scheme: 'maka-ui', + privileges: { + standard: true, + secure: true, + supportFetchAPI: false, + corsEnabled: false, + bypassCSP: false, + }, + }, +]); + // The macOS app menu title and app.getName() consumers read this name. Set it // before ready, unchanged from its historical pre-ready position. // diff --git a/apps/desktop/src/main/runtime-host-boot.ts b/apps/desktop/src/main/runtime-host-boot.ts index cf058b5278..ad3b38375a 100644 --- a/apps/desktop/src/main/runtime-host-boot.ts +++ b/apps/desktop/src/main/runtime-host-boot.ts @@ -126,6 +126,8 @@ import { updateRuntimeHostSettings, } from "./runtime-host-settings-ipc-main.js"; import { registerRuntimeHostSkillsIpc } from "./runtime-host-skills-ipc-main.js"; +import { registerRuntimeHostUiExtensionsIpc } from "./runtime-host-ui-extensions-ipc-main.js"; +import { registerUiExtensionFrameProtocol } from "./ui-extension-frame-protocol-main.js"; import { registerRuntimeHostUsageIpc } from "./runtime-host-usage-ipc-main.js"; import { registerRuntimeHostWorkspaceIpc } from "./runtime-host-workspace-ipc-main.js"; import { resolveShellEnv } from "./shell-env.js"; @@ -937,6 +939,16 @@ function registerHostClientIpc( openPath: (path) => shell.openPath(path), allowLocalPaths: target.kind === "local", }); + registerRuntimeHostUiExtensionsIpc({ + ipcMain: scopedIpc, + client, + mainWindowController, + allowLocalPaths: target.kind === "local", + automatedImportSourcePath: isIsolatedE2e + ? process.env.MAKA_E2E_UI_EXTENSION_PATH + : undefined, + }); + registerUiExtensionFrameProtocol(client); registerRuntimeHostSearchIpc({ ipcMain: scopedIpc, client }); registerRuntimeHostUsageIpc({ ipcMain: scopedIpc, diff --git a/apps/desktop/src/main/runtime-host-renderer-ipc-main.ts b/apps/desktop/src/main/runtime-host-renderer-ipc-main.ts index 9bb25af465..b5301b376b 100644 --- a/apps/desktop/src/main/runtime-host-renderer-ipc-main.ts +++ b/apps/desktop/src/main/runtime-host-renderer-ipc-main.ts @@ -77,11 +77,21 @@ function request( return client.request(operation, HOST_OPERATION_SPECS[operation].decodeInput(value)); case 'execution.inspect.query': return client.request(operation, HOST_OPERATION_SPECS[operation].decodeInput(value)); + case 'extension.ui.snapshot': + return client.request(operation, HOST_OPERATION_SPECS[operation].decodeInput(value)); + case 'extension.ui.state.query': + return client.request(operation, HOST_OPERATION_SPECS[operation].decodeInput(value)); + case 'extension.configuration.query': + return client.request(operation, HOST_OPERATION_SPECS[operation].decodeInput(value)); case 'scheduled-task.mutate': return client.request(operation, HOST_OPERATION_SPECS[operation].decodeInput(value)); case 'scheduled-task.query': return client.request(operation, HOST_OPERATION_SPECS[operation].decodeInput(value)); case 'web-search.execute': return client.request(operation, HOST_OPERATION_SPECS[operation].decodeInput(value)); + case 'extension.ui.state.mutate': + return client.request(operation, HOST_OPERATION_SPECS[operation].decodeInput(value)); + case 'extension.ui.rpc.invoke': + return client.request(operation, HOST_OPERATION_SPECS[operation].decodeInput(value)); } } diff --git a/apps/desktop/src/main/runtime-host-ui-extensions-ipc-main.ts b/apps/desktop/src/main/runtime-host-ui-extensions-ipc-main.ts new file mode 100644 index 0000000000..060ecaf838 --- /dev/null +++ b/apps/desktop/src/main/runtime-host-ui-extensions-ipc-main.ts @@ -0,0 +1,240 @@ +import { createHash } from 'node:crypto'; +import { readFile, stat } from 'node:fs/promises'; +import { basename, join } from 'node:path'; +import type { ReconnectableReadIpcMain } from './ipc-reconnect-policy.js'; +import { handleReconnectableRead } from './ipc-reconnect-policy.js'; +import type { DesktopRuntimeHostClient } from './runtime-host-client.js'; +import type { createMainWindowController } from './main-window.js'; + +const DESKTOP_UI_SCOPE = 'desktop-ui'; +const PROFILE_EXTENSION_SCOPE = 'profile'; +type MainWindowController = ReturnType; + +export function registerRuntimeHostUiExtensionsIpc(input: { + readonly ipcMain: ReconnectableReadIpcMain; + readonly client: DesktopRuntimeHostClient; + readonly mainWindowController: MainWindowController; + readonly allowLocalPaths: boolean; + readonly automatedImportSourcePath?: string; +}): void { + handleReconnectableRead(input.ipcMain, 'ui-extensions:list', async () => listUiExtensions(input.client)); + + input.ipcMain.handle('ui-extensions:importLocal', async () => { + if (!input.allowLocalPaths) throw new Error('Local UI Extension import is unavailable for a remote Runtime Host'); + const selected = input.automatedImportSourcePath + ? { canceled: false, filePaths: [input.automatedImportSourcePath] } + : await input.mainWindowController.showOpenDialog({ + title: 'Import Extension', + properties: ['openDirectory', 'openFile'], + filters: [{ name: 'Maka Extension', extensions: ['maka-extension'] }], + }); + const sourcePath = selected.filePaths[0]; + if (selected.canceled || !sourcePath) return { ok: false as const, reason: 'cancelled' as const }; + const manifest = await previewPackage(sourcePath); + const confirmation = input.automatedImportSourcePath + ? { response: 0 } + : await input.mainWindowController.showMessageBox({ + type: 'warning', + title: `Import ${manifest.id}`, + message: `Install Extension “${manifest.id}”?`, + detail: [ + `${manifest.uiCount} UI contribution${manifest.uiCount === 1 ? '' : 's'}`, + `${manifest.toolCount} Tool contribution${manifest.toolCount === 1 ? '' : 's'}`, + `${manifest.eventCount} Event/Listener contribution${manifest.eventCount === 1 ? '' : 's'}`, + `${manifest.serviceCount} Service contribution${manifest.serviceCount === 1 ? '' : 's'}`, + `${manifest.timerCount} Timer contribution${manifest.timerCount === 1 ? '' : 's'}`, + `Host state: ${manifest.permissions.hostState ? 'allowed' : 'not allowed'}`, + `Session control: ${manifest.permissions.sessionAccess ? 'allowed' : 'not allowed'}`, + `Host methods: ${manifest.hostMethods.length === 0 ? 'none' : manifest.hostMethods.join(', ')}`, + `Network: ${manifest.permissions.network ? 'allowed' : 'blocked'}`, + `Workspace: ${manifest.permissions.workspace}`, + '', + 'Trusted code warning: enabling this Extension executes its code inside the Runtime Host process. It has the same authority as a local application or Bash command and may read credentials, access files and the network, change MAKA behavior, block, or terminate the Runtime. Manifest permissions are approval and audit metadata, not a security boundary against malicious code.', + ].join('\n'), + buttons: ['Install and enable', 'Cancel'], + defaultId: 0, + cancelId: 1, + }); + if (confirmation.response !== 0) return { ok: false as const, reason: 'cancelled' as const }; + const installed = await input.client.request('extension.package.install', { sourcePath }); + const catalog = await input.client.request('extension.composition.query', {}); + for (const scopeId of new Set([ + ...(installed.uiContributionIds.length > 0 ? [DESKTOP_UI_SCOPE] : []), + ...(installed.toolNames.length > 0 || installed.eventContributionIds.length > 0 || (installed.serviceContributionIds?.length ?? 0) > 0 || (installed.timerContributionIds?.length ?? 0) > 0 + ? [PROFILE_EXTENSION_SCOPE] + : []), + ])) { + const current = catalog.entries.find( + (entry) => entry.scopeId === scopeId && entry.extensionId === installed.extensionId, + ); + await input.client.request( + 'extension.composition.mutate', + current + ? { kind: 'reload', entryId: current.entryId } + : { + kind: 'enable', + entryId: userEntryId(installed.extensionId, scopeId), + scopeId, + extensionId: installed.extensionId, + }, + ); + } + return { ok: true as const, extensionId: installed.extensionId }; + }); + + input.ipcMain.handle('ui-extensions:setEnabled', async (_event, extensionId: string, enabled: boolean) => { + const catalog = await input.client.request('extension.composition.query', {}); + const entries = catalog.entries.filter((item) => item.extensionId === extensionId); + if (entries.length === 0) throw new Error('Extension entry is not installed'); + for (const entry of entries) { + await input.client.request('extension.composition.mutate', enabled + ? { kind: 'enable', entryId: entry.entryId, scopeId: entry.scopeId, extensionId: entry.extensionId } + : { kind: 'disable', entryId: entry.entryId }); + } + return { ok: true as const }; + }); + + input.ipcMain.handle('ui-extensions:remove', async (_event, extensionId: string) => { + const catalog = await input.client.request('extension.composition.query', {}); + for (const entry of catalog.entries.filter((item) => item.extensionId === extensionId)) { + await input.client.request('extension.composition.mutate', { kind: 'remove', entryId: entry.entryId }); + } + if (catalog.extensions.some((item) => item.extensionId === extensionId)) + await input.client.request('extension.package.uninstall', { extensionId }); + return { ok: true as const }; + }); + + input.ipcMain.handle('ui-extensions:configure', async (_event, entryId: string, configuration: Record) => { + const result = await input.client.request('extension.configuration.mutate', { entryId, configuration }); + return { ok: true as const, configuration: result.configuration }; + }); + + handleReconnectableRead(input.ipcMain, 'ui-extensions:getConfiguration', async (_event, entryId: string) => + input.client.request('extension.configuration.query', { entryId }), + ); + + input.ipcMain.handle('ui-extensions:export', async (_event, extensionId: string) => { + if (!input.allowLocalPaths) throw new Error('Extension export is unavailable for a remote Runtime Host'); + const selected = await input.mainWindowController.showSaveDialog({ + title: `Export ${extensionId}`, + defaultPath: `${extensionId}.maka-extension`, + filters: [{ name: 'Maka Extension', extensions: ['maka-extension'] }], + }); + if (selected.canceled || !selected.filePath) return { ok: false as const, reason: 'cancelled' as const }; + await input.client.request('extension.package.export', { extensionId, targetPath: selected.filePath }); + return { ok: true as const, path: selected.filePath }; + }); +} + +async function listUiExtensions(client: DesktopRuntimeHostClient) { + const catalog = await client.request('extension.composition.query', {}); + const contracts = await client.request('extension.contract.query', {}).catch(() => ({ packages: [] })); + return catalog.extensions + .map((extension) => { + const entries = catalog.entries.filter((item) => item.extensionId === extension.extensionId); + const contract = contracts.packages.find((item) => item.extensionId === extension.extensionId); + return { + extensionId: extension.extensionId, + displayName: contract?.displayName ?? extension.extensionId, + description: contract?.description ?? '', + contributionIds: [ + ...extension.toolNames, + ...extension.uiContributionIds, + ...extension.eventContributionIds, + ...(extension.serviceContributionIds ?? []), + ...(extension.timerContributionIds ?? []), + ], + toolNames: extension.toolNames, + uiContributionIds: extension.uiContributionIds, + eventContributionIds: extension.eventContributionIds, + serviceContributionIds: extension.serviceContributionIds ?? [], + timerContributionIds: extension.timerContributionIds ?? [], + dependencies: contract?.dependencies ?? [], + configuration: contract?.configuration ?? { properties: {}, required: [] }, + entries, + active: entries.some((item) => item.status === 'active'), + enabled: entries.some((item) => item.enabled), + status: entries.some((item) => item.status === 'failed') ? 'failed' : entries.some((item) => item.status === 'active') ? 'active' : entries.some((item) => item.status === 'waiting') ? 'waiting' : 'disabled', + error: entries.find((item) => item.error)?.error ?? null, + }; + }); +} + +async function previewPackage(sourcePath: string): Promise<{ id: string; uiCount: number; toolCount: number; eventCount: number; serviceCount: number; timerCount: number; hostMethods: string[]; permissions: { network: boolean; hostState: boolean; sessionAccess: boolean; workspace: string } }> { + if (!(await stat(sourcePath)).isDirectory()) return previewBundle(sourcePath); + const encoded = await readFile(join(sourcePath, 'maka.extension.json'), 'utf8'); + return previewManifest(JSON.parse(encoded) as Record); +} + +async function previewBundle(sourcePath: string): ReturnType { + const encoded = await readFile(sourcePath); + if (encoded.byteLength > 32 * 1024 * 1024) throw new Error('Extension Bundle is too large'); + const bundle = JSON.parse(encoded.toString('utf8')) as { files?: unknown }; + if (!Array.isArray(bundle.files)) throw new Error('Extension Bundle is invalid'); + let manifest: string | undefined; + for (const value of bundle.files) { + const file = value as { path?: unknown; content?: unknown }; + if (typeof file.path !== 'string' || typeof file.content !== 'string') { + throw new Error('Extension Bundle file is invalid'); + } + if (file.path === 'maka.extension.json') { + manifest = Buffer.from(file.content, 'base64').toString('utf8'); + } + } + if (!manifest) { + throw new Error(`Extension Bundle is missing manifests: ${basename(sourcePath)}`); + } + return previewManifest(JSON.parse(manifest) as Record); +} + +function previewManifest(value: Record): Awaited> { + if (typeof value.id !== 'string') { + throw new Error('Extension manifest is invalid'); + } + const runtime = value.runtime as Record | undefined; + const uiValue = value.ui as Record | undefined; + const ui = Array.isArray(uiValue?.contributions) ? uiValue.contributions : []; + const tools = Array.isArray(runtime?.tools) ? runtime.tools : []; + const eventDefinitions = Array.isArray(runtime?.events) ? runtime.events : []; + const listeners = Array.isArray(runtime?.listeners) ? runtime.listeners : []; + const services = Array.isArray(runtime?.services) ? runtime.services : []; + const timers = Array.isArray(runtime?.timers) ? runtime.timers : []; + if ( + ui.length === 0 && + tools.length === 0 && + eventDefinitions.length === 0 && + listeners.length === 0 && + services.length === 0 && + timers.length === 0 + ) { + throw new Error('Extension package has no contributions'); + } + const permissions = uiValue?.permissions as Record | undefined; + const runtimePermissions = runtime?.permissions as Record | undefined; + const host = uiValue?.host as Record | undefined; + const methods = Array.isArray(host?.methods) ? host.methods : []; + const hostMethods = methods.map((item) => (item as Record)?.name); + if (hostMethods.some((name) => typeof name !== 'string')) { + throw new Error('Extension Bundle Host methods are invalid'); + } + return { + id: value.id, + uiCount: ui.length, + toolCount: tools.length, + eventCount: eventDefinitions.length + listeners.length, + serviceCount: services.length, + timerCount: timers.length, + hostMethods: hostMethods as string[], + permissions: { + network: permissions?.network === true || runtimePermissions?.network === true, + hostState: permissions?.hostState === true, + sessionAccess: permissions?.sessionAccess === true, + workspace: + typeof runtimePermissions?.workspace === 'string' ? runtimePermissions.workspace : 'none', + }, + }; +} + +function userEntryId(extensionId: string, scopeId: string): string { + return `user_extension_${createHash('sha256').update(`${scopeId}\u0000${extensionId}`).digest('hex').slice(0, 32)}`; +} diff --git a/apps/desktop/src/main/ui-extension-frame-document.ts b/apps/desktop/src/main/ui-extension-frame-document.ts new file mode 100644 index 0000000000..2055fa04a3 --- /dev/null +++ b/apps/desktop/src/main/ui-extension-frame-document.ts @@ -0,0 +1,25 @@ +export function uiExtensionFramePolicy(network: boolean): string { + const networkPolicy = network + ? "connect-src https: wss:; img-src data: blob: https:; media-src blob: https:; font-src data: https:;" + : "connect-src 'none'; img-src data: blob:; media-src blob:; font-src data:;"; + return `default-src 'none'; base-uri 'none'; object-src 'none'; frame-src 'none'; form-action 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; ${networkPolicy}`; +} + +export function withUiSandboxPolicy( + document: string, + network: boolean, + bridgeToken?: string, + declaredSlots: readonly string[] = [], +): string { + const policy = ``; + const bridge = bridgeToken + ? `` + : ''; + const head = /^\s*(?:]*>\s*)?]*)?>\s*]*)?>/iu; + if (head.test(document)) return document.replace(head, (match) => `${match}${policy}${bridge}`); + return `${policy}${bridge}${document}`; +} + +function bridgeBootstrap(token: string, declaredSlots: readonly string[]): string { + return `(function(){const token=${JSON.stringify(token)},declaredSlots=${JSON.stringify(declaredSlots)},pending=new Map(),queued=[];let sequence=0,ready=false,layoutFrame=0;const announce=function(){if(!ready)parent.postMessage({channel:'maka-ui-bridge-ready/v1',token:token},'*');},retry=setInterval(announce,50);function publishSlots(){layoutFrame=0;const slots=[];for(const name of declaredSlots){const element=document.querySelector('[data-maka-slot="'+CSS.escape(name)+'"]');if(!element)continue;const rect=element.getBoundingClientRect();slots.push({name:name,x:rect.x,y:rect.y,width:rect.width,height:rect.height});}parent.postMessage({channel:'maka-ui-slot-layout/v1',token:token,slots:slots},'*');}function scheduleSlots(){if(!layoutFrame)layoutFrame=requestAnimationFrame(publishSlots);}window.addEventListener('resize',scheduleSlots);window.addEventListener('scroll',scheduleSlots,true);new MutationObserver(scheduleSlots).observe(document.documentElement,{attributes:true,childList:true,subtree:true});if(typeof ResizeObserver==='function')new ResizeObserver(scheduleSlots).observe(document.documentElement);window.addEventListener('keydown',function(event){if((event.metaKey||event.ctrlKey)&&event.shiftKey&&event.key==='Backspace'){event.preventDefault();event.stopImmediatePropagation();const id=String(++sequence),envelope={channel:'maka-ui-bridge/v1',token:token,id:id,kind:'safe_mode'};ready?parent.postMessage(envelope,'*'):queued.push(envelope);}},true);window.addEventListener('message',function(event){const data=event.data;if(event.source!==parent||!data||data.token!==token)return;if(data.channel==='maka-ui-host-ready/v1'){if(ready)return;ready=true;clearInterval(retry);while(queued.length)parent.postMessage(queued.shift(),'*');scheduleSlots();return;}if(data.channel!=='maka-ui-host/v1')return;const task=pending.get(data.id);if(!task)return;pending.delete(data.id);data.ok?task.resolve(data.result):task.reject(new Error(data.error||'Host request failed'));});function call(message){return new Promise(function(resolve,reject){const id=String(++sequence),envelope=Object.assign({channel:'maka-ui-bridge/v1',token,id},message);pending.set(id,{resolve,reject});ready?parent.postMessage(envelope,'*'):queued.push(envelope);});}const sessions=Object.freeze({list:function(){return call({kind:'session_list'});},send:function(input){input=input||{};return call({kind:'session_send',sessionId:input.sessionId,text:input.text});},stop:function(sessionId){return call({kind:'session_stop',sessionId:sessionId});}});Object.defineProperty(window,'makaUI',{value:Object.freeze({getConfig:function(){return call({kind:'config'}).then(function(result){return result.configuration;});},getState:function(key){return call({kind:'get',key:key});},setState:function(key,value){return call({kind:'set',key:key,value:value});},deleteState:function(key){return call({kind:'delete',key:key});},invoke:function(method,args){return call({kind:'invoke',method:method,args:args===undefined?null:args});},sessions:sessions}),writable:false,configurable:false});setTimeout(announce,0);})();`; +} diff --git a/apps/desktop/src/main/ui-extension-frame-protocol-main.ts b/apps/desktop/src/main/ui-extension-frame-protocol-main.ts new file mode 100644 index 0000000000..9172dc6332 --- /dev/null +++ b/apps/desktop/src/main/ui-extension-frame-protocol-main.ts @@ -0,0 +1,19 @@ +import { protocol } from 'electron'; +import type { DesktopRuntimeHostClient } from './runtime-host-client.js'; +import { + createUiExtensionFrameRequestHandler, + type UiExtensionFrameClient, +} from './ui-extension-frame-protocol.js'; + +let installed = false; +let activeClient: UiExtensionFrameClient | null = null; + +export function registerUiExtensionFrameProtocol(client: DesktopRuntimeHostClient): void { + activeClient = client; + if (installed) return; + installed = true; + protocol.handle( + 'maka-ui', + createUiExtensionFrameRequestHandler(() => activeClient), + ); +} diff --git a/apps/desktop/src/main/ui-extension-frame-protocol.ts b/apps/desktop/src/main/ui-extension-frame-protocol.ts new file mode 100644 index 0000000000..5478982940 --- /dev/null +++ b/apps/desktop/src/main/ui-extension-frame-protocol.ts @@ -0,0 +1,110 @@ +import type { ExtensionUiSnapshotResult } from '@maka/runtime-host/protocol'; +import { + uiExtensionFramePolicy, + withUiSandboxPolicy, +} from './ui-extension-frame-document.js'; + +const TOKEN_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/u; +const EXPECTED_QUERY_KEYS = Object.freeze([ + 'contributionId', + 'entryId', + 'extensionId', + 'generation', + 'scopeId', + 'token', +]); + +export interface UiExtensionFrameClient { + request( + operation: 'extension.ui.snapshot', + input: { readonly scopeId: string }, + ): Promise; +} + +export function createUiExtensionFrameRequestHandler( + resolveClient: () => UiExtensionFrameClient | null, +): (request: Request) => Promise { + return async (request) => { + if (request.method !== 'GET') return response('Method not allowed', 405); + const identity = decodeFrameIdentity(request.url); + if (!identity) return response('Invalid UI Extension frame request', 400); + const client = resolveClient(); + if (!client) return response('Runtime Host unavailable', 503); + try { + const snapshot = await client.request('extension.ui.snapshot', { + scopeId: identity.scopeId, + }); + const contribution = snapshot.contributions.find( + (item) => + item.entryId === identity.entryId && + item.extensionId === identity.extensionId && + item.generation === identity.generation && + item.id === identity.contributionId, + ); + if (!contribution) return response('UI Extension contribution not active', 404); + const document = withUiSandboxPolicy( + contribution.document, + contribution.network, + identity.token, + contribution.slots ?? [], + ); + return new Response(document, { + status: 200, + headers: { + 'cache-control': 'no-store', + 'content-security-policy': uiExtensionFramePolicy(contribution.network), + 'content-type': 'text/html; charset=utf-8', + 'cross-origin-resource-policy': 'cross-origin', + }, + }); + } catch { + return response('Runtime Host unavailable', 503); + } + }; +} + +function decodeFrameIdentity(urlValue: string): { + readonly scopeId: 'desktop-ui'; + readonly entryId: string; + readonly extensionId: string; + readonly generation: number; + readonly contributionId: string; + readonly token: string; +} | null { + const url = new URL(urlValue); + if (url.protocol !== 'maka-ui:' || url.hostname !== 'frame' || url.pathname !== '/v1') { + return null; + } + if ([...url.searchParams.keys()].sort().join('\0') !== EXPECTED_QUERY_KEYS.join('\0')) { + return null; + } + const scopeId = url.searchParams.get('scopeId'); + const entryId = url.searchParams.get('entryId'); + const extensionId = url.searchParams.get('extensionId'); + const generation = Number(url.searchParams.get('generation')); + const contributionId = url.searchParams.get('contributionId'); + const token = url.searchParams.get('token'); + if ( + scopeId !== 'desktop-ui' || + !entryId || + !extensionId || + !Number.isSafeInteger(generation) || + generation <= 0 || + !contributionId || + !token || + !TOKEN_PATTERN.test(token) + ) { + return null; + } + return { scopeId, entryId, extensionId, generation, contributionId, token }; +} + +function response(body: string, status: number): Response { + return new Response(body, { + status, + headers: { + 'cache-control': 'no-store', + 'content-type': 'text/plain; charset=utf-8', + }, + }); +} diff --git a/apps/desktop/src/preload/bridge-contract.d.ts b/apps/desktop/src/preload/bridge-contract.d.ts index 56aec7f324..88d75ea5e1 100644 --- a/apps/desktop/src/preload/bridge-contract.d.ts +++ b/apps/desktop/src/preload/bridge-contract.d.ts @@ -305,6 +305,40 @@ export interface PetPackChangedEvent { readonly ts: number; } +export interface UiExtensionEntry { + readonly extensionId: string; + readonly displayName: string; + readonly description: string; + readonly contributionIds: readonly string[]; + readonly toolNames: readonly string[]; + readonly uiContributionIds: readonly string[]; + readonly eventContributionIds: readonly string[]; + readonly serviceContributionIds: readonly string[]; + readonly timerContributionIds: readonly string[]; + readonly dependencies: readonly { readonly id: string }[]; + readonly configuration: { + readonly properties: Readonly>; + readonly required: readonly string[]; + }; + readonly entries: readonly { + readonly entryId: string; + readonly scopeId: string; + readonly enabled: boolean; + readonly status: 'disabled' | 'active' | 'waiting' | 'failed'; + }[]; + readonly active: boolean; + readonly enabled: boolean; + readonly status: 'disabled' | 'active' | 'waiting' | 'failed'; + readonly error: string | null; +} + export interface MakaBridge { runtimeHost: { query( @@ -317,6 +351,16 @@ export interface MakaBridge { ): Promise>; }; + uiExtensions: { + list(): Promise; + importLocal(): Promise<{ ok: true; extensionId: string } | { ok: false; reason: 'cancelled' }>; + setEnabled(extensionId: string, enabled: boolean): Promise<{ ok: true }>; + getConfiguration(entryId: string): Promise<{ configuration: Record }>; + configure(entryId: string, configuration: Record): Promise<{ ok: true; configuration: Record }>; + export(extensionId: string): Promise<{ ok: true; path: string } | { ok: false; reason: 'cancelled' }>; + remove(extensionId: string): Promise<{ ok: true }>; + }; + runtimeHostProfiles: { getSnapshot(): Promise; addAndSelect( diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index 7c789e09a8..2521ae1f0f 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -496,6 +496,29 @@ async function bridgeResult(operation: () => Promise, code: string): Promi const makaBridge = { runtimeHost, + uiExtensions: { + list() { + return invokeActiveRuntimeHost('ui-extensions:list'); + }, + importLocal() { + return invokeActiveRuntimeHost('ui-extensions:importLocal'); + }, + setEnabled(extensionId: string, enabled: boolean) { + return invokeActiveRuntimeHost('ui-extensions:setEnabled', extensionId, enabled); + }, + getConfiguration(entryId: string) { + return invokeActiveRuntimeHost('ui-extensions:getConfiguration', entryId); + }, + configure(entryId: string, configuration: Record) { + return invokeActiveRuntimeHost('ui-extensions:configure', entryId, configuration); + }, + export(extensionId: string) { + return invokeActiveRuntimeHost('ui-extensions:export', extensionId); + }, + remove(extensionId: string) { + return invokeActiveRuntimeHost('ui-extensions:remove', extensionId); + }, + }, runtimeHostProfiles: { getSnapshot() { return ipcRenderer.invoke('runtime-host-profiles:getSnapshot'); diff --git a/apps/desktop/src/preload/runtime-host-renderer-operations.ts b/apps/desktop/src/preload/runtime-host-renderer-operations.ts index e0dde01165..0e65bbc3cd 100644 --- a/apps/desktop/src/preload/runtime-host-renderer-operations.ts +++ b/apps/desktop/src/preload/runtime-host-renderer-operations.ts @@ -7,6 +7,9 @@ export const RENDERER_RUNTIME_HOST_QUERY_OPERATIONS = [ 'context.diagnostics.query', 'daily-review.query', 'execution.inspect.query', + 'extension.ui.snapshot', + 'extension.ui.state.query', + 'extension.configuration.query', 'scheduled-task.query', ] as const satisfies readonly (keyof OperationSpecMap)[]; @@ -14,6 +17,8 @@ export const RENDERER_RUNTIME_HOST_COMMAND_OPERATIONS = [ 'daily-review.mutate', 'scheduled-task.mutate', 'web-search.execute', + 'extension.ui.state.mutate', + 'extension.ui.rpc.invoke', ] as const satisfies readonly (keyof OperationSpecMap)[]; /** Runtime Host operations that the sandboxed renderer may invoke directly. */ diff --git a/apps/desktop/src/renderer/app-shell-detail-panel.tsx b/apps/desktop/src/renderer/app-shell-detail-panel.tsx index 1778f6f961..c0ba43a315 100644 --- a/apps/desktop/src/renderer/app-shell-detail-panel.tsx +++ b/apps/desktop/src/renderer/app-shell-detail-panel.tsx @@ -4,7 +4,7 @@ type AppShellDetailPanelProps = Omit< ComponentPropsWithoutRef<'div'>, 'className' | 'data-agents-view' > & { - agentsView: 'skills' | 'mcp' | 'cron' | 'daily-review' | 'im_hub'; + agentsView: 'skills' | 'mcp' | 'ui' | 'cron' | 'daily-review' | 'im_hub'; }; export function AppShellDetailPanel({ diff --git a/apps/desktop/src/renderer/app-shell.tsx b/apps/desktop/src/renderer/app-shell.tsx index 10d4637f8d..617c4d4407 100644 --- a/apps/desktop/src/renderer/app-shell.tsx +++ b/apps/desktop/src/renderer/app-shell.tsx @@ -7,6 +7,7 @@ import { useState, type CSSProperties, type Dispatch, + type ReactNode, type SetStateAction, } from 'react'; import type { ScheduledTask } from '@maka/core/scheduled-task'; @@ -93,6 +94,8 @@ import { usePlanModeState, } from './plan-mode-panel'; import { McpPage } from './mcp-page'; +import { UiExtensionsPage } from './ui-extensions-page'; +import { UiExtensionSlot } from './ui-extension-host'; import { getOnboardingActivationCandidate, useOnboardingSnapshot } from './use-onboarding-snapshot'; import type { AppUpdateStatus, OnboardingSnapshot } from '../preload/bridge-contract.js'; import { DESKTOP_TRANSCRIPT_RANGE_MAX_BYTES } from '../preload/transcript-contract.js'; @@ -232,7 +235,9 @@ type AppShellProps = { initialOnboardingSnapshot?: OnboardingSnapshot | null; }; -export function AppShell({ initialOnboardingSnapshot = null }: AppShellProps = {}) { +export function AppShell({ + initialOnboardingSnapshot = null, +}: AppShellProps = {}) { const [uiLocalePreference, setUiLocalePreference] = useState('auto'); const [uiLocaleOverride, setUiLocaleOverride] = useState(null); const systemUiLocale = useSystemUiLocale(); @@ -2780,6 +2785,7 @@ function AppShellContent({ onImport={() => setExternalImportOpen(true)} rowActions={sessionRowActions} projectActions={projectRowActions} + footerExtension={} /> } > @@ -2825,6 +2831,8 @@ function AppShellContent({ /> ) : navSelection.section === 'extensions' && navSelection.module === 'mcp' ? ( + ) : navSelection.section === 'extensions' && navSelection.module === 'ui' ? ( + ) : navSelection.section === 'automations' && navSelection.module === 'scheduled-tasks' ? ( {navSelection.section === 'sessions' ? ( } sessionUiController={sessionUiController} activeSessionId={activeId} hasOlderHistory={activeTranscriptRange?.hasOlder === true} diff --git a/apps/desktop/src/renderer/app.tsx b/apps/desktop/src/renderer/app.tsx index 75d5e20573..af7f8725e8 100644 --- a/apps/desktop/src/renderer/app.tsx +++ b/apps/desktop/src/renderer/app.tsx @@ -7,6 +7,7 @@ import { useAstryxThemeMode } from './astryx-theme-mode'; import type { OnboardingSnapshot } from '../preload/bridge-contract.js'; import { RuntimeHostSshTerminalDialog } from './settings/runtime-host-ssh-terminal-dialog.js'; import { readSystemUiLocale } from './use-system-ui-locale'; +import { UiExtensionHost } from './ui-extension-host'; export function App({ initialOnboardingSnapshot = null, @@ -63,7 +64,11 @@ export function App({ {runtimeHostReady ? ( - + ( + + )} + /> ) : ( diff --git a/apps/desktop/src/renderer/chat-message-surface.tsx b/apps/desktop/src/renderer/chat-message-surface.tsx index 5719dd55f9..feda212f6e 100644 --- a/apps/desktop/src/renderer/chat-message-surface.tsx +++ b/apps/desktop/src/renderer/chat-message-surface.tsx @@ -74,6 +74,8 @@ interface ChatMessageSurfaceProps extends Omit< historyLoadPending: boolean; onLoadEarlierHistory: () => Promise | void; onReturnToLatestHistory: () => Promise | void; + /** Independently lifecycle-managed UI contributions above the transcript. */ + headerExtension?: ReactNode; } function captureLiveContent( @@ -112,6 +114,7 @@ export function ChatMessageSurface({ historyLoadPending, onLoadEarlierHistory, onReturnToLatestHistory, + headerExtension, ...chatViewRest }: ChatMessageSurfaceProps) { const locale = useUiLocale(); @@ -214,6 +217,7 @@ export function ChatMessageSurface({ return ( <> + {headerExtension} Maka