@zseven-w/dsh-openpencil 0.1.0-rc.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +134 -0
- package/THIRD_PARTY_NOTICES.md +69 -0
- package/cordis.patch.yml +6 -0
- package/docs/images/dsh-openpencil-overview.png +0 -0
- package/lib/client/details-compat.d.ts +28 -0
- package/lib/client/details-compat.js +10 -0
- package/lib/client/editor-bridge.d.ts +84 -0
- package/lib/client/editor-bridge.js +127 -0
- package/lib/client/editor-dock-layout.d.ts +15 -0
- package/lib/client/editor-dock-layout.js +43 -0
- package/lib/client/editor-recovery.d.ts +27 -0
- package/lib/client/editor-recovery.js +83 -0
- package/lib/client/editor-successor.d.ts +28 -0
- package/lib/client/editor-successor.js +109 -0
- package/lib/client/presentation-hydration.d.ts +36 -0
- package/lib/client/presentation-hydration.js +168 -0
- package/lib/client/selection-polling.d.ts +24 -0
- package/lib/client/selection-polling.js +79 -0
- package/lib/client/selection-store.d.ts +26 -0
- package/lib/client/selection-store.js +85 -0
- package/lib/client.js +4033 -0
- package/lib/design-tools.d.ts +21 -0
- package/lib/design-tools.js +214 -0
- package/lib/editor-host.d.ts +62 -0
- package/lib/editor-host.js +1147 -0
- package/lib/editor-recovery.d.ts +38 -0
- package/lib/editor-recovery.js +319 -0
- package/lib/index.d.ts +24 -0
- package/lib/index.js +132 -0
- package/lib/mcp-client.d.ts +47 -0
- package/lib/mcp-client.js +148 -0
- package/lib/new-tool.d.ts +23 -0
- package/lib/new-tool.js +136 -0
- package/lib/presentation-hydration.d.ts +55 -0
- package/lib/presentation-hydration.js +561 -0
- package/lib/renderer.d.ts +203 -0
- package/lib/renderer.js +772 -0
- package/lib/tool-names.d.ts +13 -0
- package/lib/tool-names.js +19 -0
- package/lib/tool.d.ts +27 -0
- package/lib/tool.js +221 -0
- package/lib/types/client/details-compat.d.ts +28 -0
- package/lib/types/client/editor-bridge.d.ts +84 -0
- package/lib/types/client/editor-dock-layout.d.ts +15 -0
- package/lib/types/client/editor-modal.d.ts +54 -0
- package/lib/types/client/editor-panel.d.ts +127 -0
- package/lib/types/client/editor-recovery.d.ts +27 -0
- package/lib/types/client/editor-successor.d.ts +28 -0
- package/lib/types/client/editor-workbench-host.d.ts +43 -0
- package/lib/types/client/frame-gallery.d.ts +98 -0
- package/lib/types/client/index.d.ts +176 -0
- package/lib/types/client/presentation-hydration.d.ts +36 -0
- package/lib/types/client/selection-dock.d.ts +18 -0
- package/lib/types/client/selection-polling.d.ts +24 -0
- package/lib/types/client/selection-store.d.ts +26 -0
- package/lib/types/tool-names.d.ts +13 -0
- package/lib/viewer-assets/canvaskit/canvaskit.js +288 -0
- package/lib/viewer-assets/canvaskit/canvaskit.wasm +0 -0
- package/lib/viewer-assets/manifest.json +22 -0
- package/lib/viewer-assets/op_web_sdk_bg.wasm +0 -0
- package/lib/viewer-assets/sdk.js +225 -0
- package/lib/viewer-assets.d.ts +74 -0
- package/lib/viewer-assets.js +303 -0
- package/package.json +95 -0
- package/scripts/build-client.mjs +27 -0
- package/scripts/run-tool.mjs +63 -0
- package/scripts/sync-viewer-assets.mjs +304 -0
- package/scripts/test-host.mjs +311 -0
- package/scripts/test-viewer-assets.mjs +54 -0
- package/src/client/details-compat.ts +44 -0
- package/src/client/editor-bridge.ts +170 -0
- package/src/client/editor-dock-layout.ts +56 -0
- package/src/client/editor-modal.tsx +607 -0
- package/src/client/editor-panel.tsx +823 -0
- package/src/client/editor-recovery.ts +116 -0
- package/src/client/editor-successor.ts +136 -0
- package/src/client/editor-workbench-host.tsx +280 -0
- package/src/client/frame-gallery.tsx +546 -0
- package/src/client/index.tsx +987 -0
- package/src/client/presentation-hydration.ts +210 -0
- package/src/client/react-dom-compat.d.ts +22 -0
- package/src/client/selection-dock.tsx +89 -0
- package/src/client/selection-polling.ts +93 -0
- package/src/client/selection-store.ts +108 -0
- package/src/design-tools.ts +242 -0
- package/src/editor-host.ts +1238 -0
- package/src/editor-recovery.ts +374 -0
- package/src/index.ts +219 -0
- package/src/mcp-client.ts +193 -0
- package/src/new-tool.ts +160 -0
- package/src/presentation-hydration.ts +634 -0
- package/src/renderer.ts +884 -0
- package/src/tool-names.ts +21 -0
- package/src/tool.ts +258 -0
- package/src/viewer-assets.ts +353 -0
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/** Durable, explicit recovery snapshots for managed OpenPencil editors. */
|
|
2
|
+
export type EditorRecoveryReason = 'client-dispose' | 'plugin-dispose';
|
|
3
|
+
export interface EditorRecoverySummary {
|
|
4
|
+
id: string;
|
|
5
|
+
capturedAt: number;
|
|
6
|
+
bytes: number;
|
|
7
|
+
sourceName: string;
|
|
8
|
+
sourceChangedSinceCapture: boolean;
|
|
9
|
+
cacheLabel: string;
|
|
10
|
+
}
|
|
11
|
+
export interface ManagedDaemonDocument {
|
|
12
|
+
documentJson: string;
|
|
13
|
+
version: number;
|
|
14
|
+
}
|
|
15
|
+
export interface EditorRecoveryDocument {
|
|
16
|
+
documentJson: string;
|
|
17
|
+
}
|
|
18
|
+
/** Read the daemon's authoritative in-memory document without exposing its token. */
|
|
19
|
+
export declare function readManagedDaemonDocument(baseUrl: string, token: string, fetcher?: typeof fetch, signal?: AbortSignal): Promise<ManagedDaemonDocument>;
|
|
20
|
+
/** Replace only the live daemon document; persisting to `.op` remains a separate Save. */
|
|
21
|
+
export declare function restoreManagedDaemonDocument(baseUrl: string, token: string, recovery: ManagedDaemonDocument, fetcher?: typeof fetch): Promise<number>;
|
|
22
|
+
/** Filesystem-backed store keyed by an HMAC of the source path. */
|
|
23
|
+
export declare class EditorRecoveryStore {
|
|
24
|
+
#private;
|
|
25
|
+
constructor(key: Buffer, root?: string, now?: () => number);
|
|
26
|
+
capture(options: {
|
|
27
|
+
sourcePath: string;
|
|
28
|
+
sourceSha256: string;
|
|
29
|
+
sourceDocumentJson: string;
|
|
30
|
+
daemonDocument: ManagedDaemonDocument;
|
|
31
|
+
reason: EditorRecoveryReason;
|
|
32
|
+
}): Promise<EditorRecoverySummary | undefined>;
|
|
33
|
+
find(sourcePathValue: string, currentSourceSha256: string, currentSourceDocumentJson?: string): Promise<EditorRecoverySummary | undefined>;
|
|
34
|
+
read(sourcePathValue: string, id: string): Promise<EditorRecoveryDocument | undefined>;
|
|
35
|
+
discard(sourcePathValue: string, id: string): Promise<boolean>;
|
|
36
|
+
discardFor(sourcePathValue: string): Promise<boolean>;
|
|
37
|
+
prune(): Promise<void>;
|
|
38
|
+
}
|
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
/** Durable, explicit recovery snapshots for managed OpenPencil editors. */
|
|
2
|
+
import { createHash, createHmac, randomUUID } from 'node:crypto';
|
|
3
|
+
import { lstat, mkdir, open, readFile, readdir, rename, rm } from 'node:fs/promises';
|
|
4
|
+
import { basename, join, resolve } from 'node:path';
|
|
5
|
+
import { stateRoot } from './renderer.js';
|
|
6
|
+
const RECOVERY_VERSION = 1;
|
|
7
|
+
const RECOVERY_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
|
8
|
+
const MAX_RECOVERY_FILES = 32;
|
|
9
|
+
const MAX_DOCUMENT_BYTES = 64 * 1024 * 1024;
|
|
10
|
+
// A recovery record stores the normalized document as a JSON string. In the
|
|
11
|
+
// worst case every byte in that string needs one additional escape byte in the
|
|
12
|
+
// outer JSON envelope. Keep separate document and envelope limits so every
|
|
13
|
+
// document accepted by the host can still be recovered.
|
|
14
|
+
const MAX_RECOVERY_ENVELOPE_BYTES = 1024 * 1024;
|
|
15
|
+
const MAX_ESCAPED_DOCUMENT_BYTES = MAX_DOCUMENT_BYTES * 2 + 2;
|
|
16
|
+
const MAX_RECOVERY_RECORD_BYTES = MAX_ESCAPED_DOCUMENT_BYTES + MAX_RECOVERY_ENVELOPE_BYTES;
|
|
17
|
+
const RECOVERY_ID_PATTERN = /^[A-Za-z0-9_-]{43}$/;
|
|
18
|
+
function sha256(value) {
|
|
19
|
+
return createHash('sha256').update(value).digest('hex');
|
|
20
|
+
}
|
|
21
|
+
function isRecord(value) {
|
|
22
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
23
|
+
}
|
|
24
|
+
function normalizedDocumentJson(value, label) {
|
|
25
|
+
const bytes = Buffer.byteLength(value);
|
|
26
|
+
if (bytes <= 0 || bytes > MAX_DOCUMENT_BYTES)
|
|
27
|
+
throw new Error(`${label} size is invalid`);
|
|
28
|
+
let document;
|
|
29
|
+
try {
|
|
30
|
+
document = JSON.parse(value);
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
throw new Error(`${label} is not valid JSON`);
|
|
34
|
+
}
|
|
35
|
+
if (!isRecord(document))
|
|
36
|
+
throw new Error(`${label} must be a document object`);
|
|
37
|
+
const normalized = JSON.stringify(document);
|
|
38
|
+
const normalizedBytes = Buffer.byteLength(normalized);
|
|
39
|
+
if (normalizedBytes <= 0 || normalizedBytes > MAX_DOCUMENT_BYTES)
|
|
40
|
+
throw new Error(`${label} size is invalid`);
|
|
41
|
+
return normalized;
|
|
42
|
+
}
|
|
43
|
+
function serializedRecoveryRecord(record) {
|
|
44
|
+
const serialized = JSON.stringify(record);
|
|
45
|
+
const serializedBytes = Buffer.byteLength(serialized);
|
|
46
|
+
const escapedDocumentBytes = Buffer.byteLength(JSON.stringify(record.documentJson));
|
|
47
|
+
const envelopeBytes = serializedBytes - escapedDocumentBytes;
|
|
48
|
+
if (envelopeBytes > MAX_RECOVERY_ENVELOPE_BYTES || serializedBytes > MAX_RECOVERY_RECORD_BYTES) {
|
|
49
|
+
throw new Error('OpenPencil recovery snapshot exceeds the cache size limit');
|
|
50
|
+
}
|
|
51
|
+
return serialized;
|
|
52
|
+
}
|
|
53
|
+
function recoveryRecordFrom(value) {
|
|
54
|
+
if (!isRecord(value))
|
|
55
|
+
return undefined;
|
|
56
|
+
if (value.v !== RECOVERY_VERSION
|
|
57
|
+
|| typeof value.id !== 'string' || !RECOVERY_ID_PATTERN.test(value.id)
|
|
58
|
+
|| typeof value.sourcePath !== 'string' || resolve(value.sourcePath) !== value.sourcePath
|
|
59
|
+
|| typeof value.sourceSha256AtCapture !== 'string' || !/^[a-f0-9]{64}$/.test(value.sourceSha256AtCapture)
|
|
60
|
+
|| typeof value.documentSha256 !== 'string' || !/^[a-f0-9]{64}$/.test(value.documentSha256)
|
|
61
|
+
|| typeof value.documentJson !== 'string'
|
|
62
|
+
|| typeof value.daemonVersion !== 'number' || !Number.isSafeInteger(value.daemonVersion) || value.daemonVersion < 0
|
|
63
|
+
|| typeof value.capturedAt !== 'number' || !Number.isSafeInteger(value.capturedAt) || value.capturedAt <= 0
|
|
64
|
+
|| (value.reason !== 'client-dispose' && value.reason !== 'plugin-dispose'))
|
|
65
|
+
return undefined;
|
|
66
|
+
try {
|
|
67
|
+
const documentJson = normalizedDocumentJson(value.documentJson, 'OpenPencil recovery document');
|
|
68
|
+
if (sha256(documentJson) !== value.documentSha256)
|
|
69
|
+
return undefined;
|
|
70
|
+
return { ...value, documentJson };
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
return undefined;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
function daemonOrigin(baseUrl) {
|
|
77
|
+
const origin = new URL(baseUrl);
|
|
78
|
+
const loopback = origin.hostname === '127.0.0.1' || origin.hostname === 'localhost' || origin.hostname === '::1';
|
|
79
|
+
if (!loopback || (origin.protocol !== 'http:' && origin.protocol !== 'https:')) {
|
|
80
|
+
throw new Error('OpenPencil recovery endpoint must use an HTTP loopback origin');
|
|
81
|
+
}
|
|
82
|
+
if (origin.pathname !== '/' || origin.search !== '' || origin.hash !== '') {
|
|
83
|
+
throw new Error('OpenPencil recovery base URL must be an origin');
|
|
84
|
+
}
|
|
85
|
+
return origin;
|
|
86
|
+
}
|
|
87
|
+
async function boundedResponseBytes(response, label) {
|
|
88
|
+
const declared = Number(response.headers.get('content-length'));
|
|
89
|
+
if (Number.isFinite(declared) && declared > MAX_DOCUMENT_BYTES + 1024 * 1024) {
|
|
90
|
+
throw new Error(`${label} is too large`);
|
|
91
|
+
}
|
|
92
|
+
const bytes = Buffer.from(await response.arrayBuffer());
|
|
93
|
+
if (bytes.length > MAX_DOCUMENT_BYTES + 1024 * 1024)
|
|
94
|
+
throw new Error(`${label} is too large`);
|
|
95
|
+
return bytes;
|
|
96
|
+
}
|
|
97
|
+
/** Read the daemon's authoritative in-memory document without exposing its token. */
|
|
98
|
+
export async function readManagedDaemonDocument(baseUrl, token, fetcher = fetch, signal) {
|
|
99
|
+
const origin = daemonOrigin(baseUrl);
|
|
100
|
+
const timeout = AbortSignal.timeout(5_000);
|
|
101
|
+
const response = await fetcher(new URL('/api/mcp/document', origin).href, {
|
|
102
|
+
headers: {
|
|
103
|
+
authorization: `Bearer ${token}`,
|
|
104
|
+
'x-openpencil-token': token,
|
|
105
|
+
accept: 'application/json',
|
|
106
|
+
},
|
|
107
|
+
signal: signal === undefined ? timeout : AbortSignal.any([signal, timeout]),
|
|
108
|
+
});
|
|
109
|
+
const bytes = await boundedResponseBytes(response, 'OpenPencil recovery response');
|
|
110
|
+
if (!response.ok)
|
|
111
|
+
throw new Error(`OpenPencil recovery snapshot failed (${response.status})`);
|
|
112
|
+
let value;
|
|
113
|
+
try {
|
|
114
|
+
value = JSON.parse(bytes.toString('utf8'));
|
|
115
|
+
}
|
|
116
|
+
catch {
|
|
117
|
+
throw new Error('OpenPencil recovery snapshot returned invalid JSON');
|
|
118
|
+
}
|
|
119
|
+
if (!isRecord(value) || !isRecord(value.document) || typeof value.version !== 'number' || !Number.isSafeInteger(value.version) || value.version < 0) {
|
|
120
|
+
throw new Error('OpenPencil recovery snapshot returned an invalid document');
|
|
121
|
+
}
|
|
122
|
+
return { documentJson: normalizedDocumentJson(JSON.stringify(value.document), 'OpenPencil recovery document'), version: value.version };
|
|
123
|
+
}
|
|
124
|
+
/** Replace only the live daemon document; persisting to `.op` remains a separate Save. */
|
|
125
|
+
export async function restoreManagedDaemonDocument(baseUrl, token, recovery, fetcher = fetch) {
|
|
126
|
+
const origin = daemonOrigin(baseUrl);
|
|
127
|
+
const documentJson = normalizedDocumentJson(recovery.documentJson, 'OpenPencil recovery document');
|
|
128
|
+
const response = await fetcher(new URL('/api/mcp/document', origin).href, {
|
|
129
|
+
method: 'POST',
|
|
130
|
+
headers: {
|
|
131
|
+
authorization: `Bearer ${token}`,
|
|
132
|
+
'x-openpencil-token': token,
|
|
133
|
+
'content-type': 'application/json',
|
|
134
|
+
accept: 'application/json',
|
|
135
|
+
},
|
|
136
|
+
body: JSON.stringify({
|
|
137
|
+
document: JSON.parse(documentJson),
|
|
138
|
+
sourceClientId: 'dsh-openpencil-recovery',
|
|
139
|
+
baseVersion: recovery.version,
|
|
140
|
+
}),
|
|
141
|
+
signal: AbortSignal.timeout(5_000),
|
|
142
|
+
});
|
|
143
|
+
const bytes = await boundedResponseBytes(response, 'OpenPencil recovery restore response');
|
|
144
|
+
if (!response.ok)
|
|
145
|
+
throw new Error(`OpenPencil recovery restore failed (${response.status})`);
|
|
146
|
+
let value;
|
|
147
|
+
try {
|
|
148
|
+
value = JSON.parse(bytes.toString('utf8'));
|
|
149
|
+
}
|
|
150
|
+
catch {
|
|
151
|
+
throw new Error('OpenPencil recovery restore returned invalid JSON');
|
|
152
|
+
}
|
|
153
|
+
if (!isRecord(value) || value.ok !== true || typeof value.version !== 'number' || !Number.isSafeInteger(value.version)) {
|
|
154
|
+
throw new Error('OpenPencil recovery restore returned an invalid result');
|
|
155
|
+
}
|
|
156
|
+
return value.version;
|
|
157
|
+
}
|
|
158
|
+
/** Filesystem-backed store keyed by an HMAC of the source path. */
|
|
159
|
+
export class EditorRecoveryStore {
|
|
160
|
+
#key;
|
|
161
|
+
#root;
|
|
162
|
+
#now;
|
|
163
|
+
constructor(key, root = join(stateRoot(), 'recovery'), now = Date.now) {
|
|
164
|
+
if (key.length < 16)
|
|
165
|
+
throw new TypeError('editor recovery key is too short');
|
|
166
|
+
this.#key = Buffer.from(key);
|
|
167
|
+
this.#root = root;
|
|
168
|
+
this.#now = now;
|
|
169
|
+
}
|
|
170
|
+
async capture(options) {
|
|
171
|
+
const sourcePath = resolve(options.sourcePath);
|
|
172
|
+
const sourceDocumentJson = normalizedDocumentJson(options.sourceDocumentJson, 'OpenPencil source document');
|
|
173
|
+
const documentJson = normalizedDocumentJson(options.daemonDocument.documentJson, 'OpenPencil recovery document');
|
|
174
|
+
const id = this.#idFor(sourcePath);
|
|
175
|
+
if (documentJson === sourceDocumentJson) {
|
|
176
|
+
await this.discard(sourcePath, id);
|
|
177
|
+
return undefined;
|
|
178
|
+
}
|
|
179
|
+
if (!/^[a-f0-9]{64}$/.test(options.sourceSha256))
|
|
180
|
+
throw new Error('OpenPencil source hash is invalid');
|
|
181
|
+
const capturedAt = this.#now();
|
|
182
|
+
const record = {
|
|
183
|
+
v: RECOVERY_VERSION,
|
|
184
|
+
id,
|
|
185
|
+
sourcePath,
|
|
186
|
+
sourceSha256AtCapture: options.sourceSha256,
|
|
187
|
+
documentSha256: sha256(documentJson),
|
|
188
|
+
documentJson,
|
|
189
|
+
daemonVersion: options.daemonDocument.version,
|
|
190
|
+
capturedAt,
|
|
191
|
+
reason: options.reason,
|
|
192
|
+
};
|
|
193
|
+
const serialized = serializedRecoveryRecord(record);
|
|
194
|
+
await mkdir(this.#root, { recursive: true, mode: 0o700 });
|
|
195
|
+
await this.#atomicWrite(this.#pathFor(id), serialized);
|
|
196
|
+
await this.prune();
|
|
197
|
+
return this.#summary(record, options.sourceSha256);
|
|
198
|
+
}
|
|
199
|
+
async find(sourcePathValue, currentSourceSha256, currentSourceDocumentJson) {
|
|
200
|
+
const sourcePath = resolve(sourcePathValue);
|
|
201
|
+
const record = await this.#read(sourcePath, this.#idFor(sourcePath));
|
|
202
|
+
if (record === undefined)
|
|
203
|
+
return undefined;
|
|
204
|
+
if (currentSourceDocumentJson !== undefined
|
|
205
|
+
&& record.documentJson === normalizedDocumentJson(currentSourceDocumentJson, 'OpenPencil source document')) {
|
|
206
|
+
await this.discard(sourcePath, record.id);
|
|
207
|
+
return undefined;
|
|
208
|
+
}
|
|
209
|
+
return this.#summary(record, currentSourceSha256);
|
|
210
|
+
}
|
|
211
|
+
async read(sourcePathValue, id) {
|
|
212
|
+
const sourcePath = resolve(sourcePathValue);
|
|
213
|
+
const record = await this.#read(sourcePath, id);
|
|
214
|
+
return record === undefined ? undefined : { documentJson: record.documentJson };
|
|
215
|
+
}
|
|
216
|
+
async discard(sourcePathValue, id) {
|
|
217
|
+
const sourcePath = resolve(sourcePathValue);
|
|
218
|
+
if (!RECOVERY_ID_PATTERN.test(id) || id !== this.#idFor(sourcePath))
|
|
219
|
+
return false;
|
|
220
|
+
try {
|
|
221
|
+
await rm(this.#pathFor(id));
|
|
222
|
+
return true;
|
|
223
|
+
}
|
|
224
|
+
catch (error) {
|
|
225
|
+
if (error.code === 'ENOENT')
|
|
226
|
+
return false;
|
|
227
|
+
throw error;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
async discardFor(sourcePathValue) {
|
|
231
|
+
const sourcePath = resolve(sourcePathValue);
|
|
232
|
+
return this.discard(sourcePath, this.#idFor(sourcePath));
|
|
233
|
+
}
|
|
234
|
+
async prune() {
|
|
235
|
+
let names;
|
|
236
|
+
try {
|
|
237
|
+
names = await readdir(this.#root);
|
|
238
|
+
}
|
|
239
|
+
catch (error) {
|
|
240
|
+
if (error.code === 'ENOENT')
|
|
241
|
+
return;
|
|
242
|
+
throw error;
|
|
243
|
+
}
|
|
244
|
+
const candidates = [];
|
|
245
|
+
for (const name of names) {
|
|
246
|
+
if (!RECOVERY_ID_PATTERN.test(name.replace(/\.json$/, '')) || !name.endsWith('.json'))
|
|
247
|
+
continue;
|
|
248
|
+
const path = join(this.#root, name);
|
|
249
|
+
const record = await this.#readPath(path);
|
|
250
|
+
if (record === undefined || this.#expired(record)) {
|
|
251
|
+
await rm(path, { force: true }).catch(() => { });
|
|
252
|
+
}
|
|
253
|
+
else {
|
|
254
|
+
candidates.push({ path, capturedAt: record.capturedAt });
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
candidates.sort((left, right) => right.capturedAt - left.capturedAt);
|
|
258
|
+
await Promise.all(candidates.slice(MAX_RECOVERY_FILES).map(candidate => rm(candidate.path, { force: true })));
|
|
259
|
+
}
|
|
260
|
+
#idFor(sourcePath) {
|
|
261
|
+
return createHmac('sha256', this.#key).update('dsh-openpencil/recovery/v1\0').update(sourcePath).digest('base64url');
|
|
262
|
+
}
|
|
263
|
+
#pathFor(id) {
|
|
264
|
+
return join(this.#root, `${id}.json`);
|
|
265
|
+
}
|
|
266
|
+
#expired(record) {
|
|
267
|
+
const age = this.#now() - record.capturedAt;
|
|
268
|
+
return age < 0 || age > RECOVERY_TTL_MS;
|
|
269
|
+
}
|
|
270
|
+
async #read(sourcePath, id) {
|
|
271
|
+
if (!RECOVERY_ID_PATTERN.test(id) || id !== this.#idFor(sourcePath))
|
|
272
|
+
return undefined;
|
|
273
|
+
const path = this.#pathFor(id);
|
|
274
|
+
const record = await this.#readPath(path);
|
|
275
|
+
if (record === undefined || record.sourcePath !== sourcePath || this.#expired(record)) {
|
|
276
|
+
await rm(path, { force: true }).catch(() => { });
|
|
277
|
+
return undefined;
|
|
278
|
+
}
|
|
279
|
+
return record;
|
|
280
|
+
}
|
|
281
|
+
async #readPath(path) {
|
|
282
|
+
try {
|
|
283
|
+
const info = await lstat(path);
|
|
284
|
+
if (info.isSymbolicLink() || !info.isFile() || info.size <= 0 || info.size > MAX_RECOVERY_RECORD_BYTES)
|
|
285
|
+
return undefined;
|
|
286
|
+
const value = JSON.parse(await readFile(path, 'utf8'));
|
|
287
|
+
return recoveryRecordFrom(value);
|
|
288
|
+
}
|
|
289
|
+
catch {
|
|
290
|
+
return undefined;
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
async #atomicWrite(path, value) {
|
|
294
|
+
const temp = join(this.#root, `.recovery-${randomUUID()}.tmp`);
|
|
295
|
+
let handle;
|
|
296
|
+
try {
|
|
297
|
+
handle = await open(temp, 'wx', 0o600);
|
|
298
|
+
await handle.writeFile(value);
|
|
299
|
+
await handle.sync();
|
|
300
|
+
await handle.close();
|
|
301
|
+
handle = undefined;
|
|
302
|
+
await rename(temp, path);
|
|
303
|
+
}
|
|
304
|
+
finally {
|
|
305
|
+
await handle?.close().catch(() => { });
|
|
306
|
+
await rm(temp, { force: true }).catch(() => { });
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
#summary(record, currentSourceSha256) {
|
|
310
|
+
return {
|
|
311
|
+
id: record.id,
|
|
312
|
+
capturedAt: record.capturedAt,
|
|
313
|
+
bytes: Buffer.byteLength(record.documentJson),
|
|
314
|
+
sourceName: basename(record.sourcePath),
|
|
315
|
+
sourceChangedSinceCapture: currentSourceSha256 !== record.sourceSha256AtCapture,
|
|
316
|
+
cacheLabel: `dsh-openpencil/recovery/${record.id}.json`,
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
}
|
package/lib/index.d.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @zseven-w/dsh-openpencil — preview `.op` design documents in DSH.
|
|
3
|
+
*
|
|
4
|
+
* Plugin lifecycle: register the model-facing tool plus signed routes for
|
|
5
|
+
* exact PNGs, immutable document snapshots, and the optional read-only Web
|
|
6
|
+
* SDK canvas. Everything is
|
|
7
|
+
* registered through `ctx.effect` (or a returned disposer) so unloading the
|
|
8
|
+
* plugin removes every contribution.
|
|
9
|
+
*
|
|
10
|
+
* The `openpencil_render` tool never returns an ImageBlock — the DeepSeek
|
|
11
|
+
* adapter rejects image blocks anywhere in a request. The browser-only
|
|
12
|
+
* envelope rides `output.presentationMeta` into `ToolCallBlock.meta`, and
|
|
13
|
+
* the keyed `tool.call.toolview` client component renders a PNG-first card
|
|
14
|
+
* and lazily mounts the OpenPencil canvas on demand.
|
|
15
|
+
* @module @zseven-w/dsh-openpencil
|
|
16
|
+
*/
|
|
17
|
+
import type { Context } from '@deepseek-ai/cordis';
|
|
18
|
+
export { LEGACY_DESIGN_RENDER_TOOL_NAME, OPENPENCIL_CREATE_TOOL_NAME, OPENPENCIL_EDIT_TOOL_NAME, OPENPENCIL_NEW_TOOL_NAME, OPENPENCIL_RENDER_TOOL_NAME, OPENPENCIL_SELECTION_TOOL_NAME, OPENPENCIL_TOOL_NAMES, } from './tool-names.js';
|
|
19
|
+
/** Stable plugin name (the loader entry id in cordis.patch.yml). */
|
|
20
|
+
export declare const name = "@zseven-w/dsh-openpencil";
|
|
21
|
+
/** Services this plugin's root fiber requires. */
|
|
22
|
+
export declare const inject: string[];
|
|
23
|
+
/** Plugin entry: mount every model-facing contribution. */
|
|
24
|
+
export declare function apply(ctx: Context): Promise<() => Promise<void>>;
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @zseven-w/dsh-openpencil — preview `.op` design documents in DSH.
|
|
3
|
+
*
|
|
4
|
+
* Plugin lifecycle: register the model-facing tool plus signed routes for
|
|
5
|
+
* exact PNGs, immutable document snapshots, and the optional read-only Web
|
|
6
|
+
* SDK canvas. Everything is
|
|
7
|
+
* registered through `ctx.effect` (or a returned disposer) so unloading the
|
|
8
|
+
* plugin removes every contribution.
|
|
9
|
+
*
|
|
10
|
+
* The `openpencil_render` tool never returns an ImageBlock — the DeepSeek
|
|
11
|
+
* adapter rejects image blocks anywhere in a request. The browser-only
|
|
12
|
+
* envelope rides `output.presentationMeta` into `ToolCallBlock.meta`, and
|
|
13
|
+
* the keyed `tool.call.toolview` client component renders a PNG-first card
|
|
14
|
+
* and lazily mounts the OpenPencil canvas on demand.
|
|
15
|
+
* @module @zseven-w/dsh-openpencil
|
|
16
|
+
*/
|
|
17
|
+
import { RENDER_ROUTE_PREFIX, RenderAccessController, prepareRenderAccessKey, } from './renderer.js';
|
|
18
|
+
import { createDesignRenderTool } from './tool.js';
|
|
19
|
+
import { createDesignCreateTool, createDesignEditTool, createDesignSelectionTool, } from './design-tools.js';
|
|
20
|
+
import { createDesignNewTool } from './new-tool.js';
|
|
21
|
+
import { VIEWER_ASSET_ROUTE_PREFIX, prepareViewerAssets, } from './viewer-assets.js';
|
|
22
|
+
import { EDITOR_ROUTE_PREFIX, EditorHostController, } from './editor-host.js';
|
|
23
|
+
import { OPENPENCIL_CREATE_TOOL_NAME, OPENPENCIL_EDIT_TOOL_NAME, OPENPENCIL_NEW_TOOL_NAME, OPENPENCIL_RENDER_TOOL_NAME, OPENPENCIL_SELECTION_TOOL_NAME, OPENPENCIL_TOOL_NAMES, } from './tool-names.js';
|
|
24
|
+
import { PRESENTATION_HYDRATION_ROUTE, PresentationHydrationController, } from './presentation-hydration.js';
|
|
25
|
+
export { LEGACY_DESIGN_RENDER_TOOL_NAME, OPENPENCIL_CREATE_TOOL_NAME, OPENPENCIL_EDIT_TOOL_NAME, OPENPENCIL_NEW_TOOL_NAME, OPENPENCIL_RENDER_TOOL_NAME, OPENPENCIL_SELECTION_TOOL_NAME, OPENPENCIL_TOOL_NAMES, } from './tool-names.js';
|
|
26
|
+
/** Stable plugin name (the loader entry id in cordis.patch.yml). */
|
|
27
|
+
export const name = '@zseven-w/dsh-openpencil';
|
|
28
|
+
/** Services this plugin's root fiber requires. */
|
|
29
|
+
export const inject = ['tools', 'sessions', 'fs', 'sandboxPolicy'];
|
|
30
|
+
/** Read the optional bind-time trust snapshot without making Web-only runtime glue a hard peer. */
|
|
31
|
+
function webRuntimeTrustedHosts(ctx) {
|
|
32
|
+
const get = ctx.get;
|
|
33
|
+
const runtime = typeof get === 'function' ? get.call(ctx, 'webRuntime') : undefined;
|
|
34
|
+
if (typeof runtime !== 'object' || runtime === null || !('trustedHosts' in runtime))
|
|
35
|
+
return [];
|
|
36
|
+
const trustedHosts = runtime.trustedHosts;
|
|
37
|
+
return Array.isArray(trustedHosts) && trustedHosts.every(value => typeof value === 'string')
|
|
38
|
+
? trustedHosts
|
|
39
|
+
: [];
|
|
40
|
+
}
|
|
41
|
+
/** Plugin entry: mount every model-facing contribution. */
|
|
42
|
+
export async function apply(ctx) {
|
|
43
|
+
const hostCtx = ctx;
|
|
44
|
+
const eventCtx = ctx;
|
|
45
|
+
const disposers = [];
|
|
46
|
+
const accessKey = await prepareRenderAccessKey();
|
|
47
|
+
const controller = new RenderAccessController(accessKey);
|
|
48
|
+
const viewerAssets = await prepareViewerAssets();
|
|
49
|
+
const editorHost = new EditorHostController(accessKey);
|
|
50
|
+
const presentationHydration = new PresentationHydrationController({
|
|
51
|
+
sessions: hostCtx.sessions,
|
|
52
|
+
render: controller,
|
|
53
|
+
viewer: viewerAssets,
|
|
54
|
+
editor: editorHost,
|
|
55
|
+
// webRuntime is provided after bind by the official Web bundle. Resolve
|
|
56
|
+
// it at request time so loopback still works on older/headless hosts while
|
|
57
|
+
// configured LAN authorities receive the same Host fence as DSH /api.
|
|
58
|
+
trustedHosts: () => webRuntimeTrustedHosts(ctx),
|
|
59
|
+
});
|
|
60
|
+
let editorHostDisposePromise;
|
|
61
|
+
const disposeEditorHost = () => {
|
|
62
|
+
editorHostDisposePromise ??= editorHost.dispose();
|
|
63
|
+
return editorHostDisposePromise;
|
|
64
|
+
};
|
|
65
|
+
// Tool registration: global (every agent sees it). The tool's
|
|
66
|
+
// presentationMeta consults `controller.routeAvailable`, so a profile
|
|
67
|
+
// without the webserver still gets a plain-JSON result — no dangling URL.
|
|
68
|
+
disposers.push(ctx.effect(() => hostCtx.tools.register(createDesignRenderTool(controller, viewerAssets, editorHost)), `dsh-openpencil: ${OPENPENCIL_RENDER_TOOL_NAME} tool`));
|
|
69
|
+
disposers.push(ctx.effect(() => hostCtx.tools.register(createDesignSelectionTool(editorHost)), `dsh-openpencil: ${OPENPENCIL_SELECTION_TOOL_NAME} tool`));
|
|
70
|
+
disposers.push(ctx.effect(() => hostCtx.tools.register(createDesignNewTool(editorHost, {
|
|
71
|
+
fs: hostCtx.fs,
|
|
72
|
+
sandboxPolicy: hostCtx.sandboxPolicy,
|
|
73
|
+
observe: (target, observation, exec) => eventCtx.emit('fs/observed', target, observation, exec),
|
|
74
|
+
})), `dsh-openpencil: ${OPENPENCIL_NEW_TOOL_NAME} tool`));
|
|
75
|
+
disposers.push(ctx.effect(() => hostCtx.tools.register(createDesignCreateTool(editorHost)), `dsh-openpencil: ${OPENPENCIL_CREATE_TOOL_NAME} tool`));
|
|
76
|
+
disposers.push(ctx.effect(() => hostCtx.tools.register(createDesignEditTool(editorHost)), `dsh-openpencil: ${OPENPENCIL_EDIT_TOOL_NAME} tool`));
|
|
77
|
+
disposers.push(ctx.effect(() => eventCtx.on('tools/result', (exec, result) => presentationHydration.observeToolResult(exec, result)), 'dsh-openpencil: nested presentation result observer'));
|
|
78
|
+
disposers.push(ctx.effect(() => eventCtx.on('session/disposed', session => presentationHydration.forgetSession(String(session.id))), 'dsh-openpencil: nested presentation session cleanup'));
|
|
79
|
+
// Optional Web routes: only mounted when a webServer service exists
|
|
80
|
+
// (headless profiles never attach, and `routeAvailable` stays false).
|
|
81
|
+
// The inject fiber is parent-scoped and tears itself down with this ctx;
|
|
82
|
+
// the inner effect's disposer is the route removal.
|
|
83
|
+
ctx.inject(['webServer'], (webCtx) => webCtx.effect(() => {
|
|
84
|
+
const webServer = webCtx.webServer;
|
|
85
|
+
const detach = controller.attachRoute();
|
|
86
|
+
const disposeRoute = webServer.register({
|
|
87
|
+
kind: 'prefix',
|
|
88
|
+
path: RENDER_ROUTE_PREFIX,
|
|
89
|
+
handler: (req, res) => controller.handle(req, res),
|
|
90
|
+
});
|
|
91
|
+
const disposePresentationRoute = webServer.register({
|
|
92
|
+
kind: 'exact',
|
|
93
|
+
path: PRESENTATION_HYDRATION_ROUTE,
|
|
94
|
+
handler: (req, res) => presentationHydration.handle(req, res),
|
|
95
|
+
});
|
|
96
|
+
const disposeViewerRoute = viewerAssets.available
|
|
97
|
+
? (() => {
|
|
98
|
+
const detachViewer = viewerAssets.attachRoute();
|
|
99
|
+
const disposeViewer = webServer.register({
|
|
100
|
+
kind: 'prefix',
|
|
101
|
+
path: VIEWER_ASSET_ROUTE_PREFIX,
|
|
102
|
+
handler: (req, res) => viewerAssets.handle(req, res),
|
|
103
|
+
});
|
|
104
|
+
return () => {
|
|
105
|
+
disposeViewer();
|
|
106
|
+
detachViewer();
|
|
107
|
+
};
|
|
108
|
+
})()
|
|
109
|
+
: undefined;
|
|
110
|
+
const detachEditor = editorHost.attachRoute();
|
|
111
|
+
const disposeEditorRoute = webServer.register({
|
|
112
|
+
kind: 'prefix',
|
|
113
|
+
path: EDITOR_ROUTE_PREFIX,
|
|
114
|
+
handler: (req, res) => editorHost.handle(req, res),
|
|
115
|
+
});
|
|
116
|
+
return async () => {
|
|
117
|
+
disposeEditorRoute();
|
|
118
|
+
detachEditor();
|
|
119
|
+
disposeViewerRoute?.();
|
|
120
|
+
disposePresentationRoute();
|
|
121
|
+
disposeRoute();
|
|
122
|
+
detach();
|
|
123
|
+
await disposeEditorHost();
|
|
124
|
+
};
|
|
125
|
+
}, 'dsh-openpencil: render route'));
|
|
126
|
+
ctx.logger.info(`dsh-openpencil mounted (${OPENPENCIL_TOOL_NAMES.join(' + ')}; viewer assets: ${viewerAssets.available ? 'ready' : 'unavailable'}; editor: ${editorHost.available ? 'ready' : 'unavailable'})`);
|
|
127
|
+
return async () => {
|
|
128
|
+
for (const dispose of disposers.reverse())
|
|
129
|
+
await dispose();
|
|
130
|
+
await disposeEditorHost();
|
|
131
|
+
};
|
|
132
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/** Strict loopback JSON-RPC client for one managed OpenPencil editor. */
|
|
2
|
+
export interface OpenPencilNodeSummary {
|
|
3
|
+
id: string;
|
|
4
|
+
type?: string;
|
|
5
|
+
name?: string;
|
|
6
|
+
x?: number;
|
|
7
|
+
y?: number;
|
|
8
|
+
width?: number;
|
|
9
|
+
height?: number;
|
|
10
|
+
}
|
|
11
|
+
export interface OpenPencilSelectionSnapshot {
|
|
12
|
+
sourcePath: string;
|
|
13
|
+
activePageId: string;
|
|
14
|
+
selectedIds: string[];
|
|
15
|
+
nodes: OpenPencilNodeSummary[];
|
|
16
|
+
updatedAt: number;
|
|
17
|
+
}
|
|
18
|
+
export interface OpenPencilMcpResult {
|
|
19
|
+
tool: string;
|
|
20
|
+
value: unknown;
|
|
21
|
+
text: string;
|
|
22
|
+
}
|
|
23
|
+
interface McpCallOptions {
|
|
24
|
+
baseUrl: string;
|
|
25
|
+
token: string;
|
|
26
|
+
tool: string;
|
|
27
|
+
arguments?: Record<string, unknown>;
|
|
28
|
+
signal?: AbortSignal;
|
|
29
|
+
fetcher?: typeof fetch;
|
|
30
|
+
}
|
|
31
|
+
interface McpVersionOptions {
|
|
32
|
+
baseUrl: string;
|
|
33
|
+
token: string;
|
|
34
|
+
signal?: AbortSignal;
|
|
35
|
+
fetcher?: typeof fetch;
|
|
36
|
+
/** Test seam; production uses the same bounded timeout as MCP calls. */
|
|
37
|
+
timeoutMs?: number;
|
|
38
|
+
}
|
|
39
|
+
/** Decode a JSON-RPC tools/call envelope, including MCP's HTTP-200 errors. */
|
|
40
|
+
export declare function parseOpenPencilMcpResponse(tool: string, value: unknown): OpenPencilMcpResult;
|
|
41
|
+
/** Call a first-party tool on a managed editor without exposing its daemon token. */
|
|
42
|
+
export declare function callOpenPencilMcp(options: McpCallOptions): Promise<OpenPencilMcpResult>;
|
|
43
|
+
/** Probe the daemon document version before and after a write. */
|
|
44
|
+
export declare function getOpenPencilMcpVersion(options: McpVersionOptions): Promise<number>;
|
|
45
|
+
/** Project get_selection into the bounded shape shared with the DSH client. */
|
|
46
|
+
export declare function selectionSnapshotFromMcp(sourcePath: string, value: unknown, updatedAt?: number): OpenPencilSelectionSnapshot;
|
|
47
|
+
export {};
|