Fix MCP auth, collab state, and export bounds

This commit is contained in:
Danila Poyarkov 2026-04-01 15:48:01 +03:00
parent fd15ab6636
commit 943995fb70
24 changed files with 537 additions and 149 deletions

View file

@ -25,8 +25,7 @@ export async function getAppToken(): Promise<string> {
return cachedToken
}
export async function rpc<T = unknown>(command: string, args: unknown = {}): Promise<T> {
const token = await getAppToken()
async function doRpc<T>(token: string, command: string, args: unknown): Promise<T> {
const res = await fetch(RPC_URL, {
method: 'POST',
headers: {
@ -49,6 +48,19 @@ export async function rpc<T = unknown>(command: string, args: unknown = {}): Pro
return body.result as T
}
export async function rpc<T = unknown>(command: string, args: unknown = {}): Promise<T> {
let token = await getAppToken()
try {
return await doRpc<T>(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<T>(token, command, args)
}
}
export function isAppMode(file?: string): boolean {
return !file
}

View file

@ -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>): 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 }
}

View file

@ -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
)

View file

@ -38,12 +38,17 @@ export async function headlessRenderNodes(
options: { scale?: number; format?: ExportFormat; quality?: number } = {}
): Promise<Uint8Array | null> {
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<Uint8Array | null> {
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()
}
}

View file

@ -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<ReturnType<SceneGraph['getNode']>> => !!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,

View file

@ -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<string, string | number | undefined> {
const attrs: Record<string, string | number | undefined> = {}
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<string, string | number | undefined> {
if (visibleStrokes.length === 0) return {}
const stroke = visibleStrokes[0]
const attrs: Record<string, string | number | undefined> = {
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[] = []

View file

@ -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)
}

View file

@ -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<void> {
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(

View file

@ -80,12 +80,17 @@ export function startServer(options: ServerOptions = {}) {
const pending = new Map<string, PendingRequest>()
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<string, unknown>): Promise<unknown> {
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<Response> }
const mcpSessions = new Map<string, MCPTransport>()
interface MCPSession {
transport: MCPTransport
lastSeen: number
}
const mcpSessions = new Map<string, MCPSession>()
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)

View file

@ -206,7 +206,9 @@ export class ACPChatTransport implements ChatTransport<UIMessage> {
})
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<UIMessage> {
})
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<UIMessage> {
controller.enqueue(buffered)
return
}
await new Promise<void>((resolve) => {
stdoutResolver = (chunk) => {
controller.enqueue(chunk)
resolve()
}
if (stdoutClosed) {
if (stdoutClosedError) controller.error(stdoutClosedError)
else controller.close()
return
}
const chunk = await new Promise<Uint8Array | null>((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<UIMessage> {
}
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<UIMessage> {
type: 'http' as const,
name: 'open-pencil',
url: 'http://127.0.0.1:7600/mcp',
headers: []
headers: automationAuthToken
? [{ name: 'Authorization', value: `Bearer ${automationAuthToken}` }]
: []
}
]
})

View file

@ -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<EditorStore, RunState>()
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<string, SceneNode> | null = null
const runState = getRunState(store)
return toolsToAI(
CORE_TOOLS,

View file

@ -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<typeof setTimeout> | undefined

View file

@ -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<boolean> {
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<AutomationHealth | null> {
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<boolean> {
async function pollHealth(retries: number, delayMs: number): Promise<AutomationHealth | null> {
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<string | null> {
if (runtimeAutomationAuthToken) return runtimeAutomationAuthToken
const health = await readHealth()
runtimeAutomationAuthToken = health?.token ?? null
return runtimeAutomationAuthToken
}
if (await checkHealth()) return null
export async function spawnMCPIfNeeded(): Promise<AutomationServerHandle | null> {
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
}
}

View file

@ -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<typeof spawn> | 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) => {

View file

@ -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' })

View file

@ -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

View file

@ -1,6 +1,6 @@
<script setup lang="ts">
import { useClipboard } from '@vueuse/core'
import { computed, ref } from 'vue'
import { computed, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { PopoverRoot, PopoverTrigger, PopoverPortal, PopoverContent } from 'reka-ui'
@ -21,8 +21,10 @@ const { dialogs } = useI18n()
const joinInput = ref('')
const nameDraft = ref(collab?.state.value.localName ?? '')
const pendingRoomId = (route.params.roomId as string) || null
const popoverOpen = ref(!!pendingRoomId)
const pendingRoomId = computed(() =>
typeof route.params.roomId === 'string' ? route.params.roomId : null
)
const popoverOpen = ref(!!pendingRoomId.value)
const state = computed(() => collab?.state.value ?? DEFAULT_COLLAB_STATE)
const peers = computed(() => collab?.remotePeers.value ?? [])
@ -33,7 +35,17 @@ const shareUrl = computed(() => {
return `${window.location.origin}/share/${state.value.roomId}`
})
const isJoining = computed(() => !!pendingRoomId && !state.value.connected)
const isJoining = computed(() => !!pendingRoomId.value && !state.value.connected)
watch(
pendingRoomId,
(roomId) => {
if (!state.value.connected) {
popoverOpen.value = !!roomId
}
},
{ immediate: true }
)
function copyLink() {
if (!shareUrl.value) return
@ -53,7 +65,7 @@ function onShare() {
function onJoin() {
if (!collab) return
const roomId = pendingRoomId || joinInput.value.trim().replace(/.*\/share\//, '')
const roomId = pendingRoomId.value || joinInput.value.trim().replace(/.*\/share\//, '')
if (!roomId || !nameDraft.value.trim()) return
collab.setLocalName(nameDraft.value.trim())
collab.connect(roomId)
@ -64,6 +76,7 @@ function onJoin() {
function onDisconnect() {
if (!collab) return
collab.disconnect()
popoverOpen.value = false
router.push('/')
}
</script>

View file

@ -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<typeof getActiveEditorStore> | null = null
let currentChatMessages = new WeakMap<ReturnType<typeof getActiveEditorStore>, 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<typeof getActiveEditorStore>) {
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<Chat<UIMessage> | 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<UIMessage>({ transport, messages })
currentChatStore = store
transportDirty = false
}
return chat
}
function resetChat() {
if (currentChatStore) {
currentChatMessages.delete(currentChatStore)
}
chat = null
currentChatStore = null
transportDirty = false
}

View file

@ -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<CollabState>({
connected: false,
@ -58,9 +62,11 @@ export function useCollab(store: EditorStore) {
let yimages: Y.Map<Uint8Array> | 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<Y.Map<unknown>>[]) {
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<unknown>) {
const store = connectedStore ?? getStore()
const existing = store.graph.getNode(nodeId)
const props: Record<string, unknown> = {}
@ -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) {

2
src/env.d.ts vendored
View file

@ -2,6 +2,8 @@
/// <reference types="vite-plugin-pwa/vanillajs" />
/// <reference types="unplugin-icons/types/vue" />
declare const __OPENPENCIL_LOCAL_AUTOMATION_TOKEN__: string | null
declare module '*.vue' {
import type { DefineComponent } from 'vue'
const component: DefineComponent<object, object, unknown>

View file

@ -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,

View file

@ -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<void> {
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)

View file

@ -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)
}

View file

@ -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',