@1agh/maude 1.0.7 → 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 +23 -6
- 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 +45 -12
- package/apps/studio/bin/_video-playwright.mjs +123 -11
- package/apps/studio/canvas-edit.ts +33 -0
- package/apps/studio/canvas-lib.tsx +7 -0
- package/apps/studio/client/app.jsx +84 -23
- package/apps/studio/client/export-lane.js +37 -2
- package/apps/studio/dist/client.bundle.js +601 -601
- package/apps/studio/export-dialog.tsx +24 -19
- package/apps/studio/exporters/_runtime.ts +31 -1
- package/apps/studio/exporters/capture-chrome.ts +111 -0
- package/apps/studio/exporters/capture-core.ts +659 -3
- 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/remote.ts +73 -4
- package/apps/studio/exporters/scope.ts +14 -3
- package/apps/studio/exporters/video.ts +75 -8
- package/apps/studio/http.ts +74 -7
- package/apps/studio/test/canvas-hide-chrome.test.ts +30 -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/whats-new.json +9 -0
- package/package.json +8 -8
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
// _html-playwright.mjs — playwright shim for the HTML exporter.
|
|
2
2
|
//
|
|
3
|
-
// Walks the rendered DOM, serializes the
|
|
4
|
-
// inlined
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
3
|
+
// Walks the rendered DOM, serializes the artboard with every stylesheet
|
|
4
|
+
// inlined and EVERY resource embedded as a data: URI (images, fonts, CSS
|
|
5
|
+
// url()s, out-of-artboard SVG defs), and emits a self-contained `index.html`
|
|
6
|
+
// that opens under file:// with nothing else around. v1 referenced assets by
|
|
7
|
+
// absolute URL against a `<base>` — dead the moment the serving origin went
|
|
8
|
+
// away, which on the render worker is the end of the job (DDR-232 follow-up).
|
|
8
9
|
//
|
|
9
10
|
// Invocation (Bun.spawn from exporters/html.ts — not invoked directly):
|
|
10
11
|
// npm exec --package=playwright -- node _html-playwright.mjs \
|
|
@@ -13,7 +14,7 @@
|
|
|
13
14
|
|
|
14
15
|
import { mkdirSync, writeFileSync } from 'node:fs';
|
|
15
16
|
import { dirname, join } from 'node:path';
|
|
16
|
-
import { launchChromium } from './_pw-launch.mjs';
|
|
17
|
+
import { launchChromium, safeArtboardFilename } from './_pw-launch.mjs';
|
|
17
18
|
|
|
18
19
|
const args = Object.fromEntries(
|
|
19
20
|
process.argv.slice(2).reduce((acc, cur, i, all) => {
|
|
@@ -43,7 +44,10 @@ const timeoutMs = Number(timeout) * 1000;
|
|
|
43
44
|
|
|
44
45
|
const browser = await launchChromium();
|
|
45
46
|
try {
|
|
46
|
-
const ctx = await browser.newContext({
|
|
47
|
+
const ctx = await browser.newContext({
|
|
48
|
+
viewport: { width: 1440, height: 900 },
|
|
49
|
+
serviceWorkers: 'block',
|
|
50
|
+
});
|
|
47
51
|
const page = await ctx.newPage();
|
|
48
52
|
await page.goto(url, { waitUntil: 'load', timeout: timeoutMs });
|
|
49
53
|
await page.evaluate(() => document.fonts.ready);
|
|
@@ -100,7 +104,10 @@ try {
|
|
|
100
104
|
const restore = await pinArtboard(handle);
|
|
101
105
|
const html = await serializeOne(handle, false);
|
|
102
106
|
await restore();
|
|
103
|
-
|
|
107
|
+
// Tenant-authored id (untrusted, DDR-054) — sanitize before it becomes a
|
|
108
|
+
// path segment, same as the png shim (security review F1: arbitrary-path
|
|
109
|
+
// write of attacker bytes via a `../`-laden data-dc-screen).
|
|
110
|
+
const target = join(outDir, safeArtboardFilename(id, i, 'html'));
|
|
104
111
|
writeFileSync(target, html, 'utf8');
|
|
105
112
|
written.push(target);
|
|
106
113
|
console.log(`MAUDE_PROGRESS {"current":${i + 1},"total":${screens.length}}`);
|
|
@@ -128,30 +135,202 @@ try {
|
|
|
128
135
|
|
|
129
136
|
async function serializeOne(locator, widenToArtboard) {
|
|
130
137
|
return await locator.evaluate(
|
|
131
|
-
(el, opts) => {
|
|
138
|
+
async (el, opts) => {
|
|
132
139
|
const target = opts.widenToArtboard ? (el.closest('[data-dc-screen]') ?? el) : el;
|
|
140
|
+
// Work on a CLONE — the live canvas must not be mutated (the member may
|
|
141
|
+
// be looking at it on the browser lane's sibling host, and the worker
|
|
142
|
+
// captures several artboards from one page).
|
|
143
|
+
const clone = target.cloneNode(true);
|
|
144
|
+
|
|
145
|
+
// Editor chrome never ships (the artboard's own title bar is a child of
|
|
146
|
+
// the artboard — DDR-232 §2). capture-core exposes the one shared list.
|
|
147
|
+
const hidden = window.__maudeCaptureCore?.CAPTURE_HIDDEN_SELECTORS ?? [];
|
|
148
|
+
for (const sel of hidden) {
|
|
149
|
+
if (sel.startsWith('#')) continue;
|
|
150
|
+
for (const n of Array.from(clone.querySelectorAll(sel))) n.remove();
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// ── resource inlining ────────────────────────────────────────────────
|
|
154
|
+
// Until now every src/url() stayed RELATIVE and the doc carried
|
|
155
|
+
// `<base href=<this origin>>`. On the desktop that origin is the dev
|
|
156
|
+
// server (alive only while Maude runs); on the render worker it is the
|
|
157
|
+
// job's THROWAWAY token-proxy loopback, dead the moment the job ends —
|
|
158
|
+
// so a cloud HTML export had no assets at all (DDR-232 follow-up). The
|
|
159
|
+
// file is now self-contained: same-origin fetch → data: URI.
|
|
160
|
+
const cache = new Map();
|
|
161
|
+
const toDataUrl = async (raw) => {
|
|
162
|
+
if (!raw || /^(data:|blob:|#)/.test(raw)) return null;
|
|
163
|
+
let abs;
|
|
164
|
+
try {
|
|
165
|
+
abs = new URL(raw, document.baseURI).href;
|
|
166
|
+
} catch {
|
|
167
|
+
return null;
|
|
168
|
+
}
|
|
169
|
+
if (cache.has(abs)) return cache.get(abs);
|
|
170
|
+
let out = null;
|
|
171
|
+
try {
|
|
172
|
+
const r = await fetch(abs);
|
|
173
|
+
if (r.ok) {
|
|
174
|
+
const blob = await r.blob();
|
|
175
|
+
out = await new Promise((resolve) => {
|
|
176
|
+
const fr = new FileReader();
|
|
177
|
+
fr.onload = () => resolve(String(fr.result));
|
|
178
|
+
fr.onerror = () => resolve(null);
|
|
179
|
+
fr.readAsDataURL(blob);
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
} catch {
|
|
183
|
+
out = null;
|
|
184
|
+
}
|
|
185
|
+
cache.set(abs, out);
|
|
186
|
+
return out;
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
// <img> — bake a CSS `filter` into the bitmap (a duotone/posterize via an
|
|
190
|
+
// in-page SVG filter), the same rule the SVG/PNG spine applies; an
|
|
191
|
+
// unfiltered image keeps its original bytes.
|
|
192
|
+
const liveImgs = Array.from(target.querySelectorAll('img'));
|
|
193
|
+
const cloneImgs = Array.from(clone.querySelectorAll('img'));
|
|
194
|
+
for (let i = 0; i < cloneImgs.length; i += 1) {
|
|
195
|
+
const c = cloneImgs[i];
|
|
196
|
+
const live = liveImgs[i];
|
|
197
|
+
let data = null;
|
|
198
|
+
const filter = live ? getComputedStyle(live).filter : 'none';
|
|
199
|
+
if (live && filter && filter !== 'none' && live.naturalWidth && live.naturalHeight) {
|
|
200
|
+
try {
|
|
201
|
+
const cv = document.createElement('canvas');
|
|
202
|
+
cv.width = live.naturalWidth;
|
|
203
|
+
cv.height = live.naturalHeight;
|
|
204
|
+
const g = cv.getContext('2d');
|
|
205
|
+
if (g) {
|
|
206
|
+
g.filter = filter;
|
|
207
|
+
if (g.filter === filter) {
|
|
208
|
+
g.drawImage(live, 0, 0);
|
|
209
|
+
data = cv.toDataURL('image/png');
|
|
210
|
+
c.style.filter = 'none';
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
} catch {
|
|
214
|
+
data = null;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
if (!data) data = await toDataUrl(c.getAttribute('src'));
|
|
218
|
+
if (data) {
|
|
219
|
+
c.setAttribute('src', data);
|
|
220
|
+
c.removeAttribute('srcset');
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
for (const n of Array.from(clone.querySelectorAll('[poster]'))) {
|
|
224
|
+
const d = await toDataUrl(n.getAttribute('poster'));
|
|
225
|
+
if (d) n.setAttribute('poster', d);
|
|
226
|
+
}
|
|
227
|
+
for (const n of Array.from(clone.querySelectorAll('image'))) {
|
|
228
|
+
const href = n.getAttribute('href') ?? n.getAttribute('xlink:href');
|
|
229
|
+
const d = await toDataUrl(href);
|
|
230
|
+
if (d) {
|
|
231
|
+
n.setAttribute('href', d);
|
|
232
|
+
n.setAttribute('xlink:href', d);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// Neutralize executable markup before serializing. The artboard is
|
|
237
|
+
// TENANT-authored (untrusted, DDR-054) and this file is opened LOCALLY by
|
|
238
|
+
// a member (file:// origin) — so a `<script>`, an `onerror=`, a
|
|
239
|
+
// `javascript:` href or a `<foreignObject>` in the canvas would run as
|
|
240
|
+
// the member on open (security review F1). Strip them from anything we
|
|
241
|
+
// serialize; a `script-src 'none'` CSP meta (below) is the belt to this
|
|
242
|
+
// braces. The clone is a throwaway — the live canvas is untouched.
|
|
243
|
+
const neutralize = (rootEl) => {
|
|
244
|
+
for (const el of Array.from(rootEl.querySelectorAll('script,foreignObject'))) el.remove();
|
|
245
|
+
const walk = rootEl.querySelectorAll('*');
|
|
246
|
+
for (const el of [rootEl, ...Array.from(walk)]) {
|
|
247
|
+
for (const attr of Array.from(el.attributes ?? [])) {
|
|
248
|
+
const name = attr.name.toLowerCase();
|
|
249
|
+
if (name.startsWith('on')) {
|
|
250
|
+
el.removeAttribute(attr.name);
|
|
251
|
+
continue;
|
|
252
|
+
}
|
|
253
|
+
if (
|
|
254
|
+
(name === 'href' || name === 'xlink:href' || name === 'src') &&
|
|
255
|
+
/^\s*javascript:/i.test(attr.value)
|
|
256
|
+
) {
|
|
257
|
+
el.removeAttribute(attr.name);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
};
|
|
262
|
+
neutralize(clone);
|
|
263
|
+
|
|
264
|
+
// SVG defs the artboard references but does not CONTAIN — a design
|
|
265
|
+
// system typically mounts its <filter>/<linearGradient>/<pattern> once
|
|
266
|
+
// per canvas, outside any artboard. `outerHTML` of the artboard alone
|
|
267
|
+
// silently dropped them ("the image is still missing some filter").
|
|
268
|
+
const defsHtml = [];
|
|
269
|
+
for (const d of Array.from(
|
|
270
|
+
document.querySelectorAll(
|
|
271
|
+
'filter,linearGradient,radialGradient,pattern,clipPath,mask,symbol'
|
|
272
|
+
)
|
|
273
|
+
)) {
|
|
274
|
+
if (target.contains(d)) continue;
|
|
275
|
+
// A def can equally smuggle script (a <symbol>/<mask> wrapping a
|
|
276
|
+
// <foreignObject>) — clone + neutralize before taking its markup.
|
|
277
|
+
const dc = d.cloneNode(true);
|
|
278
|
+
neutralize(dc);
|
|
279
|
+
defsHtml.push(dc.outerHTML);
|
|
280
|
+
}
|
|
281
|
+
const defsBlock = defsHtml.length
|
|
282
|
+
? `<svg width="0" height="0" style="position:absolute" aria-hidden="true"><defs>${defsHtml.join('')}</defs></svg>`
|
|
283
|
+
: '';
|
|
284
|
+
|
|
285
|
+
// Stylesheets — every rule, every url() inlined (fonts, background images).
|
|
133
286
|
const cssChunks = [];
|
|
134
287
|
for (const sheet of Array.from(document.styleSheets)) {
|
|
135
288
|
try {
|
|
136
|
-
for (const rule of Array.from(sheet.cssRules))
|
|
137
|
-
cssChunks.push(rule.cssText);
|
|
138
|
-
}
|
|
289
|
+
for (const rule of Array.from(sheet.cssRules)) cssChunks.push(rule.cssText);
|
|
139
290
|
} catch {
|
|
140
|
-
|
|
291
|
+
/* cross-origin sheet — skip */
|
|
141
292
|
}
|
|
142
293
|
}
|
|
143
|
-
|
|
144
|
-
const
|
|
294
|
+
let css = cssChunks.join('\n');
|
|
295
|
+
const urls = new Set();
|
|
296
|
+
for (const m of css.matchAll(/url\(\s*(['"]?)([^)'"]+)\1\s*\)/g)) urls.add(m[2]);
|
|
297
|
+
for (const u of urls) {
|
|
298
|
+
const d = await toDataUrl(u);
|
|
299
|
+
if (d) css = css.split(u).join(d);
|
|
300
|
+
}
|
|
301
|
+
// BOUNDARY (security review F5): any `url(http(s)://…)` still here is a
|
|
302
|
+
// resource we could NOT inline (blocked/failed) — a tenant-authored
|
|
303
|
+
// beacon that would fire when a DIFFERENT user opens the file. Neutralize
|
|
304
|
+
// it to `url(about:blank)` rather than leave the phone-home in the CSS.
|
|
305
|
+
css = css.replace(/url\(\s*(['"]?)(https?:[^)'"]+)\1\s*\)/gi, 'url(about:blank)');
|
|
306
|
+
// Same for any element `src`/`href` the img/image loops left remote.
|
|
307
|
+
for (const el of Array.from(clone.querySelectorAll('[src],[href],[xlink\\:href]'))) {
|
|
308
|
+
for (const a of ['src', 'href', 'xlink:href']) {
|
|
309
|
+
const v = el.getAttribute(a);
|
|
310
|
+
if (v && /^\s*https?:/i.test(v)) el.removeAttribute(a);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
const escapeHtml = (s) =>
|
|
315
|
+
String(s).replace(
|
|
316
|
+
/[&<>"']/g,
|
|
317
|
+
(c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]
|
|
318
|
+
);
|
|
319
|
+
|
|
320
|
+
// `script-src 'none'` is the belt to `neutralize()`'s braces — even if a
|
|
321
|
+
// sink is missed, the opened artifact executes no script. `object-src`
|
|
322
|
+
// blocks plugin embeds; the design still renders (it is static markup +
|
|
323
|
+
// inlined CSS/images). `document.title` is tenant-influenced — escape it.
|
|
145
324
|
return `<!doctype html>
|
|
146
325
|
<html lang="en">
|
|
147
326
|
<head>
|
|
148
327
|
<meta charset="utf-8" />
|
|
328
|
+
<meta http-equiv="Content-Security-Policy" content="script-src 'none'; object-src 'none'; base-uri 'none'" />
|
|
149
329
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
150
|
-
<title>${document.title || 'Maude export'}</title>
|
|
151
|
-
<
|
|
152
|
-
${styleBlock}
|
|
330
|
+
<title>${escapeHtml(document.title || 'Maude export')}</title>
|
|
331
|
+
<style>${css}</style>
|
|
153
332
|
</head>
|
|
154
|
-
<body>${
|
|
333
|
+
<body>${defsBlock}${clone.outerHTML}</body>
|
|
155
334
|
</html>`;
|
|
156
335
|
},
|
|
157
336
|
{ widenToArtboard }
|
|
@@ -64,6 +64,7 @@ const deviceScaleFactor = Math.max(1, Math.min(8, Number(scale) || 1));
|
|
|
64
64
|
const browser = await launchChromium();
|
|
65
65
|
try {
|
|
66
66
|
const ctx = await browser.newContext({
|
|
67
|
+
serviceWorkers: 'block',
|
|
67
68
|
viewport: { width: 1440, height: 900 },
|
|
68
69
|
deviceScaleFactor,
|
|
69
70
|
});
|
|
@@ -73,6 +73,7 @@ try {
|
|
|
73
73
|
// 1440x900 matches the canvas viewport the design tool uses everywhere;
|
|
74
74
|
// exporters resize per-target before each shot to fit the artboard exactly.
|
|
75
75
|
const ctx = await browser.newContext({
|
|
76
|
+
serviceWorkers: 'block',
|
|
76
77
|
viewport: { width: 1440, height: 900 },
|
|
77
78
|
deviceScaleFactor,
|
|
78
79
|
});
|
|
@@ -157,22 +158,38 @@ try {
|
|
|
157
158
|
width: Math.max(1, Math.ceil(rect.width)),
|
|
158
159
|
height: Math.max(1, Math.ceil(rect.height)),
|
|
159
160
|
});
|
|
161
|
+
// Anchor the CAPTURED ELEMENT at (0,0), not just its artboard. For a
|
|
162
|
+
// selection capture the element sits at an offset INSIDE the pinned
|
|
163
|
+
// artboard (padding, layout), while the viewport was just sized to the
|
|
164
|
+
// ELEMENT — so a clip at the element's rect ran past the viewport edge
|
|
165
|
+
// and Playwright refused with "Clipped area is either empty or outside
|
|
166
|
+
// the resulting image". The canvas page doesn't scroll (the world plane
|
|
167
|
+
// owns overflow), so the old scrollIntoView was a no-op there; shifting
|
|
168
|
+
// the pinned artboard by the element's offset is deterministic.
|
|
160
169
|
await widenedHandle.evaluate((el) => {
|
|
161
|
-
el.
|
|
170
|
+
const r = el.getBoundingClientRect();
|
|
171
|
+
const ab = el.closest('[data-dc-screen]') ?? el;
|
|
172
|
+
if (r.left !== 0 || r.top !== 0) {
|
|
173
|
+
ab.style.left = `${parseFloat(ab.style.left || '0') - r.left}px`;
|
|
174
|
+
ab.style.top = `${parseFloat(ab.style.top || '0') - r.top}px`;
|
|
175
|
+
}
|
|
162
176
|
window.scrollTo(0, 0);
|
|
163
177
|
});
|
|
164
|
-
// After scroll, recompute rect — it's now anchored near (0,0).
|
|
165
178
|
const finalRect = await widenedHandle.evaluate((el) => {
|
|
166
179
|
const r = el.getBoundingClientRect();
|
|
167
180
|
return { x: r.left, y: r.top, width: r.width, height: r.height };
|
|
168
181
|
});
|
|
182
|
+
// Clamp to the viewport — a clip must never extend past it.
|
|
183
|
+
const vp = page.viewportSize();
|
|
184
|
+
const clipX = Math.min(Math.max(0, Math.floor(finalRect.x)), Math.max(0, vp.width - 1));
|
|
185
|
+
const clipY = Math.min(Math.max(0, Math.floor(finalRect.y)), Math.max(0, vp.height - 1));
|
|
169
186
|
await page.screenshot({
|
|
170
187
|
path: target,
|
|
171
188
|
clip: {
|
|
172
|
-
x:
|
|
173
|
-
y:
|
|
174
|
-
width: Math.max(1, Math.ceil(finalRect.width)),
|
|
175
|
-
height: Math.max(1, Math.ceil(finalRect.height)),
|
|
189
|
+
x: clipX,
|
|
190
|
+
y: clipY,
|
|
191
|
+
width: Math.max(1, Math.min(Math.ceil(finalRect.width), vp.width - clipX)),
|
|
192
|
+
height: Math.max(1, Math.min(Math.ceil(finalRect.height), vp.height - clipY)),
|
|
176
193
|
},
|
|
177
194
|
});
|
|
178
195
|
written.push(target);
|
|
@@ -36,7 +36,10 @@ mkdirSync(dirname(out), { recursive: true });
|
|
|
36
36
|
|
|
37
37
|
const browser = await launchChromium();
|
|
38
38
|
try {
|
|
39
|
-
const ctx = await browser.newContext({
|
|
39
|
+
const ctx = await browser.newContext({
|
|
40
|
+
viewport: { width: 1440, height: 900 },
|
|
41
|
+
serviceWorkers: 'block',
|
|
42
|
+
});
|
|
40
43
|
const page = await ctx.newPage();
|
|
41
44
|
await page.goto(url, { waitUntil: 'networkidle', timeout: timeoutMs });
|
|
42
45
|
await page.evaluate(() => document.fonts.ready);
|
|
@@ -165,3 +165,51 @@ export function assertRenderOutputSizeOk(widthCss, heightCss, deviceScaleFactor,
|
|
|
165
165
|
process.exit(1);
|
|
166
166
|
}
|
|
167
167
|
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Inject a JS bundle into a page whose CSP refuses inline scripts.
|
|
171
|
+
*
|
|
172
|
+
* `page.addScriptTag({ content | path })` writes an INLINE `<script>`, which the
|
|
173
|
+
* canvas origin's shell CSP (`script-src 'self' 'sha256-…'`) refuses outright —
|
|
174
|
+
* and the render worker (DDR-230) always loads the canvas from that origin, so
|
|
175
|
+
* every shim that injected this way failed there (worker-lane SVG, then mp4 /
|
|
176
|
+
* webm / gif: "Executing inline script violates the following Content Security
|
|
177
|
+
* Policy directive"). See DDR-232 §3.
|
|
178
|
+
*
|
|
179
|
+
* This serves the bundle at a SAME-ORIGIN virtual URL through Playwright
|
|
180
|
+
* request interception and adds it as `<script src>`. CSP is enforced on the
|
|
181
|
+
* script's URL origin, which is `'self'`, so it passes — and because the tag is
|
|
182
|
+
* a real module script in the document, bare specifiers still resolve through
|
|
183
|
+
* the shell's importmap (the render lib NEEDS that: it externalizes `remotion`
|
|
184
|
+
* to share the page's instance, DDR-148). Measured, not assumed: a route-
|
|
185
|
+
* fulfilled module under the exact CSP shape resolves an importmap specifier;
|
|
186
|
+
* `addInitScript` cannot (it runs before the importmap exists).
|
|
187
|
+
*
|
|
188
|
+
* No CSP is relaxed. On the main origin (desktop), where the CSP is env-gated
|
|
189
|
+
* off, this is simply equivalent to the inline injection it replaces.
|
|
190
|
+
*
|
|
191
|
+
* @param {import('playwright').Page} page
|
|
192
|
+
* @param {{ content: string, type?: 'module' | undefined, name?: string, timeout?: number }} opts
|
|
193
|
+
*/
|
|
194
|
+
let injectedSeq = 0;
|
|
195
|
+
export async function addScriptCspSafe(page, { content, type, name = 'lib', timeout }) {
|
|
196
|
+
const origin = new URL(page.url()).origin;
|
|
197
|
+
injectedSeq += 1;
|
|
198
|
+
const url = `${origin}/__maude-inject/${injectedSeq}-${name}.js`;
|
|
199
|
+
// `page.route` is per-URL; route each bundle exactly once and unroute after so
|
|
200
|
+
// a long-lived page (the frame-step loop) carries no stale handlers.
|
|
201
|
+
await page.route(url, (route) =>
|
|
202
|
+
route.fulfill({
|
|
203
|
+
status: 200,
|
|
204
|
+
contentType: 'text/javascript; charset=utf-8',
|
|
205
|
+
headers: { 'cache-control': 'no-store' },
|
|
206
|
+
body: content,
|
|
207
|
+
})
|
|
208
|
+
);
|
|
209
|
+
try {
|
|
210
|
+
await page.addScriptTag({ url, ...(type ? { type } : {}) });
|
|
211
|
+
} finally {
|
|
212
|
+
await page.unroute(url).catch(() => {});
|
|
213
|
+
}
|
|
214
|
+
void timeout;
|
|
215
|
+
}
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
|
|
19
19
|
import { mkdirSync, writeFileSync } from 'node:fs';
|
|
20
20
|
import { dirname, join } from 'node:path';
|
|
21
|
-
import { launchChromium } from './_pw-launch.mjs';
|
|
21
|
+
import { launchChromium, safeArtboardFilename } from './_pw-launch.mjs';
|
|
22
22
|
|
|
23
23
|
const args = Object.fromEntries(
|
|
24
24
|
process.argv.slice(2).reduce((acc, cur, i, all) => {
|
|
@@ -53,7 +53,10 @@ const timeoutMs = Number(timeout) * 1000;
|
|
|
53
53
|
|
|
54
54
|
const browser = await launchChromium();
|
|
55
55
|
try {
|
|
56
|
-
const ctx = await browser.newContext({
|
|
56
|
+
const ctx = await browser.newContext({
|
|
57
|
+
viewport: { width: 1440, height: 900 },
|
|
58
|
+
serviceWorkers: 'block',
|
|
59
|
+
});
|
|
57
60
|
const page = await ctx.newPage();
|
|
58
61
|
await page.goto(url, { waitUntil: 'load', timeout: timeoutMs });
|
|
59
62
|
await page.evaluate(() => document.fonts.ready);
|
|
@@ -73,15 +76,41 @@ try {
|
|
|
73
76
|
world.style.transform = 'none';
|
|
74
77
|
}
|
|
75
78
|
});
|
|
76
|
-
//
|
|
77
|
-
//
|
|
78
|
-
// the
|
|
79
|
-
//
|
|
80
|
-
//
|
|
81
|
-
//
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
79
|
+
// Get dom-to-svg (`window.domToSvg`) and the SHARED capture spine
|
|
80
|
+
// (`window.__maudeCaptureCore`) into the page. The core is the SAME source
|
|
81
|
+
// the canvas runtime's export-capture bridge imports (exporters/
|
|
82
|
+
// capture-core.ts, DDR-231 single-spine): the serializer logic must never be
|
|
83
|
+
// re-inlined here — it forked once and the browser lane would silently drift
|
|
84
|
+
// from this shim.
|
|
85
|
+
//
|
|
86
|
+
// PREFER WHAT THE PAGE ALREADY HAS (DDR-231 Phase 2 T4). A real canvas
|
|
87
|
+
// bundles capture-core through canvas-lib, so `__maudeCaptureCore` is
|
|
88
|
+
// already set, and `dom-to-svg` resolves through the shell's importmap.
|
|
89
|
+
// `addScriptTag({path})` injects an INLINE script, which the canvas origin's
|
|
90
|
+
// strict shell CSP (`script-src 'self' 'sha256-…'`) refuses outright — so on
|
|
91
|
+
// the render-worker lane, where the canvas is ALWAYS loaded from the canvas
|
|
92
|
+
// origin, every SVG export died with "Executing inline script violates the
|
|
93
|
+
// following Content Security Policy directive". Reaching for the in-page
|
|
94
|
+
// copy first fixes that lane without relaxing anyone's CSP, and makes the
|
|
95
|
+
// worker run literally the same code the member's browser runs.
|
|
96
|
+
const reuse = await page.evaluate(async () => {
|
|
97
|
+
const w = window;
|
|
98
|
+
if (!w.domToSvg) {
|
|
99
|
+
try {
|
|
100
|
+
w.domToSvg = await import('dom-to-svg');
|
|
101
|
+
} catch {
|
|
102
|
+
/* no importmap on this page — the injection fallback below covers it */
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return !!w.__maudeCaptureCore && !!w.domToSvg;
|
|
106
|
+
});
|
|
107
|
+
if (!reuse) {
|
|
108
|
+
await page.addScriptTag({ path: bundlePath });
|
|
109
|
+
await page.addScriptTag({ path: corePath, type: 'module' });
|
|
110
|
+
}
|
|
111
|
+
await page.waitForFunction(() => !!window.__maudeCaptureCore && !!window.domToSvg, {
|
|
112
|
+
timeout: timeoutMs,
|
|
113
|
+
});
|
|
85
114
|
|
|
86
115
|
const written = [];
|
|
87
116
|
|
|
@@ -130,7 +159,11 @@ try {
|
|
|
130
159
|
// Each multi handle is already a `[data-dc-screen]` artboard — no widen.
|
|
131
160
|
const svg = await serializeOne(handle, false);
|
|
132
161
|
await restore();
|
|
133
|
-
|
|
162
|
+
// `id` is the tenant-authored `data-dc-screen` (untrusted, DDR-054) — a
|
|
163
|
+
// `../`-laden id in `join(outDir, `${id}.svg`)` is an arbitrary-path
|
|
164
|
+
// write of attacker bytes (security review F1). The png shim already
|
|
165
|
+
// routes through this; the svg/html siblings did not.
|
|
166
|
+
const target = join(outDir, safeArtboardFilename(id, i, 'svg'));
|
|
134
167
|
writeFileSync(target, svg, 'utf8');
|
|
135
168
|
written.push(target);
|
|
136
169
|
console.log(`MAUDE_PROGRESS {"current":${i + 1},"total":${screens.length}}`);
|