@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.
Files changed (48) hide show
  1. package/apps/studio/bin/_html-playwright.mjs +198 -19
  2. package/apps/studio/bin/_pdf-playwright.mjs +1 -0
  3. package/apps/studio/bin/_png-playwright.mjs +28 -8
  4. package/apps/studio/bin/_pptx-playwright.mjs +4 -1
  5. package/apps/studio/bin/_pw-launch.mjs +48 -0
  6. package/apps/studio/bin/_svg-playwright.mjs +63 -149
  7. package/apps/studio/bin/_video-playwright.mjs +123 -11
  8. package/apps/studio/canvas-edit.ts +33 -0
  9. package/apps/studio/canvas-lib.tsx +136 -0
  10. package/apps/studio/client/app.jsx +316 -28
  11. package/apps/studio/client/export-lane.js +191 -0
  12. package/apps/studio/dist/client.bundle.js +907 -907
  13. package/apps/studio/dist/runtime/.min-sizes.json +2 -1
  14. package/apps/studio/dist/runtime/dom-to-svg.js +31 -0
  15. package/apps/studio/export-dialog.tsx +24 -19
  16. package/apps/studio/exporters/_browser-bundles.ts +30 -0
  17. package/apps/studio/exporters/_runtime.ts +31 -1
  18. package/apps/studio/exporters/capture-chrome.ts +111 -0
  19. package/apps/studio/exporters/capture-core.ts +947 -0
  20. package/apps/studio/exporters/format-scopes.ts +88 -0
  21. package/apps/studio/exporters/html.ts +3 -2
  22. package/apps/studio/exporters/jobs.ts +180 -18
  23. package/apps/studio/exporters/pdf.ts +21 -1
  24. package/apps/studio/exporters/pptx.ts +46 -11
  25. package/apps/studio/exporters/remote.ts +94 -11
  26. package/apps/studio/exporters/scope.ts +14 -3
  27. package/apps/studio/exporters/svg.ts +17 -3
  28. package/apps/studio/exporters/video.ts +75 -8
  29. package/apps/studio/http.ts +175 -7
  30. package/apps/studio/runtime-bundle.ts +6 -0
  31. package/apps/studio/test/canvas-hide-chrome.test.ts +30 -0
  32. package/apps/studio/test/capture-core.test.ts +32 -0
  33. package/apps/studio/test/export-assemble.test.ts +53 -0
  34. package/apps/studio/test/export-browser-lane.test.ts +126 -0
  35. package/apps/studio/test/export-capture-fidelity.test.ts +146 -0
  36. package/apps/studio/test/export-capture-hygiene.test.ts +675 -0
  37. package/apps/studio/test/export-e2e-lanes.test.ts +857 -0
  38. package/apps/studio/test/export-format-scope-coherence.test.ts +89 -0
  39. package/apps/studio/test/exporters/jobs.test.ts +73 -0
  40. package/apps/studio/test/exporters/scope.test.ts +33 -0
  41. package/apps/studio/test/fixtures/tiny-subset.woff2 +0 -0
  42. package/apps/studio/test/pdf-print-boxes.test.ts +33 -0
  43. package/apps/studio/test/photo-canvas-bundle.test.ts +18 -0
  44. package/apps/studio/test/workspace-containment.test.ts +8 -0
  45. package/apps/studio/whats-new.json +18 -0
  46. package/apps/studio/workspace-mode.ts +15 -2
  47. package/package.json +8 -8
  48. 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
+ }