mirror of
https://github.com/ZSeven-W/openpencil.git
synced 2026-05-31 19:04:29 +07:00
Keep the Electron sync fix viable without project proxy helpers
This rebuilds the original PR on the current release branch, keeps the dev-startup, sync-noise, and bitmap-dragging fixes, and drops the extra loopback proxy helper files that upstream explicitly rejected. The readiness probe now uses direct socket checks inside the existing desktop dev entrypoint, while the sync hardening stays focused on request diagnostics, backpressure, and drag-time clip rect correctness. Constraint: Upstream PR feedback explicitly rejected adding loopback no_proxy helper scripts to the repository\nConstraint: The original v0.6.1 base branch no longer exists on upstream; this redo is rebased onto upstream/v0.7.0\nRejected: Reintroduce .envrc and loopback helper scripts | conflicts with maintainer review on PR #92\nRejected: Keep fetch-based Vite readiness checks | still vulnerable to localhost proxy interference\nConfidence: medium\nScope-risk: moderate\nReversibility: clean\nDirective: Keep future Electron dev networking fixes inside the existing launcher unless upstream asks for a shared proxy utility\nTested: bunx oxfmt on changed files\nTested: bunx oxlint on changed files\nTested: vitest root run for apps/web/server/__tests__/mcp-sync-state-active.test.ts\nTested: file-level TS diagnostics for changed web/desktop files\nNot-tested: Full workspace typecheck and package test suite (upstream worktree currently lacks resolvable workspace setup in this environment)
This commit is contained in:
parent
1cfabc73a6
commit
b046a0dac6
8 changed files with 384 additions and 69 deletions
|
|
@ -10,13 +10,10 @@
|
|||
import { spawn, execSync, type ChildProcess } from 'node:child_process';
|
||||
import { build } from 'esbuild';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { Socket } from 'node:net';
|
||||
import { join } from 'node:path';
|
||||
import { compileSkills } from '../../packages/pen-ai-skills/vite-plugin-skills';
|
||||
import {
|
||||
getDevServerConflictMessage,
|
||||
getElectronBinaryPath,
|
||||
getElectronSpawnEnv,
|
||||
} from './dev-utils';
|
||||
import { getElectronBinaryPath, getElectronSpawnEnv } from './dev-utils';
|
||||
|
||||
const DESKTOP_DIR = import.meta.dirname;
|
||||
const ROOT = join(DESKTOP_DIR, '..', '..');
|
||||
|
|
@ -38,55 +35,43 @@ const GENERATED_SKILL_REGISTRY = join(
|
|||
async function waitForViteServer(
|
||||
baseUrl: string,
|
||||
vite: ChildProcess,
|
||||
port: number,
|
||||
timeoutMs = 30_000,
|
||||
): Promise<void> {
|
||||
const target = new URL(baseUrl);
|
||||
const port = Number.parseInt(target.port || '80', 10);
|
||||
const hosts =
|
||||
target.hostname === 'localhost' ? ['127.0.0.1', '::1', 'localhost'] : [target.hostname];
|
||||
const start = Date.now();
|
||||
let viteExit: { code: number | null; signal: NodeJS.Signals | null } | null = null;
|
||||
const handleExit = (code: number | null, signal: NodeJS.Signals | null) => {
|
||||
viteExit = { code, signal };
|
||||
};
|
||||
|
||||
async function canConnect(host: string): Promise<boolean> {
|
||||
return await new Promise((resolve) => {
|
||||
const socket = new Socket();
|
||||
|
||||
const finish = (ok: boolean) => {
|
||||
socket.removeAllListeners();
|
||||
socket.destroy();
|
||||
resolve(ok);
|
||||
};
|
||||
|
||||
socket.setTimeout(800);
|
||||
socket.once('connect', () => finish(true));
|
||||
socket.once('timeout', () => finish(false));
|
||||
socket.once('error', () => finish(false));
|
||||
socket.connect(port, host);
|
||||
});
|
||||
}
|
||||
|
||||
vite.once('exit', handleExit);
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
let baseReachable = false;
|
||||
let viteClientReachable = false;
|
||||
let viteClientStatus: number | null = null;
|
||||
|
||||
try {
|
||||
const res = await fetch(baseUrl, {
|
||||
signal: AbortSignal.timeout(500),
|
||||
});
|
||||
baseReachable = res.ok || res.status < 500;
|
||||
} catch {
|
||||
// server not ready yet
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(`${baseUrl}/@vite/client`, {
|
||||
signal: AbortSignal.timeout(500),
|
||||
});
|
||||
viteClientStatus = res.status;
|
||||
viteClientReachable = res.ok;
|
||||
if (viteClientReachable) {
|
||||
for (const host of hosts) {
|
||||
if (await canConnect(host)) {
|
||||
vite.off('exit', handleExit);
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// Vite client not ready yet.
|
||||
}
|
||||
|
||||
const conflict = getDevServerConflictMessage(
|
||||
{
|
||||
baseReachable,
|
||||
viteClientReachable,
|
||||
viteClientStatus,
|
||||
},
|
||||
port,
|
||||
);
|
||||
if (conflict) {
|
||||
vite.off('exit', handleExit);
|
||||
throw new Error(conflict);
|
||||
}
|
||||
|
||||
if (viteExit) {
|
||||
|
|
@ -170,7 +155,7 @@ async function main(): Promise<void> {
|
|||
// 2. Wait for Vite to be ready
|
||||
console.log(`[electron-dev] Waiting for Vite on port ${VITE_DEV_PORT}...`);
|
||||
try {
|
||||
await waitForViteServer(`http://localhost:${VITE_DEV_PORT}`, vite, VITE_DEV_PORT);
|
||||
await waitForViteServer(`http://localhost:${VITE_DEV_PORT}`, vite);
|
||||
} catch (error) {
|
||||
stopVite();
|
||||
throw error;
|
||||
|
|
|
|||
|
|
@ -60,4 +60,23 @@ describe('mcp-sync-state: active client tracking', () => {
|
|||
it('sendToClient returns false for unknown client', () => {
|
||||
expect(sendToClient('nope', { type: 'x' })).toBe(false);
|
||||
});
|
||||
|
||||
it('broadcasts document updates to other clients and prunes broken writers', () => {
|
||||
const received: string[] = [];
|
||||
registerSSEClient('client-source', { push: () => {} });
|
||||
registerSSEClient('client-target', { push: (data: string) => received.push(data) });
|
||||
registerSSEClient('client-broken', {
|
||||
push: () => {
|
||||
throw new Error('writer closed');
|
||||
},
|
||||
});
|
||||
|
||||
setSyncDocument({ version: '1.0.0', children: [] } as PenDocument, 'client-source');
|
||||
|
||||
expect(received).toHaveLength(1);
|
||||
expect(JSON.parse(received[0]).type).toBe('document:update');
|
||||
expect(isClientConnected('client-source')).toBe(true);
|
||||
expect(isClientConnected('client-target')).toBe(true);
|
||||
expect(isClientConnected('client-broken')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { defineEventHandler, readBody, createError } from 'h3';
|
||||
import { defineEventHandler, readBody, createError, getRequestHeader, setResponseStatus } from 'h3';
|
||||
import { setSyncDocument } from '../../utils/mcp-sync-state';
|
||||
import { serverLog } from '../../utils/server-logger';
|
||||
import type { PenDocument } from '../../../src/types/pen';
|
||||
|
||||
interface PostBody {
|
||||
|
|
@ -7,16 +8,138 @@ interface PostBody {
|
|||
sourceClientId?: string;
|
||||
}
|
||||
|
||||
interface DocumentStats {
|
||||
nodeCount: number;
|
||||
imageCount: number;
|
||||
dataUrlImageCount: number;
|
||||
dataUrlChars: number;
|
||||
}
|
||||
|
||||
function collectDocumentStats(doc: PenDocument): DocumentStats {
|
||||
const stats: DocumentStats = {
|
||||
nodeCount: 0,
|
||||
imageCount: 0,
|
||||
dataUrlImageCount: 0,
|
||||
dataUrlChars: 0,
|
||||
};
|
||||
|
||||
const visit = (nodes?: unknown): void => {
|
||||
if (!Array.isArray(nodes)) return;
|
||||
for (const node of nodes) {
|
||||
if (!node || typeof node !== 'object') continue;
|
||||
stats.nodeCount++;
|
||||
|
||||
const typedNode = node as {
|
||||
type?: string;
|
||||
src?: string;
|
||||
children?: unknown;
|
||||
};
|
||||
|
||||
if (typedNode.type === 'image') {
|
||||
stats.imageCount++;
|
||||
if (typeof typedNode.src === 'string' && typedNode.src.startsWith('data:')) {
|
||||
stats.dataUrlImageCount++;
|
||||
stats.dataUrlChars += typedNode.src.length;
|
||||
}
|
||||
}
|
||||
|
||||
visit(typedNode.children);
|
||||
}
|
||||
};
|
||||
|
||||
visit(doc.children);
|
||||
if (Array.isArray(doc.pages)) {
|
||||
for (const page of doc.pages) {
|
||||
visit(page?.children);
|
||||
}
|
||||
}
|
||||
|
||||
return stats;
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number | null): string {
|
||||
if (bytes == null || Number.isNaN(bytes)) return 'unknown';
|
||||
if (bytes < 1024) return `${bytes}B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KiB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(2)}MiB`;
|
||||
}
|
||||
|
||||
function isConnectionClosedError(error: unknown): boolean {
|
||||
if (!error || typeof error !== 'object') return false;
|
||||
const maybeError = error as { name?: string; message?: string; cause?: unknown };
|
||||
const message = maybeError.message ?? '';
|
||||
const causeMessage =
|
||||
typeof maybeError.cause === 'object' && maybeError.cause
|
||||
? String((maybeError.cause as { message?: string }).message ?? '')
|
||||
: '';
|
||||
|
||||
return (
|
||||
maybeError.name === 'AbortError' ||
|
||||
/connection was closed/i.test(message) ||
|
||||
/connection was closed/i.test(causeMessage) ||
|
||||
/abort/i.test(message)
|
||||
);
|
||||
}
|
||||
|
||||
/** POST /api/mcp/document — Receives document update from MCP or renderer, triggers SSE broadcast. */
|
||||
export default defineEventHandler(async (event) => {
|
||||
const body = await readBody<PostBody>(event);
|
||||
if (!body?.document) {
|
||||
throw createError({ statusCode: 400, statusMessage: 'Missing document in request body' });
|
||||
const startedAt = Date.now();
|
||||
const contentLengthHeader = getRequestHeader(event, 'content-length');
|
||||
const bodyBytesHeader = getRequestHeader(event, 'x-openpencil-body-bytes');
|
||||
const contentLength = contentLengthHeader
|
||||
? Number.parseInt(contentLengthHeader, 10)
|
||||
: bodyBytesHeader
|
||||
? Number.parseInt(bodyBytesHeader, 10)
|
||||
: null;
|
||||
const sourceClientIdHeader = getRequestHeader(event, 'x-openpencil-client-id') ?? 'unknown';
|
||||
let phase = 'readBody';
|
||||
|
||||
try {
|
||||
const body = await readBody<PostBody>(event);
|
||||
if (!body?.document) {
|
||||
throw createError({ statusCode: 400, statusMessage: 'Missing document in request body' });
|
||||
}
|
||||
const doc = body.document;
|
||||
if (!doc.version || (!Array.isArray(doc.children) && !Array.isArray(doc.pages))) {
|
||||
throw createError({ statusCode: 400, statusMessage: 'Invalid document format' });
|
||||
}
|
||||
|
||||
const stats = collectDocumentStats(doc);
|
||||
phase = 'setSyncDocument';
|
||||
const version = setSyncDocument(doc, body.sourceClientId);
|
||||
const elapsedMs = Date.now() - startedAt;
|
||||
|
||||
serverLog.info(
|
||||
`[mcp-document] ok version=${version} sourceClientId=${body.sourceClientId ?? sourceClientIdHeader} ` +
|
||||
`contentLength=${formatBytes(contentLength)} nodes=${stats.nodeCount} images=${stats.imageCount} ` +
|
||||
`dataUrlImages=${stats.dataUrlImageCount} dataUrlChars=${stats.dataUrlChars} elapsedMs=${elapsedMs}`,
|
||||
);
|
||||
|
||||
return { ok: true, version };
|
||||
} catch (error) {
|
||||
const elapsedMs = Date.now() - startedAt;
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
|
||||
if (isConnectionClosedError(error)) {
|
||||
serverLog.warn(
|
||||
`[mcp-document] connection-closed phase=${phase} contentLength=${formatBytes(contentLength)} ` +
|
||||
`sourceClientId=${sourceClientIdHeader} elapsedMs=${elapsedMs} message=${message}`,
|
||||
);
|
||||
|
||||
// The client already closed the request while Nitro was still reading it.
|
||||
// Returning a soft status keeps expected sync churn out of the 500 logs.
|
||||
setResponseStatus(event, 202, 'Client closed request during MCP document sync');
|
||||
return {
|
||||
ok: false,
|
||||
aborted: true,
|
||||
phase,
|
||||
};
|
||||
}
|
||||
|
||||
serverLog.error(
|
||||
`[mcp-document] failed phase=${phase} contentLength=${formatBytes(contentLength)} ` +
|
||||
`sourceClientId=${sourceClientIdHeader} elapsedMs=${elapsedMs} message=${message}`,
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
const doc = body.document;
|
||||
if (!doc.version || (!Array.isArray(doc.children) && !Array.isArray(doc.pages))) {
|
||||
throw createError({ statusCode: 400, statusMessage: 'Invalid document format' });
|
||||
}
|
||||
const version = setSyncDocument(doc, body.sourceClientId);
|
||||
return { ok: true, version };
|
||||
});
|
||||
|
|
|
|||
|
|
@ -65,13 +65,20 @@ export function unregisterSSEClient(id: string): void {
|
|||
}
|
||||
|
||||
function broadcast(payload: Record<string, unknown>, excludeClientId?: string): void {
|
||||
const data = JSON.stringify(payload);
|
||||
const recipients: SSEClient[] = [];
|
||||
for (const [id, client] of clients) {
|
||||
if (id === excludeClientId) continue;
|
||||
recipients.push(client);
|
||||
}
|
||||
|
||||
if (recipients.length === 0) return;
|
||||
|
||||
const data = JSON.stringify(payload);
|
||||
for (const client of recipients) {
|
||||
try {
|
||||
client.writer.push(data);
|
||||
} catch {
|
||||
clients.delete(id);
|
||||
clients.delete(client.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { createEmptyDocument } from '@/stores/document-tree-utils';
|
||||
import { useCanvasStore } from '@/stores/canvas-store';
|
||||
|
|
@ -70,8 +70,11 @@ function resetStores() {
|
|||
}
|
||||
|
||||
describe('SkiaInteractionManager continuous interaction commits', () => {
|
||||
it('defers resize store writes until mouseup', () => {
|
||||
beforeEach(() => {
|
||||
resetStores();
|
||||
});
|
||||
|
||||
it('defers resize store writes until mouseup', () => {
|
||||
let node: any = {
|
||||
id: 'path-1',
|
||||
type: 'path',
|
||||
|
|
@ -143,7 +146,6 @@ describe('SkiaInteractionManager continuous interaction commits', () => {
|
|||
});
|
||||
|
||||
it('defers rotate store writes until mouseup', () => {
|
||||
resetStores();
|
||||
let node: any = {
|
||||
id: 'rect-1',
|
||||
type: 'rectangle',
|
||||
|
|
@ -199,7 +201,6 @@ describe('SkiaInteractionManager continuous interaction commits', () => {
|
|||
});
|
||||
|
||||
it('defers arc handle store writes until mouseup', () => {
|
||||
resetStores();
|
||||
let node: any = {
|
||||
id: 'ellipse-1',
|
||||
type: 'ellipse',
|
||||
|
|
@ -253,4 +254,101 @@ describe('SkiaInteractionManager continuous interaction commits', () => {
|
|||
expect(updateNodeCalls).toHaveLength(1);
|
||||
expect(updateNodeCalls[0]?.[1]).toHaveProperty('innerRadius');
|
||||
});
|
||||
|
||||
it('keeps an image-backed node selected instead of auto-selecting its parent frame', () => {
|
||||
const frame = {
|
||||
id: 'frame-1',
|
||||
type: 'frame',
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 300,
|
||||
height: 300,
|
||||
children: [],
|
||||
} as PenNode;
|
||||
const imageBackedRect = {
|
||||
id: 'child-1',
|
||||
type: 'rectangle',
|
||||
x: 10,
|
||||
y: 20,
|
||||
width: 120,
|
||||
height: 80,
|
||||
fill: [{ type: 'image', src: 'memory://image.png' }],
|
||||
} as PenNode;
|
||||
|
||||
useDocumentStore.setState({
|
||||
getNodeById: (id: string) => {
|
||||
if (id === frame.id) return frame;
|
||||
if (id === imageBackedRect.id) return imageBackedRect;
|
||||
return undefined;
|
||||
},
|
||||
getParentOf: (id: string) => (id === imageBackedRect.id ? frame : null),
|
||||
isDescendantOf: () => false,
|
||||
} as any);
|
||||
|
||||
const engine = createEngineStub([
|
||||
{
|
||||
node: { ...imageBackedRect },
|
||||
absX: 10,
|
||||
absY: 20,
|
||||
absW: 120,
|
||||
absH: 80,
|
||||
},
|
||||
]);
|
||||
(engine as any).spatialIndex.hitTest = () => [{ node: imageBackedRect } as any];
|
||||
|
||||
const manager = new SkiaInteractionManager(
|
||||
{ current: engine as any },
|
||||
createCanvasStub(),
|
||||
() => {},
|
||||
) as any;
|
||||
|
||||
manager.handleSelectMouseDown(
|
||||
{ shiftKey: false } as MouseEvent,
|
||||
{ x: 20, y: 30 },
|
||||
engine as any,
|
||||
);
|
||||
|
||||
expect(useCanvasStore.getState().selection.selectedIds).toEqual(['child-1']);
|
||||
expect(useCanvasStore.getState().selection.activeId).toBe('child-1');
|
||||
});
|
||||
|
||||
it('moves clip rects together with dragged render nodes', () => {
|
||||
const node = {
|
||||
id: 'frame-1',
|
||||
type: 'frame',
|
||||
x: 50,
|
||||
y: 60,
|
||||
width: 200,
|
||||
height: 120,
|
||||
children: [],
|
||||
} as PenNode;
|
||||
|
||||
const renderNode = {
|
||||
node: { ...node },
|
||||
absX: 50,
|
||||
absY: 60,
|
||||
absW: 200,
|
||||
absH: 120,
|
||||
clipRect: { x: 45, y: 55, w: 210, h: 130, rx: 8 },
|
||||
};
|
||||
const engine = createEngineStub([renderNode]);
|
||||
const manager = new SkiaInteractionManager(
|
||||
{ current: engine as any },
|
||||
createCanvasStub(),
|
||||
() => {},
|
||||
) as any;
|
||||
|
||||
manager.isDragging = true;
|
||||
manager.dragNodeIds = ['frame-1'];
|
||||
manager.dragStartSceneX = 0;
|
||||
manager.dragStartSceneY = 0;
|
||||
|
||||
manager.handleDragMove({ x: 20, y: 15 }, engine as any);
|
||||
|
||||
expect(renderNode.absX).toBe(70);
|
||||
expect(renderNode.absY).toBe(75);
|
||||
expect(renderNode.clipRect).toMatchObject({ x: 65, y: 70, w: 210, h: 130, rx: 8 });
|
||||
expect(engine.rebuildCount).toBeGreaterThan(0);
|
||||
expect(engine.dirtyCount).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -79,6 +79,13 @@ export function toolToCursor(tool: ToolType): string {
|
|||
}
|
||||
}
|
||||
|
||||
function hasImageVisual(node: PenNode | undefined): boolean {
|
||||
if (!node) return false;
|
||||
if (node.type === 'image') return true;
|
||||
if (!('fill' in node)) return false;
|
||||
return Array.isArray(node.fill) && node.fill.some((fill: any) => fill?.type === 'image');
|
||||
}
|
||||
|
||||
/**
|
||||
* Encapsulates all canvas mouse/keyboard interaction state and handlers.
|
||||
* Extracted from SkiaCanvas to keep the component focused on lifecycle and rendering.
|
||||
|
|
@ -371,8 +378,13 @@ export class SkiaInteractionManager {
|
|||
if (isChildOfSelected) {
|
||||
// Don't change selection
|
||||
} else if (!currentSelection.includes(nodeId)) {
|
||||
const clickedNode = docStore.getNodeById(nodeId);
|
||||
const parent = docStore.getParentOf(nodeId);
|
||||
if (parent && (parent.type === 'frame' || parent.type === 'group')) {
|
||||
if (
|
||||
!hasImageVisual(clickedNode) &&
|
||||
parent &&
|
||||
(parent.type === 'frame' || parent.type === 'group')
|
||||
) {
|
||||
const grandparent = docStore.getParentOf(parent.id);
|
||||
if (!grandparent || grandparent.type === 'frame') {
|
||||
nodeId = parent.id;
|
||||
|
|
@ -906,6 +918,13 @@ export class SkiaInteractionManager {
|
|||
if (this.dragAllIds!.has(rn.node.id)) {
|
||||
rn.absX += incrDx;
|
||||
rn.absY += incrDy;
|
||||
if (rn.clipRect) {
|
||||
rn.clipRect = {
|
||||
...rn.clipRect,
|
||||
x: rn.clipRect.x + incrDx,
|
||||
y: rn.clipRect.y + incrDy,
|
||||
};
|
||||
}
|
||||
rn.node = { ...rn.node, x: rn.absX, y: rn.absY };
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,9 @@ const PUSH_DEBOUNCE_MS = 2000;
|
|||
const SELECTION_DEBOUNCE_MS = 300;
|
||||
const RECONNECT_DELAY_MS = 3000;
|
||||
const MAX_RECONNECT_ATTEMPTS = 3;
|
||||
const SYNC_MAX_BODY_BYTES = 2 * 1024 * 1024;
|
||||
|
||||
let oversizeSyncWarned = false;
|
||||
|
||||
async function handleScreenshotRequest(
|
||||
req: {
|
||||
|
|
@ -92,13 +95,38 @@ function getBaseUrl(): string {
|
|||
return window.location.origin;
|
||||
}
|
||||
|
||||
function pushDocumentToServer(clientId: string | null) {
|
||||
async function pushDocumentToServer(clientId: string | null) {
|
||||
const doc = useDocumentStore.getState().document;
|
||||
fetch(`${getBaseUrl()}/api/mcp/document`, {
|
||||
const body = JSON.stringify({ document: doc, sourceClientId: clientId });
|
||||
const bodyBytes = new TextEncoder().encode(body).byteLength;
|
||||
|
||||
if (bodyBytes > SYNC_MAX_BODY_BYTES) {
|
||||
if (!oversizeSyncWarned) {
|
||||
oversizeSyncWarned = true;
|
||||
console.warn(
|
||||
`[mcp-sync] Skip oversized document push: ${(bodyBytes / (1024 * 1024)).toFixed(
|
||||
2,
|
||||
)}MiB > ${(SYNC_MAX_BODY_BYTES / (1024 * 1024)).toFixed(2)}MiB`,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
oversizeSyncWarned = false;
|
||||
|
||||
await fetch(`${getBaseUrl()}/api/mcp/document`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ document: doc, sourceClientId: clientId }),
|
||||
}).catch(() => {});
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-openpencil-client-id': clientId ?? 'renderer:unknown',
|
||||
'x-openpencil-body-bytes': String(bodyBytes),
|
||||
},
|
||||
// Keep smaller requests alive through page transitions and HMR churn.
|
||||
...(bodyBytes <= 60_000 ? { keepalive: true } : {}),
|
||||
// Large local sync payloads need a wider timeout budget than the fetch default.
|
||||
signal: AbortSignal.timeout(30_000),
|
||||
body,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -109,6 +137,9 @@ function pushDocumentToServer(clientId: string | null) {
|
|||
export function useMcpSync() {
|
||||
const clientIdRef = useRef<string | null>(null);
|
||||
const pushTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const pushInFlightRef = useRef(false);
|
||||
const pushQueuedRef = useRef(false);
|
||||
const queuedClientIdRef = useRef<string | null>(null);
|
||||
// Skip debounce pushes briefly after applying an external document.
|
||||
// Use a timestamp instead of a boolean so cascading setState calls
|
||||
// (e.g. canvas sync page switch handler) are also suppressed.
|
||||
|
|
@ -121,6 +152,30 @@ export function useMcpSync() {
|
|||
let disposed = false;
|
||||
let reconnectAttempts = 0;
|
||||
|
||||
async function flushDocumentPush(clientId: string | null) {
|
||||
if (disposed) return;
|
||||
if (pushInFlightRef.current) {
|
||||
pushQueuedRef.current = true;
|
||||
queuedClientIdRef.current = clientId;
|
||||
return;
|
||||
}
|
||||
|
||||
pushInFlightRef.current = true;
|
||||
try {
|
||||
await pushDocumentToServer(clientId);
|
||||
} catch {
|
||||
// MCP sync is a best-effort enhancement and should not interrupt editing.
|
||||
} finally {
|
||||
pushInFlightRef.current = false;
|
||||
if (pushQueuedRef.current && !disposed) {
|
||||
const nextClientId = queuedClientIdRef.current;
|
||||
pushQueuedRef.current = false;
|
||||
queuedClientIdRef.current = null;
|
||||
void flushDocumentPush(nextClientId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Focus / visibility ping: keep lastActiveClientId accurate ----
|
||||
const sendActivePing = () => {
|
||||
if (typeof document !== 'undefined' && document.hidden) return;
|
||||
|
|
@ -150,7 +205,7 @@ export function useMcpSync() {
|
|||
if (data.type === 'client:id') {
|
||||
clientIdRef.current = data.clientId;
|
||||
// Push current document so MCP can read it immediately
|
||||
pushDocumentToServer(data.clientId);
|
||||
void flushDocumentPush(data.clientId);
|
||||
// Announce this tab as the active one
|
||||
sendActivePing();
|
||||
} else if (data.type === 'document:update' || data.type === 'document:init') {
|
||||
|
|
@ -203,17 +258,20 @@ export function useMcpSync() {
|
|||
// On loadDocument/newDocument (isDirty transitions to false), push
|
||||
// immediately so the server cache is replaced without waiting 2s.
|
||||
const unsubDoc = useDocumentStore.subscribe((state, prevState) => {
|
||||
const documentChanged = state.document !== prevState.document;
|
||||
const dirtyChanged = state.isDirty !== prevState.isDirty;
|
||||
if (!documentChanged && !dirtyChanged) return;
|
||||
if (Date.now() < skipPushUntilRef.current) return;
|
||||
if (pushTimerRef.current) clearTimeout(pushTimerRef.current);
|
||||
|
||||
const isLoadEvent = !state.isDirty && prevState.isDirty !== state.isDirty;
|
||||
if (isLoadEvent) {
|
||||
pushDocumentToServer(clientIdRef.current);
|
||||
void flushDocumentPush(clientIdRef.current);
|
||||
return;
|
||||
}
|
||||
|
||||
pushTimerRef.current = setTimeout(() => {
|
||||
pushDocumentToServer(clientIdRef.current);
|
||||
void flushDocumentPush(clientIdRef.current);
|
||||
}, PUSH_DEBOUNCE_MS);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -261,10 +261,16 @@ async function pushLiveDocument(doc: PenDocument): Promise<void> {
|
|||
const syncUrl = cachedUrl ?? (await getSyncUrl());
|
||||
if (!syncUrl) return;
|
||||
try {
|
||||
const body = JSON.stringify({ document: doc });
|
||||
const bodyBytes = new TextEncoder().encode(body).byteLength;
|
||||
await fetch(`${syncUrl}/api/mcp/document`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ document: doc }),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-openpencil-client-id': 'mcp-server:live-canvas',
|
||||
'x-openpencil-body-bytes': String(bodyBytes),
|
||||
},
|
||||
body,
|
||||
});
|
||||
} catch {
|
||||
// Network error — Electron might have quit between check and request
|
||||
|
|
|
|||
Loading…
Reference in a new issue