@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
package/src/renderer.ts
ADDED
|
@@ -0,0 +1,884 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Offscreen rendering + signed HTTP delivery for `.op` documents.
|
|
3
|
+
*
|
|
4
|
+
* `openpencil_render` snapshots the source document, uses OpenPencil's own
|
|
5
|
+
* headless exporter for design-fidelity PNG output, retaining every
|
|
6
|
+
* top-level frame for the conversation gallery, and only invokes Jian
|
|
7
|
+
* as an explicitly disclosed runtime-preview fallback when the exact binary
|
|
8
|
+
* is unavailable. Content-addressed image/document capabilities bind name,
|
|
9
|
+
* byte length, and SHA-256 without exposing arbitrary host paths. Serving
|
|
10
|
+
* refuses symbolic links so a delivered artifact cannot be redirected.
|
|
11
|
+
*
|
|
12
|
+
* Model-visible result values stay plain JSON; the browser-only envelope
|
|
13
|
+
* rides `presentationMeta` (see `tool.ts`). Never return an ImageBlock.
|
|
14
|
+
* @module dsh-openpencil/renderer
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { createHash, createHmac, randomBytes, timingSafeEqual } from 'node:crypto'
|
|
18
|
+
import { constants as fsConstants, statSync } from 'node:fs'
|
|
19
|
+
import type { Stats } from 'node:fs'
|
|
20
|
+
import type { FileHandle } from 'node:fs/promises'
|
|
21
|
+
import { lstat, mkdir, mkdtemp, open, readFile, readdir, realpath, rename, rm, stat, writeFile } from 'node:fs/promises'
|
|
22
|
+
import type { IncomingMessage, ServerResponse } from 'node:http'
|
|
23
|
+
import { spawn } from 'node:child_process'
|
|
24
|
+
import { homedir } from 'node:os'
|
|
25
|
+
import { basename, delimiter, isAbsolute, join, relative, resolve, sep } from 'node:path'
|
|
26
|
+
import { randomUUID } from 'node:crypto'
|
|
27
|
+
import type { JsonValue } from '@deepseek-ai/dsh-tools'
|
|
28
|
+
import type { ViewerGrant } from './viewer-assets.js'
|
|
29
|
+
import type { EditorGrant } from './editor-host.js'
|
|
30
|
+
import { OPENPENCIL_RENDER_TOOL_NAME } from './tool-names.js'
|
|
31
|
+
|
|
32
|
+
/** HTTP prefix owned by the render capability route. */
|
|
33
|
+
export const RENDER_ROUTE_PREFIX = '/_dsh/dsh-openpencil/render'
|
|
34
|
+
|
|
35
|
+
/** Presentation metadata key reserved by the browser half of this package. */
|
|
36
|
+
export const PRESENTATION_META_KEY = '$dshOpenPencil'
|
|
37
|
+
|
|
38
|
+
/** Refuse to deliver anything above this size (also enforced at render time). */
|
|
39
|
+
export const MAX_RENDER_BYTES = 32 * 1024 * 1024
|
|
40
|
+
|
|
41
|
+
/** Refuse unusually large source documents before copying them into managed state. */
|
|
42
|
+
export const MAX_DOCUMENT_BYTES = 64 * 1024 * 1024
|
|
43
|
+
|
|
44
|
+
/** Bound decoded image geometry as well as compressed bytes. */
|
|
45
|
+
const MAX_RENDER_DIMENSION = 32_768
|
|
46
|
+
const MAX_RENDER_PIXELS = 128 * 1024 * 1024
|
|
47
|
+
|
|
48
|
+
/** Hard per-render wall clock for either renderer. */
|
|
49
|
+
const RENDER_TIMEOUT_MS = 60_000
|
|
50
|
+
|
|
51
|
+
const KEY_BYTES = 32
|
|
52
|
+
const TOKEN_PATTERN = /^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/
|
|
53
|
+
const MAX_TOKEN_LENGTH = 16 * 1024
|
|
54
|
+
|
|
55
|
+
/** One render delivery capability. */
|
|
56
|
+
interface LegacyRenderTokenPayload {
|
|
57
|
+
v: 1
|
|
58
|
+
path: string
|
|
59
|
+
filename: string
|
|
60
|
+
bytes: number
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
interface ArtifactTokenPayload {
|
|
64
|
+
v: 2
|
|
65
|
+
kind: 'image' | 'document'
|
|
66
|
+
filename: string
|
|
67
|
+
bytes: number
|
|
68
|
+
sha256: string
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
type RenderTokenPayload = LegacyRenderTokenPayload | ArtifactTokenPayload
|
|
72
|
+
|
|
73
|
+
/** Browser grant paired to one rendered PNG. */
|
|
74
|
+
export interface RenderGrant {
|
|
75
|
+
path: string
|
|
76
|
+
previewUrl: string
|
|
77
|
+
downloadUrl: string
|
|
78
|
+
width?: number
|
|
79
|
+
height?: number
|
|
80
|
+
id?: string
|
|
81
|
+
name?: string
|
|
82
|
+
index?: number
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** One immutable top-level frame retained from an exact render. */
|
|
86
|
+
export interface RenderFrame {
|
|
87
|
+
path: string
|
|
88
|
+
filename: string
|
|
89
|
+
mimeType: 'image/png'
|
|
90
|
+
bytes: number
|
|
91
|
+
width: number
|
|
92
|
+
height: number
|
|
93
|
+
sha256: string
|
|
94
|
+
/** Canonical top-level node identity and user-facing name. */
|
|
95
|
+
id?: string
|
|
96
|
+
name?: string
|
|
97
|
+
index?: number
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export interface DocumentSnapshot {
|
|
101
|
+
path: string
|
|
102
|
+
filename: string
|
|
103
|
+
mimeType: 'application/json'
|
|
104
|
+
bytes: number
|
|
105
|
+
sha256: string
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export interface DocumentGrant extends DocumentSnapshot {
|
|
109
|
+
url: string
|
|
110
|
+
previewUrl: string
|
|
111
|
+
downloadUrl: string
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Canonical result shape the tool returns and the envelope enriches. */
|
|
115
|
+
export interface RenderResult {
|
|
116
|
+
path: string
|
|
117
|
+
filename: string
|
|
118
|
+
mimeType: 'image/png'
|
|
119
|
+
kind: 'image'
|
|
120
|
+
description: string
|
|
121
|
+
sourceTool: typeof OPENPENCIL_RENDER_TOOL_NAME
|
|
122
|
+
previewIntent: 'image'
|
|
123
|
+
bytes: number
|
|
124
|
+
width?: number
|
|
125
|
+
height?: number
|
|
126
|
+
sha256?: string
|
|
127
|
+
sourcePath?: string
|
|
128
|
+
renderer?: 'openpencil' | 'jian'
|
|
129
|
+
rendererBinary?: string
|
|
130
|
+
fidelity?: 'exact' | 'runtime-preview'
|
|
131
|
+
warnings?: string[]
|
|
132
|
+
/** Ordered top-level frames. The first entry is also exposed by legacy image fields. */
|
|
133
|
+
frames?: RenderFrame[]
|
|
134
|
+
frameCount?: number
|
|
135
|
+
editable?: boolean
|
|
136
|
+
/** Live-card intent: expand the editor once after a newly-created design renders. */
|
|
137
|
+
autoOpenEditor?: boolean
|
|
138
|
+
document?: DocumentSnapshot
|
|
139
|
+
note?: string
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function dshHome(): string {
|
|
143
|
+
const env = process.env.DSH_HOME?.trim()
|
|
144
|
+
return env === undefined || env.length === 0 ? join(homedir(), '.dsh') : resolve(env)
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Plugin-managed state root (mirrors the dsh-vision-toolkit convention). */
|
|
148
|
+
export function stateRoot(): string {
|
|
149
|
+
return join(dshHome(), 'cache', 'dsh-openpencil')
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Plugin-managed render output directory. */
|
|
153
|
+
export function renderDir(): string {
|
|
154
|
+
return join(stateRoot(), 'renders')
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Content-addressed immutable `.op` snapshots served to the web viewer. */
|
|
158
|
+
export function snapshotDir(): string {
|
|
159
|
+
return join(stateRoot(), 'snapshots')
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
163
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function mac(key: Buffer, payload: string): Buffer {
|
|
167
|
+
return createHmac('sha256', key).update(payload).digest()
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function safeEqual(left: Buffer, right: Buffer): boolean {
|
|
171
|
+
return left.length === right.length && timingSafeEqual(left, right)
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
async function readKey(path: string): Promise<Buffer> {
|
|
175
|
+
const info = await lstat(path)
|
|
176
|
+
if (info.isSymbolicLink() || !info.isFile()) throw new Error('render access key is not a regular file')
|
|
177
|
+
const key = await readFile(path)
|
|
178
|
+
if (key.length !== KEY_BYTES) throw new Error('render access key has an invalid length')
|
|
179
|
+
return key
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Load or atomically create the per-DSH-home signing key. */
|
|
183
|
+
export async function prepareRenderAccessKey(): Promise<Buffer> {
|
|
184
|
+
await mkdir(stateRoot(), { recursive: true, mode: 0o700 })
|
|
185
|
+
const path = join(stateRoot(), 'render-access.key')
|
|
186
|
+
try {
|
|
187
|
+
return await readKey(path)
|
|
188
|
+
} catch (error) {
|
|
189
|
+
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
|
|
190
|
+
}
|
|
191
|
+
const candidate = randomBytes(KEY_BYTES)
|
|
192
|
+
try {
|
|
193
|
+
await writeFile(path, candidate, { flag: 'wx', mode: 0o600 })
|
|
194
|
+
return candidate
|
|
195
|
+
} catch (error) {
|
|
196
|
+
if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error
|
|
197
|
+
return readKey(path)
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function parsePayload(value: unknown): RenderTokenPayload | undefined {
|
|
202
|
+
if (!isRecord(value)) return undefined
|
|
203
|
+
if (value.v === 2) {
|
|
204
|
+
if (
|
|
205
|
+
(value.kind !== 'image' && value.kind !== 'document')
|
|
206
|
+
|| typeof value.filename !== 'string'
|
|
207
|
+
|| basename(value.filename) !== value.filename
|
|
208
|
+
|| typeof value.bytes !== 'number'
|
|
209
|
+
|| !Number.isSafeInteger(value.bytes)
|
|
210
|
+
|| value.bytes <= 0
|
|
211
|
+
|| value.bytes > (value.kind === 'image' ? MAX_RENDER_BYTES : MAX_DOCUMENT_BYTES)
|
|
212
|
+
|| typeof value.sha256 !== 'string'
|
|
213
|
+
|| !/^[a-f0-9]{64}$/.test(value.sha256)
|
|
214
|
+
) return undefined
|
|
215
|
+
if (value.kind === 'image' && !/^render-[A-Za-z0-9-]+\.png$/.test(value.filename)) return undefined
|
|
216
|
+
if (value.kind === 'document' && value.filename !== `${value.sha256}.op`) return undefined
|
|
217
|
+
return value as unknown as ArtifactTokenPayload
|
|
218
|
+
}
|
|
219
|
+
if (
|
|
220
|
+
value.v !== 1
|
|
221
|
+
|| typeof value.path !== 'string'
|
|
222
|
+
|| !isAbsolute(value.path)
|
|
223
|
+
|| typeof value.filename !== 'string'
|
|
224
|
+
|| basename(value.path) !== value.filename
|
|
225
|
+
|| typeof value.bytes !== 'number'
|
|
226
|
+
|| !Number.isSafeInteger(value.bytes)
|
|
227
|
+
|| value.bytes < 0
|
|
228
|
+
|| value.bytes > MAX_RENDER_BYTES
|
|
229
|
+
) return undefined
|
|
230
|
+
return value as unknown as RenderTokenPayload
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function payloadPath(payload: RenderTokenPayload): string {
|
|
234
|
+
if (payload.v === 1) return payload.path
|
|
235
|
+
return join(payload.kind === 'image' ? renderDir() : snapshotDir(), payload.filename)
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function payloadMimeType(payload: RenderTokenPayload): 'image/png' | 'application/json' {
|
|
239
|
+
return payload.v === 1 || payload.kind === 'image' ? 'image/png' : 'application/json'
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Walk every path component from the managed root with `lstat`, refusing
|
|
244
|
+
* any symbolic link — even one whose target stays inside the root — and
|
|
245
|
+
* finish with a `realpath` containment check.
|
|
246
|
+
*/
|
|
247
|
+
async function assertNoSymlinkPath(root: string, path: string): Promise<void> {
|
|
248
|
+
const rootInfo = await lstat(root)
|
|
249
|
+
if (rootInfo.isSymbolicLink() || !rootInfo.isDirectory()) throw new Error('render root is not a real directory')
|
|
250
|
+
const rel = relative(root, path)
|
|
251
|
+
if (rel === '' || rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel)) {
|
|
252
|
+
throw new Error('render path escaped its managed root')
|
|
253
|
+
}
|
|
254
|
+
let current = root
|
|
255
|
+
const parts = rel.split(sep)
|
|
256
|
+
for (let index = 0; index < parts.length; index += 1) {
|
|
257
|
+
const part = parts[index]
|
|
258
|
+
if (part === undefined || part.length === 0 || part === '.' || part === '..') throw new Error('render path is malformed')
|
|
259
|
+
current = join(current, part)
|
|
260
|
+
const info = await lstat(current)
|
|
261
|
+
if (info.isSymbolicLink()) throw new Error('render path contains a symbolic link')
|
|
262
|
+
const final = index === parts.length - 1
|
|
263
|
+
if (final ? !info.isFile() : !info.isDirectory()) throw new Error('render path contains an unexpected entry type')
|
|
264
|
+
}
|
|
265
|
+
const [realRoot, realFile] = await Promise.all([realpath(root), realpath(path)])
|
|
266
|
+
const relReal = relative(realRoot, realFile)
|
|
267
|
+
if (relReal === '..' || relReal.startsWith(`..${sep}`) || isAbsolute(relReal)) {
|
|
268
|
+
throw new Error('render path escaped its managed root')
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function sameFile(opened: Stats, current: Stats): boolean {
|
|
273
|
+
if (opened.dev === 0 || current.dev === 0 || opened.ino === 0 || current.ino === 0) return true
|
|
274
|
+
return opened.dev === current.dev && opened.ino === current.ino
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
async function openVerifiedRender(payload: RenderTokenPayload): Promise<{ handle: FileHandle; info: Stats }> {
|
|
278
|
+
const root = payload.v === 1 || payload.kind === 'image' ? renderDir() : snapshotDir()
|
|
279
|
+
const path = payloadPath(payload)
|
|
280
|
+
await assertNoSymlinkPath(root, path)
|
|
281
|
+
const noFollow = typeof fsConstants.O_NOFOLLOW === 'number' ? fsConstants.O_NOFOLLOW : 0
|
|
282
|
+
const handle = await open(path, fsConstants.O_RDONLY | noFollow)
|
|
283
|
+
try {
|
|
284
|
+
const info = await handle.stat()
|
|
285
|
+
if (!info.isFile() || info.size !== payload.bytes) throw new Error('render no longer matches its delivered descriptor')
|
|
286
|
+
const current = await lstat(path)
|
|
287
|
+
if (current.isSymbolicLink() || !current.isFile() || !sameFile(info, current)) {
|
|
288
|
+
throw new Error('render changed while it was being opened')
|
|
289
|
+
}
|
|
290
|
+
if (payload.v === 2) {
|
|
291
|
+
// Hash the same opened inode that will be served. Reading the pathname
|
|
292
|
+
// here would leave a rename race between verification and streaming.
|
|
293
|
+
const bytes = await handle.readFile()
|
|
294
|
+
const digest = createHash('sha256').update(bytes).digest('hex')
|
|
295
|
+
if (digest !== payload.sha256) throw new Error('artifact content hash changed')
|
|
296
|
+
}
|
|
297
|
+
await assertNoSymlinkPath(root, path)
|
|
298
|
+
return { handle, info }
|
|
299
|
+
} catch (error) {
|
|
300
|
+
await handle.close().catch(() => {})
|
|
301
|
+
throw error
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function asciiFilename(filename: string): string {
|
|
306
|
+
const fallback = filename.replace(/[^A-Za-z0-9._-]/g, '_').slice(0, 160)
|
|
307
|
+
return fallback.length === 0 ? 'render.png' : fallback
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function securityHeaders(res: ServerResponse, payload: RenderTokenPayload, download: boolean): void {
|
|
311
|
+
res.setHeader('Content-Type', payloadMimeType(payload))
|
|
312
|
+
res.setHeader('Content-Length', String(payload.bytes))
|
|
313
|
+
res.setHeader('Content-Disposition', `${download ? 'attachment' : 'inline'}; filename="${asciiFilename(payload.filename)}"`)
|
|
314
|
+
res.setHeader('X-Content-Type-Options', 'nosniff')
|
|
315
|
+
res.setHeader('Cache-Control', 'private, no-store, max-age=0')
|
|
316
|
+
res.setHeader('Referrer-Policy', 'no-referrer')
|
|
317
|
+
res.setHeader('Cross-Origin-Resource-Policy', 'same-origin')
|
|
318
|
+
res.setHeader('Content-Security-Policy', "sandbox; default-src 'none'")
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/** Signed render-capability encoder and safe route handler. */
|
|
322
|
+
export class RenderAccessController {
|
|
323
|
+
private routeCount = 0
|
|
324
|
+
|
|
325
|
+
constructor(private readonly key: Buffer) {
|
|
326
|
+
if (key.length !== KEY_BYTES) throw new TypeError(`render access key must be ${KEY_BYTES} bytes`)
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/** Whether at least one HTTP carrier currently owns the route. */
|
|
330
|
+
get routeAvailable(): boolean {
|
|
331
|
+
return this.routeCount > 0
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/** Mark one route attachment; the returned disposer removes that attachment. */
|
|
335
|
+
attachRoute(): () => void {
|
|
336
|
+
this.routeCount += 1
|
|
337
|
+
let active = true
|
|
338
|
+
return () => {
|
|
339
|
+
if (!active) return
|
|
340
|
+
active = false
|
|
341
|
+
this.routeCount -= 1
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/** Mint a deterministic, tamper-evident capability for one render result. */
|
|
346
|
+
sign(result: RenderResult): string {
|
|
347
|
+
if (result.sha256 !== undefined) {
|
|
348
|
+
return this.signArtifact({
|
|
349
|
+
kind: 'image',
|
|
350
|
+
filename: result.filename,
|
|
351
|
+
bytes: result.bytes,
|
|
352
|
+
sha256: result.sha256,
|
|
353
|
+
})
|
|
354
|
+
}
|
|
355
|
+
const payload: RenderTokenPayload = {
|
|
356
|
+
v: 1,
|
|
357
|
+
path: result.path,
|
|
358
|
+
filename: result.filename,
|
|
359
|
+
bytes: result.bytes,
|
|
360
|
+
}
|
|
361
|
+
const encoded = Buffer.from(JSON.stringify(payload)).toString('base64url')
|
|
362
|
+
return `${encoded}.${mac(this.key, encoded).toString('base64url')}`
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/** Mint an immutable capability without embedding an absolute local path. */
|
|
366
|
+
signArtifact(artifact: Omit<ArtifactTokenPayload, 'v'>): string {
|
|
367
|
+
const payload: ArtifactTokenPayload = { v: 2, ...artifact }
|
|
368
|
+
const parsed = parsePayload(payload)
|
|
369
|
+
if (parsed === undefined || parsed.v !== 2) throw new Error('invalid artifact descriptor')
|
|
370
|
+
const encoded = Buffer.from(JSON.stringify(payload)).toString('base64url')
|
|
371
|
+
return `${encoded}.${mac(this.key, encoded).toString('base64url')}`
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/** Verify and decode one capability without touching the filesystem. */
|
|
375
|
+
verify(token: string): RenderTokenPayload | undefined {
|
|
376
|
+
if (token.length === 0 || token.length > MAX_TOKEN_LENGTH || !TOKEN_PATTERN.test(token)) return undefined
|
|
377
|
+
const [encoded, signature] = token.split('.')
|
|
378
|
+
if (encoded === undefined || signature === undefined) return undefined
|
|
379
|
+
let supplied: Buffer
|
|
380
|
+
try {
|
|
381
|
+
supplied = Buffer.from(signature, 'base64url')
|
|
382
|
+
} catch {
|
|
383
|
+
return undefined
|
|
384
|
+
}
|
|
385
|
+
if (!safeEqual(mac(this.key, encoded), supplied)) return undefined
|
|
386
|
+
try {
|
|
387
|
+
return parsePayload(JSON.parse(Buffer.from(encoded, 'base64url').toString('utf8')))
|
|
388
|
+
} catch {
|
|
389
|
+
return undefined
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/** Serve one GET/HEAD capability request. */
|
|
394
|
+
async handle(req: IncomingMessage, res: ServerResponse): Promise<void> {
|
|
395
|
+
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
|
396
|
+
res.setHeader('Allow', 'GET, HEAD')
|
|
397
|
+
res.writeHead(405)
|
|
398
|
+
res.end()
|
|
399
|
+
return
|
|
400
|
+
}
|
|
401
|
+
let url: URL
|
|
402
|
+
try {
|
|
403
|
+
url = new URL(req.url ?? '/', 'http://dsh.local')
|
|
404
|
+
} catch {
|
|
405
|
+
res.writeHead(400)
|
|
406
|
+
res.end()
|
|
407
|
+
return
|
|
408
|
+
}
|
|
409
|
+
const prefix = `${RENDER_ROUTE_PREFIX}/`
|
|
410
|
+
if (!url.pathname.startsWith(prefix) || url.pathname.slice(prefix.length).includes('/')) {
|
|
411
|
+
res.writeHead(404)
|
|
412
|
+
res.end()
|
|
413
|
+
return
|
|
414
|
+
}
|
|
415
|
+
let token: string
|
|
416
|
+
try {
|
|
417
|
+
token = decodeURIComponent(url.pathname.slice(prefix.length))
|
|
418
|
+
} catch {
|
|
419
|
+
res.writeHead(404)
|
|
420
|
+
res.end()
|
|
421
|
+
return
|
|
422
|
+
}
|
|
423
|
+
const payload = this.verify(token)
|
|
424
|
+
if (payload === undefined) {
|
|
425
|
+
res.writeHead(404)
|
|
426
|
+
res.end()
|
|
427
|
+
return
|
|
428
|
+
}
|
|
429
|
+
const downloadValue = url.searchParams.get('download')
|
|
430
|
+
if ([...url.searchParams.keys()].some(key => key !== 'download') || (downloadValue !== null && downloadValue !== '1')) {
|
|
431
|
+
res.writeHead(400)
|
|
432
|
+
res.end()
|
|
433
|
+
return
|
|
434
|
+
}
|
|
435
|
+
let opened: { handle: FileHandle; info: Stats }
|
|
436
|
+
try {
|
|
437
|
+
opened = await openVerifiedRender(payload)
|
|
438
|
+
} catch {
|
|
439
|
+
res.writeHead(404)
|
|
440
|
+
res.end()
|
|
441
|
+
return
|
|
442
|
+
}
|
|
443
|
+
securityHeaders(res, payload, downloadValue === '1')
|
|
444
|
+
res.writeHead(200)
|
|
445
|
+
if (req.method === 'HEAD') {
|
|
446
|
+
await opened.handle.close().catch(() => {})
|
|
447
|
+
res.end()
|
|
448
|
+
return
|
|
449
|
+
}
|
|
450
|
+
const stream = opened.handle.createReadStream({ autoClose: true, start: 0, end: opened.info.size - 1 })
|
|
451
|
+
stream.on('error', () => {
|
|
452
|
+
if (!res.headersSent) res.writeHead(500)
|
|
453
|
+
res.destroy()
|
|
454
|
+
})
|
|
455
|
+
stream.pipe(res)
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/** Expand `~` / `~/` prefixes (the model frequently writes home-relative paths). */
|
|
460
|
+
export function expandUserHome(raw: string): string {
|
|
461
|
+
if (raw === '~') return homedir()
|
|
462
|
+
if (raw.startsWith('~/') || raw.startsWith(`~${sep}`)) return join(homedir(), raw.slice(2))
|
|
463
|
+
return raw
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
/**
|
|
467
|
+
* Validate the `.op` input path: expand `~`, resolve against the session
|
|
468
|
+
* workspace, realpath, and require a regular `.op` file.
|
|
469
|
+
*/
|
|
470
|
+
export async function resolveInputFile(raw: string, cwd: string): Promise<string> {
|
|
471
|
+
if (typeof raw !== 'string' || raw.trim().length === 0) {
|
|
472
|
+
throw new Error(`${OPENPENCIL_RENDER_TOOL_NAME}: path is required`)
|
|
473
|
+
}
|
|
474
|
+
const expanded = expandUserHome(raw.trim())
|
|
475
|
+
const target = isAbsolute(expanded) ? expanded : resolve(cwd, expanded)
|
|
476
|
+
let real: string
|
|
477
|
+
try {
|
|
478
|
+
real = await realpath(target)
|
|
479
|
+
} catch {
|
|
480
|
+
throw new Error(`${OPENPENCIL_RENDER_TOOL_NAME}: .op file not found: ${raw}`)
|
|
481
|
+
}
|
|
482
|
+
const info = await stat(real)
|
|
483
|
+
if (!info.isFile()) throw new Error(`${OPENPENCIL_RENDER_TOOL_NAME}: not a regular file: ${raw}`)
|
|
484
|
+
const extension = real.slice(real.lastIndexOf('.')).toLowerCase()
|
|
485
|
+
if (extension !== '.op') {
|
|
486
|
+
throw new Error(`${OPENPENCIL_RENDER_TOOL_NAME}: expected a .op file (got "${extension || '(none)'}")`)
|
|
487
|
+
}
|
|
488
|
+
return real
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
/** Freeze source bytes before rendering so preview and web viewer cannot diverge. */
|
|
492
|
+
export async function createDocumentSnapshot(input: string): Promise<DocumentSnapshot> {
|
|
493
|
+
const bytes = await readFile(input)
|
|
494
|
+
if (bytes.length === 0) throw new Error(`${OPENPENCIL_RENDER_TOOL_NAME}: source document is empty`)
|
|
495
|
+
if (bytes.length > MAX_DOCUMENT_BYTES) {
|
|
496
|
+
throw new Error(`${OPENPENCIL_RENDER_TOOL_NAME}: source document exceeds ${MAX_DOCUMENT_BYTES} bytes`)
|
|
497
|
+
}
|
|
498
|
+
const sha256 = createHash('sha256').update(bytes).digest('hex')
|
|
499
|
+
const filename = `${sha256}.op`
|
|
500
|
+
await mkdir(snapshotDir(), { recursive: true, mode: 0o700 })
|
|
501
|
+
const path = join(snapshotDir(), filename)
|
|
502
|
+
try {
|
|
503
|
+
await writeFile(path, bytes, { flag: 'wx', mode: 0o600 })
|
|
504
|
+
} catch (error) {
|
|
505
|
+
if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error
|
|
506
|
+
const existing = await readFile(path)
|
|
507
|
+
if (existing.length !== bytes.length || createHash('sha256').update(existing).digest('hex') !== sha256) {
|
|
508
|
+
throw new Error(`${OPENPENCIL_RENDER_TOOL_NAME}: content-addressed snapshot was modified`)
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
return { path, filename, mimeType: 'application/json', bytes: bytes.length, sha256 }
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
/** Locate the exact OpenPencil renderer, preferring an explicit override. */
|
|
515
|
+
export function findOpenPencilBinary(): string | undefined {
|
|
516
|
+
const override = process.env.DSH_OPENPENCIL_BINARY?.trim()
|
|
517
|
+
|| process.env.DSH_OPENPENCIL_DESKTOP?.trim()
|
|
518
|
+
const candidates = [
|
|
519
|
+
...(override === undefined || override.length === 0 ? [] : [expandUserHome(override)]),
|
|
520
|
+
'/Applications/OpenPencil.app/Contents/MacOS/openpencil-desktop',
|
|
521
|
+
join(homedir(), 'Applications', 'OpenPencil.app', 'Contents', 'MacOS', 'openpencil-desktop'),
|
|
522
|
+
]
|
|
523
|
+
const pathEnv = process.env.PATH ?? ''
|
|
524
|
+
for (const dir of pathEnv.split(delimiter)) {
|
|
525
|
+
if (dir.length > 0) candidates.push(join(dir, 'openpencil-desktop'))
|
|
526
|
+
}
|
|
527
|
+
for (const candidate of candidates) {
|
|
528
|
+
try {
|
|
529
|
+
if (statSync(candidate).isFile()) return candidate
|
|
530
|
+
} catch {
|
|
531
|
+
// keep looking
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
return undefined
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
/** Locate the `jian` binary: env override, known build location, then PATH. */
|
|
538
|
+
export function findJianBinary(): string {
|
|
539
|
+
const override = process.env.DSH_OPENPENCIL_JIAN?.trim()
|
|
540
|
+
if (override !== undefined && override.length > 0) return override
|
|
541
|
+
const candidates = [join(homedir(), 'workspace', 'jian', 'target', 'release', 'jian')]
|
|
542
|
+
for (const candidate of candidates) {
|
|
543
|
+
try {
|
|
544
|
+
if (statSync(candidate).isFile()) return candidate
|
|
545
|
+
} catch {
|
|
546
|
+
// keep looking
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
const pathEnv = process.env.PATH ?? ''
|
|
550
|
+
for (const dir of pathEnv.split(':')) {
|
|
551
|
+
if (dir.length === 0) continue
|
|
552
|
+
const candidate = join(dir, 'jian')
|
|
553
|
+
try {
|
|
554
|
+
if (statSync(candidate).isFile()) return candidate
|
|
555
|
+
} catch {
|
|
556
|
+
// keep looking
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
return 'jian'
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
/** Parse the physical size from `jian render`'s success line. */
|
|
563
|
+
export function parseRenderSize(stdout: string): { width?: number; height?: number } {
|
|
564
|
+
const match = /\((\d+)x(\d+) physical/.exec(stdout)
|
|
565
|
+
if (match === null) return {}
|
|
566
|
+
return { width: Number(match[1]), height: Number(match[2]) }
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
/**
|
|
570
|
+
* Run `jian render` for one input and wait for the PNG on disk.
|
|
571
|
+
* Observes `signal` so caller cancellation stops the child promptly.
|
|
572
|
+
*/
|
|
573
|
+
export async function runJianRender(options: {
|
|
574
|
+
binary: string
|
|
575
|
+
input: string
|
|
576
|
+
out: string
|
|
577
|
+
width?: number
|
|
578
|
+
height?: number
|
|
579
|
+
scale?: number
|
|
580
|
+
signal: AbortSignal
|
|
581
|
+
}): Promise<{ stdout: string; stderr: string }> {
|
|
582
|
+
const args = ['render', options.input, '--out', options.out]
|
|
583
|
+
if (options.width !== undefined) args.push('--width', String(options.width))
|
|
584
|
+
if (options.height !== undefined) args.push('--height', String(options.height))
|
|
585
|
+
if (options.scale !== undefined && options.scale !== 1) args.push('--scale', String(options.scale))
|
|
586
|
+
|
|
587
|
+
const child = spawn(options.binary, args, {
|
|
588
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
589
|
+
env: process.env,
|
|
590
|
+
})
|
|
591
|
+
const stdoutChunks: Buffer[] = []
|
|
592
|
+
const stderrChunks: Buffer[] = []
|
|
593
|
+
child.stdout.on('data', (chunk: Buffer) => stdoutChunks.push(chunk))
|
|
594
|
+
child.stderr.on('data', (chunk: Buffer) => stderrChunks.push(chunk))
|
|
595
|
+
|
|
596
|
+
const timeout = setTimeout(() => {
|
|
597
|
+
child.kill('SIGKILL')
|
|
598
|
+
}, RENDER_TIMEOUT_MS)
|
|
599
|
+
|
|
600
|
+
const onAbort = (): void => {
|
|
601
|
+
child.kill('SIGKILL')
|
|
602
|
+
}
|
|
603
|
+
options.signal.addEventListener('abort', onAbort, { once: true })
|
|
604
|
+
|
|
605
|
+
try {
|
|
606
|
+
const code = await new Promise<number | null>((resolveCode, reject) => {
|
|
607
|
+
child.on('error', reject)
|
|
608
|
+
child.on('close', (closeCode) => resolveCode(closeCode))
|
|
609
|
+
})
|
|
610
|
+
clearTimeout(timeout)
|
|
611
|
+
const stdout = Buffer.concat(stdoutChunks).toString('utf8')
|
|
612
|
+
const stderr = Buffer.concat(stderrChunks).toString('utf8')
|
|
613
|
+
if (code !== 0) {
|
|
614
|
+
const detail = stderr.trim() !== '' ? stderr.trim() : stdout.trim()
|
|
615
|
+
throw new Error(`jian render exited with ${String(code)}${detail !== '' ? `: ${detail}` : ''}`)
|
|
616
|
+
}
|
|
617
|
+
return { stdout, stderr }
|
|
618
|
+
} finally {
|
|
619
|
+
clearTimeout(timeout)
|
|
620
|
+
options.signal.removeEventListener('abort', onAbort)
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
export class RendererBinaryMissingError extends Error {
|
|
625
|
+
constructor(message: string, options?: ErrorOptions) {
|
|
626
|
+
super(message, options)
|
|
627
|
+
this.name = 'RendererBinaryMissingError'
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
interface SourceFrameDescriptor {
|
|
632
|
+
id: string
|
|
633
|
+
name?: string
|
|
634
|
+
index: number
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
function rendererFilename(id: string): string {
|
|
638
|
+
return `${id.replace(/[^A-Za-z0-9_-]/g, '_')}.png`
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
/** Read active-page top-level metadata without changing renderer semantics. */
|
|
642
|
+
async function sourceFrameDescriptors(path: string): Promise<SourceFrameDescriptor[]> {
|
|
643
|
+
try {
|
|
644
|
+
const value: unknown = JSON.parse(await readFile(path, 'utf8'))
|
|
645
|
+
if (!isRecord(value)) return []
|
|
646
|
+
const editorMeta = isRecord(value.editorMeta) ? value.editorMeta : undefined
|
|
647
|
+
const rawPageIndex = editorMeta?.activePageIndex ?? editorMeta?.active_page_index
|
|
648
|
+
const pageIndex = typeof rawPageIndex === 'number' && Number.isInteger(rawPageIndex) && rawPageIndex >= 0
|
|
649
|
+
? rawPageIndex
|
|
650
|
+
: 0
|
|
651
|
+
const pages = Array.isArray(value.pages) ? value.pages.filter(isRecord) : undefined
|
|
652
|
+
const selectedPage = pages === undefined || pages.length === 0
|
|
653
|
+
? undefined
|
|
654
|
+
: pages[Math.min(pageIndex, pages.length - 1)]
|
|
655
|
+
const children = Array.isArray(selectedPage?.children)
|
|
656
|
+
? selectedPage.children
|
|
657
|
+
: Array.isArray(value.children) ? value.children : []
|
|
658
|
+
return children.flatMap((child, index) => {
|
|
659
|
+
if (!isRecord(child) || typeof child.id !== 'string' || child.id.length === 0) return []
|
|
660
|
+
return [{
|
|
661
|
+
id: child.id,
|
|
662
|
+
...(typeof child.name === 'string' && child.name.length > 0 ? { name: child.name } : {}),
|
|
663
|
+
index,
|
|
664
|
+
}]
|
|
665
|
+
})
|
|
666
|
+
} catch {
|
|
667
|
+
// The exact renderer remains authoritative; metadata is presentation-only.
|
|
668
|
+
return []
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
/** Render all top-level active-page nodes through OpenPencil's exact scene exporter. */
|
|
673
|
+
export async function runOpenPencilRender(options: {
|
|
674
|
+
binary: string
|
|
675
|
+
input: string
|
|
676
|
+
scale?: number
|
|
677
|
+
signal: AbortSignal
|
|
678
|
+
}): Promise<{
|
|
679
|
+
png: string
|
|
680
|
+
frames: Array<{ png: string; id?: string; name?: string; index: number }>
|
|
681
|
+
warnings: string[]
|
|
682
|
+
stdout: string
|
|
683
|
+
stderr: string
|
|
684
|
+
}> {
|
|
685
|
+
const scale = options.scale ?? 1
|
|
686
|
+
if (!Number.isFinite(scale) || scale <= 0 || scale > 8) {
|
|
687
|
+
throw new Error(`${OPENPENCIL_RENDER_TOOL_NAME}: scale must be a finite number greater than 0 and at most 8`)
|
|
688
|
+
}
|
|
689
|
+
await mkdir(stateRoot(), { recursive: true, mode: 0o700 })
|
|
690
|
+
const tempRoot = await mkdtemp(join(stateRoot(), 'exact-'))
|
|
691
|
+
const outDir = join(tempRoot, 'shots')
|
|
692
|
+
await mkdir(outDir, { mode: 0o700 })
|
|
693
|
+
const env: NodeJS.ProcessEnv = { ...process.env, OPENPENCIL_RENDER_MARGIN: '0' }
|
|
694
|
+
delete env.OPENPENCIL_DUMP_LAYOUT
|
|
695
|
+
const child = spawn(options.binary, ['--render-shots', options.input, outDir, String(scale)], {
|
|
696
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
697
|
+
env,
|
|
698
|
+
})
|
|
699
|
+
const stdoutChunks: Buffer[] = []
|
|
700
|
+
const stderrChunks: Buffer[] = []
|
|
701
|
+
child.stdout.on('data', (chunk: Buffer) => stdoutChunks.push(chunk))
|
|
702
|
+
child.stderr.on('data', (chunk: Buffer) => stderrChunks.push(chunk))
|
|
703
|
+
let timedOut = false
|
|
704
|
+
const timeout = setTimeout(() => {
|
|
705
|
+
timedOut = true
|
|
706
|
+
child.kill('SIGKILL')
|
|
707
|
+
}, RENDER_TIMEOUT_MS)
|
|
708
|
+
const onAbort = (): void => { child.kill('SIGKILL') }
|
|
709
|
+
options.signal.addEventListener('abort', onAbort, { once: true })
|
|
710
|
+
try {
|
|
711
|
+
const code = await new Promise<number | null>((resolveCode, reject) => {
|
|
712
|
+
child.on('error', (error: NodeJS.ErrnoException) => {
|
|
713
|
+
if (error.code === 'ENOENT') reject(new RendererBinaryMissingError(`OpenPencil binary not found: ${options.binary}`, { cause: error }))
|
|
714
|
+
else reject(error)
|
|
715
|
+
})
|
|
716
|
+
child.on('close', resolveCode)
|
|
717
|
+
})
|
|
718
|
+
const stdout = Buffer.concat(stdoutChunks).toString('utf8')
|
|
719
|
+
const stderr = Buffer.concat(stderrChunks).toString('utf8')
|
|
720
|
+
if (options.signal.aborted) throw new Error(`${OPENPENCIL_RENDER_TOOL_NAME}: OpenPencil render was cancelled`)
|
|
721
|
+
if (timedOut) throw new Error(`${OPENPENCIL_RENDER_TOOL_NAME}: OpenPencil render timed out after ${RENDER_TIMEOUT_MS} ms`)
|
|
722
|
+
if (code !== 0) throw new Error(`OpenPencil render exited with ${String(code)}${stderr.trim() === '' ? '' : `: ${stderr.trim()}`}`)
|
|
723
|
+
const discoveredPngs = (await readdir(outDir)).filter(name => name.toLowerCase().endsWith('.png'))
|
|
724
|
+
const sourceFrames = await sourceFrameDescriptors(options.input)
|
|
725
|
+
const writtenPrefix = 'render-shots: wrote '
|
|
726
|
+
const writtenPngs = stderr
|
|
727
|
+
.split(/\r?\n/)
|
|
728
|
+
.filter(line => line.startsWith(writtenPrefix))
|
|
729
|
+
.map(line => basename(line.slice(writtenPrefix.length).trim()))
|
|
730
|
+
.filter(name => discoveredPngs.includes(name))
|
|
731
|
+
const sourceOrderedPngs = sourceFrames
|
|
732
|
+
.map(frame => rendererFilename(frame.id))
|
|
733
|
+
.filter(name => discoveredPngs.includes(name))
|
|
734
|
+
// Prefer canonical active-page child order. The CLI also reports files in
|
|
735
|
+
// that order, which covers document variants our lightweight JSON metadata
|
|
736
|
+
// reader does not understand. Unknown files are appended stably.
|
|
737
|
+
const pngs = [...new Set([
|
|
738
|
+
...sourceOrderedPngs,
|
|
739
|
+
...writtenPngs,
|
|
740
|
+
...discoveredPngs
|
|
741
|
+
.filter(name => !sourceOrderedPngs.includes(name) && !writtenPngs.includes(name))
|
|
742
|
+
.sort(),
|
|
743
|
+
])]
|
|
744
|
+
if (pngs.length === 0) throw new Error(`${OPENPENCIL_RENDER_TOOL_NAME}: OpenPencil produced no PNG`)
|
|
745
|
+
const sourceFrameByPng = new Map(sourceFrames.map(frame => [rendererFilename(frame.id), frame]))
|
|
746
|
+
const frames: Array<{ png: string; id?: string; name?: string; index: number }> = []
|
|
747
|
+
try {
|
|
748
|
+
for (const [index, filename] of pngs.entries()) {
|
|
749
|
+
const out = await createRenderOutput()
|
|
750
|
+
await rename(join(outDir, filename), out)
|
|
751
|
+
const sourceFrame = sourceFrameByPng.get(filename)
|
|
752
|
+
frames.push({
|
|
753
|
+
png: out,
|
|
754
|
+
...(sourceFrame?.id === undefined ? {} : { id: sourceFrame.id }),
|
|
755
|
+
...(sourceFrame?.name === undefined ? {} : { name: sourceFrame.name }),
|
|
756
|
+
index,
|
|
757
|
+
})
|
|
758
|
+
}
|
|
759
|
+
} catch (error) {
|
|
760
|
+
await Promise.all(frames.map(frame => rm(frame.png, { force: true }).catch(() => {})))
|
|
761
|
+
throw error
|
|
762
|
+
}
|
|
763
|
+
return { png: frames[0]!.png, frames, warnings: [], stdout, stderr }
|
|
764
|
+
} finally {
|
|
765
|
+
clearTimeout(timeout)
|
|
766
|
+
options.signal.removeEventListener('abort', onAbort)
|
|
767
|
+
await rm(tempRoot, { recursive: true, force: true }).catch(() => {})
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
/**
|
|
772
|
+
* Purely enrich a canonical tool-result value with a browser render grant.
|
|
773
|
+
* Returns the value unchanged when no route/artifact exists.
|
|
774
|
+
*/
|
|
775
|
+
export function projectRenderGrant(
|
|
776
|
+
value: JsonValue,
|
|
777
|
+
controller: RenderAccessController,
|
|
778
|
+
viewer?: ViewerGrant,
|
|
779
|
+
editor?: EditorGrant,
|
|
780
|
+
): JsonValue {
|
|
781
|
+
if (!controller.routeAvailable || !isRecord(value)) return value
|
|
782
|
+
if (
|
|
783
|
+
typeof value.path !== 'string'
|
|
784
|
+
|| typeof value.filename !== 'string'
|
|
785
|
+
|| typeof value.bytes !== 'number'
|
|
786
|
+
) return value
|
|
787
|
+
const result = value as unknown as RenderResult
|
|
788
|
+
const grantFor = (frame: RenderFrame, index?: number): RenderGrant => {
|
|
789
|
+
const token = controller.signArtifact({
|
|
790
|
+
kind: 'image',
|
|
791
|
+
filename: frame.filename,
|
|
792
|
+
bytes: frame.bytes,
|
|
793
|
+
sha256: frame.sha256,
|
|
794
|
+
})
|
|
795
|
+
return {
|
|
796
|
+
path: frame.path,
|
|
797
|
+
previewUrl: `${RENDER_ROUTE_PREFIX}/${token}`,
|
|
798
|
+
downloadUrl: `${RENDER_ROUTE_PREFIX}/${token}?download=1`,
|
|
799
|
+
width: frame.width,
|
|
800
|
+
height: frame.height,
|
|
801
|
+
...(frame.id === undefined ? {} : { id: frame.id }),
|
|
802
|
+
...(frame.name === undefined ? {} : { name: frame.name }),
|
|
803
|
+
...(index === undefined ? {} : { index }),
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
const projectedFrames = result.frames?.map((frame, index) => grantFor(frame, index))
|
|
807
|
+
const primaryToken = controller.sign(result)
|
|
808
|
+
const grant: RenderGrant = projectedFrames?.[0] ?? {
|
|
809
|
+
path: result.path,
|
|
810
|
+
previewUrl: `${RENDER_ROUTE_PREFIX}/${primaryToken}`,
|
|
811
|
+
downloadUrl: `${RENDER_ROUTE_PREFIX}/${primaryToken}?download=1`,
|
|
812
|
+
...(result.width === undefined ? {} : { width: result.width }),
|
|
813
|
+
...(result.height === undefined ? {} : { height: result.height }),
|
|
814
|
+
}
|
|
815
|
+
let document: DocumentGrant | undefined
|
|
816
|
+
if (result.document !== undefined) {
|
|
817
|
+
const documentToken = controller.signArtifact({
|
|
818
|
+
kind: 'document',
|
|
819
|
+
filename: result.document.filename,
|
|
820
|
+
bytes: result.document.bytes,
|
|
821
|
+
sha256: result.document.sha256,
|
|
822
|
+
})
|
|
823
|
+
const url = `${RENDER_ROUTE_PREFIX}/${documentToken}`
|
|
824
|
+
document = {
|
|
825
|
+
...result.document,
|
|
826
|
+
path: result.sourcePath ?? result.document.path,
|
|
827
|
+
url,
|
|
828
|
+
previewUrl: url,
|
|
829
|
+
downloadUrl: `${url}?download=1`,
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
const envelope = {
|
|
833
|
+
schemaVersion: projectedFrames === undefined ? 1 : 2,
|
|
834
|
+
image: grant,
|
|
835
|
+
...(projectedFrames === undefined ? {} : { frames: projectedFrames }),
|
|
836
|
+
...(document === undefined ? {} : { document }),
|
|
837
|
+
...(result.sourcePath === undefined ? {} : { sourcePath: result.sourcePath }),
|
|
838
|
+
...(result.renderer === undefined ? {} : { renderer: result.renderer }),
|
|
839
|
+
...(result.rendererBinary === undefined ? {} : { rendererBinary: result.rendererBinary }),
|
|
840
|
+
...(result.fidelity === undefined ? {} : { fidelity: result.fidelity }),
|
|
841
|
+
...(result.warnings === undefined ? {} : { warnings: result.warnings }),
|
|
842
|
+
...(viewer === undefined ? {} : { viewer }),
|
|
843
|
+
...(editor === undefined ? {} : { editor }),
|
|
844
|
+
...(editor === undefined || result.autoOpenEditor !== true ? {} : { autoOpenEditor: true }),
|
|
845
|
+
}
|
|
846
|
+
return { ...value, [PRESENTATION_META_KEY]: envelope } as unknown as JsonValue
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
/** Reserve a fresh output path inside the managed render directory. */
|
|
850
|
+
export async function createRenderOutput(): Promise<string> {
|
|
851
|
+
await mkdir(renderDir(), { recursive: true, mode: 0o700 })
|
|
852
|
+
return join(renderDir(), `render-${randomUUID()}.png`)
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
/** Stat + cap-check a freshly rendered PNG. */
|
|
856
|
+
export async function verifyRenderOutput(out: string): Promise<{
|
|
857
|
+
bytes: number
|
|
858
|
+
width: number
|
|
859
|
+
height: number
|
|
860
|
+
sha256: string
|
|
861
|
+
}> {
|
|
862
|
+
const info = await lstat(out)
|
|
863
|
+
if (info.isSymbolicLink() || !info.isFile()) throw new Error(`${OPENPENCIL_RENDER_TOOL_NAME}: renderer did not produce a regular file`)
|
|
864
|
+
if (info.size > MAX_RENDER_BYTES) throw new Error(`${OPENPENCIL_RENDER_TOOL_NAME}: rendered PNG exceeds ${MAX_RENDER_BYTES} bytes`)
|
|
865
|
+
if (info.size < 33) throw new Error(`${OPENPENCIL_RENDER_TOOL_NAME}: rendered PNG is truncated`)
|
|
866
|
+
const bytes = await readFile(out)
|
|
867
|
+
const signature = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
|
|
868
|
+
if (!bytes.subarray(0, 8).equals(signature)) throw new Error(`${OPENPENCIL_RENDER_TOOL_NAME}: renderer output is not a PNG`)
|
|
869
|
+
if (bytes.readUInt32BE(8) !== 13 || bytes.toString('ascii', 12, 16) !== 'IHDR') {
|
|
870
|
+
throw new Error(`${OPENPENCIL_RENDER_TOOL_NAME}: PNG has no valid IHDR`)
|
|
871
|
+
}
|
|
872
|
+
const width = bytes.readUInt32BE(16)
|
|
873
|
+
const height = bytes.readUInt32BE(20)
|
|
874
|
+
if (width === 0 || height === 0 || width > MAX_RENDER_DIMENSION || height > MAX_RENDER_DIMENSION) {
|
|
875
|
+
throw new Error(`${OPENPENCIL_RENDER_TOOL_NAME}: rendered PNG dimensions are invalid`)
|
|
876
|
+
}
|
|
877
|
+
if (width * height > MAX_RENDER_PIXELS) throw new Error(`${OPENPENCIL_RENDER_TOOL_NAME}: rendered PNG has too many pixels`)
|
|
878
|
+
return {
|
|
879
|
+
bytes: info.size,
|
|
880
|
+
width,
|
|
881
|
+
height,
|
|
882
|
+
sha256: createHash('sha256').update(bytes).digest('hex'),
|
|
883
|
+
}
|
|
884
|
+
}
|