@1agh/maude 1.0.6 → 1.0.8
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/apps/studio/bin/_html-playwright.mjs +198 -19
- package/apps/studio/bin/_pdf-playwright.mjs +1 -0
- package/apps/studio/bin/_png-playwright.mjs +28 -8
- package/apps/studio/bin/_pptx-playwright.mjs +4 -1
- package/apps/studio/bin/_pw-launch.mjs +48 -0
- package/apps/studio/bin/_svg-playwright.mjs +63 -149
- package/apps/studio/bin/_video-playwright.mjs +123 -11
- package/apps/studio/canvas-edit.ts +33 -0
- package/apps/studio/canvas-lib.tsx +136 -0
- package/apps/studio/client/app.jsx +316 -28
- package/apps/studio/client/export-lane.js +191 -0
- package/apps/studio/dist/client.bundle.js +907 -907
- package/apps/studio/dist/runtime/.min-sizes.json +2 -1
- package/apps/studio/dist/runtime/dom-to-svg.js +31 -0
- package/apps/studio/export-dialog.tsx +24 -19
- package/apps/studio/exporters/_browser-bundles.ts +30 -0
- package/apps/studio/exporters/_runtime.ts +31 -1
- package/apps/studio/exporters/capture-chrome.ts +111 -0
- package/apps/studio/exporters/capture-core.ts +947 -0
- package/apps/studio/exporters/format-scopes.ts +88 -0
- package/apps/studio/exporters/html.ts +3 -2
- package/apps/studio/exporters/jobs.ts +180 -18
- package/apps/studio/exporters/pdf.ts +21 -1
- package/apps/studio/exporters/pptx.ts +46 -11
- package/apps/studio/exporters/remote.ts +94 -11
- package/apps/studio/exporters/scope.ts +14 -3
- package/apps/studio/exporters/svg.ts +17 -3
- package/apps/studio/exporters/video.ts +75 -8
- package/apps/studio/http.ts +175 -7
- package/apps/studio/runtime-bundle.ts +6 -0
- package/apps/studio/test/canvas-hide-chrome.test.ts +30 -0
- package/apps/studio/test/capture-core.test.ts +32 -0
- package/apps/studio/test/export-assemble.test.ts +53 -0
- package/apps/studio/test/export-browser-lane.test.ts +126 -0
- package/apps/studio/test/export-capture-fidelity.test.ts +146 -0
- package/apps/studio/test/export-capture-hygiene.test.ts +675 -0
- package/apps/studio/test/export-e2e-lanes.test.ts +857 -0
- package/apps/studio/test/export-format-scope-coherence.test.ts +89 -0
- package/apps/studio/test/exporters/jobs.test.ts +73 -0
- package/apps/studio/test/exporters/scope.test.ts +33 -0
- package/apps/studio/test/fixtures/tiny-subset.woff2 +0 -0
- package/apps/studio/test/pdf-print-boxes.test.ts +33 -0
- package/apps/studio/test/photo-canvas-bundle.test.ts +18 -0
- package/apps/studio/test/workspace-containment.test.ts +8 -0
- package/apps/studio/whats-new.json +18 -0
- package/apps/studio/workspace-mode.ts +15 -2
- package/package.json +8 -8
- package/plugins/design/templates/_shell.html +2 -1
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
// export-lane.js — DDR-231 (hybrid export lanes), client half.
|
|
2
|
+
//
|
|
3
|
+
// The SINGLE definition of "can the member's own browser capture this export
|
|
4
|
+
// request", shared by the shell ExportDialog and the bridged in-canvas
|
|
5
|
+
// handler in app.jsx — and unit-tested without a DOM
|
|
6
|
+
// (test/export-browser-lane.test.ts). If the rule forks between the two entry
|
|
7
|
+
// points, one dialog silently routes through the fleet the other avoids.
|
|
8
|
+
//
|
|
9
|
+
// The rule: a workspace (any non-local lane) captures png/svg of a KNOWN
|
|
10
|
+
// active artboard in the browser — the canvas already renders there, so the
|
|
11
|
+
// capture is instant and needs no render service. Everything else (other
|
|
12
|
+
// formats, multi-artboard scopes, selection captures) continues to the jobs
|
|
13
|
+
// lane: `remote` renders it on maude-render, `none` refuses with a remedy.
|
|
14
|
+
|
|
15
|
+
/** Formats the canvas-lib export-capture bridge can produce today. */
|
|
16
|
+
export const BROWSER_CAPTURE_FORMATS = new Set(['png', 'svg']);
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Formats a workspace can serve WITHOUT the render service thanks to the
|
|
20
|
+
* browser lane — the dialog's card-blocking check on lane `none`. pptx rides
|
|
21
|
+
* the capture bridge for its PNGs and the in-cell assemble route
|
|
22
|
+
* (`/_api/export-assemble`) for composition.
|
|
23
|
+
*/
|
|
24
|
+
export const BROWSER_SERVABLE_FORMATS = new Set(['png', 'svg', 'pptx']);
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* @param {{ exportLane?: string, format?: string, scope?: string, artboardId?: unknown }} req
|
|
28
|
+
* @returns {boolean}
|
|
29
|
+
*/
|
|
30
|
+
export function browserCaptureEligible({ exportLane, format, scope, artboardId }) {
|
|
31
|
+
if (exportLane === 'local' || exportLane === undefined) return false;
|
|
32
|
+
if (BROWSER_CAPTURE_FORMATS.has(format)) return scope === 'artboard' && !!artboardId;
|
|
33
|
+
// The deck: every artboard captured as PNG in the browser, composed in-cell.
|
|
34
|
+
if (format === 'pptx') return scope === 'canvas-as-separate';
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* DDR-231 — the pptx browser lane, shared by the shell ExportDialog and the
|
|
40
|
+
* bridged in-canvas handler: capture EVERY artboard as PNG via the bridge,
|
|
41
|
+
* POST them to the in-cell assemble route, hand back the finished deck.
|
|
42
|
+
* 3× mirrors the desktop PNG-deck fallback's FALLBACK_SCALE (crisp in
|
|
43
|
+
* non-vector viewers).
|
|
44
|
+
*
|
|
45
|
+
* @param {{ capture: Function, name?: string, scale?: number, onProgress?: Function,
|
|
46
|
+
* onAssemble?: Function }} args
|
|
47
|
+
* @returns {Promise<{ filename: string, blob: Blob }>}
|
|
48
|
+
*/
|
|
49
|
+
export async function captureDeckViaBrowser({
|
|
50
|
+
capture,
|
|
51
|
+
name = 'export',
|
|
52
|
+
scale = 3,
|
|
53
|
+
onProgress,
|
|
54
|
+
onAssemble,
|
|
55
|
+
}) {
|
|
56
|
+
const items = await capture({
|
|
57
|
+
format: 'png',
|
|
58
|
+
artboardIds: null,
|
|
59
|
+
scale,
|
|
60
|
+
onProgress,
|
|
61
|
+
timeoutMs: 300_000,
|
|
62
|
+
});
|
|
63
|
+
if (!items || !items.length) throw new Error('capture returned nothing');
|
|
64
|
+
const fd = new FormData();
|
|
65
|
+
fd.set('format', 'pptx');
|
|
66
|
+
fd.set('scale', String(scale));
|
|
67
|
+
fd.set('name', name);
|
|
68
|
+
for (const it of items) fd.append('image', it.blob, it.name);
|
|
69
|
+
// Composition is a distinct, non-instant phase — tell the caller so the
|
|
70
|
+
// status stops claiming the capture is still running.
|
|
71
|
+
onAssemble?.();
|
|
72
|
+
const r = await fetch('/_api/export-assemble', { method: 'POST', body: fd });
|
|
73
|
+
if (!r.ok) throw new Error((await r.text()) || `deck assembly failed (${r.status})`);
|
|
74
|
+
const disp = r.headers.get('content-disposition') || '';
|
|
75
|
+
const filename = /filename="([^"]+)"/.exec(disp)?.[1] || `${name}.pptx`;
|
|
76
|
+
return { filename, blob: await r.blob() };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const SAFE_CAPTURE_MIME = { png: 'image/png', svg: 'image/svg+xml' };
|
|
80
|
+
const PNG_MAGIC = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
|
|
81
|
+
// A forged reply can't be allowed to drive an unbounded number of downloads or
|
|
82
|
+
// buffer an arbitrary blob into the member's browser (DDR-231 adversarial pass,
|
|
83
|
+
// F1 caps). A png/svg export targets ONE artboard, so these are generous.
|
|
84
|
+
export const MAX_CAPTURE_ITEMS = 64;
|
|
85
|
+
export const MAX_CAPTURE_TOTAL_BYTES = 96 * 1024 * 1024;
|
|
86
|
+
|
|
87
|
+
/** Safe download basename for a captured item: path-stripped, charset-limited,
|
|
88
|
+
* with the requested format's extension forced on. */
|
|
89
|
+
function safeCaptureName(name, format) {
|
|
90
|
+
const ext = format === 'svg' ? 'svg' : 'png';
|
|
91
|
+
const raw = typeof name === 'string' ? name : '';
|
|
92
|
+
let base = raw
|
|
93
|
+
.replace(/^.*[\\/]/, '') // drop any path
|
|
94
|
+
.replace(/[^A-Za-z0-9._-]/g, '_')
|
|
95
|
+
.replace(new RegExp(`\\.${ext}$`, 'i'), '');
|
|
96
|
+
if (!base || /^\.+$/.test(base)) base = 'artboard';
|
|
97
|
+
return `${base.slice(0, 200)}.${ext}`;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** True iff `bytes` actually begins with the requested format's signature —
|
|
101
|
+
* PNG 8-byte magic, or SVG/XML text for svg. */
|
|
102
|
+
function magicMatches(bytes, format) {
|
|
103
|
+
if (format === 'png') return PNG_MAGIC.every((b, i) => bytes[i] === b);
|
|
104
|
+
// svg: allow a leading BOM/whitespace, then `<svg` or `<?xml`.
|
|
105
|
+
const head = new TextDecoder('utf-8', { fatal: false })
|
|
106
|
+
.decode(bytes.slice(0, 64))
|
|
107
|
+
.replace(/^/, '')
|
|
108
|
+
.trimStart()
|
|
109
|
+
.toLowerCase();
|
|
110
|
+
return head.startsWith('<svg') || head.startsWith('<?xml');
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* DDR-231 security (adversarial pass, F1) — the capture bridge lives in the
|
|
115
|
+
* CANVAS iframe, and tenant-authored TSX executes in that same window, so
|
|
116
|
+
* tenant code can FORGE an `export-capture-done` reply carrying arbitrary bytes
|
|
117
|
+
* + name that passes the shell's `e.source` check. Before the shell writes any
|
|
118
|
+
* of it to disk, treat the whole reply as untrusted DATA:
|
|
119
|
+
* - cap the item count and total bytes (a forged reply must not drive 1000
|
|
120
|
+
* downloads or buffer a giant blob);
|
|
121
|
+
* - for each item, SNIFF the leading magic bytes and reject anything that
|
|
122
|
+
* isn't actually the requested format (an HTML/exe payload disguised as a
|
|
123
|
+
* `.png` is dropped, not merely relabelled);
|
|
124
|
+
* - derive the filename from the request, not the reply, and force the
|
|
125
|
+
* format's extension + MIME.
|
|
126
|
+
*
|
|
127
|
+
* @param {Array<{ name?: string, blob: Blob }>} items
|
|
128
|
+
* @param {string} format 'png' | 'svg'
|
|
129
|
+
* @returns {Promise<Array<{ name: string, blob: Blob }>>}
|
|
130
|
+
*/
|
|
131
|
+
export async function sanitizeCapturedItems(items, format) {
|
|
132
|
+
if (!Array.isArray(items) || !items.length) throw new Error('capture returned nothing');
|
|
133
|
+
if (items.length > MAX_CAPTURE_ITEMS)
|
|
134
|
+
throw new Error(`capture returned too many items (${items.length})`);
|
|
135
|
+
const mime = SAFE_CAPTURE_MIME[format] || 'application/octet-stream';
|
|
136
|
+
const out = [];
|
|
137
|
+
let total = 0;
|
|
138
|
+
for (const item of items) {
|
|
139
|
+
if (!(item?.blob instanceof Blob)) throw new Error('capture item is not a blob');
|
|
140
|
+
total += item.blob.size;
|
|
141
|
+
if (total > MAX_CAPTURE_TOTAL_BYTES) throw new Error('capture payload too large');
|
|
142
|
+
const head = new Uint8Array(await item.blob.slice(0, 64).arrayBuffer());
|
|
143
|
+
if (!magicMatches(head, format))
|
|
144
|
+
throw new Error(`capture item is not a valid ${format} — refusing to download`);
|
|
145
|
+
out.push({
|
|
146
|
+
name: safeCaptureName(item.name, format),
|
|
147
|
+
// Re-wrap so the download Content-Type is the format's MIME regardless of
|
|
148
|
+
// what the (untrusted) reply claimed.
|
|
149
|
+
blob: new Blob([item.blob], { type: mime }),
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
return out;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Fold the dialog's resolution options (scale multiplier OR physical dpi —
|
|
157
|
+
* dpi wins, mirroring exporters/png.ts resolveDeviceScale) into the capture
|
|
158
|
+
* bridge's deviceScale, clamped to the raster guard's 1–8 window
|
|
159
|
+
* (capture-core.ts assertRasterSizeOk enforces the byte/side ceilings).
|
|
160
|
+
*
|
|
161
|
+
* @param {{ dpi?: number, scale?: number }} [options]
|
|
162
|
+
* @returns {number}
|
|
163
|
+
*/
|
|
164
|
+
export function captureScale(options = {}) {
|
|
165
|
+
return Math.max(1, Math.min(8, options.dpi ? options.dpi / 96 : options.scale || 1));
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Record a browser-lane export in the cell's ledger so it shows up in the
|
|
170
|
+
* Recent tab and the notification center like every other export.
|
|
171
|
+
*
|
|
172
|
+
* DDR-231 Phase 2 T6: the browser lane produces the file in the member's own
|
|
173
|
+
* browser, so there is no job to complete and nothing wrote a history row —
|
|
174
|
+
* the reported symptom was "the modal just closes and I see nothing in the
|
|
175
|
+
* export dialog". Best-effort by design: the member already HAS the file, so a
|
|
176
|
+
* failed ledger write must never surface as a failed export.
|
|
177
|
+
*
|
|
178
|
+
* @param {{ format: string, scope: string, filename: string }} entry
|
|
179
|
+
* @returns {Promise<void>}
|
|
180
|
+
*/
|
|
181
|
+
export async function recordBrowserExport(entry) {
|
|
182
|
+
try {
|
|
183
|
+
await fetch('/_api/export-history', {
|
|
184
|
+
method: 'POST',
|
|
185
|
+
headers: { 'content-type': 'application/json' },
|
|
186
|
+
body: JSON.stringify(entry),
|
|
187
|
+
});
|
|
188
|
+
} catch {
|
|
189
|
+
/* the file is already saved — the ledger is a convenience, not the export */
|
|
190
|
+
}
|
|
191
|
+
}
|