From 943995fb70462ffc0b95ee570b2d0638411a7388 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Wed, 1 Apr 2026 15:48:01 +0300 Subject: [PATCH] Fix MCP auth, collab state, and export bounds --- packages/cli/src/app-client.ts | 16 +++- packages/core/src/geometry.ts | 65 ++++++++++++++++ packages/core/src/io/formats.ts | 28 ++++--- .../core/src/io/formats/raster/headless.ts | 28 +++++-- packages/core/src/io/formats/raster/render.ts | 76 +++++++++++++----- packages/core/src/io/formats/svg/export.ts | 34 ++++---- packages/core/src/layout.ts | 10 ++- packages/core/src/renderer/renderer.ts | 15 +++- packages/mcp/src/server.ts | 69 +++++++++++++---- src/ai/acp-transport.ts | 35 +++++++-- src/ai/tools.ts | 38 +++++---- src/automation/server.ts | 4 +- src/automation/spawn-mcp.ts | 77 +++++++++++++++---- src/automation/vite-plugin.ts | 10 ++- src/components/CanvasMenu.vue | 14 +++- src/components/ChatPanel.vue | 8 ++ src/components/CollabPanel.vue | 23 ++++-- src/composables/use-chat.ts | 46 +++++++---- src/composables/use-collab.ts | 24 +++++- src/env.d.ts | 2 + src/stores/editor.ts | 28 ++++++- src/stores/tabs.ts | 12 ++- src/views/EditorView.vue | 11 +-- vite.config.ts | 13 +++- 24 files changed, 537 insertions(+), 149 deletions(-) diff --git a/packages/cli/src/app-client.ts b/packages/cli/src/app-client.ts index fd7538e51..63ae8fe7f 100644 --- a/packages/cli/src/app-client.ts +++ b/packages/cli/src/app-client.ts @@ -25,8 +25,7 @@ export async function getAppToken(): Promise { return cachedToken } -export async function rpc(command: string, args: unknown = {}): Promise { - const token = await getAppToken() +async function doRpc(token: string, command: string, args: unknown): Promise { const res = await fetch(RPC_URL, { method: 'POST', headers: { @@ -49,6 +48,19 @@ export async function rpc(command: string, args: unknown = {}): Pro return body.result as T } +export async function rpc(command: string, args: unknown = {}): Promise { + let token = await getAppToken() + try { + return await doRpc(token, command, args) + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + if (!message.includes('Unauthorized')) throw error + cachedToken = null + token = await getAppToken() + return doRpc(token, command, args) + } +} + export function isAppMode(file?: string): boolean { return !file } diff --git a/packages/core/src/geometry.ts b/packages/core/src/geometry.ts index 4fe9bd5b3..c86a7404e 100644 --- a/packages/core/src/geometry.ts +++ b/packages/core/src/geometry.ts @@ -1,3 +1,4 @@ +import type { Effect, Stroke } from './scene-graph' import type { Rect, Vector } from './types' export function degToRad(degrees: number): number { @@ -81,6 +82,39 @@ export function computeBounds(items: Iterable): Rect { return { x: minX, y: minY, width: maxX - minX, height: maxY - minY } } +function strokeOverflow(strokes?: Stroke[]): number { + let overflow = 0 + for (const stroke of strokes ?? []) { + if (!stroke.visible) continue + let extra = 0 + if (stroke.align === 'OUTSIDE') extra = stroke.weight + else if (stroke.align === 'CENTER') extra = stroke.weight / 2 + overflow = Math.max(overflow, extra) + } + return overflow +} + +function effectOverflow(effects?: Effect[]) { + let left = 0 + let right = 0 + let top = 0 + let bottom = 0 + + for (const effect of effects ?? []) { + if (!effect.visible) continue + if (effect.type !== 'DROP_SHADOW' && effect.type !== 'LAYER_BLUR' && effect.type !== 'FOREGROUND_BLUR') { + continue + } + const blurSpread = effect.radius + effect.spread + left = Math.max(left, blurSpread + Math.max(0, -effect.offset.x)) + right = Math.max(right, blurSpread + Math.max(0, effect.offset.x)) + top = Math.max(top, blurSpread + Math.max(0, -effect.offset.y)) + bottom = Math.max(bottom, blurSpread + Math.max(0, effect.offset.y)) + } + + return { left, right, top, bottom } +} + export function computeAbsoluteBounds( nodes: Iterable<{ id: string; width: number; height: number }>, getAbsolutePosition: (id: string) => Vector @@ -99,3 +133,34 @@ export function computeAbsoluteBounds( if (minX === Infinity) return { x: 0, y: 0, width: 0, height: 0 } return { x: minX, y: minY, width: maxX - minX, height: maxY - minY } } + +export function computeVisualBounds( + nodes: Iterable<{ + id: string + width: number + height: number + rotation?: number + strokes?: Stroke[] + effects?: Effect[] + }>, + getAbsolutePosition: (id: string) => Vector +): Rect { + let minX = Infinity, + minY = Infinity, + maxX = -Infinity, + maxY = -Infinity + + for (const n of nodes) { + const abs = getAbsolutePosition(n.id) + const bbox = rotatedBBox(abs.x, abs.y, n.width, n.height, n.rotation ?? 0) + const stroke = strokeOverflow(n.strokes) + const effects = effectOverflow(n.effects) + minX = Math.min(minX, bbox.left - stroke - effects.left) + minY = Math.min(minY, bbox.top - stroke - effects.top) + maxX = Math.max(maxX, bbox.right + stroke + effects.right) + maxY = Math.max(maxY, bbox.bottom + stroke + effects.bottom) + } + + if (minX === Infinity) return { x: 0, y: 0, width: 0, height: 0 } + return { x: minX, y: minY, width: maxX - minX, height: maxY - minY } +} diff --git a/packages/core/src/io/formats.ts b/packages/core/src/io/formats.ts index c9e28cf34..88916a399 100644 --- a/packages/core/src/io/formats.ts +++ b/packages/core/src/io/formats.ts @@ -28,6 +28,17 @@ function ensureSingleNode(target: ExportRequest['target']): string | null { return null } +function findPageId(graph: ExportRequest['graph'], nodeId: string): string | null { + let current = graph.getNode(nodeId) + while (current?.parentId) { + const parent = graph.getNode(current.parentId) + if (!parent) return null + if (parent.type === 'CANVAS') return parent.id + current = parent + } + return current?.type === 'CANVAS' ? current.id : null +} + function resolveExportNodes(request: ExportRequest): { pageId: string; nodeIds: string[] } | null { switch (request.target.scope) { case 'document': { @@ -42,14 +53,12 @@ function resolveExportNodes(request: ExportRequest): { pageId: string; nodeIds: case 'selection': { const first = request.target.nodeIds[0] if (!first) return null - const node = request.graph.getNode(first) - if (!node) return null - let current = node.parentId ? request.graph.getNode(node.parentId) : undefined - while (current && current.type !== 'CANVAS') { - current = current.parentId ? request.graph.getNode(current.parentId) : undefined + const pageId = findPageId(request.graph, first) + if (!pageId) return null + if (!request.target.nodeIds.every((nodeId) => findPageId(request.graph, nodeId) === pageId)) { + throw new Error('Export selection must stay on a single page') } - if (!current) return null - return { pageId: current.id, nodeIds: request.target.nodeIds } + return { pageId, nodeIds: request.target.nodeIds } } case 'node': return resolveExportNodes({ @@ -114,7 +123,7 @@ function rasterFormat(format: RasterExportFormat): IOFormatAdapter { exportOptions: { scale: true, quality: format !== 'PNG', - colorSpace: true + colorSpace: false }, async exportContent(request, options?: RasterExportOptions, context?: IOContext) { const data = await renderRaster( @@ -122,8 +131,7 @@ function rasterFormat(format: RasterExportFormat): IOFormatAdapter { { format, scale: options?.scale, - quality: options?.quality, - colorSpace: options?.colorSpace + quality: options?.quality }, context ) diff --git a/packages/core/src/io/formats/raster/headless.ts b/packages/core/src/io/formats/raster/headless.ts index 77e4c4fad..e471df71a 100644 --- a/packages/core/src/io/formats/raster/headless.ts +++ b/packages/core/src/io/formats/raster/headless.ts @@ -38,12 +38,17 @@ export async function headlessRenderNodes( options: { scale?: number; format?: ExportFormat; quality?: number } = {} ): Promise { const { ck, renderer } = await getRenderer() - await renderer.prepareForExport(graph, pageId, nodeIds) - return renderNodesToImage(ck, renderer, graph, pageId, nodeIds, { - scale: options.scale ?? 1, - format: options.format ?? 'PNG', - quality: options.quality - }) + renderer.invalidateAllPictures() + const restoreTextMeasurer = await renderer.prepareForExport(graph, pageId, nodeIds) + try { + return renderNodesToImage(ck, renderer, graph, pageId, nodeIds, { + scale: options.scale ?? 1, + format: options.format ?? 'PNG', + quality: options.quality + }) + } finally { + restoreTextMeasurer() + } } export async function headlessRenderThumbnail( @@ -53,7 +58,14 @@ export async function headlessRenderThumbnail( height: number ): Promise { const { ck, renderer } = await getRenderer() + renderer.invalidateAllPictures() const page = graph.getNode(pageId) - if (page) await renderer.prepareForExport(graph, pageId, page.childIds) - return renderThumbnail(ck, renderer, graph, pageId, width, height) + const restoreTextMeasurer = page + ? await renderer.prepareForExport(graph, pageId, page.childIds) + : () => undefined + try { + return renderThumbnail(ck, renderer, graph, pageId, width, height) + } finally { + restoreTextMeasurer() + } } diff --git a/packages/core/src/io/formats/raster/render.ts b/packages/core/src/io/formats/raster/render.ts index b65e9678b..712d9114a 100644 --- a/packages/core/src/io/formats/raster/render.ts +++ b/packages/core/src/io/formats/raster/render.ts @@ -1,3 +1,6 @@ +import { computeVisualBounds } from '../../../geometry' +import { extractExportGraph } from '../../subgraph' + import type { RenderColorSpace } from '@open-pencil/core/color-management' import type { SkiaRenderer } from '@open-pencil/core/renderer' import type { SceneGraph } from '@open-pencil/core/scene-graph' @@ -13,27 +16,50 @@ interface RenderOptions { colorSpace?: RenderColorSpace } +function findPageId(graph: SceneGraph, nodeId: string): string | null { + let current = graph.getNode(nodeId) + while (current?.parentId) { + const parent = graph.getNode(current.parentId) + if (!parent) return null + if (parent.type === 'CANVAS') return parent.id + current = parent + } + return current?.type === 'CANVAS' ? current.id : null +} + +function ensureSinglePageSelection(graph: SceneGraph, pageId: string, nodeIds: string[]): boolean { + return nodeIds.every((nodeId) => findPageId(graph, nodeId) === pageId) +} + +function nodeNeedsSceneBackdrop(graph: SceneGraph, nodeId: string): boolean { + const node = graph.getNode(nodeId) + if (!node) return false + if (node.blendMode !== 'NORMAL' && node.blendMode !== 'PASS_THROUGH') return true + if (node.effects.some((effect) => effect.visible && effect.type === 'BACKGROUND_BLUR')) { + return true + } + return node.childIds.some((childId) => nodeNeedsSceneBackdrop(graph, childId)) +} + export function computeContentBounds( graph: SceneGraph, nodeIds: string[] ): { minX: number; minY: number; maxX: number; maxY: number } | null { - let minX = Infinity, - minY = Infinity, - maxX = -Infinity, - maxY = -Infinity + const nodes = nodeIds + .map((id) => graph.getNode(id)) + .filter( + (node): node is NonNullable> => !!node && node.visible + ) - for (const id of nodeIds) { - const node = graph.getNode(id) - if (!node || !node.visible) continue - const abs = graph.getAbsolutePosition(id) - minX = Math.min(minX, abs.x) - minY = Math.min(minY, abs.y) - maxX = Math.max(maxX, abs.x + node.width) - maxY = Math.max(maxY, abs.y + node.height) + if (nodes.length === 0) return null + + const bounds = computeVisualBounds(nodes, (id) => graph.getAbsolutePosition(id)) + return { + minX: bounds.x, + minY: bounds.y, + maxX: bounds.x + bounds.width, + maxY: bounds.y + bounds.height } - - if (!isFinite(minX)) return null - return { minX, minY, maxX, maxY } } function ckImageFormat(ck: CanvasKit, format: ExportFormat) { @@ -50,7 +76,7 @@ function ckImageFormat(ck: CanvasKit, format: ExportFormat) { function renderToSurface( ck: CanvasKit, renderer: SkiaRenderer, - graph: SceneGraph, + renderGraph: SceneGraph, pageId: string, width: number, height: number, @@ -64,7 +90,7 @@ function renderToSurface( try { const canvas = surface.getCanvas() setup(canvas) - renderer.renderSceneToCanvas(canvas, graph, pageId) + renderer.renderSceneToCanvas(canvas, renderGraph, pageId) surface.flush() const image = surface.makeImageSnapshot() const encoded = image.encodeToBytes(ckImageFormat(ck, format), quality) @@ -83,6 +109,10 @@ export function renderNodesToImage( nodeIds: string[], options: RenderOptions ): Uint8Array | null { + if (!ensureSinglePageSelection(graph, pageId, nodeIds)) { + throw new Error('Raster export selection must stay on a single page') + } + const bounds = computeContentBounds(graph, nodeIds) if (!bounds) return null @@ -94,12 +124,20 @@ export function renderNodesToImage( const pixelH = Math.ceil(contentH * options.scale) if (pixelW <= 0 || pixelH <= 0) return null + const extracted = extractExportGraph(graph, { scope: 'selection', nodeIds }) + if (!extracted.pageId) return null + + const renderGraph = nodeIds.some((nodeId) => nodeNeedsSceneBackdrop(graph, nodeId)) + ? graph + : extracted.graph + const renderPageId = renderGraph === graph ? pageId : extracted.pageId + const quality = options.quality ?? (options.format === 'PNG' ? 100 : 90) return renderToSurface( ck, renderer, - graph, - pageId, + renderGraph, + renderPageId, pixelW, pixelH, options.format, diff --git a/packages/core/src/io/formats/svg/export.ts b/packages/core/src/io/formats/svg/export.ts index 79d2fe228..ae2f33961 100644 --- a/packages/core/src/io/formats/svg/export.ts +++ b/packages/core/src/io/formats/svg/export.ts @@ -1,6 +1,6 @@ -import { getDefaultRenderColorSpace } from '@open-pencil/core/color-management' import { computeContentBounds } from '@open-pencil/core/io/formats/raster' +import { resolveNodeTextDirection } from '../../../direction' import { nextDefId, formatColor, @@ -19,7 +19,6 @@ import { roundedRectPath, arcPath } from './paths' -import { resolveNodeTextDirection } from '../../../direction' export { geometryBlobToSVGPath, vectorNetworkToSVGPaths } from './paths' @@ -148,7 +147,8 @@ function nodeShapeElements( } function styleOverrideToTspanAttrs( - style: CharacterStyleOverride + style: CharacterStyleOverride, + colorSpace: 'srgb' | 'display-p3' ): Record { const attrs: Record = {} if (style.fontFamily) attrs['font-family'] = style.fontFamily @@ -161,7 +161,7 @@ function styleOverrideToTspanAttrs( if (style.fills) { const visibleFill = style.fills.find((f) => f.visible && f.type === 'SOLID') if (visibleFill) { - attrs.fill = formatColor(visibleFill.color, visibleFill.opacity) + attrs.fill = formatColor(visibleFill.color, visibleFill.opacity, colorSpace) } } return attrs @@ -174,7 +174,10 @@ function isLogicalTextEnd(node: SceneNode, direction: 'LTR' | 'RTL'): boolean { ) } -function textAnchorForNode(node: SceneNode, direction: 'LTR' | 'RTL'): 'middle' | 'end' | undefined { +function textAnchorForNode( + node: SceneNode, + direction: 'LTR' | 'RTL' +): 'middle' | 'end' | undefined { if (node.textAlignHorizontal === 'CENTER') return 'middle' if (isLogicalTextEnd(node, direction)) return 'end' return undefined @@ -186,7 +189,11 @@ function textXForNode(node: SceneNode, direction: 'LTR' | 'RTL'): number { return 0 } -function renderTextNode(node: SceneNode, fillAttr: string | null): SVGNode { +function renderTextNode( + node: SceneNode, + fillAttr: string | null, + colorSpace: 'srgb' | 'display-p3' +): SVGNode { const direction = resolveNodeTextDirection(node) const textAnchor = textAnchorForNode(node, direction) @@ -215,7 +222,7 @@ function renderTextNode(node: SceneNode, fillAttr: string | null): SVGNode { for (const run of node.styleRuns) { const text = node.text.slice(pos, pos + run.length) pos += run.length - spans.push(svg('tspan', styleOverrideToTspanAttrs(run.style), text)) + spans.push(svg('tspan', styleOverrideToTspanAttrs(run.style, colorSpace), text)) } return svg('text', { x, y, ...attrs }, ...spans) @@ -282,12 +289,13 @@ function buildGroupAttrs( } function buildSVGStrokeAttrs( - visibleStrokes: Stroke[] + visibleStrokes: Stroke[], + colorSpace: 'srgb' | 'display-p3' ): Record { if (visibleStrokes.length === 0) return {} const stroke = visibleStrokes[0] const attrs: Record = { - stroke: formatColor(stroke.color, 1), + stroke: formatColor(stroke.color, 1, colorSpace), 'stroke-width': round(stroke.weight) } if (stroke.opacity < 1) attrs['stroke-opacity'] = round(stroke.opacity) @@ -344,14 +352,14 @@ function renderNode(node: SceneNode, ctx: SVGExportContext): SVGNode | null { if (node.type === 'TEXT') { const firstFill = node.fills.find((f) => f.visible) const fillAttr = firstFill ? resolveFill(firstFill, node, ctx) : null - const textEl = renderTextNode(node, fillAttr) + const textEl = renderTextNode(node, fillAttr, ctx.colorSpace) return svg('g', groupAttrs, textEl) } const visibleFills = node.fills.filter((f) => f.visible) const visibleStrokes = node.strokes.filter((s) => s.visible) const fillAttr = visibleFills.length > 0 ? resolveFill(visibleFills[0], node, ctx) : null - const strokeAttrs = buildSVGStrokeAttrs(visibleStrokes) + const strokeAttrs = buildSVGStrokeAttrs(visibleStrokes, ctx.colorSpace) const children: (SVGNode | null)[] = buildShapeChildren( node, @@ -397,7 +405,7 @@ function isGroupLike(node: SceneNode): boolean { export interface SVGExportOptions { /** Include XML declaration (default: true) */ xmlDeclaration?: boolean - /** Target export color space (default: display-p3) */ + /** Target export color space (default: srgb) */ colorSpace?: 'srgb' | 'display-p3' } @@ -418,7 +426,7 @@ export function renderNodesToSVG( defs: [], defIdCounter: 0, graph, - colorSpace: options.colorSpace ?? getDefaultRenderColorSpace() + colorSpace: options.colorSpace ?? 'srgb' } const contentNodes: SVGNode[] = [] diff --git a/packages/core/src/layout.ts b/packages/core/src/layout.ts index bf02a73b3..510a1f00f 100644 --- a/packages/core/src/layout.ts +++ b/packages/core/src/layout.ts @@ -46,6 +46,10 @@ function estimateTextSize(node: SceneNode, maxWidth?: number): { width: number; return { width: singleLineWidth, height: lineH } } +export function getTextMeasurer(): TextMeasurer | null { + return globalTextMeasurer +} + export function setTextMeasurer(measurer: TextMeasurer | null): void { globalTextMeasurer = measurer } @@ -183,7 +187,11 @@ function buildGridTree( root.insertChild(yogaChild, root.getChildCount()) } else { const yogaChild = createGridChildNode(child) - if (child.layoutMode === 'GRID' || child.layoutMode === 'HORIZONTAL' || child.layoutMode === 'VERTICAL') { + if ( + child.layoutMode === 'GRID' || + child.layoutMode === 'HORIZONTAL' || + child.layoutMode === 'VERTICAL' + ) { const childDirection = resolveNodeLayoutDirection(child, direction) yogaChild.setDirection(childDirection === 'RTL' ? Direction.RTL : Direction.LTR) } diff --git a/packages/core/src/renderer/renderer.ts b/packages/core/src/renderer/renderer.ts index 6d2d7410a..7217c4349 100644 --- a/packages/core/src/renderer/renderer.ts +++ b/packages/core/src/renderer/renderer.ts @@ -33,7 +33,7 @@ import { DEFAULT_FONT_FAMILY, IS_BROWSER } from '../constants' -import { computeAbsoluteBounds } from '../geometry' +import { computeVisualBounds } from '../geometry' import { RenderProfiler } from '../profiler' import { drawAiOverlays as drawAiOverlaysFn } from './ai-overlays' import { @@ -473,16 +473,23 @@ export class SkiaRenderer { * Collects all font family+weight pairs used by `nodeIds`, loads them, * wires up the text measurer for Yoga layout, and recomputes layout. */ - async prepareForExport(graph: SceneGraph, pageId: string, nodeIds: string[]): Promise { + async prepareForExport( + graph: SceneGraph, + pageId: string, + nodeIds: string[] + ): Promise<() => void> { const { collectFontKeys, loadFont } = await import('../fonts') - const { setTextMeasurer, computeAllLayouts } = await import('../layout') + const { getTextMeasurer, setTextMeasurer, computeAllLayouts } = await import('../layout') + const previousTextMeasurer = getTextMeasurer() setTextMeasurer((node, maxWidth) => this.measureTextNode(node, maxWidth)) const fontKeys = collectFontKeys(graph, nodeIds) await Promise.all(fontKeys.map(([family, style]) => loadFont(family, style))) computeAllLayouts(graph, pageId) + + return () => setTextMeasurer(previousTextMeasurer) } replaceSurface(surface: Surface): void { @@ -902,7 +909,7 @@ export class SkiaRenderer { : [] const sceneBounds = sceneNodes.length > 0 - ? computeAbsoluteBounds(sceneNodes, (id) => graph.getAbsolutePosition(id)) + ? computeVisualBounds(sceneNodes, (id) => graph.getAbsolutePosition(id)) : { x: 0, y: 0, width: 1, height: 1 } const padding = 1024 const bounds = this.ck.LTRBRect( diff --git a/packages/mcp/src/server.ts b/packages/mcp/src/server.ts index acf5cb82f..423280309 100644 --- a/packages/mcp/src/server.ts +++ b/packages/mcp/src/server.ts @@ -80,12 +80,17 @@ export function startServer(options: ServerOptions = {}) { const pending = new Map() let browserWs: WebSocket | null = null let browserToken: string | null = null + let browserRegistered = false + + function currentRpcToken(): string | null { + return authToken ?? browserToken + } // --- WebSocket: browser connects here --- function sendToBrowser(body: Record): Promise { return new Promise((resolve, reject) => { - if (!browserWs || browserWs.readyState !== browserWs.OPEN) { + if (!browserWs || browserWs.readyState !== browserWs.OPEN || !browserRegistered) { reject(new Error('OpenPencil app is not connected')) return } @@ -99,7 +104,7 @@ export function startServer(options: ServerOptions = {}) { }) } - function handleBrowserMessage(data: string) { + function handleBrowserMessage(data: string, ws: WebSocket) { try { const msg = JSON.parse(data) as { type: string @@ -110,9 +115,20 @@ export function startServer(options: ServerOptions = {}) { ok?: boolean } if (msg.type === 'register' && msg.token) { + if (authToken && msg.token !== authToken) { + ws.close() + return + } + if (browserWs && browserWs !== ws && browserWs.readyState === WebSocket.OPEN) { + browserWs.close() + rejectAllPending('Browser reconnected') + } + browserWs = ws browserToken = msg.token + browserRegistered = true return } + if (!browserRegistered || browserWs !== ws) return if (msg.type === 'response' && msg.id) { const req = pending.get(msg.id) if (!req) return @@ -140,14 +156,10 @@ export function startServer(options: ServerOptions = {}) { const wss = new WebSocketServer({ port: wsPort, host: '127.0.0.1' }) wss.on('connection', (ws) => { - if (browserWs && browserWs.readyState === WebSocket.OPEN) browserWs.close() - rejectAllPending('Browser reconnected') - browserWs = ws - browserToken = null - ws.on('message', (raw) => { handleBrowserMessage( - typeof raw === 'string' ? raw : Buffer.from(raw as Buffer).toString('utf-8') + typeof raw === 'string' ? raw : Buffer.from(raw as Buffer).toString('utf-8'), + ws ) }) @@ -155,6 +167,7 @@ export function startServer(options: ServerOptions = {}) { if (browserWs === ws) { browserWs = null browserToken = null + browserRegistered = false rejectAllPending('Browser disconnected') } }) @@ -201,24 +214,24 @@ export function startServer(options: ServerOptions = {}) { exposeHeaders: ['mcp-session-id', 'mcp-protocol-version'] }) ) - } else { - app.use('*', cors()) } app.get('/health', (c) => c.json({ - status: browserWs ? 'ok' : 'no_app', - ...(browserWs && browserToken ? { token: browserToken } : {}) + status: browserWs && browserRegistered ? 'ok' : 'no_app', + authRequired: authToken !== null, + ...(currentRpcToken() ? { token: currentRpcToken() } : {}) }) ) app.use('/rpc', async (c, next) => { - if (!browserWs || !browserToken) { + const rpcToken = currentRpcToken() + if (!browserWs || !browserRegistered || !rpcToken) { return c.json({ error: 'OpenPencil app is not connected. Is a document open?' }, 503) } const auth = c.req.header('authorization') const provided = auth?.startsWith('Bearer ') ? auth.slice(7) : null - if (provided !== browserToken) { + if (provided !== rpcToken) { return c.json({ error: 'Unauthorized' }, 401) } return next() @@ -242,8 +255,22 @@ export function startServer(options: ServerOptions = {}) { // --- MCP Streamable HTTP --- type MCPTransport = { handleRequest: (r: Request) => Promise } - const mcpSessions = new Map() + interface MCPSession { + transport: MCPTransport + lastSeen: number + } + const mcpSessions = new Map() const MAX_MCP_SESSIONS = 10 + const MCP_SESSION_TTL_MS = 15 * 60_000 + + function cleanupExpiredMCPSessions() { + const now = Date.now() + for (const [id, session] of mcpSessions) { + if (now - session.lastSeen > MCP_SESSION_TTL_MS) { + mcpSessions.delete(id) + } + } + } function createMCPSession(id: string): MCPTransport { const mcpServer = new McpServer({ name: 'open-pencil', version: MCP_VERSION }) @@ -297,7 +324,7 @@ export function startServer(options: ServerOptions = {}) { sessionIdGenerator: () => id }) void mcpServer.connect(transport) - mcpSessions.set(id, transport) + mcpSessions.set(id, { transport, lastSeen: Date.now() }) return transport } @@ -311,6 +338,7 @@ export function startServer(options: ServerOptions = {}) { return c.json({ error: 'Unauthorized' }, 401) } } + cleanupExpiredMCPSessions() const sessionId = c.req.header('mcp-session-id') ?? undefined const existing = sessionId ? mcpSessions.get(sessionId) : undefined if (!existing && mcpSessions.size >= MAX_MCP_SESSIONS) { @@ -319,7 +347,14 @@ export function startServer(options: ServerOptions = {}) { { status: 503, headers: { 'Retry-After': '5' } } ) } - const transport = existing ?? createMCPSession(sessionId ?? randomUUID()) + const transport = existing?.transport ?? createMCPSession(sessionId ?? randomUUID()) + const resolvedSessionId = + sessionId ?? + [...mcpSessions.entries()].find(([, entry]) => entry.transport === transport)?.[0] + if (resolvedSessionId) { + const session = mcpSessions.get(resolvedSessionId) + if (session) session.lastSeen = Date.now() + } const response = await transport.handleRequest(c.req.raw) if (c.req.method === 'DELETE' && sessionId) { mcpSessions.delete(sessionId) diff --git a/src/ai/acp-transport.ts b/src/ai/acp-transport.ts index 8b5d83e46..ebc02e478 100644 --- a/src/ai/acp-transport.ts +++ b/src/ai/acp-transport.ts @@ -206,7 +206,9 @@ export class ACPChatTransport implements ChatTransport { }) const stdoutChunks: Uint8Array[] = [] - let stdoutResolver: ((chunk: Uint8Array) => void) | null = null + let stdoutResolver: ((chunk: Uint8Array | null) => void) | null = null + let stdoutClosed = false + let stdoutClosedError: Error | null = null command.stdout.on('data', (raw: Uint8Array | number[]) => { const chunk = raw instanceof Uint8Array ? raw : new Uint8Array(raw) @@ -224,6 +226,13 @@ export class ACPChatTransport implements ChatTransport { }) command.on('close', () => { + stdoutClosed = true + stdoutClosedError = this.destroying ? null : new Error('Agent process exited unexpectedly.') + if (stdoutResolver) { + const resolve = stdoutResolver + stdoutResolver = null + resolve(null) + } if (this.destroying || !this.session) return this.session.dead = true this.session = null @@ -238,12 +247,20 @@ export class ACPChatTransport implements ChatTransport { controller.enqueue(buffered) return } - await new Promise((resolve) => { - stdoutResolver = (chunk) => { - controller.enqueue(chunk) - resolve() - } + if (stdoutClosed) { + if (stdoutClosedError) controller.error(stdoutClosedError) + else controller.close() + return + } + const chunk = await new Promise((resolve) => { + stdoutResolver = resolve }) + if (chunk) { + controller.enqueue(chunk) + return + } + if (stdoutClosedError) controller.error(stdoutClosedError) + else controller.close() } }) @@ -270,6 +287,8 @@ export class ACPChatTransport implements ChatTransport { } const connection = new ClientSideConnection((_agent: Agent) => clientImpl, stream) + const { getAutomationAuthToken } = await import('@/automation/spawn-mcp') + const automationAuthToken = await getAutomationAuthToken() await connection.initialize({ protocolVersion: PROTOCOL_VERSION, @@ -285,7 +304,9 @@ export class ACPChatTransport implements ChatTransport { type: 'http' as const, name: 'open-pencil', url: 'http://127.0.0.1:7600/mcp', - headers: [] + headers: automationAuthToken + ? [{ name: 'Authorization', value: `Bearer ${automationAuthToken}` }] + : [] } ] }) diff --git a/src/ai/tools.ts b/src/ai/tools.ts index fa7011b86..ea95d62cb 100644 --- a/src/ai/tools.ts +++ b/src/ai/tools.ts @@ -3,6 +3,7 @@ import { tool } from 'ai' import * as v from 'valibot' import { makeFigmaFromStore } from '@/automation/figma-factory' +import { getActiveEditorStore } from '@/stores/editor' import { CORE_TOOLS, collectFontKeys, @@ -46,37 +47,48 @@ class RunState { clear(): void { this.toolLog = [] this.stepUsages = [] + this.currentSteps = 0 } } -export const runState = new RunState() +const runStates = new WeakMap() -export function getToolLogEntries(): ToolLogEntry[] { - return runState.toolLog +function getRunState(store?: EditorStore): RunState { + const target = store ?? getActiveEditorStore() + const existing = runStates.get(target) + if (existing) return existing + const created = new RunState() + runStates.set(target, created) + return created } -export function getStepUsages(): StepUsage[] { - return runState.stepUsages +export function getToolLogEntries(store?: EditorStore): ToolLogEntry[] { + return getRunState(store).toolLog } -export function recordStepUsage(usage: StepUsage): void { - runState.recordStep(usage) +export function getStepUsages(store?: EditorStore): StepUsage[] { + return getRunState(store).stepUsages } -export function resetRunSteps(): void { - runState.resetSteps() +export function recordStepUsage(usage: StepUsage, store?: EditorStore): void { + getRunState(store).recordStep(usage) } -export function didHitStepLimit(): boolean { - return runState.hitLimit() +export function resetRunSteps(store?: EditorStore): void { + getRunState(store).resetSteps() } -export function clearToolLogEntries(): void { - runState.clear() +export function didHitStepLimit(store?: EditorStore): boolean { + return getRunState(store).hitLimit() +} + +export function clearToolLogEntries(store?: EditorStore): void { + getRunState(store).clear() } export function createAITools(store: EditorStore) { let beforeSnapshot: Map | null = null + const runState = getRunState(store) return toolsToAI( CORE_TOOLS, diff --git a/src/automation/server.ts b/src/automation/server.ts index 06a5d8da7..d2846b492 100644 --- a/src/automation/server.ts +++ b/src/automation/server.ts @@ -19,8 +19,8 @@ import { import type { EditorStore } from '@/stores/editor' import type { RasterExportFormat } from '@open-pencil/core' -export function connectAutomation(getStore: () => EditorStore) { - const token = randomHex(32) +export function connectAutomation(getStore: () => EditorStore, authToken: string | null = null) { + const token = authToken ?? randomHex(32) let ws: WebSocket | null = null let reconnectTimer: ReturnType | undefined diff --git a/src/automation/spawn-mcp.ts b/src/automation/spawn-mcp.ts index e3d2255b5..0ebf3fb33 100644 --- a/src/automation/spawn-mcp.ts +++ b/src/automation/spawn-mcp.ts @@ -1,32 +1,76 @@ import { decodeTauriStderr } from '@/utils/tauri' -import { AUTOMATION_HTTP_PORT, IS_TAURI } from '@open-pencil/core' +import { AUTOMATION_HTTP_PORT, IS_TAURI, randomHex } from '@open-pencil/core' -async function checkHealth(): Promise { +interface AutomationHealth { + status: 'ok' | 'no_app' + authRequired?: boolean + token?: string +} + +export interface AutomationServerHandle { + disconnect: () => void + authToken: string | null +} + +const DEV_AUTOMATION_AUTH_TOKEN = import.meta.env.DEV ? __OPENPENCIL_LOCAL_AUTOMATION_TOKEN__ : null +const noop = () => undefined + +let runtimeAutomationAuthToken: string | null = DEV_AUTOMATION_AUTH_TOKEN + +async function readHealth(): Promise { try { const res = await fetch(`http://127.0.0.1:${AUTOMATION_HTTP_PORT}/health`, { signal: AbortSignal.timeout(1000) }) - return res.ok + if (!res.ok) return null + return (await res.json()) as AutomationHealth } catch { - return false + return null } } -async function pollHealth(retries: number, delayMs: number): Promise { +async function pollHealth(retries: number, delayMs: number): Promise { for (let i = 0; i < retries; i++) { await new Promise((r) => setTimeout(r, delayMs)) - if (await checkHealth()) return true + const health = await readHealth() + if (health) return health } - return false + return null } -export async function spawnMCPIfNeeded(): Promise<(() => void) | null> { - if (import.meta.env.DEV || !IS_TAURI) return null +export async function getAutomationAuthToken(): Promise { + if (runtimeAutomationAuthToken) return runtimeAutomationAuthToken + const health = await readHealth() + runtimeAutomationAuthToken = health?.token ?? null + return runtimeAutomationAuthToken +} - if (await checkHealth()) return null +export async function spawnMCPIfNeeded(): Promise { + if (import.meta.env.DEV || !IS_TAURI) { + return DEV_AUTOMATION_AUTH_TOKEN + ? { disconnect: noop, authToken: DEV_AUTOMATION_AUTH_TOKEN } + : null + } + + const existing = await readHealth() + if (existing) { + runtimeAutomationAuthToken = existing.token ?? null + return { + disconnect: noop, + authToken: runtimeAutomationAuthToken + } + } + + const authToken = randomHex(32) + runtimeAutomationAuthToken = authToken const { Command } = await import('@tauri-apps/plugin-shell') - const command = Command.create('openpencil-mcp', []) + const command = Command.create('openpencil-mcp', [], { + env: { + OPENPENCIL_MCP_AUTH_TOKEN: authToken, + OPENPENCIL_MCP_CORS_ORIGIN: window.location.origin + } + }) command.stderr.on('data', (raw: Uint8Array | number[] | string) => { console.error('[MCP]', decodeTauriStderr(raw)) @@ -37,10 +81,15 @@ export async function spawnMCPIfNeeded(): Promise<(() => void) | null> { }) const child = await command.spawn() + const health = await pollHealth(5, 1000) - if (await pollHealth(5, 1000)) { - return () => { - void child.kill() + if (health) { + runtimeAutomationAuthToken = health.token ?? authToken + return { + disconnect: () => { + void child.kill() + }, + authToken: runtimeAutomationAuthToken } } diff --git a/src/automation/vite-plugin.ts b/src/automation/vite-plugin.ts index d8b1d4243..9f442ed1a 100644 --- a/src/automation/vite-plugin.ts +++ b/src/automation/vite-plugin.ts @@ -3,7 +3,7 @@ import { spawn } from 'node:child_process' import type { Plugin } from 'vite' // TODO: production — bundle MCP server as Tauri sidecar or spawn via shell plugin -export function automationPlugin(): Plugin { +export function automationPlugin(authToken: string | null, corsOrigin: string): Plugin { let child: ReturnType | null = null return { @@ -13,7 +13,13 @@ export function automationPlugin(): Plugin { child = spawn('bun', ['run', 'packages/mcp/src/index.ts'], { stdio: ['ignore', 'inherit', 'pipe'], - env: { ...process.env, PORT: '7600', WS_PORT: '7601' } + env: { + ...process.env, + PORT: '7600', + WS_PORT: '7601', + ...(authToken ? { OPENPENCIL_MCP_AUTH_TOKEN: authToken } : {}), + OPENPENCIL_MCP_CORS_ORIGIN: corsOrigin + } }) child.stderr?.on('data', (data: Buffer) => { diff --git a/src/components/CanvasMenu.vue b/src/components/CanvasMenu.vue index ab02669bd..ee776b2a8 100644 --- a/src/components/CanvasMenu.vue +++ b/src/components/CanvasMenu.vue @@ -26,8 +26,14 @@ function ids() { return [...selectedIds.value] } -function execCommand(cmd: string) { - window.document.execCommand(cmd) +function execCommand(cmd: 'copy' | 'cut' | 'paste') { + try { + if (window.document.execCommand(cmd)) return + } catch (error) { + console.warn(`Clipboard command ${cmd} failed`, error) + } + + toast.show('Clipboard access is blocked in this browser context', 'error') } async function clipboardWrite(text: string | null, label: string) { @@ -37,6 +43,10 @@ async function clipboardWrite(text: string | null, label: string) { } async function copyAsPNG() { + if (!navigator.clipboard?.write || typeof ClipboardItem === 'undefined') { + toast.show('PNG clipboard export is not available in this browser', 'error') + return + } const data = await store.renderExportImage([...selectedIds.value], 2, 'PNG') if (!data) return const blob = new Blob([data], { type: 'image/png' }) diff --git a/src/components/ChatPanel.vue b/src/components/ChatPanel.vue index f5a5ad6b7..4b2bb7e80 100644 --- a/src/components/ChatPanel.vue +++ b/src/components/ChatPanel.vue @@ -5,6 +5,7 @@ import { computed, markRaw, nextTick, ref, watch } from 'vue' import { getAcpDebugText, clearAcpDebugLog, hasAcpDebugEntries } from '@/ai/acp-transport' import { copyChatLog } from '@/ai/chat-debug' import { clearToolLogEntries, didHitStepLimit } from '@/ai/tools' +import { activeTab } from '@/stores/tabs' import ACPPermissionDialog from '@/components/chat/ACPPermissionDialog.vue' import ChatInput from '@/components/chat/ChatInput.vue' import ChatMessage from '@/components/chat/ChatMessage.vue' @@ -61,6 +62,13 @@ function scrollToBottom() { } watch(messages, scrollToBottom, { deep: true }) +watch( + () => activeTab.value?.id, + async () => { + const nextChat = await ensureChat() + chat.value = nextChat ? markRaw(nextChat) : null + } +) async function handleSubmit(text: string) { if (status.value === 'streaming' || status.value === 'submitted') return diff --git a/src/components/CollabPanel.vue b/src/components/CollabPanel.vue index c51aa772f..6cf0c76ca 100644 --- a/src/components/CollabPanel.vue +++ b/src/components/CollabPanel.vue @@ -1,6 +1,6 @@ diff --git a/src/composables/use-chat.ts b/src/composables/use-chat.ts index b2588af5e..eecb8c403 100644 --- a/src/composables/use-chat.ts +++ b/src/composables/use-chat.ts @@ -9,7 +9,7 @@ import { computed, ref, watch } from 'vue' import SYSTEM_PROMPT from '@/ai/system-prompt.md?raw' import { MAX_AGENT_STEPS, createAITools, recordStepUsage, resetRunSteps } from '@/ai/tools' -import { useEditorStore } from '@/stores/editor' +import { getActiveEditorStore } from '@/stores/editor' import { ACP_AGENTS, AI_PROVIDERS, @@ -78,9 +78,13 @@ const isConfigured = computed(() => { }) let transportDirty = false +let currentChatStore: ReturnType | null = null +let currentChatMessages = new WeakMap, UIMessage[]>() function markTransportDirty() { transportDirty = true + currentChatStore = null + currentChatMessages = new WeakMap() } watch( @@ -217,13 +221,13 @@ async function createACPTransport() { return transport } -function createTransport() { +function createTransport(store: ReturnType) { if (overrideTransport) return overrideTransport() void acpTransportInstance?.destroy() acpTransportInstance = null - const tools = createAITools(useEditorStore()) + const tools = createAITools(store) const cacheProviderOptions = supportsAnthropicCaching() ? ANTHROPIC_CACHE_CONTROL : undefined const agent = new ToolLoopAgent({ @@ -234,7 +238,7 @@ function createTransport() { maxOutputTokens: maxOutputTokens.value, providerOptions: cacheProviderOptions, prepareCall: (options) => { - resetRunSteps() + resetRunSteps(store) return { ...options, maxOutputTokens: maxOutputTokens.value, @@ -242,13 +246,16 @@ function createTransport() { } }, onStepFinish: ({ usage }) => { - recordStepUsage({ - inputTokens: usage.inputTokens ?? 0, - outputTokens: usage.outputTokens ?? 0, - cacheReadTokens: usage.inputTokenDetails.cacheReadTokens ?? 0, - cacheWriteTokens: usage.inputTokenDetails.cacheWriteTokens ?? 0, - timestamp: Date.now() - }) + recordStepUsage( + { + inputTokens: usage.inputTokens ?? 0, + outputTokens: usage.outputTokens ?? 0, + cacheReadTokens: usage.inputTokenDetails.cacheReadTokens ?? 0, + cacheWriteTokens: usage.inputTokenDetails.cacheWriteTokens ?? 0, + timestamp: Date.now() + }, + store + ) } }) @@ -257,17 +264,28 @@ function createTransport() { async function ensureChat(): Promise | null> { if (!isConfigured.value) return null - if (!chat || transportDirty) { - const messages = chat?.messages - const transport = isACPProvider.value ? await createACPTransport() : createTransport() + + const store = getActiveEditorStore() + if (currentChatStore && chat) { + currentChatMessages.set(currentChatStore, chat.messages) + } + + if (!chat || transportDirty || currentChatStore !== store) { + const messages = currentChatMessages.get(store) + const transport = isACPProvider.value ? await createACPTransport() : createTransport(store) chat = new Chat({ transport, messages }) + currentChatStore = store transportDirty = false } return chat } function resetChat() { + if (currentChatStore) { + currentChatMessages.delete(currentChatStore) + } chat = null + currentChatStore = null transportDirty = false } diff --git a/src/composables/use-collab.ts b/src/composables/use-collab.ts index 018086552..0cea1c4ee 100644 --- a/src/composables/use-collab.ts +++ b/src/composables/use-collab.ts @@ -42,7 +42,11 @@ export const DEFAULT_COLLAB_STATE: CollabState = { localColor: { r: 0.5, g: 0.5, b: 0.5, a: 1 } } -export function useCollab(store: EditorStore) { +export function useCollab(storeOrGetter: EditorStore | (() => EditorStore)) { + const getStore = () => + typeof storeOrGetter === 'function' + ? (storeOrGetter as () => EditorStore)() + : storeOrGetter const storedName = useLocalStorage('op-collab-name', '') const state = ref({ connected: false, @@ -58,9 +62,11 @@ export function useCollab(store: EditorStore) { let yimages: Y.Map | null = null let room: Room | null = null let persistence: IndexeddbPersistence | null = null + let connectedStore: EditorStore | null = null let suppressGraphSync = false let suppressYjsEvents = false let unbindGraphEvents: (() => void) | null = null + let stopZoomWatch: (() => void) | null = null let sendYjsUpdate: ((data: Uint8Array, peerId?: string) => void) | null = null let sendAwareness: ((data: Uint8Array, peerId?: string) => void) | null = null let sendSyncStep1: ((data: Uint8Array, peerId?: string) => void) | null = null @@ -70,6 +76,8 @@ export function useCollab(store: EditorStore) { function connect(roomId: string) { if (room) disconnect() + const store = getStore() + connectedStore = store state.value.roomId = roomId ydoc = new Y.Doc() awareness = new awarenessProtocol.Awareness(ydoc) @@ -203,7 +211,7 @@ export function useCollab(store: EditorStore) { state.value.connected = true broadcastAwareness() - watch( + stopZoomWatch = watch( () => store.state.zoom, (zoom) => { if (!awareness) return @@ -251,8 +259,11 @@ export function useCollab(store: EditorStore) { } function disconnect() { + const store = connectedStore ?? getStore() unbindGraphEvents?.() unbindGraphEvents = null + stopZoomWatch?.() + stopZoomWatch = null void room?.leave() room = null sendYjsUpdate = null @@ -276,11 +287,14 @@ export function useCollab(store: EditorStore) { state.value.connected = false state.value.roomId = null state.value.peers = [] + followingPeer.value = null store.state.remoteCursors = [] store.requestRender() + connectedStore = null } function syncNodeToYjs(nodeId: string) { + const store = connectedStore ?? getStore() if (!ydoc || !ynodes) return const node = store.graph.getNode(nodeId) if (!node) return @@ -319,6 +333,7 @@ export function useCollab(store: EditorStore) { } function syncAllNodesToYjs() { + const store = connectedStore ?? getStore() if (!ydoc || !ynodes) return const localYnodes = ynodes const localYimages = yimages @@ -346,6 +361,7 @@ export function useCollab(store: EditorStore) { } function applyYjsToGraph(events: Y.YEvent>[]) { + const store = connectedStore ?? getStore() if (!ynodes) return const localYnodes = ynodes for (const event of events) { @@ -377,6 +393,7 @@ export function useCollab(store: EditorStore) { } function applyYnodeToGraph(nodeId: string, ynode: Y.Map) { + const store = connectedStore ?? getStore() const existing = store.graph.getNode(nodeId) const props: Record = {} @@ -415,6 +432,7 @@ export function useCollab(store: EditorStore) { } function updateCursor(x: number, y: number, pageId: string) { + const store = connectedStore ?? getStore() if (!awareness) return awareness.setLocalStateField('cursor', { x, y, pageId, zoom: store.state.zoom }) } @@ -425,6 +443,7 @@ export function useCollab(store: EditorStore) { } function updatePeersList() { + const store = connectedStore ?? getStore() if (!awareness) return const states = awareness.getStates() const peers: RemotePeer[] = [] @@ -492,6 +511,7 @@ export function useCollab(store: EditorStore) { } function tickFollow() { + const store = connectedStore ?? getStore() if (!followingPeer.value || !awareness) return const peerState = awareness.getStates().get(followingPeer.value) if (!peerState?.cursor) { diff --git a/src/env.d.ts b/src/env.d.ts index 4a9769c46..3de35245f 100644 --- a/src/env.d.ts +++ b/src/env.d.ts @@ -2,6 +2,8 @@ /// /// +declare const __OPENPENCIL_LOCAL_AUTOMATION_TOKEN__: string | null + declare module '*.vue' { import type { DefineComponent } from 'vue' const component: DefineComponent diff --git a/src/stores/editor.ts b/src/stores/editor.ts index 773dd4a12..218a040ca 100644 --- a/src/stores/editor.ts +++ b/src/stores/editor.ts @@ -851,6 +851,27 @@ export function createEditorStore(initialGraph?: SceneGraph) { return new Promise((r) => requestAnimationFrame(() => r())) } + function setDocumentSource( + fileName: string, + sourceFormat: string, + handle?: FileSystemFileHandle, + path?: string + ) { + stopWatchingFile() + fileHandle = sourceFormat === 'fig' ? (handle ?? null) : null + filePath = sourceFormat === 'fig' ? (path ?? null) : null + downloadName = sourceFormat === 'fig' ? fileName : fileName.replace(/\.[^.]+$/i, '.fig') + savedVersion = state.sceneVersion + if (sourceFormat === 'fig' && (fileHandle || filePath)) { + void startWatchingFile() + } + } + + function dispose() { + stopWatchingFile() + ;(debouncedAutosave as typeof debouncedAutosave & { cancel?: () => void }).cancel?.() + } + async function openFigFile(file: File, handle?: FileSystemFileHandle, path?: string) { try { state.loading = true @@ -859,16 +880,13 @@ export function createEditorStore(initialGraph?: SceneGraph) { await yieldToUI() editor.replaceGraph(imported) editor.undo.clear() - fileHandle = handle ?? null - filePath = path ?? null state.documentName = file.name.replace(/\.fig$/i, '') - downloadName = file.name + setDocumentSource(file.name, 'fig', handle, path) state.selectedIds = new Set() const firstPage = editor.graph.getPages()[0] as SceneNode | undefined const pageId = firstPage?.id ?? editor.graph.rootId await editor.switchPage(pageId) editor.requestRender() - void startWatchingFile() } catch (e) { console.error('Failed to open .fig file:', e) toast.show(`Failed to open file: ${e instanceof Error ? e.message : String(e)}`, 'error') @@ -1235,6 +1253,8 @@ export function createEditorStore(initialGraph?: SceneGraph) { openFigFile, saveFigFile, saveFigFileAs, + setDocumentSource, + dispose, renderExportImage, listSelectionExportFormats, exportTarget, diff --git a/src/stores/tabs.ts b/src/stores/tabs.ts index 31b0b6836..fc5c83311 100644 --- a/src/stores/tabs.ts +++ b/src/stores/tabs.ts @@ -64,11 +64,13 @@ export function closeTab(tabId: string) { const idx = tabsRef.value.findIndex((t) => t.id === tabId) if (idx === -1) return + const closingTab = tabsRef.value[idx] const wasActive = activeTabId.value === tabId tabsRef.value = tabsRef.value.filter((t) => t.id !== tabId) if (tabsRef.value.length === 0) { createTab() + closingTab.store.dispose() return } @@ -76,18 +78,20 @@ export function closeTab(tabId: string) { const newIdx = Math.min(idx, tabsRef.value.length - 1) activateTab(tabsRef.value[newIdx]) } + + closingTab.store.dispose() } export async function openFileInNewTab( file: File, - _handle?: FileSystemFileHandle, - _path?: string + handle?: FileSystemFileHandle, + path?: string ): Promise { const current = activeTab.value const isUntouched = current?.store.state.documentName === 'Untitled' && !current.store.undo.canUndo const bytes = new Uint8Array(await file.arrayBuffer()) - const { graph: imported } = await io.readDocument({ + const { graph: imported, sourceFormat } = await io.readDocument({ name: file.name, mimeType: file.type || undefined, data: bytes @@ -98,6 +102,7 @@ export async function openFileInNewTab( current.store.replaceGraph(imported) current.store.undo.clear() current.store.state.documentName = documentName + current.store.setDocumentSource(file.name, sourceFormat, handle, path) current.store.state.selectedIds = new Set() const pageId = current.store.graph.getPages()[0]?.id ?? current.store.graph.rootId await current.store.switchPage(pageId) @@ -106,6 +111,7 @@ export async function openFileInNewTab( createTab(store) store.undo.clear() store.state.documentName = documentName + store.setDocumentSource(file.name, sourceFormat, handle, path) store.state.selectedIds = new Set() const pageId = store.graph.getPages()[0]?.id ?? store.graph.rootId await store.switchPage(pageId) diff --git a/src/views/EditorView.vue b/src/views/EditorView.vue index 03ed50e71..9590a4cec 100644 --- a/src/views/EditorView.vue +++ b/src/views/EditorView.vue @@ -42,7 +42,7 @@ useHead({ title: route.meta.demo ? 'Demo' : undefined }) useKeyboard() useMenu() -const collab = useCollab(firstTab.store) +const collab = useCollab(getActiveStore) provide(COLLAB_KEY, collab) useEventListener( @@ -58,11 +58,12 @@ const automationCleanup = ref<(() => void) | null>(null) const mcpCleanup = ref<(() => void) | null>(null) onMounted(async () => { - if (import.meta.env.DEV || IS_TAURI) { - automationCleanup.value = connectAutomation(getActiveStore).disconnect - } try { - mcpCleanup.value = await spawnMCPIfNeeded() + const mcp = await spawnMCPIfNeeded() + mcpCleanup.value = mcp?.disconnect ?? null + if (import.meta.env.DEV || IS_TAURI) { + automationCleanup.value = connectAutomation(getActiveStore, mcp?.authToken ?? null).disconnect + } } catch (e) { console.error(e) } diff --git a/vite.config.ts b/vite.config.ts index 018774ab8..0e66c44f8 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,3 +1,4 @@ +import { randomUUID } from 'crypto' import { resolve } from 'path' import { defineConfig } from 'vite' @@ -11,10 +12,13 @@ import { copyFileSync, existsSync, mkdirSync } from 'fs' import { automationPlugin } from './src/automation/vite-plugin' +const devAutomationAuthToken = randomUUID() + // @ts-expect-error process is a nodejs global const host = process.env.TAURI_DEV_HOST +const devAutomationCorsOrigin = host ? `http://${host}:1420` : 'http://localhost:1420' -export default defineConfig(async () => ({ +export default defineConfig(async ({ command }) => ({ resolve: { alias: { '@': resolve(__dirname, 'src'), @@ -26,6 +30,11 @@ export default defineConfig(async () => ({ 'beautiful-mermaid': resolve(__dirname, 'src/shims/mermaid.ts') } }, + define: { + __OPENPENCIL_LOCAL_AUTOMATION_TOKEN__: JSON.stringify( + command === 'serve' ? devAutomationAuthToken : null + ) + }, plugins: [ { name: 'copy-canvaskit-wasm', @@ -55,7 +64,7 @@ export default defineConfig(async () => ({ tailwindcss(), Icons({ compiler: 'vue3' }), Components({ resolvers: [IconsResolver({ prefix: 'icon' })] }), - automationPlugin(), + automationPlugin(command === 'serve' ? devAutomationAuthToken : null, devAutomationCorsOrigin), vue(), VitePWA({ registerType: 'autoUpdate',