codeplay-common 4.5.2 → 4.5.4

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.
@@ -1,2269 +1,2269 @@
1
- const fs = require('node:fs');
2
- const path = require('node:path');
3
- const readline = require('node:readline/promises');
4
- const { spawn, spawnSync } = require('node:child_process');
5
-
6
- const screenshotDirectory = path.join(__dirname, 'Auto-Screenshot', 'Store-image-Screenshot');
7
- const inFrameDirectory = path.join(__dirname, 'Auto-Screenshot', 'Store-image-Frame');
8
- const aiAssetsDirectory = path.join(__dirname, 'AI-Assets');
9
- const amazonAssetsDirectory = path.join(aiAssetsDirectory, 'Amazon-Assets');
10
- const samsungAssetsDirectory = path.join(aiAssetsDirectory, 'Samsung-Assets');
11
- const playstoreAssetsDirectory = path.join(aiAssetsDirectory, 'Playstore-Assets');
12
- const capacitorConfigPath = path.join(__dirname, 'capacitor.config.json');
13
- const amazonIconPath = path.join(__dirname, 'resources', 'icon-only.png');
14
- const temporaryDirectory = path.join(__dirname, 'agent-temp');
15
- const amazonAssetDefinitions = [
16
- { fileName: 'icon-1280x720.png', type: 'icon', width: 1280, height: 720 },
17
- { fileName: 'background-image-1920x1080.png', type: 'background-image', width: 1920, height: 1080 },
18
- { fileName: 'featured-content-logo-640x260.png', type: 'featured-logo', width: 640, height: 260, transparent: true },
19
- { fileName: 'featured-content-background-1920x720.png', type: 'featured-background', width: 1920, height: 720 },
20
- ];
21
- const samsungAssetDefinitions = [
22
- { fileName: 'edge-screen-160x2560.png', type: 'edge-screen', width: 160, height: 2560 },
23
- { fileName: 'edge-screen-single-550x2560.png', type: 'edge-screen-single-plus', width: 550, height: 2560 },
24
- ];
25
- const playstoreAssetDefinitions = [
26
- { fileName: 'feature-graphic-1024x500.png', width: 1024, height: 500 },
27
- ];
28
- const browserExecutablePaths = [
29
- 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe',
30
- 'C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe',
31
- 'C:\\Program Files\\Microsoft\\Edge\\Application\\msedge.exe',
32
- 'C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe',
33
- ];
34
-
35
- const deviceProfiles = [
36
- {
37
- name: 'android-phone',
38
- label: 'Android phone',
39
- outputDirectory: 'Android',
40
- frameType: 'android-phone',
41
- width: 360,
42
- height: 800,
43
- deviceScaleFactor: 3,
44
- orientationSensitive: true,
45
- mobile: true,
46
- },
47
- {
48
- name: 'android-tablet-7-inch',
49
- aliases: ['android-tablet'],
50
- label: 'Android 7-inch Play Store tablet',
51
- outputDirectory: 'Android-Tab-7inch',
52
- frameType: 'tablet',
53
- width: 675,
54
- height: 1200,
55
- deviceScaleFactor: 2,
56
- framedWidth: 1350,
57
- framedHeight: 2400,
58
- orientationSensitive: true,
59
- mobile: true,
60
- },
61
- {
62
- name: 'android-tablet-10-inch',
63
- label: 'Android 10-inch Play Store tablet',
64
- outputDirectory: 'Android-Tab-10inch',
65
- frameType: 'tablet',
66
- width: 900,
67
- height: 1600,
68
- deviceScaleFactor: 2,
69
- framedWidth: 1800,
70
- framedHeight: 3200,
71
- orientationSensitive: true,
72
- mobile: true,
73
- },
74
- {
75
- name: 'iphone',
76
- label: 'iPhone 6.9-inch App Store viewport',
77
- outputDirectory: 'iPhone',
78
- frameType: 'iphone',
79
- width: 440,
80
- height: 956,
81
- deviceScaleFactor: 3,
82
- framedWidth: 1284,
83
- framedHeight: 2778,
84
- orientationSensitive: true,
85
- mobile: true,
86
- },
87
- {
88
- name: 'ipad',
89
- label: 'iPad 13-inch App Store viewport',
90
- outputDirectory: 'iPad',
91
- frameType: 'tablet',
92
- width: 1032,
93
- height: 1376,
94
- deviceScaleFactor: 2,
95
- framedWidth: 2048,
96
- framedHeight: 2732,
97
- orientationSensitive: true,
98
- mobile: true,
99
- },
100
- {
101
- name: 'apple-vision-pro',
102
- label: 'Apple Vision Pro',
103
- outputDirectory: 'Apple-Vision-Pro',
104
- frameType: 'apple-vision-pro',
105
- width: 3840,
106
- height: 2160,
107
- deviceScaleFactor: 1,
108
- framedWidth: 3840,
109
- framedHeight: 2160,
110
- mobile: false,
111
- },
112
- {
113
- name: 'apple-tv',
114
- label: 'Apple TV',
115
- outputDirectory: 'Apple-TV',
116
- frameType: 'apple-tv',
117
- width: 1920,
118
- height: 1080,
119
- deviceScaleFactor: 1,
120
- framedWidth: 1920,
121
- framedHeight: 1080,
122
- mobile: false,
123
- },
124
- {
125
- name: 'apple-watch',
126
- label: 'Apple Watch Ultra 3',
127
- outputDirectory: 'Apple-Watch',
128
- frameType: 'apple-watch',
129
- width: 422,
130
- height: 514,
131
- deviceScaleFactor: 1,
132
- framedWidth: 422,
133
- framedHeight: 514,
134
- mobile: true,
135
- },
136
- {
137
- name: 'amazon-mobile',
138
- label: 'Amazon mobile store viewport',
139
- outputDirectory: 'Amazon-Phone',
140
- frameType: 'android-phone',
141
- width: 360,
142
- height: 640,
143
- deviceScaleFactor: 3,
144
- orientationSensitive: true,
145
- mobile: true,
146
- },
147
- {
148
- name: 'amazon-fire-tablet',
149
- label: 'Amazon Fire tablet store viewport',
150
- outputDirectory: 'Amazon-Tab',
151
- frameType: 'tablet',
152
- width: 800,
153
- height: 1280,
154
- deviceScaleFactor: 2,
155
- orientationSensitive: true,
156
- mobile: true,
157
- },
158
- {
159
- name: 'amazon-fire-tv',
160
- label: 'Amazon Fire TV store viewport',
161
- outputDirectory: 'Amazon-TV',
162
- frameType: 'tv',
163
- width: 1920,
164
- height: 1080,
165
- deviceScaleFactor: 1,
166
- mobile: false,
167
- },
168
- {
169
- name: 'android-tv',
170
- label: 'Android TV',
171
- outputDirectory: 'Android-TV',
172
- frameType: 'tv',
173
- width: 1920,
174
- height: 1080,
175
- deviceScaleFactor: 1,
176
- mobile: false,
177
- },
178
- {
179
- name: 'chromebook',
180
- label: 'Chromebook 4K Play Store viewport',
181
- outputDirectory: 'Chromebook',
182
- frameType: 'laptop',
183
- width: 1920,
184
- height: 1080,
185
- deviceScaleFactor: 2,
186
- framedWidth: 3840,
187
- framedHeight: 2160,
188
- mobile: false,
189
- },
190
- {
191
- name: 'android-xr',
192
- label: 'Android XR 2D panel approximation',
193
- outputDirectory: 'Android-XR',
194
- frameType: 'xr',
195
- width: 1920,
196
- height: 1080,
197
- deviceScaleFactor: 1,
198
- mobile: false,
199
- },
200
- {
201
- name: 'mac',
202
- label: 'Mac-sized viewport',
203
- outputDirectory: 'Mac',
204
- frameType: 'laptop',
205
- width: 1440,
206
- height: 900,
207
- deviceScaleFactor: 2,
208
- mobile: false,
209
- },
210
- ];
211
-
212
- function getConfiguredOrientation() {
213
- const config = JSON.parse(fs.readFileSync(capacitorConfigPath, 'utf8'));
214
- return String(config.android?.ORIENTATION || 'portrait').toLowerCase() === 'landscape'
215
- ? 'landscape'
216
- : 'portrait';
217
- }
218
-
219
- function orientProfile(profile, orientation) {
220
- if (orientation !== 'landscape' || !profile.orientationSensitive) {
221
- return profile;
222
- }
223
-
224
- return {
225
- ...profile,
226
- width: profile.height,
227
- height: profile.width,
228
- framedWidth: profile.framedHeight,
229
- framedHeight: profile.framedWidth,
230
- };
231
- }
232
-
233
- function runAdb(args, options = {}) {
234
- const result = spawnSync('adb', args, {
235
- encoding: 'utf8',
236
- timeout: 30000,
237
- windowsHide: true,
238
- ...options,
239
- });
240
-
241
- if (result.error) {
242
- if (result.error.code === 'ENOENT') {
243
- throw new Error('ADB was not found. Add Android platform-tools to your PATH.');
244
- }
245
-
246
- if (result.error.code === 'ETIMEDOUT') {
247
- throw new Error('ADB did not respond within 30 seconds. Check the device connection.');
248
- }
249
-
250
- throw result.error;
251
- }
252
-
253
- if (result.status !== 0) {
254
- throw new Error(result.stderr.trim() || `ADB exited with code ${result.status}.`);
255
- }
256
-
257
- return result.stdout.trim();
258
- }
259
-
260
- function getConnectedDevice() {
261
- const devices = runAdb(['devices'])
262
- .split(/\r?\n/)
263
- .map((line) => line.match(/^(\S+)\s+device$/))
264
- .filter(Boolean)
265
- .map((match) => match[1]);
266
-
267
- if (!devices.length) {
268
- throw new Error('no_connected_device');
269
- }
270
-
271
- return devices[0];
272
- }
273
-
274
- function getAppId() {
275
- const config = JSON.parse(fs.readFileSync(capacitorConfigPath, 'utf8'));
276
-
277
- if (!config.appId) {
278
- throw new Error('capacitor.config.json does not contain an appId.');
279
- }
280
-
281
- return config.appId;
282
- }
283
-
284
- function getWebViewSocket(deviceId, appId) {
285
- const processIds = runAdb(['-s', deviceId, 'shell', 'pidof', appId])
286
- .split(/\s+/)
287
- .filter(Boolean);
288
-
289
- if (!processIds.length) {
290
- throw new Error(`The app process is not running: ${appId}`);
291
- }
292
-
293
- const sockets = runAdb(['-s', deviceId, 'shell', 'cat', '/proc/net/unix']);
294
-
295
- for (const processId of processIds) {
296
- const socketName = `webview_devtools_remote_${processId}`;
297
-
298
- if (sockets.includes(`@${socketName}`)) {
299
- return socketName;
300
- }
301
- }
302
-
303
- throw new Error(
304
- 'The running app does not expose a debuggable WebView. Use a debug/live-reload build with WebView debugging enabled.',
305
- );
306
- }
307
-
308
- function sanitizeName(input, fallback) {
309
- const sanitized = input
310
- .trim()
311
- .replace(/[<>:"/\\|?*\u0000-\u001F]/g, '_')
312
- .replace(/[. ]+$/g, '');
313
-
314
- return sanitized || fallback;
315
- }
316
-
317
- function getSelectedOutputDirectories(selectedProfiles) {
318
- return [...new Set(selectedProfiles.flatMap((profile) => ([
319
- path.join(screenshotDirectory, profile.outputDirectory),
320
- path.join(inFrameDirectory, profile.outputDirectory),
321
- ])))];
322
- }
323
-
324
- function getNextNumberedFileName(selectedProfiles) {
325
- const highestNumber = getSelectedOutputDirectories(selectedProfiles)
326
- .filter((directory) => fs.existsSync(directory))
327
- .reduce((highestAcrossDirectories, directory) => {
328
- const highestInDirectory = fs.readdirSync(directory, { withFileTypes: true })
329
- .filter((entry) => entry.isFile())
330
- .reduce((highest, entry) => {
331
- const match = entry.name.match(/^(\d+)\.png$/i);
332
- return match ? Math.max(highest, Number(match[1])) : highest;
333
- }, 0);
334
-
335
- return Math.max(highestAcrossDirectories, highestInDirectory);
336
- }, 0);
337
-
338
- return `${highestNumber + 1}.png`;
339
- }
340
-
341
- function getUniqueFileName(prefix, selectedProfiles) {
342
- const outputDirectories = getSelectedOutputDirectories(selectedProfiles);
343
- let fileName = `${prefix}.png`;
344
- let duplicateNumber = 2;
345
-
346
- while (outputDirectories.some((directory) => (
347
- fs.existsSync(path.join(directory, fileName))
348
- ))) {
349
- fileName = `${prefix}-${duplicateNumber}.png`;
350
- duplicateNumber += 1;
351
- }
352
-
353
- return fileName;
354
- }
355
-
356
- function getScreenshotFileName(fileNameInput, selectedProfiles) {
357
- const inputWithoutExtension = fileNameInput.trim().replace(/\.png$/i, '');
358
-
359
- if (!inputWithoutExtension) {
360
- return getNextNumberedFileName(selectedProfiles);
361
- }
362
-
363
- let prefix = sanitizeName(inputWithoutExtension, '');
364
-
365
- if (!prefix) {
366
- return getNextNumberedFileName(selectedProfiles);
367
- }
368
-
369
- if (/^(con|prn|aux|nul|com[1-9]|lpt[1-9])$/i.test(prefix)) {
370
- prefix = `${prefix}_`;
371
- }
372
-
373
- return getUniqueFileName(prefix, selectedProfiles);
374
- }
375
-
376
- function parseArguments() {
377
- const args = process.argv.slice(2);
378
- const options = {
379
- amazonAssets: false,
380
- samsungAssets: false,
381
- dryRun: false,
382
- frameExisting: false,
383
- help: false,
384
- list: false,
385
- prefix: '',
386
- profiles: '',
387
- };
388
-
389
- for (let index = 0; index < args.length; index += 1) {
390
- const argument = args[index];
391
-
392
- if (argument === '--amazon-assets') {
393
- options.amazonAssets = true;
394
- } else if (argument === '--samsung-assets') {
395
- options.samsungAssets = true;
396
- } else if (argument === '--dry-run') {
397
- options.dryRun = true;
398
- } else if (argument === '--frame-existing') {
399
- options.frameExisting = true;
400
- } else if (argument === '--help' || argument === '-h') {
401
- options.help = true;
402
- } else if (argument === '--list') {
403
- options.list = true;
404
- } else if (argument === '--prefix') {
405
- options.prefix = args[index + 1] || '';
406
- index += 1;
407
- } else if (argument === '--profiles') {
408
- options.profiles = args[index + 1] || '';
409
- index += 1;
410
- } else {
411
- throw new Error(`Unknown argument: ${argument}`);
412
- }
413
- }
414
-
415
- return options;
416
- }
417
-
418
- function printProfiles() {
419
- const orientation = getConfiguredOrientation();
420
-
421
- for (const baseProfile of deviceProfiles) {
422
- const profile = orientProfile(baseProfile, orientation);
423
- const outputWidth = profile.width * profile.deviceScaleFactor;
424
- const outputHeight = profile.height * profile.deviceScaleFactor;
425
- const framedSize = profile.framedWidth
426
- ? `, framed PNG ${profile.framedWidth}x${profile.framedHeight}`
427
- : '';
428
- console.log(
429
- `${profile.name.padEnd(15)} ${profile.label} `
430
- + `(folder ${profile.outputDirectory}, viewport ${profile.width}x${profile.height}, `
431
- + `PNG ${outputWidth}x${outputHeight}${framedSize})`,
432
- );
433
- }
434
- }
435
-
436
- function printHelp() {
437
- console.log('Capture the current live Android WebView at multiple responsive viewport sizes.');
438
- console.log('');
439
- console.log('Usage:');
440
- console.log(' node take-screen-image.js');
441
- console.log(' node take-screen-image.js --profiles iphone,ipad');
442
- console.log(' node take-screen-image.js --prefix home --profiles android-phone,ipad');
443
- console.log(' node take-screen-image.js --frame-existing');
444
- console.log(' node take-screen-image.js --amazon-assets');
445
- console.log(' node take-screen-image.js --samsung-assets');
446
- console.log(' node take-screen-image.js --dry-run');
447
- console.log(' node take-screen-image.js --list');
448
- console.log('');
449
- console.log('A normal capture automatically creates any missing Amazon, Samsung, and Play Store assets.');
450
- }
451
-
452
- function selectProfiles(input) {
453
- const orientation = getConfiguredOrientation();
454
- const names = input
455
- .split(',')
456
- .map((name) => name.trim().toLowerCase())
457
- .filter(Boolean);
458
-
459
- if (!names.length || names.includes('all')) {
460
- return deviceProfiles.map((profile) => orientProfile(profile, orientation));
461
- }
462
-
463
- const selectedProfiles = names.map((name) => {
464
- const profile = deviceProfiles.find((candidate) => (
465
- candidate.name === name || candidate.aliases?.includes(name)
466
- ));
467
-
468
- if (!profile) {
469
- throw new Error(`Unknown profile: ${name}. Run with --list to see valid profiles.`);
470
- }
471
-
472
- return profile;
473
- });
474
-
475
- return [...new Map(selectedProfiles.map((profile) => [profile.name, profile])).values()]
476
- .map((profile) => orientProfile(profile, orientation));
477
- }
478
-
479
- class CdpClient {
480
- constructor(webSocketUrl) {
481
- this.nextId = 1;
482
- this.pendingCommands = new Map();
483
- this.socket = new WebSocket(webSocketUrl);
484
- }
485
-
486
- connect() {
487
- return new Promise((resolve, reject) => {
488
- const timeout = setTimeout(() => {
489
- reject(new Error('Timed out while connecting to the app WebView.'));
490
- }, 10000);
491
-
492
- this.socket.addEventListener('open', () => {
493
- clearTimeout(timeout);
494
- resolve();
495
- }, { once: true });
496
-
497
- this.socket.addEventListener('error', () => {
498
- clearTimeout(timeout);
499
- reject(new Error('Could not connect to the app WebView debugging socket.'));
500
- }, { once: true });
501
-
502
- this.socket.addEventListener('message', (event) => {
503
- const message = JSON.parse(event.data);
504
-
505
- if (!message.id || !this.pendingCommands.has(message.id)) {
506
- return;
507
- }
508
-
509
- const pendingCommand = this.pendingCommands.get(message.id);
510
- this.pendingCommands.delete(message.id);
511
-
512
- if (message.error) {
513
- pendingCommand.reject(new Error(message.error.message));
514
- } else {
515
- pendingCommand.resolve(message.result);
516
- }
517
- });
518
-
519
- this.socket.addEventListener('close', () => {
520
- for (const pendingCommand of this.pendingCommands.values()) {
521
- pendingCommand.reject(new Error('The app WebView debugging connection closed.'));
522
- }
523
-
524
- this.pendingCommands.clear();
525
- });
526
- });
527
- }
528
-
529
- send(method, params = {}) {
530
- return new Promise((resolve, reject) => {
531
- const id = this.nextId;
532
- this.nextId += 1;
533
- this.pendingCommands.set(id, { resolve, reject });
534
- this.socket.send(JSON.stringify({ id, method, params }));
535
- });
536
- }
537
-
538
- close() {
539
- this.socket.close();
540
- }
541
- }
542
-
543
- async function getWebViewTarget(localPort) {
544
- const response = await fetch(`http://127.0.0.1:${localPort}/json/list`);
545
-
546
- if (!response.ok) {
547
- throw new Error(`WebView target discovery failed with HTTP ${response.status}.`);
548
- }
549
-
550
- const targets = await response.json();
551
- const pageTargets = targets.filter((target) => (
552
- target.type === 'page'
553
- && target.webSocketDebuggerUrl
554
- && target.url !== 'about:blank'
555
- ));
556
-
557
- const visibleTarget = pageTargets.find((target) => {
558
- try {
559
- return JSON.parse(target.description || '{}').visible !== false;
560
- } catch {
561
- return true;
562
- }
563
- });
564
-
565
- const target = visibleTarget || pageTargets[0];
566
-
567
- if (!target) {
568
- throw new Error('No rendered page was found in the app WebView.');
569
- }
570
-
571
- return target;
572
- }
573
-
574
- async function getPageDetails(client) {
575
- const evaluation = await client.send('Runtime.evaluate', {
576
- expression: `JSON.stringify({
577
- title: document.title,
578
- url: location.href,
579
- width: innerWidth,
580
- height: innerHeight,
581
- devicePixelRatio: devicePixelRatio,
582
- readyState: document.readyState
583
- })`,
584
- returnByValue: true,
585
- });
586
-
587
- return JSON.parse(evaluation.result.value);
588
- }
589
-
590
- async function capturePageSnapshot(client) {
591
- const evaluation = await client.send('Runtime.evaluate', {
592
- expression: `(async () => {
593
- const clonedRoot = document.documentElement.cloneNode(true);
594
- const originalElements = [
595
- document.documentElement,
596
- ...document.documentElement.querySelectorAll('*'),
597
- ];
598
- const clonedElements = [clonedRoot, ...clonedRoot.querySelectorAll('*')];
599
-
600
- const blobToDataUrl = (blob) => new Promise((resolve, reject) => {
601
- const reader = new FileReader();
602
- reader.onload = () => resolve(reader.result);
603
- reader.onerror = () => reject(reader.error);
604
- reader.readAsDataURL(blob);
605
- });
606
-
607
- const imageToDataUrl = (element) => {
608
- const width = element.naturalWidth || element.clientWidth;
609
- const height = element.naturalHeight || element.clientHeight;
610
-
611
- if (!width || !height) return '';
612
-
613
- const canvas = document.createElement('canvas');
614
- canvas.width = width;
615
- canvas.height = height;
616
- canvas.getContext('2d').drawImage(element, 0, 0, width, height);
617
- return canvas.toDataURL('image/png');
618
- };
619
-
620
- for (let index = 0; index < originalElements.length; index += 1) {
621
- const original = originalElements[index];
622
- const clone = clonedElements[index];
623
-
624
- if (!clone) continue;
625
-
626
- if (original.scrollTop || original.scrollLeft) {
627
- clone.setAttribute('data-responsive-scroll-top', String(original.scrollTop));
628
- clone.setAttribute('data-responsive-scroll-left', String(original.scrollLeft));
629
- }
630
-
631
- if (original.classList.contains('swiper-initialized')) {
632
- clone.setAttribute('data-responsive-swiper-width', String(original.clientWidth));
633
- clone.setAttribute('data-responsive-swiper-height', String(original.clientHeight));
634
- }
635
-
636
- if (original instanceof HTMLInputElement) {
637
- clone.setAttribute('value', original.value);
638
- clone.toggleAttribute('checked', original.checked);
639
- } else if (original instanceof HTMLTextAreaElement) {
640
- clone.textContent = original.value;
641
- } else if (original instanceof HTMLSelectElement) {
642
- for (let optionIndex = 0; optionIndex < original.options.length; optionIndex += 1) {
643
- clone.options[optionIndex].selected = original.options[optionIndex].selected;
644
- }
645
- } else if (original instanceof HTMLCanvasElement) {
646
- try {
647
- clone.style.setProperty(
648
- 'background-image',
649
- 'url("' + original.toDataURL('image/png') + '")',
650
- 'important',
651
- );
652
- clone.style.setProperty('background-position', 'center', 'important');
653
- clone.style.setProperty('background-repeat', 'no-repeat', 'important');
654
- clone.style.setProperty('background-size', '100% 100%', 'important');
655
- } catch {
656
- // Keep the canvas element when its pixels cannot be exported.
657
- }
658
- } else if (original instanceof HTMLVideoElement && original.readyState >= 2) {
659
- try {
660
- const replacement = document.createElement('img');
661
- replacement.src = imageToDataUrl(original);
662
- replacement.alt = original.getAttribute('aria-label') || '';
663
- replacement.setAttribute('style', original.getAttribute('style') || '');
664
- clone.replaceWith(replacement);
665
- } catch {
666
- clone.setAttribute('poster', original.poster || '');
667
- }
668
- } else if (original instanceof HTMLImageElement && original.complete) {
669
- try {
670
- clone.setAttribute('src', imageToDataUrl(original) || original.currentSrc || original.src);
671
- clone.removeAttribute('srcset');
672
- } catch {
673
- clone.setAttribute('src', original.currentSrc || original.src);
674
- }
675
- }
676
- }
677
-
678
- const stylesheetLinks = [...document.querySelectorAll('link[rel="stylesheet"]')];
679
- const clonedStylesheetLinks = [...clonedRoot.querySelectorAll('link[rel="stylesheet"]')];
680
-
681
- await Promise.all(stylesheetLinks.map(async (link, index) => {
682
- try {
683
- const response = await fetch(link.href);
684
- if (!response.ok) return;
685
-
686
- const cssText = (await response.text()).replace(
687
- /url\\(\\s*(['"]?)(?!data:|blob:|#)([^'"\\)]+)\\1\\s*\\)/gi,
688
- (match, quote, resourceUrl) => {
689
- try {
690
- return 'url("' + new URL(resourceUrl.trim(), link.href).href + '")';
691
- } catch {
692
- return match;
693
- }
694
- },
695
- );
696
- const style = document.createElement('style');
697
- style.textContent = cssText;
698
- clonedStylesheetLinks[index].replaceWith(style);
699
- } catch {
700
- clonedStylesheetLinks[index].href = link.href;
701
- }
702
- }));
703
-
704
- clonedRoot.querySelectorAll('script, link[rel="modulepreload"]').forEach((element) => element.remove());
705
- clonedRoot.querySelectorAll('meta[http-equiv="Content-Security-Policy"]').forEach(
706
- (element) => element.remove(),
707
- );
708
-
709
- const base = document.createElement('base');
710
- base.href = location.href;
711
- clonedRoot.querySelector('head').prepend(base);
712
-
713
- const replayStyle = document.createElement('style');
714
- replayStyle.textContent = [
715
- '*,*::before,*::after{animation-play-state:paused!important;}',
716
- '*{scrollbar-width:none!important;}',
717
- '*::-webkit-scrollbar{width:0!important;height:0!important;display:none!important;}',
718
- ].join('');
719
- clonedRoot.querySelector('head').append(replayStyle);
720
-
721
- return {
722
- html: '<!doctype html>' + clonedRoot.outerHTML,
723
- scrollY,
724
- };
725
- })()`,
726
- awaitPromise: true,
727
- returnByValue: true,
728
- });
729
-
730
- if (evaluation.exceptionDetails) {
731
- throw new Error('The current WebView page could not be serialized.');
732
- }
733
-
734
- return evaluation.result.value;
735
- }
736
-
737
- function findBrowserExecutable() {
738
- const executablePath = browserExecutablePaths.find((candidate) => (
739
- fs.existsSync(candidate)
740
- ));
741
-
742
- if (!executablePath) {
743
- throw new Error('Google Chrome or Microsoft Edge was not found on this computer.');
744
- }
745
-
746
- return executablePath;
747
- }
748
-
749
- function delay(milliseconds) {
750
- return new Promise((resolve) => setTimeout(resolve, milliseconds));
751
- }
752
-
753
- async function launchReplayBrowser() {
754
- fs.mkdirSync(temporaryDirectory, { recursive: true });
755
-
756
- const profileDirectory = path.join(
757
- temporaryDirectory,
758
- `responsive-screenshot-chrome-${process.pid}-${Date.now()}`,
759
- );
760
- fs.mkdirSync(profileDirectory, { recursive: true });
761
-
762
- const browserProcess = spawn(findBrowserExecutable(), [
763
- '--headless=new',
764
- '--remote-debugging-port=0',
765
- `--user-data-dir=${profileDirectory}`,
766
- '--no-first-run',
767
- '--no-default-browser-check',
768
- '--disable-background-networking',
769
- '--remote-allow-origins=*',
770
- 'about:blank',
771
- ], {
772
- stdio: 'ignore',
773
- windowsHide: true,
774
- });
775
-
776
- const activePortPath = path.join(profileDirectory, 'DevToolsActivePort');
777
- const deadline = Date.now() + 15000;
778
-
779
- while (!fs.existsSync(activePortPath)) {
780
- if (browserProcess.exitCode !== null) {
781
- throw new Error('The local replay browser closed before it was ready.');
782
- }
783
-
784
- if (Date.now() >= deadline) {
785
- browserProcess.kill();
786
- throw new Error('Timed out while starting the local replay browser.');
787
- }
788
-
789
- await delay(100);
790
- }
791
-
792
- const [port] = fs.readFileSync(activePortPath, 'utf8').trim().split(/\r?\n/);
793
- const versionResponse = await fetch(`http://127.0.0.1:${port}/json/version`);
794
- const version = await versionResponse.json();
795
- const client = new CdpClient(version.webSocketDebuggerUrl);
796
- await client.connect();
797
-
798
- return {
799
- browserProcess,
800
- client,
801
- port,
802
- profileDirectory,
803
- };
804
- }
805
-
806
- async function createReplayPage(browser) {
807
- const createdTarget = await browser.client.send('Target.createTarget', {
808
- url: 'about:blank',
809
- });
810
- const deadline = Date.now() + 10000;
811
-
812
- while (Date.now() < deadline) {
813
- const response = await fetch(`http://127.0.0.1:${browser.port}/json/list`);
814
- const targets = await response.json();
815
- const target = targets.find((candidate) => candidate.id === createdTarget.targetId);
816
-
817
- if (target && target.webSocketDebuggerUrl) {
818
- const client = new CdpClient(target.webSocketDebuggerUrl);
819
- await client.connect();
820
- await client.send('Page.enable');
821
- await client.send('Runtime.enable');
822
- return client;
823
- }
824
-
825
- await delay(100);
826
- }
827
-
828
- throw new Error('The local replay page did not become available.');
829
- }
830
-
831
- function removeTemporaryBrowserProfile(profileDirectory) {
832
- const resolvedTemporaryRoot = path.resolve(temporaryDirectory);
833
- const resolvedProfileDirectory = path.resolve(profileDirectory);
834
-
835
- if (!resolvedProfileDirectory.startsWith(`${resolvedTemporaryRoot}${path.sep}`)) {
836
- throw new Error('Refusing to remove a browser profile outside agent-temp.');
837
- }
838
-
839
- fs.rmSync(resolvedProfileDirectory, { recursive: true, force: true });
840
- }
841
-
842
- async function closeReplayBrowser(browser) {
843
- try {
844
- await browser.client.send('Browser.close');
845
- } catch {
846
- // Closing the browser can close the protocol connection before it acknowledges.
847
- }
848
-
849
- const deadline = Date.now() + 3000;
850
-
851
- while (browser.browserProcess.exitCode === null && Date.now() < deadline) {
852
- await delay(100);
853
- }
854
-
855
- if (browser.browserProcess.exitCode === null) {
856
- browser.browserProcess.kill();
857
- await delay(200);
858
- }
859
-
860
- browser.client.close();
861
-
862
- for (let attempt = 1; attempt <= 5; attempt += 1) {
863
- try {
864
- removeTemporaryBrowserProfile(browser.profileDirectory);
865
- return;
866
- } catch (error) {
867
- if (attempt === 5) {
868
- throw error;
869
- }
870
-
871
- await delay(200);
872
- }
873
- }
874
- }
875
-
876
- async function waitForReplayLayout(client, snapshot) {
877
- const evaluation = await client.send('Runtime.evaluate', {
878
- expression: `(async () => {
879
- const assetWait = Promise.all([...document.images].map((image) => {
880
- if (image.complete) return Promise.resolve();
881
- return new Promise((resolve) => {
882
- image.addEventListener('load', resolve, { once: true });
883
- image.addEventListener('error', resolve, { once: true });
884
- });
885
- }));
886
-
887
- await Promise.race([
888
- Promise.all([document.fonts.ready, assetWait]),
889
- new Promise((resolve) => setTimeout(resolve, 5000)),
890
- ]);
891
-
892
- document.querySelectorAll('[data-responsive-swiper-width]').forEach((swiper) => {
893
- const wrapper = [...swiper.children].find((element) => (
894
- element.classList.contains('swiper-wrapper')
895
- ));
896
-
897
- if (!wrapper) return;
898
-
899
- const slides = [...wrapper.children].filter((element) => (
900
- element.classList.contains('swiper-slide')
901
- ));
902
- const sourceWidth = Number(swiper.dataset.responsiveSwiperWidth);
903
- const sourceHeight = Number(swiper.dataset.responsiveSwiperHeight);
904
- const widthScale = sourceWidth > 0 ? swiper.clientWidth / sourceWidth : 1;
905
- const heightScale = sourceHeight > 0 ? swiper.clientHeight / sourceHeight : 1;
906
- const isVertical = swiper.classList.contains('swiper-vertical');
907
-
908
- slides.forEach((slide) => {
909
- if (!slide.dataset.responsiveSwiperWidth && slide.style.width) {
910
- slide.dataset.responsiveSwiperWidth = String(parseFloat(slide.style.width));
911
- }
912
-
913
- if (!slide.dataset.responsiveSwiperHeight && slide.style.height) {
914
- slide.dataset.responsiveSwiperHeight = String(parseFloat(slide.style.height));
915
- }
916
-
917
- const slideWidth = Number(slide.dataset.responsiveSwiperWidth);
918
- const slideHeight = Number(slide.dataset.responsiveSwiperHeight);
919
-
920
- if (Number.isFinite(slideWidth) && slideWidth > 0) {
921
- slide.style.width = (slideWidth * widthScale) + 'px';
922
- }
923
-
924
- if (isVertical && Number.isFinite(slideHeight) && slideHeight > 0) {
925
- slide.style.height = (slideHeight * heightScale) + 'px';
926
- }
927
- });
928
-
929
- const activeSlide = slides.find((slide) => (
930
- slide.classList.contains('swiper-slide-active')
931
- )) || slides[0];
932
-
933
- if (!activeSlide) return;
934
-
935
- const offset = isVertical ? activeSlide.offsetTop : activeSlide.offsetLeft;
936
- const direction = getComputedStyle(swiper).direction;
937
- const horizontalOffset = direction === 'rtl' ? offset : -offset;
938
- const verticalOffset = isVertical ? -offset : 0;
939
-
940
- wrapper.style.transitionDuration = '0ms';
941
- wrapper.style.transform = 'translate3d('
942
- + (isVertical ? 0 : horizontalOffset) + 'px, '
943
- + verticalOffset + 'px, 0px)';
944
-
945
- if (swiper.classList.contains('swiper-autoheight')) {
946
- wrapper.style.height = activeSlide.scrollHeight + 'px';
947
- }
948
- });
949
-
950
- document.querySelectorAll('[data-responsive-scroll-top]').forEach((element) => {
951
- element.scrollTop = Number(element.dataset.responsiveScrollTop);
952
- element.scrollLeft = Number(element.dataset.responsiveScrollLeft);
953
- });
954
- scrollTo(0, ${Number(snapshot.scrollY) || 0});
955
-
956
- await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
957
-
958
- return {
959
- documentWidth: Math.max(document.documentElement.scrollWidth, document.body.scrollWidth),
960
- viewportWidth: innerWidth,
961
- };
962
- })()`,
963
- awaitPromise: true,
964
- returnByValue: true,
965
- });
966
-
967
- return evaluation.result.value;
968
- }
969
-
970
- function getFrameAdornment(frameType) {
971
- if (frameType === 'iphone') {
972
- return '<div class="dynamic-island"></div>';
973
- }
974
-
975
- if (frameType === 'android-phone') {
976
- return '<div class="camera-dot"></div>';
977
- }
978
-
979
- if (frameType === 'tablet') {
980
- return '<div class="tablet-camera"></div>';
981
- }
982
-
983
- if (frameType === 'tv' || frameType === 'apple-tv') {
984
- return '<div class="tv-stand"></div>';
985
- }
986
-
987
- if (frameType === 'apple-watch') {
988
- return '<div class="watch-crown"></div><div class="watch-button"></div>';
989
- }
990
-
991
- if (frameType === 'apple-vision-pro') {
992
- return '<div class="vision-sensor vision-sensor-left"></div><div class="vision-sensor vision-sensor-right"></div>';
993
- }
994
-
995
- if (frameType === 'laptop') {
996
- return '<div class="laptop-camera"></div><div class="laptop-base"></div>';
997
- }
998
-
999
- return '<div class="xr-status"></div>';
1000
- }
1001
-
1002
- function createDeviceFrameHtml(profile, screenshotBuffer) {
1003
- const screenshotDataUrl = `data:image/png;base64,${screenshotBuffer.toString('base64')}`;
1004
- const aspectRatio = `${profile.width} / ${profile.height}`;
1005
-
1006
- return `<!doctype html>
1007
- <html>
1008
- <head>
1009
- <meta charset="utf-8" />
1010
- <style>
1011
- * { box-sizing: border-box; }
1012
- html, body { width: 100%; height: 100%; margin: 0; overflow: hidden; }
1013
- body {
1014
- display: grid;
1015
- place-items: center;
1016
- position: relative;
1017
- background:
1018
- radial-gradient(circle at 14% 18%, rgba(45, 213, 255, 0.24), transparent 30%),
1019
- radial-gradient(circle at 86% 82%, rgba(196, 255, 61, 0.16), transparent 28%),
1020
- linear-gradient(145deg, #111827 0%, #070b12 52%, #020409 100%);
1021
- font-family: Arial, sans-serif;
1022
- }
1023
- body::before {
1024
- content: '';
1025
- position: absolute;
1026
- inset: 0;
1027
- opacity: 0.18;
1028
- background-image:
1029
- linear-gradient(rgba(255,255,255,0.045) 1px, transparent 1px),
1030
- linear-gradient(90deg, rgba(255,255,255,0.045) 1px, transparent 1px);
1031
- background-size: 7vw 7vw;
1032
- mask-image: linear-gradient(to bottom, transparent, black 28%, black 72%, transparent);
1033
- }
1034
- .device {
1035
- position: relative;
1036
- z-index: 1;
1037
- aspect-ratio: ${aspectRatio};
1038
- background: linear-gradient(145deg, #566171 0%, #171c25 18%, #05070b 55%, #384252 100%);
1039
- border: clamp(2px, 0.45vw, 9px) solid #727d8d;
1040
- box-shadow:
1041
- 0 4vh 9vh rgba(0, 0, 0, 0.68),
1042
- 0 0 0 1px rgba(255, 255, 255, 0.18) inset,
1043
- 0 0 5vh rgba(45, 213, 255, 0.12);
1044
- }
1045
- .screen {
1046
- position: relative;
1047
- width: 100%;
1048
- height: 100%;
1049
- overflow: hidden;
1050
- background: #05070b;
1051
- box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.95) inset;
1052
- }
1053
- .screen img {
1054
- display: block;
1055
- width: 100%;
1056
- height: 100%;
1057
- object-fit: cover;
1058
- }
1059
- .device.android-phone,
1060
- .device.iphone {
1061
- height: 87%;
1062
- padding: 1.25%;
1063
- border-radius: clamp(24px, 8vw, 70px);
1064
- }
1065
- .device.android-phone .screen,
1066
- .device.iphone .screen {
1067
- border-radius: clamp(19px, 6.7vw, 60px);
1068
- }
1069
- .camera-dot {
1070
- position: absolute;
1071
- z-index: 3;
1072
- top: 2.1%;
1073
- left: 50%;
1074
- width: clamp(5px, 1.8vw, 16px);
1075
- aspect-ratio: 1;
1076
- transform: translateX(-50%);
1077
- border-radius: 50%;
1078
- background: radial-gradient(circle at 36% 34%, #406891, #080b10 46%, #000 72%);
1079
- box-shadow: 0 0 0 2px #111827;
1080
- }
1081
- .dynamic-island {
1082
- position: absolute;
1083
- z-index: 3;
1084
- top: 2.2%;
1085
- left: 50%;
1086
- width: 29%;
1087
- height: 3.3%;
1088
- transform: translateX(-50%);
1089
- border-radius: 999px;
1090
- background: #000;
1091
- box-shadow: 0 0 0 1px rgba(255,255,255,0.05);
1092
- }
1093
- .device.tablet {
1094
- height: 86%;
1095
- padding: 1.05%;
1096
- border-radius: clamp(18px, 3.2vw, 48px);
1097
- }
1098
- .device.tablet .screen {
1099
- border-radius: clamp(13px, 2.5vw, 38px);
1100
- }
1101
- .device.apple-watch {
1102
- height: 82%;
1103
- padding: 2.2%;
1104
- border: clamp(3px, 1.4vw, 7px) solid #8b929b;
1105
- border-radius: 24% / 20%;
1106
- background: linear-gradient(145deg, #d5d8dc, #363b43 28%, #11151b 72%, #9299a2);
1107
- }
1108
- .device.apple-watch .screen {
1109
- border-radius: 20% / 17%;
1110
- }
1111
- .watch-crown {
1112
- position: absolute;
1113
- top: 24%;
1114
- right: -5.5%;
1115
- width: 5.5%;
1116
- height: 15%;
1117
- border-radius: 0 45% 45% 0;
1118
- background: linear-gradient(90deg, #303640, #a8afb7 55%, #424851);
1119
- box-shadow: 0 0.8vh 1.5vh rgba(0,0,0,0.45);
1120
- }
1121
- .watch-button {
1122
- position: absolute;
1123
- top: 43%;
1124
- right: -3.5%;
1125
- width: 3.5%;
1126
- height: 11%;
1127
- border-radius: 0 35% 35% 0;
1128
- background: #626a74;
1129
- }
1130
- .device.apple-vision-pro {
1131
- width: 82%;
1132
- padding: 0.72%;
1133
- border: clamp(4px, 0.45vw, 18px) solid rgba(201, 214, 224, 0.92);
1134
- border-radius: 14% / 32%;
1135
- background: linear-gradient(145deg, #dce5eb, #55616d 25%, #151b22 72%, #aebac3);
1136
- box-shadow:
1137
- 0 5vh 11vh rgba(0,0,0,0.68),
1138
- 0 0 5vh rgba(180,225,255,0.2),
1139
- 0 0 0 1px rgba(255,255,255,0.4) inset;
1140
- }
1141
- .device.apple-vision-pro .screen {
1142
- border-radius: 13% / 29%;
1143
- }
1144
- .vision-sensor {
1145
- position: absolute;
1146
- z-index: 3;
1147
- top: 48%;
1148
- width: clamp(8px, 0.8vw, 30px);
1149
- aspect-ratio: 1;
1150
- border-radius: 50%;
1151
- background: radial-gradient(circle at 38% 35%, #567793, #05080c 55%, #000 75%);
1152
- box-shadow: 0 0 0 2px rgba(147,166,181,0.55);
1153
- }
1154
- .vision-sensor-left { left: 3.2%; }
1155
- .vision-sensor-right { right: 3.2%; }
1156
- .tablet-camera,
1157
- .laptop-camera {
1158
- position: absolute;
1159
- z-index: 3;
1160
- top: 0.48%;
1161
- left: 50%;
1162
- width: clamp(3px, 0.55vw, 10px);
1163
- aspect-ratio: 1;
1164
- transform: translateX(-50%);
1165
- border-radius: 50%;
1166
- background: #080b10;
1167
- box-shadow: 0 0 0 1px #526071;
1168
- }
1169
- .device.tv,
1170
- .device.apple-tv {
1171
- width: 82%;
1172
- padding: 0.62%;
1173
- border-radius: clamp(8px, 1.1vw, 24px);
1174
- transform: translateY(-4%);
1175
- }
1176
- .device.tv .screen,
1177
- .device.apple-tv .screen { border-radius: clamp(4px, 0.55vw, 12px); }
1178
- .tv-stand {
1179
- position: absolute;
1180
- z-index: -1;
1181
- left: 50%;
1182
- bottom: -13%;
1183
- width: 24%;
1184
- height: 13%;
1185
- transform: translateX(-50%);
1186
- background: linear-gradient(90deg, #11161f, #697383 50%, #11161f);
1187
- clip-path: polygon(42% 0, 58% 0, 67% 76%, 94% 86%, 100% 100%, 0 100%, 6% 86%, 33% 76%);
1188
- filter: drop-shadow(0 1.5vh 1.5vh rgba(0,0,0,0.55));
1189
- }
1190
- .device.laptop {
1191
- width: 82%;
1192
- padding: 0.75% 0.75% 1.15%;
1193
- border-radius: clamp(8px, 1.2vw, 24px);
1194
- transform: translateY(-3%);
1195
- }
1196
- .device.laptop .screen { border-radius: clamp(4px, 0.55vw, 12px); }
1197
- .laptop-base {
1198
- position: absolute;
1199
- z-index: -1;
1200
- left: 50%;
1201
- bottom: -7.5%;
1202
- width: 112%;
1203
- height: 8%;
1204
- transform: translateX(-50%);
1205
- border-radius: 0 0 50% 50% / 0 0 70% 70%;
1206
- background: linear-gradient(#697383, #202631 45%, #090c11);
1207
- box-shadow: 0 1.5vh 2vh rgba(0,0,0,0.48);
1208
- }
1209
- .device.xr {
1210
- width: 82%;
1211
- padding: 0.58%;
1212
- border: clamp(2px, 0.32vw, 7px) solid rgba(128, 230, 255, 0.9);
1213
- border-radius: clamp(18px, 2.5vw, 48px);
1214
- background: rgba(13, 22, 34, 0.82);
1215
- box-shadow:
1216
- 0 4vh 10vh rgba(0,0,0,0.65),
1217
- 0 0 7vh rgba(45,213,255,0.34),
1218
- 0 0 0 1px rgba(255,255,255,0.22) inset;
1219
- }
1220
- .device.xr .screen { border-radius: clamp(13px, 2vw, 38px); }
1221
- .xr-status {
1222
- position: absolute;
1223
- top: -2.4%;
1224
- left: 50%;
1225
- width: 13%;
1226
- height: 1.1%;
1227
- transform: translateX(-50%);
1228
- border-radius: 999px;
1229
- background: #6fe8ff;
1230
- box-shadow: 0 0 1.8vh #2dd5ff;
1231
- }
1232
- </style>
1233
- </head>
1234
- <body>
1235
- <main class="device ${profile.frameType}">
1236
- <div class="screen"><img src="${screenshotDataUrl}" /></div>
1237
- ${getFrameAdornment(profile.frameType)}
1238
- </main>
1239
- </body>
1240
- </html>`;
1241
- }
1242
-
1243
- function escapeHtml(value) {
1244
- return String(value)
1245
- .replace(/&/g, '&amp;')
1246
- .replace(/</g, '&lt;')
1247
- .replace(/>/g, '&gt;')
1248
- .replace(/"/g, '&quot;')
1249
- .replace(/'/g, '&#39;');
1250
- }
1251
-
1252
- function cleanAssetCopy(value, maximumLength, fallback = '') {
1253
- const cleaned = String(value || '')
1254
- .replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, ' ')
1255
- .replace(/<style\b[^>]*>[\s\S]*?<\/style>/gi, ' ')
1256
- .replace(/<[^>]+>/g, ' ')
1257
- .replace(/&[a-zA-Z#0-9]+;/g, ' ')
1258
- .replace(/[_|]+/g, ' ')
1259
- .replace(/\s+/g, ' ')
1260
- .trim();
1261
- if (!cleaned) return fallback;
1262
- if (cleaned.length <= maximumLength) return cleaned;
1263
- const shortened = cleaned.slice(0, maximumLength + 1);
1264
- const lastSpace = shortened.lastIndexOf(' ');
1265
- return shortened.slice(0, lastSpace > maximumLength * 0.55 ? lastSpace : maximumLength).trim();
1266
- }
1267
-
1268
- function readJsonAssetSource(filePath) {
1269
- try {
1270
- return JSON.parse(fs.readFileSync(filePath, 'utf8'));
1271
- } catch {
1272
- return {};
1273
- }
1274
- }
1275
-
1276
- function collectProjectUiPhrases() {
1277
- const phrases = [];
1278
- const pageDirectory = path.join(__dirname, 'src', 'pages');
1279
- if (fs.existsSync(pageDirectory)) {
1280
- const pageFiles = fs.readdirSync(pageDirectory)
1281
- .filter((fileName) => /\.(?:f7|html)$/i.test(fileName))
1282
- .sort()
1283
- .slice(0, 40);
1284
- const visibleElementPattern = /<(?:h[1-4]|button|label|option|a|li)\b[^>]*>([\s\S]*?)<\/(?:h[1-4]|button|label|option|a|li)>/gi;
1285
- for (const fileName of pageFiles) {
1286
- const source = fs.readFileSync(path.join(pageDirectory, fileName), 'utf8');
1287
- let match;
1288
- while ((match = visibleElementPattern.exec(source)) && phrases.length < 120) {
1289
- const phrase = cleanAssetCopy(match[1], 52);
1290
- if (phrase) phrases.push(phrase);
1291
- }
1292
- }
1293
- }
1294
- return phrases;
1295
- }
1296
-
1297
- function getStoreAssetCopy(config) {
1298
- const configuredAppName = cleanAssetCopy(config.appName, 40, 'App');
1299
- const appUniqueId = String(config.android?.APP_UNIQUE_ID ?? '').trim();
1300
- const promoCopyPath = appUniqueId
1301
- ? path.join(
1302
- __dirname,
1303
- 'Auto-AI-Video',
1304
- `${appUniqueId}. ${sanitizeName(configuredAppName, 'App')}-promo-copy.json`,
1305
- )
1306
- : '';
1307
- const promoCopy = promoCopyPath && fs.existsSync(promoCopyPath)
1308
- ? readJsonAssetSource(promoCopyPath).copy || {}
1309
- : {};
1310
- const scenes = Array.isArray(promoCopy.scenes) ? promoCopy.scenes : [];
1311
- const footerKeywords = Array.isArray(promoCopy.footer_keywords)
1312
- ? promoCopy.footer_keywords
1313
- : [];
1314
- const manifestCandidates = [
1315
- path.join(__dirname, 'src', 'manifest.webmanifest'),
1316
- path.join(__dirname, 'public', 'manifest.webmanifest'),
1317
- path.join(__dirname, 'manifest.webmanifest'),
1318
- ];
1319
- const manifestPath = manifestCandidates.find((candidate) => fs.existsSync(candidate));
1320
- const manifest = manifestPath ? readJsonAssetSource(manifestPath) : {};
1321
- const candidates = [
1322
- ...footerKeywords,
1323
- ...scenes.flatMap((scene) => [
1324
- `${scene.title_line_1 || ''} ${scene.title_line_2 || ''}`,
1325
- scene.kicker,
1326
- scene.body,
1327
- ]),
1328
- ...(Array.isArray(manifest.categories) ? manifest.categories : []),
1329
- manifest.description,
1330
- ...collectProjectUiPhrases(),
1331
- ];
1332
- const uniquePhrases = [];
1333
- const seen = new Set([configuredAppName.toLowerCase()]);
1334
- for (const candidate of candidates) {
1335
- const phrase = cleanAssetCopy(candidate, 52);
1336
- const key = phrase.toLowerCase();
1337
- if (phrase.length < 3 || seen.has(key)) continue;
1338
- seen.add(key);
1339
- uniquePhrases.push(phrase);
1340
- }
1341
-
1342
- const featureLabels = uniquePhrases
1343
- .map((phrase) => cleanAssetCopy(phrase, 18))
1344
- .filter((phrase, index, values) => (
1345
- phrase.length >= 3
1346
- && values.findIndex((value) => value.toLowerCase() === phrase.toLowerCase()) === index
1347
- ))
1348
- .slice(0, 9);
1349
- while (featureLabels.length < 9) {
1350
- featureLabels.push(`Feature ${String(featureLabels.length + 1).padStart(2, '0')}`);
1351
- }
1352
-
1353
- const firstScene = scenes[0] || {};
1354
- const firstSceneTitle = cleanAssetCopy(
1355
- `${firstScene.title_line_1 || ''} ${firstScene.title_line_2 || ''}`,
1356
- 34,
1357
- );
1358
- const primaryPhrase = footerKeywords[0] || featureLabels[0];
1359
- return {
1360
- amazonEyebrow: cleanAssetCopy(primaryPhrase, 32, `Explore ${configuredAppName}`),
1361
- amazonCallout: cleanAssetCopy(
1362
- firstScene.body || manifest.description || uniquePhrases[0],
1363
- 52,
1364
- `Explore ${configuredAppName}`,
1365
- ),
1366
- featureLabels,
1367
- samsungKicker: cleanAssetCopy(firstScene.kicker || primaryPhrase, 28, 'App features'),
1368
- samsungAction: firstSceneTitle || cleanAssetCopy(primaryPhrase, 34, `Open ${configuredAppName}`),
1369
- samsungBody: cleanAssetCopy(
1370
- firstScene.body || manifest.description || uniquePhrases[1],
1371
- 52,
1372
- 'Explore the available features',
1373
- ),
1374
- };
1375
- }
1376
-
1377
- function createAmazonAssetHtml(asset, appName, iconBuffer, storeCopy) {
1378
- const iconDataUrl = `data:image/png;base64,${iconBuffer.toString('base64')}`;
1379
- const safeAppName = escapeHtml(appName);
1380
- const safeEyebrow = escapeHtml(storeCopy.amazonEyebrow);
1381
- const safeCallout = escapeHtml(storeCopy.amazonCallout);
1382
- const featureMarkup = storeCopy.featureLabels.slice(0, 6)
1383
- .map((label) => `<span>${escapeHtml(label)}</span>`)
1384
- .join('');
1385
- const logoMarkup = asset.type === 'featured-logo'
1386
- ? `<main class="featured-logo"><img src="${iconDataUrl}" /><div><small>${safeEyebrow}</small><strong>${safeAppName}</strong></div></main>`
1387
- : '';
1388
- const iconMarkup = asset.type === 'icon'
1389
- ? `<main class="icon-card"><img src="${iconDataUrl}" /><div><small>${safeCallout}</small><strong>${safeAppName}</strong></div></main>`
1390
- : '';
1391
- const backgroundMarkup = asset.type.includes('background')
1392
- ? `<main class="background-art"><div class="pulse pulse-one"></div><div class="pulse pulse-two"></div><div class="feature-grid">${featureMarkup}</div></main>`
1393
- : '';
1394
-
1395
- return `<!doctype html>
1396
- <html>
1397
- <head>
1398
- <meta charset="utf-8" />
1399
- <style>
1400
- * { box-sizing: border-box; }
1401
- html, body { width: 100%; height: 100%; margin: 0; overflow: hidden; }
1402
- body {
1403
- position: relative;
1404
- font-family: Arial, Helvetica, sans-serif;
1405
- color: #f8fbff;
1406
- background: transparent;
1407
- }
1408
- body.icon,
1409
- body.background-image,
1410
- body.featured-background {
1411
- background:
1412
- radial-gradient(circle at 16% 18%, rgba(52, 211, 235, 0.32), transparent 28%),
1413
- radial-gradient(circle at 82% 82%, rgba(201, 255, 65, 0.24), transparent 30%),
1414
- linear-gradient(135deg, #24113c 0%, #15152d 48%, #071725 100%);
1415
- }
1416
- body.icon::before,
1417
- body.background-image::before,
1418
- body.featured-background::before {
1419
- content: '';
1420
- position: absolute;
1421
- inset: 0;
1422
- opacity: 0.22;
1423
- background-image:
1424
- linear-gradient(rgba(255,255,255,0.07) 1px, transparent 1px),
1425
- linear-gradient(90deg, rgba(255,255,255,0.07) 1px, transparent 1px);
1426
- background-size: 72px 72px;
1427
- }
1428
- .icon-card {
1429
- position: relative;
1430
- z-index: 2;
1431
- width: 100%;
1432
- height: 100%;
1433
- display: flex;
1434
- align-items: center;
1435
- justify-content: center;
1436
- gap: 72px;
1437
- padding: 72px 110px;
1438
- }
1439
- .icon-card img {
1440
- width: 500px;
1441
- height: 500px;
1442
- object-fit: contain;
1443
- border-radius: 104px;
1444
- box-shadow: 0 38px 80px rgba(0,0,0,0.48), 0 0 70px rgba(52,211,235,0.2);
1445
- }
1446
- .icon-card div { display: flex; flex-direction: column; gap: 18px; }
1447
- .icon-card small,
1448
- .featured-logo small {
1449
- color: #35d4eb;
1450
- font-size: 24px;
1451
- font-weight: 700;
1452
- letter-spacing: 7px;
1453
- }
1454
- .icon-card strong { font-size: 86px; line-height: 0.96; max-width: 500px; }
1455
- .featured-logo {
1456
- width: 100%;
1457
- height: 100%;
1458
- display: flex;
1459
- align-items: center;
1460
- gap: 24px;
1461
- padding: 20px 26px;
1462
- color: #111827;
1463
- }
1464
- .featured-logo img {
1465
- width: 210px;
1466
- height: 210px;
1467
- object-fit: contain;
1468
- border-radius: 44px;
1469
- }
1470
- .featured-logo div { display: flex; min-width: 0; flex-direction: column; gap: 8px; }
1471
- .featured-logo small { font-size: 14px; letter-spacing: 3px; }
1472
- .featured-logo strong {
1473
- max-width: 360px;
1474
- overflow: hidden;
1475
- color: #ffffff;
1476
- font-size: 56px;
1477
- line-height: 1;
1478
- text-overflow: ellipsis;
1479
- white-space: nowrap;
1480
- text-shadow: 0 3px 12px rgba(0,0,0,0.75);
1481
- }
1482
- .background-art { position: absolute; inset: 0; }
1483
- .pulse {
1484
- position: absolute;
1485
- border: 3px solid rgba(53,212,235,0.28);
1486
- border-radius: 50%;
1487
- box-shadow: 0 0 80px rgba(53,212,235,0.15);
1488
- }
1489
- .pulse-one { width: 520px; height: 520px; top: -180px; left: -110px; }
1490
- .pulse-two { width: 620px; height: 620px; right: -180px; bottom: -310px; border-color: rgba(201,255,65,0.22); }
1491
- .feature-grid {
1492
- position: absolute;
1493
- left: 8%;
1494
- right: 8%;
1495
- top: 50%;
1496
- display: grid;
1497
- grid-template-columns: repeat(2, minmax(0, 1fr));
1498
- gap: 24px;
1499
- transform: translateY(-50%);
1500
- }
1501
- .feature-grid span {
1502
- display: flex;
1503
- min-height: 108px;
1504
- align-items: center;
1505
- justify-content: center;
1506
- padding: 20px;
1507
- color: #f8fbff;
1508
- border: 2px solid rgba(255,255,255,0.17);
1509
- border-radius: 22px;
1510
- background: linear-gradient(145deg, rgba(42,55,75,0.96), rgba(11,18,29,0.96));
1511
- box-shadow: 0 20px 28px rgba(0,0,0,0.28);
1512
- font-size: clamp(20px, 2vw, 34px);
1513
- font-weight: 700;
1514
- text-align: center;
1515
- }
1516
- .feature-grid span:nth-child(3n+1) { border-top: 7px solid #35d4eb; }
1517
- .feature-grid span:nth-child(3n+2) { border-top: 7px solid #ff9f43; }
1518
- .feature-grid span:nth-child(3n) { border-top: 7px solid #c9ff41; }
1519
- body.featured-background .feature-grid { left: 5%; right: 5%; gap: 18px; }
1520
- </style>
1521
- </head>
1522
- <body class="${asset.type}">
1523
- ${iconMarkup}
1524
- ${logoMarkup}
1525
- ${backgroundMarkup}
1526
- </body>
1527
- </html>`;
1528
- }
1529
-
1530
- function createPlaystoreFeatureHtml(appName, iconBuffer, storeCopy) {
1531
- const iconDataUrl = `data:image/png;base64,${iconBuffer.toString('base64')}`;
1532
- const safeAppName = escapeHtml(appName);
1533
- const safeCallout = escapeHtml(storeCopy.amazonCallout);
1534
- const featureMarkup = storeCopy.featureLabels.slice(0, 5)
1535
- .map((label, index) => `<span><i>0${index + 1}</i>${escapeHtml(label)}</span>`)
1536
- .join('');
1537
-
1538
- return `<!doctype html>
1539
- <html>
1540
- <head>
1541
- <meta charset="utf-8" />
1542
- <style>
1543
- * { box-sizing: border-box; }
1544
- html, body { width: 100%; height: 100%; margin: 0; overflow: hidden; }
1545
- body {
1546
- display: flex;
1547
- align-items: center;
1548
- padding: 42px 64px;
1549
- color: #f7fbff;
1550
- background: radial-gradient(circle at 84% 18%, rgba(53,212,235,0.3), transparent 31%), linear-gradient(120deg, #111729, #231039 58%, #071522);
1551
- font-family: Arial, Helvetica, sans-serif;
1552
- }
1553
- main { display: flex; width: 100%; height: 100%; align-items: center; justify-content: space-between; gap: 48px; }
1554
- .copy { max-width: 570px; }
1555
- img { width: 128px; height: 128px; border-radius: 30px; object-fit: contain; }
1556
- small { display: block; margin-top: 24px; color: #35d4eb; font-size: 18px; font-weight: 700; letter-spacing: 4px; text-transform: uppercase; }
1557
- h1 { margin: 12px 0 18px; font-size: 64px; line-height: 0.98; }
1558
- p { margin: 0; color: #b8c5d4; font-size: 23px; line-height: 1.35; }
1559
- .features { display: grid; width: 340px; gap: 12px; }
1560
- .features span { display: flex; align-items: center; gap: 16px; padding: 17px 20px; border: 1px solid rgba(255,255,255,0.16); border-radius: 15px; background: rgba(8,16,28,0.6); font-size: 18px; font-weight: 700; }
1561
- .features i { color: #c9ff41; font-size: 13px; font-style: normal; }
1562
- </style>
1563
- </head>
1564
- <body><main><section class="copy"><img src="${iconDataUrl}" /><small>Explore ${safeAppName}</small><h1>${safeAppName}</h1><p>${safeCallout}</p></section><section class="features">${featureMarkup}</section></main></body>
1565
- </html>`;
1566
- }
1567
-
1568
- async function createPlaystoreAssets(client, skipExisting = false) {
1569
- if (!fs.existsSync(amazonIconPath)) {
1570
- throw new Error(`Play Store asset source icon was not found: ${amazonIconPath}`);
1571
- }
1572
-
1573
- const config = JSON.parse(fs.readFileSync(capacitorConfigPath, 'utf8'));
1574
- const appName = config.appName || 'App';
1575
- const storeCopy = getStoreAssetCopy(config);
1576
- const iconBuffer = fs.readFileSync(amazonIconPath);
1577
- fs.mkdirSync(playstoreAssetsDirectory, { recursive: true });
1578
-
1579
- for (const asset of playstoreAssetDefinitions) {
1580
- const outputPath = path.join(playstoreAssetsDirectory, asset.fileName);
1581
- if (skipExisting && fs.existsSync(outputPath)) continue;
1582
-
1583
- await client.send('Emulation.setDeviceMetricsOverride', {
1584
- width: asset.width, height: asset.height, deviceScaleFactor: 1, mobile: false,
1585
- screenWidth: asset.width, screenHeight: asset.height, positionX: 0, positionY: 0,
1586
- });
1587
- const frameTree = await client.send('Page.getFrameTree');
1588
- await client.send('Page.setDocumentContent', {
1589
- frameId: frameTree.frameTree.frame.id,
1590
- html: createPlaystoreFeatureHtml(appName, iconBuffer, storeCopy),
1591
- });
1592
- await client.send('Runtime.evaluate', {
1593
- expression: `Promise.all([...document.images].map((image) => image.complete ? Promise.resolve() : new Promise((resolve) => image.addEventListener('load', resolve, { once: true })))).then(() => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))))`,
1594
- awaitPromise: true,
1595
- returnByValue: true,
1596
- });
1597
- const screenshot = await client.send('Page.captureScreenshot', {
1598
- format: 'png', fromSurface: true, captureBeyondViewport: false,
1599
- });
1600
- fs.writeFileSync(outputPath, Buffer.from(screenshot.data, 'base64'));
1601
- console.log(`Saved Play Store feature ${asset.width}x${asset.height}: ${outputPath}`);
1602
- }
1603
- }
1604
-
1605
- async function createAmazonAssets(client, skipExisting = false) {
1606
- if (!fs.existsSync(amazonIconPath)) {
1607
- throw new Error(`Amazon asset source icon was not found: ${amazonIconPath}`);
1608
- }
1609
-
1610
- const config = JSON.parse(fs.readFileSync(capacitorConfigPath, 'utf8'));
1611
- const appName = config.appName || 'App';
1612
- const storeCopy = getStoreAssetCopy(config);
1613
- const iconBuffer = fs.readFileSync(amazonIconPath);
1614
- fs.mkdirSync(amazonAssetsDirectory, { recursive: true });
1615
-
1616
- for (const asset of amazonAssetDefinitions) {
1617
- const outputPath = path.join(amazonAssetsDirectory, asset.fileName);
1618
-
1619
- if (skipExisting && fs.existsSync(outputPath)) {
1620
- continue;
1621
- }
1622
-
1623
- await client.send('Emulation.setDeviceMetricsOverride', {
1624
- width: asset.width,
1625
- height: asset.height,
1626
- deviceScaleFactor: 1,
1627
- mobile: false,
1628
- screenWidth: asset.width,
1629
- screenHeight: asset.height,
1630
- positionX: 0,
1631
- positionY: 0,
1632
- });
1633
-
1634
- const frameTree = await client.send('Page.getFrameTree');
1635
- await client.send('Page.setDocumentContent', {
1636
- frameId: frameTree.frameTree.frame.id,
1637
- html: createAmazonAssetHtml(asset, appName, iconBuffer, storeCopy),
1638
- });
1639
- await client.send('Runtime.evaluate', {
1640
- expression: `Promise.all([...document.images].map((image) => image.complete
1641
- ? Promise.resolve()
1642
- : new Promise((resolve) => {
1643
- image.addEventListener('load', resolve, { once: true });
1644
- image.addEventListener('error', resolve, { once: true });
1645
- }))).then(() => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))))`,
1646
- awaitPromise: true,
1647
- returnByValue: true,
1648
- });
1649
-
1650
- const screenshot = await client.send('Page.captureScreenshot', {
1651
- format: 'png',
1652
- fromSurface: true,
1653
- captureBeyondViewport: false,
1654
- omitBackground: Boolean(asset.transparent),
1655
- });
1656
- fs.writeFileSync(outputPath, Buffer.from(screenshot.data, 'base64'));
1657
- console.log(`Saved Amazon asset ${asset.width}x${asset.height}: ${outputPath}`);
1658
- }
1659
- }
1660
-
1661
- function createSamsungAssetHtml(asset, appName, iconBuffer, storeCopy) {
1662
- const iconDataUrl = `data:image/png;base64,${iconBuffer.toString('base64')}`;
1663
- const safeAppName = escapeHtml(appName);
1664
- const featureButtons = storeCopy.featureLabels.map((label, index) => (
1665
- `<button><i>0${index + 1}</i><span>${escapeHtml(label.toUpperCase())}</span></button>`
1666
- )).join('');
1667
- const safeKicker = escapeHtml(storeCopy.samsungKicker.toUpperCase());
1668
- const safeAction = escapeHtml(storeCopy.samsungAction.toUpperCase());
1669
- const safeBody = escapeHtml(storeCopy.samsungBody);
1670
- const content = asset.type === 'edge-screen'
1671
- ? `<main class="edge-panel"><header><img src="${iconDataUrl}" /><strong>${safeAppName}</strong></header><section>${featureButtons}</section></main>`
1672
- : `<main class="single-panel"><header><img src="${iconDataUrl}" /><div><small>${safeKicker}</small><strong>${safeAppName}</strong></div></header><div class="single-action"><span>+</span><strong>${safeAction}</strong><small>${safeBody}</small></div><div class="accent-bars"><i></i><i></i><i></i><i></i><i></i></div></main>`;
1673
-
1674
- return `<!doctype html>
1675
- <html>
1676
- <head>
1677
- <meta charset="utf-8" />
1678
- <style>
1679
- * { box-sizing: border-box; }
1680
- html, body { width: 100%; height: 100%; margin: 0; overflow: hidden; }
1681
- body {
1682
- position: relative;
1683
- color: #f7fbff;
1684
- background:
1685
- radial-gradient(circle at 50% 8%, rgba(53,212,235,0.26), transparent 24%),
1686
- radial-gradient(circle at 50% 88%, rgba(201,255,65,0.16), transparent 25%),
1687
- linear-gradient(180deg, #231039, #111729 48%, #071522);
1688
- font-family: Arial, Helvetica, sans-serif;
1689
- }
1690
- body::before {
1691
- content: '';
1692
- position: absolute;
1693
- inset: 0;
1694
- opacity: 0.18;
1695
- background: repeating-linear-gradient(0deg, transparent 0 79px, rgba(255,255,255,0.08) 80px);
1696
- }
1697
- main { position: relative; z-index: 1; width: 100%; height: 100%; }
1698
- .edge-panel { display: flex; flex-direction: column; padding: 28px 12px; }
1699
- .edge-panel header { display: flex; flex-direction: column; align-items: center; gap: 12px; min-height: 230px; }
1700
- .edge-panel header img { width: 112px; height: 112px; border-radius: 25px; object-fit: contain; }
1701
- .edge-panel header strong { max-width: 136px; overflow: hidden; font-size: 18px; text-overflow: ellipsis; white-space: nowrap; }
1702
- .edge-panel section { display: grid; flex: 1; grid-template-rows: repeat(9, 1fr); gap: 13px; }
1703
- .edge-panel button {
1704
- display: flex;
1705
- width: 100%;
1706
- min-height: 0;
1707
- flex-direction: column;
1708
- align-items: center;
1709
- justify-content: center;
1710
- gap: 8px;
1711
- color: #f7fbff;
1712
- border: 1px solid rgba(255,255,255,0.16);
1713
- border-top: 5px solid #35d4eb;
1714
- border-radius: 18px;
1715
- background: linear-gradient(145deg, rgba(41,55,76,0.98), rgba(10,17,29,0.98));
1716
- box-shadow: 0 10px 18px rgba(0,0,0,0.3);
1717
- }
1718
- .edge-panel button:nth-child(3n+2) { border-top-color: #ff9f43; }
1719
- .edge-panel button:nth-child(3n) { border-top-color: #c9ff41; }
1720
- .edge-panel button i { color: #35d4eb; font-size: 14px; font-style: normal; font-weight: 700; }
1721
- .edge-panel button span { font-size: 12px; font-weight: 700; letter-spacing: 1px; }
1722
- .single-panel { display: flex; flex-direction: column; align-items: center; padding: 100px 44px; }
1723
- .single-panel header { display: flex; width: 100%; align-items: center; gap: 24px; }
1724
- .single-panel header img { width: 160px; height: 160px; border-radius: 36px; object-fit: contain; }
1725
- .single-panel header div { display: flex; min-width: 0; flex-direction: column; gap: 9px; }
1726
- .single-panel header small { color: #35d4eb; font-size: 17px; font-weight: 700; letter-spacing: 3px; }
1727
- .single-panel header strong { overflow: hidden; font-size: 48px; text-overflow: ellipsis; white-space: nowrap; }
1728
- .single-action {
1729
- display: flex;
1730
- width: 100%;
1731
- height: 880px;
1732
- margin-top: 380px;
1733
- flex-direction: column;
1734
- align-items: center;
1735
- justify-content: center;
1736
- border: 3px solid rgba(53,212,235,0.5);
1737
- border-radius: 90px;
1738
- background: linear-gradient(145deg, rgba(39,53,75,0.96), rgba(8,16,28,0.98));
1739
- box-shadow: 0 50px 90px rgba(0,0,0,0.42), 0 0 75px rgba(53,212,235,0.16);
1740
- }
1741
- .single-action span {
1742
- display: grid;
1743
- width: 260px;
1744
- height: 260px;
1745
- place-items: center;
1746
- color: #0d1721;
1747
- border-radius: 50%;
1748
- background: linear-gradient(145deg, #35d4eb, #c9ff41);
1749
- box-shadow: 0 25px 55px rgba(53,212,235,0.3);
1750
- font-size: 170px;
1751
- font-weight: 200;
1752
- line-height: 1;
1753
- }
1754
- .single-action strong { margin-top: 65px; font-size: 42px; letter-spacing: 5px; }
1755
- .single-action small { margin-top: 18px; color: #a9b7c9; font-size: 24px; }
1756
- .accent-bars { display: flex; align-items: center; gap: 18px; height: 300px; margin-top: auto; }
1757
- .accent-bars i { width: 14px; height: 70px; border-radius: 999px; background: #35d4eb; }
1758
- .accent-bars i:nth-child(2), .accent-bars i:nth-child(4) { height: 140px; background: #ff5964; }
1759
- .accent-bars i:nth-child(3) { height: 210px; background: #c9ff41; }
1760
- </style>
1761
- </head>
1762
- <body>${content}</body>
1763
- </html>`;
1764
- }
1765
-
1766
- async function createSamsungAssets(client, skipExisting = false) {
1767
- if (!fs.existsSync(amazonIconPath)) {
1768
- throw new Error(`Samsung asset source icon was not found: ${amazonIconPath}`);
1769
- }
1770
-
1771
- const config = JSON.parse(fs.readFileSync(capacitorConfigPath, 'utf8'));
1772
- const appName = config.appName || 'App';
1773
- const storeCopy = getStoreAssetCopy(config);
1774
- const iconBuffer = fs.readFileSync(amazonIconPath);
1775
- const maximumFileSize = 1024 * 1024;
1776
-
1777
- fs.mkdirSync(samsungAssetsDirectory, { recursive: true });
1778
-
1779
- for (const asset of samsungAssetDefinitions) {
1780
- const outputPath = path.join(samsungAssetsDirectory, asset.fileName);
1781
-
1782
- if (skipExisting && fs.existsSync(outputPath)) {
1783
- continue;
1784
- }
1785
-
1786
- await client.send('Emulation.setDeviceMetricsOverride', {
1787
- width: asset.width,
1788
- height: asset.height,
1789
- deviceScaleFactor: 1,
1790
- mobile: false,
1791
- screenWidth: asset.width,
1792
- screenHeight: asset.height,
1793
- positionX: 0,
1794
- positionY: 0,
1795
- });
1796
- const frameTree = await client.send('Page.getFrameTree');
1797
- await client.send('Page.setDocumentContent', {
1798
- frameId: frameTree.frameTree.frame.id,
1799
- html: createSamsungAssetHtml(asset, appName, iconBuffer, storeCopy),
1800
- });
1801
- await client.send('Runtime.evaluate', {
1802
- expression: `Promise.all([...document.images].map((image) => image.complete
1803
- ? Promise.resolve()
1804
- : new Promise((resolve) => image.addEventListener('load', resolve, { once: true }))))
1805
- .then(() => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))))`,
1806
- awaitPromise: true,
1807
- returnByValue: true,
1808
- });
1809
- const screenshot = await client.send('Page.captureScreenshot', {
1810
- format: 'png',
1811
- fromSurface: true,
1812
- captureBeyondViewport: false,
1813
- });
1814
- const outputBuffer = Buffer.from(screenshot.data, 'base64');
1815
-
1816
- if (outputBuffer.length >= maximumFileSize) {
1817
- throw new Error(
1818
- `Samsung asset exceeds 1024 KB (${Math.ceil(outputBuffer.length / 1024)} KB): ${asset.fileName}`,
1819
- );
1820
- }
1821
-
1822
- fs.writeFileSync(outputPath, outputBuffer);
1823
- console.log(
1824
- `Saved Samsung asset ${asset.width}x${asset.height} `
1825
- + `(${Math.ceil(outputBuffer.length / 1024)} KB): ${outputPath}`,
1826
- );
1827
- }
1828
- }
1829
-
1830
- async function createMissingStoreAssets() {
1831
- const hasMissingAmazonAssets = amazonAssetDefinitions.some((asset) => (
1832
- !fs.existsSync(path.join(amazonAssetsDirectory, asset.fileName))
1833
- ));
1834
- const hasMissingSamsungAssets = samsungAssetDefinitions.some((asset) => (
1835
- !fs.existsSync(path.join(samsungAssetsDirectory, asset.fileName))
1836
- ));
1837
- const hasMissingPlaystoreAssets = playstoreAssetDefinitions.some((asset) => (
1838
- !fs.existsSync(path.join(playstoreAssetsDirectory, asset.fileName))
1839
- ));
1840
-
1841
- if (!hasMissingAmazonAssets && !hasMissingSamsungAssets && !hasMissingPlaystoreAssets) {
1842
- console.log('Amazon, Samsung, and Play Store assets already exist.');
1843
- return;
1844
- }
1845
-
1846
- let browser;
1847
- let client;
1848
-
1849
- try {
1850
- browser = await launchReplayBrowser();
1851
- client = await createReplayPage(browser);
1852
-
1853
- if (hasMissingAmazonAssets) {
1854
- await createAmazonAssets(client, true);
1855
- }
1856
-
1857
- if (hasMissingSamsungAssets) {
1858
- await createSamsungAssets(client, true);
1859
- }
1860
- if (hasMissingPlaystoreAssets) {
1861
- await createPlaystoreAssets(client, true);
1862
- }
1863
- } finally {
1864
- if (client) client.close();
1865
- if (browser) await closeReplayBrowser(browser);
1866
- }
1867
-
1868
- console.log('Missing Amazon, Samsung, and Play Store assets created.');
1869
- }
1870
-
1871
- async function captureFramedScreenshot(
1872
- client,
1873
- screenshotBuffer,
1874
- profile,
1875
- screenshotFileName,
1876
- skipExisting = false,
1877
- ) {
1878
- const framedProfileDirectory = path.join(inFrameDirectory, profile.outputDirectory);
1879
- const framedScreenshotPath = path.join(framedProfileDirectory, screenshotFileName);
1880
-
1881
- if (skipExisting && fs.existsSync(framedScreenshotPath)) {
1882
- console.log(`Skipped existing framed screenshot: ${framedScreenshotPath}`);
1883
- return false;
1884
- }
1885
-
1886
- const hasCustomFrameSize = Boolean(profile.framedWidth && profile.framedHeight);
1887
- const frameWidth = profile.framedWidth || profile.width;
1888
- const frameHeight = profile.framedHeight || profile.height;
1889
- const frameScaleFactor = hasCustomFrameSize ? 1 : profile.deviceScaleFactor;
1890
-
1891
- await client.send('Emulation.setDeviceMetricsOverride', {
1892
- width: frameWidth,
1893
- height: frameHeight,
1894
- deviceScaleFactor: frameScaleFactor,
1895
- mobile: false,
1896
- screenWidth: frameWidth,
1897
- screenHeight: frameHeight,
1898
- positionX: 0,
1899
- positionY: 0,
1900
- });
1901
-
1902
- const frameTree = await client.send('Page.getFrameTree');
1903
- await client.send('Page.setDocumentContent', {
1904
- frameId: frameTree.frameTree.frame.id,
1905
- html: createDeviceFrameHtml(profile, screenshotBuffer),
1906
- });
1907
- await client.send('Runtime.evaluate', {
1908
- expression: `new Promise((resolve) => {
1909
- const image = document.querySelector('img');
1910
- const ready = () => requestAnimationFrame(() => requestAnimationFrame(resolve));
1911
- if (image.complete) ready();
1912
- else {
1913
- image.addEventListener('load', ready, { once: true });
1914
- image.addEventListener('error', ready, { once: true });
1915
- }
1916
- })`,
1917
- awaitPromise: true,
1918
- returnByValue: true,
1919
- });
1920
-
1921
- const framedScreenshot = await client.send('Page.captureScreenshot', {
1922
- format: 'png',
1923
- fromSurface: true,
1924
- captureBeyondViewport: false,
1925
- });
1926
-
1927
- fs.mkdirSync(framedProfileDirectory, { recursive: true });
1928
- fs.writeFileSync(
1929
- framedScreenshotPath,
1930
- Buffer.from(framedScreenshot.data, 'base64'),
1931
- { flag: 'wx' },
1932
- );
1933
- console.log(`Saved framed ${profile.label}: ${framedScreenshotPath}`);
1934
- return true;
1935
- }
1936
-
1937
- async function captureProfile(client, snapshot, profile, screenshotFileName) {
1938
- await client.send('Emulation.setDeviceMetricsOverride', {
1939
- width: profile.width,
1940
- height: profile.height,
1941
- deviceScaleFactor: profile.deviceScaleFactor,
1942
- mobile: profile.mobile,
1943
- screenWidth: profile.width,
1944
- screenHeight: profile.height,
1945
- positionX: 0,
1946
- positionY: 0,
1947
- });
1948
-
1949
- const frameTree = await client.send('Page.getFrameTree');
1950
- await client.send('Page.setDocumentContent', {
1951
- frameId: frameTree.frameTree.frame.id,
1952
- html: snapshot.html,
1953
- });
1954
- const replayLayout = await waitForReplayLayout(client, snapshot);
1955
-
1956
- if (replayLayout.documentWidth > replayLayout.viewportWidth + 1) {
1957
- console.warn(
1958
- `Warning: ${profile.label} replay is ${replayLayout.documentWidth}px wide `
1959
- + `inside a ${replayLayout.viewportWidth}px viewport.`,
1960
- );
1961
- }
1962
-
1963
- const screenshot = await client.send('Page.captureScreenshot', {
1964
- format: 'png',
1965
- fromSurface: true,
1966
- captureBeyondViewport: false,
1967
- });
1968
-
1969
- const profileDirectory = path.join(screenshotDirectory, profile.outputDirectory);
1970
- const screenshotPath = path.join(profileDirectory, screenshotFileName);
1971
- const screenshotBuffer = Buffer.from(screenshot.data, 'base64');
1972
-
1973
- fs.mkdirSync(profileDirectory, { recursive: true });
1974
- fs.writeFileSync(screenshotPath, screenshotBuffer, { flag: 'wx' });
1975
- console.log(`Saved ${profile.label}: ${screenshotPath}`);
1976
- await captureFramedScreenshot(
1977
- client,
1978
- screenshotBuffer,
1979
- profile,
1980
- screenshotFileName,
1981
- );
1982
- }
1983
-
1984
- async function captureScreenshotSet(
1985
- deviceClient,
1986
- replayClient,
1987
- selectedProfiles,
1988
- fileNameInput,
1989
- ) {
1990
- console.log('Capturing the current rendered page and resolved native data...');
1991
- const snapshot = await capturePageSnapshot(deviceClient);
1992
- const screenshotFileName = getScreenshotFileName(fileNameInput, selectedProfiles);
1993
-
1994
- for (const profile of selectedProfiles) {
1995
- await captureProfile(replayClient, snapshot, profile, screenshotFileName);
1996
- }
1997
-
1998
- console.log(`Responsive screenshot set completed: ${screenshotFileName}`);
1999
- }
2000
-
2001
- async function frameExistingScreenshots(selectedProfiles) {
2002
- let replayBrowser;
2003
- let replayClient;
2004
- let framedCount = 0;
2005
- let failedCount = 0;
2006
-
2007
- try {
2008
- replayBrowser = await launchReplayBrowser();
2009
- replayClient = await createReplayPage(replayBrowser);
2010
-
2011
- for (const profile of selectedProfiles) {
2012
- const profileDirectory = path.join(screenshotDirectory, profile.outputDirectory);
2013
-
2014
- if (!fs.existsSync(profileDirectory)) {
2015
- continue;
2016
- }
2017
-
2018
- const screenshotFiles = fs.readdirSync(profileDirectory, { withFileTypes: true })
2019
- .filter((entry) => entry.isFile() && /\.png$/i.test(entry.name))
2020
- .map((entry) => entry.name)
2021
- .sort((first, second) => first.localeCompare(second, undefined, { numeric: true }));
2022
-
2023
- for (const screenshotFileName of screenshotFiles) {
2024
- const screenshotPath = path.join(profileDirectory, screenshotFileName);
2025
-
2026
- try {
2027
- const wasCreated = await captureFramedScreenshot(
2028
- replayClient,
2029
- fs.readFileSync(screenshotPath),
2030
- profile,
2031
- screenshotFileName,
2032
- true,
2033
- );
2034
- if (wasCreated) framedCount += 1;
2035
- } catch (error) {
2036
- failedCount += 1;
2037
- console.error(`Failed to frame ${screenshotPath}: ${error.message}`);
2038
- }
2039
- }
2040
- }
2041
- } finally {
2042
- if (replayClient) {
2043
- replayClient.close();
2044
- }
2045
-
2046
- if (replayBrowser) {
2047
- await closeReplayBrowser(replayBrowser);
2048
- }
2049
- }
2050
-
2051
- console.log(`Existing screenshot framing completed: ${framedCount} created.`);
2052
-
2053
- if (failedCount) {
2054
- throw new Error(`${failedCount} existing screenshot(s) could not be framed.`);
2055
- }
2056
- }
2057
-
2058
- async function runInteractiveCaptureLoop(
2059
- deviceClient,
2060
- replayClient,
2061
- selectedProfiles,
2062
- ) {
2063
- const terminal = readline.createInterface({
2064
- input: process.stdin,
2065
- output: process.stdout,
2066
- prompt: 'Enter a filename, or press Enter for the next number: ',
2067
- });
2068
-
2069
- terminal.on('SIGINT', () => terminal.close());
2070
-
2071
- console.log(`Screenshots will be saved in: ${screenshotDirectory}`);
2072
- console.log('Press Ctrl+C to exit.');
2073
- terminal.prompt();
2074
-
2075
- for await (const fileNameInput of terminal) {
2076
- try {
2077
- await captureScreenshotSet(
2078
- deviceClient,
2079
- replayClient,
2080
- selectedProfiles,
2081
- fileNameInput,
2082
- );
2083
- } catch (error) {
2084
- console.error(`Responsive screenshot failed: ${error.message}`);
2085
- }
2086
-
2087
- terminal.prompt();
2088
- }
2089
-
2090
- console.log('\nResponsive screenshot tool closed.');
2091
- }
2092
-
2093
- async function getInteractiveOptions(options) {
2094
- if (
2095
- !process.stdin.isTTY
2096
- || options.dryRun
2097
- || options.amazonAssets
2098
- || options.samsungAssets
2099
- ) {
2100
- return options;
2101
- }
2102
-
2103
- const terminal = readline.createInterface({
2104
- input: process.stdin,
2105
- output: process.stdout,
2106
- });
2107
-
2108
- try {
2109
- if (!options.profiles) {
2110
- console.log('Available profiles:');
2111
- printProfiles();
2112
- options.profiles = await terminal.question(
2113
- 'Profiles separated by commas, or press Enter for all: ',
2114
- );
2115
- }
2116
- } finally {
2117
- terminal.close();
2118
- }
2119
-
2120
- return options;
2121
- }
2122
-
2123
- async function main() {
2124
- let options = parseArguments();
2125
-
2126
- if (options.help) {
2127
- printHelp();
2128
- return;
2129
- }
2130
-
2131
- if (options.list) {
2132
- printProfiles();
2133
- return;
2134
- }
2135
-
2136
- options = await getInteractiveOptions(options);
2137
-
2138
- if (options.amazonAssets) {
2139
- let amazonBrowser;
2140
- let amazonClient;
2141
-
2142
- try {
2143
- amazonBrowser = await launchReplayBrowser();
2144
- amazonClient = await createReplayPage(amazonBrowser);
2145
- await createAmazonAssets(amazonClient);
2146
- } finally {
2147
- if (amazonClient) amazonClient.close();
2148
- if (amazonBrowser) await closeReplayBrowser(amazonBrowser);
2149
- }
2150
-
2151
- console.log(`Amazon asset creation completed: ${amazonAssetsDirectory}`);
2152
- return;
2153
- }
2154
-
2155
- if (options.samsungAssets) {
2156
- let samsungBrowser;
2157
- let samsungClient;
2158
-
2159
- try {
2160
- samsungBrowser = await launchReplayBrowser();
2161
- samsungClient = await createReplayPage(samsungBrowser);
2162
- await createSamsungAssets(samsungClient);
2163
- } finally {
2164
- if (samsungClient) samsungClient.close();
2165
- if (samsungBrowser) await closeReplayBrowser(samsungBrowser);
2166
- }
2167
-
2168
- console.log(`Samsung asset creation completed: ${samsungAssetsDirectory}`);
2169
- return;
2170
- }
2171
-
2172
- if (!options.dryRun) {
2173
- await createMissingStoreAssets();
2174
- }
2175
-
2176
- const selectedProfiles = selectProfiles(options.profiles);
2177
-
2178
- if (options.frameExisting) {
2179
- await frameExistingScreenshots(selectedProfiles);
2180
- return;
2181
- }
2182
-
2183
- const interactiveMode = process.stdin.isTTY && !options.prefix && !options.dryRun;
2184
- const deviceId = getConnectedDevice();
2185
- const appId = getAppId();
2186
- const socketName = getWebViewSocket(deviceId, appId);
2187
- const localPort = runAdb([
2188
- '-s',
2189
- deviceId,
2190
- 'forward',
2191
- 'tcp:0',
2192
- `localabstract:${socketName}`,
2193
- ]);
2194
-
2195
- let deviceClient;
2196
- let replayBrowser;
2197
- let replayClient;
2198
-
2199
- try {
2200
- const target = await getWebViewTarget(localPort);
2201
- deviceClient = new CdpClient(target.webSocketDebuggerUrl);
2202
- await deviceClient.connect();
2203
- await deviceClient.send('Page.enable');
2204
- await deviceClient.send('Runtime.enable');
2205
-
2206
- const pageDetails = await getPageDetails(deviceClient);
2207
- console.log(`Connected device: ${deviceId}`);
2208
- console.log(`App: ${appId}`);
2209
- console.log(`Current page: ${pageDetails.title || '(untitled)'}`);
2210
- console.log(`Current URL: ${pageDetails.url}`);
2211
- console.log(
2212
- `Current viewport: ${pageDetails.width}x${pageDetails.height} `
2213
- + `at ${pageDetails.devicePixelRatio}x (${pageDetails.readyState})`,
2214
- );
2215
-
2216
- if (options.dryRun) {
2217
- console.log('Dry run completed. No screenshots were created.');
2218
- return;
2219
- }
2220
-
2221
- fs.mkdirSync(screenshotDirectory, { recursive: true });
2222
- replayBrowser = await launchReplayBrowser();
2223
- replayClient = await createReplayPage(replayBrowser);
2224
-
2225
- console.log(
2226
- 'Apple, TV, Chromebook, Mac, and XR profiles are viewport approximations of the live Android WebView.',
2227
- );
2228
-
2229
- if (interactiveMode) {
2230
- await runInteractiveCaptureLoop(
2231
- deviceClient,
2232
- replayClient,
2233
- selectedProfiles,
2234
- );
2235
- } else {
2236
- await captureScreenshotSet(
2237
- deviceClient,
2238
- replayClient,
2239
- selectedProfiles,
2240
- options.prefix,
2241
- );
2242
- }
2243
- } finally {
2244
- if (replayClient) {
2245
- replayClient.close();
2246
- }
2247
-
2248
- if (replayBrowser) {
2249
- await closeReplayBrowser(replayBrowser);
2250
- }
2251
-
2252
- if (deviceClient) {
2253
- deviceClient.close();
2254
- }
2255
-
2256
- if (localPort) {
2257
- try {
2258
- runAdb(['-s', deviceId, 'forward', '--remove', `tcp:${localPort}`]);
2259
- } catch {
2260
- // ADB may already have removed the temporary forwarding rule.
2261
- }
2262
- }
2263
- }
2264
- }
2265
-
2266
- main().catch((error) => {
2267
- console.error(`Responsive screenshot failed: ${error.message}`);
2268
- process.exitCode = 1;
2269
- });
1
+ const fs = require('node:fs');
2
+ const path = require('node:path');
3
+ const readline = require('node:readline/promises');
4
+ const { spawn, spawnSync } = require('node:child_process');
5
+
6
+ const screenshotDirectory = path.join(__dirname, 'Auto-Screenshot', 'Store-image-Screenshot');
7
+ const inFrameDirectory = path.join(__dirname, 'Auto-Screenshot', 'Store-image-Frame');
8
+ const aiAssetsDirectory = path.join(__dirname, 'AI-Assets');
9
+ const amazonAssetsDirectory = path.join(aiAssetsDirectory, 'Amazon-Assets');
10
+ const samsungAssetsDirectory = path.join(aiAssetsDirectory, 'Samsung-Assets');
11
+ const playstoreAssetsDirectory = path.join(aiAssetsDirectory, 'Playstore-Assets');
12
+ const capacitorConfigPath = path.join(__dirname, 'capacitor.config.json');
13
+ const amazonIconPath = path.join(__dirname, 'resources', 'icon-only.png');
14
+ const temporaryDirectory = path.join(__dirname, 'agent-temp');
15
+ const amazonAssetDefinitions = [
16
+ { fileName: 'icon-1280x720.png', type: 'icon', width: 1280, height: 720 },
17
+ { fileName: 'background-image-1920x1080.png', type: 'background-image', width: 1920, height: 1080 },
18
+ { fileName: 'featured-content-logo-640x260.png', type: 'featured-logo', width: 640, height: 260, transparent: true },
19
+ { fileName: 'featured-content-background-1920x720.png', type: 'featured-background', width: 1920, height: 720 },
20
+ ];
21
+ const samsungAssetDefinitions = [
22
+ { fileName: 'edge-screen-160x2560.png', type: 'edge-screen', width: 160, height: 2560 },
23
+ { fileName: 'edge-screen-single-550x2560.png', type: 'edge-screen-single-plus', width: 550, height: 2560 },
24
+ ];
25
+ const playstoreAssetDefinitions = [
26
+ { fileName: 'feature-graphic-1024x500.png', width: 1024, height: 500 },
27
+ ];
28
+ const browserExecutablePaths = [
29
+ 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe',
30
+ 'C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe',
31
+ 'C:\\Program Files\\Microsoft\\Edge\\Application\\msedge.exe',
32
+ 'C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe',
33
+ ];
34
+
35
+ const deviceProfiles = [
36
+ {
37
+ name: 'android-phone',
38
+ label: 'Android phone',
39
+ outputDirectory: 'Android',
40
+ frameType: 'android-phone',
41
+ width: 360,
42
+ height: 800,
43
+ deviceScaleFactor: 3,
44
+ orientationSensitive: true,
45
+ mobile: true,
46
+ },
47
+ {
48
+ name: 'android-tablet-7-inch',
49
+ aliases: ['android-tablet'],
50
+ label: 'Android 7-inch Play Store tablet',
51
+ outputDirectory: 'Android-Tab-7inch',
52
+ frameType: 'tablet',
53
+ width: 675,
54
+ height: 1200,
55
+ deviceScaleFactor: 2,
56
+ framedWidth: 1350,
57
+ framedHeight: 2400,
58
+ orientationSensitive: true,
59
+ mobile: true,
60
+ },
61
+ {
62
+ name: 'android-tablet-10-inch',
63
+ label: 'Android 10-inch Play Store tablet',
64
+ outputDirectory: 'Android-Tab-10inch',
65
+ frameType: 'tablet',
66
+ width: 900,
67
+ height: 1600,
68
+ deviceScaleFactor: 2,
69
+ framedWidth: 1800,
70
+ framedHeight: 3200,
71
+ orientationSensitive: true,
72
+ mobile: true,
73
+ },
74
+ {
75
+ name: 'iphone',
76
+ label: 'iPhone 6.9-inch App Store viewport',
77
+ outputDirectory: 'iPhone',
78
+ frameType: 'iphone',
79
+ width: 440,
80
+ height: 956,
81
+ deviceScaleFactor: 3,
82
+ framedWidth: 1284,
83
+ framedHeight: 2778,
84
+ orientationSensitive: true,
85
+ mobile: true,
86
+ },
87
+ {
88
+ name: 'ipad',
89
+ label: 'iPad 13-inch App Store viewport',
90
+ outputDirectory: 'iPad',
91
+ frameType: 'tablet',
92
+ width: 1032,
93
+ height: 1376,
94
+ deviceScaleFactor: 2,
95
+ framedWidth: 2048,
96
+ framedHeight: 2732,
97
+ orientationSensitive: true,
98
+ mobile: true,
99
+ },
100
+ {
101
+ name: 'apple-vision-pro',
102
+ label: 'Apple Vision Pro',
103
+ outputDirectory: 'Apple-Vision-Pro',
104
+ frameType: 'apple-vision-pro',
105
+ width: 3840,
106
+ height: 2160,
107
+ deviceScaleFactor: 1,
108
+ framedWidth: 3840,
109
+ framedHeight: 2160,
110
+ mobile: false,
111
+ },
112
+ {
113
+ name: 'apple-tv',
114
+ label: 'Apple TV',
115
+ outputDirectory: 'Apple-TV',
116
+ frameType: 'apple-tv',
117
+ width: 1920,
118
+ height: 1080,
119
+ deviceScaleFactor: 1,
120
+ framedWidth: 1920,
121
+ framedHeight: 1080,
122
+ mobile: false,
123
+ },
124
+ {
125
+ name: 'apple-watch',
126
+ label: 'Apple Watch Ultra 3',
127
+ outputDirectory: 'Apple-Watch',
128
+ frameType: 'apple-watch',
129
+ width: 422,
130
+ height: 514,
131
+ deviceScaleFactor: 1,
132
+ framedWidth: 422,
133
+ framedHeight: 514,
134
+ mobile: true,
135
+ },
136
+ {
137
+ name: 'amazon-mobile',
138
+ label: 'Amazon mobile store viewport',
139
+ outputDirectory: 'Amazon-Phone',
140
+ frameType: 'android-phone',
141
+ width: 360,
142
+ height: 640,
143
+ deviceScaleFactor: 3,
144
+ orientationSensitive: true,
145
+ mobile: true,
146
+ },
147
+ {
148
+ name: 'amazon-fire-tablet',
149
+ label: 'Amazon Fire tablet store viewport',
150
+ outputDirectory: 'Amazon-Tab',
151
+ frameType: 'tablet',
152
+ width: 800,
153
+ height: 1280,
154
+ deviceScaleFactor: 2,
155
+ orientationSensitive: true,
156
+ mobile: true,
157
+ },
158
+ {
159
+ name: 'amazon-fire-tv',
160
+ label: 'Amazon Fire TV store viewport',
161
+ outputDirectory: 'Amazon-TV',
162
+ frameType: 'tv',
163
+ width: 1920,
164
+ height: 1080,
165
+ deviceScaleFactor: 1,
166
+ mobile: false,
167
+ },
168
+ {
169
+ name: 'android-tv',
170
+ label: 'Android TV',
171
+ outputDirectory: 'Android-TV',
172
+ frameType: 'tv',
173
+ width: 1920,
174
+ height: 1080,
175
+ deviceScaleFactor: 1,
176
+ mobile: false,
177
+ },
178
+ {
179
+ name: 'chromebook',
180
+ label: 'Chromebook 4K Play Store viewport',
181
+ outputDirectory: 'Chromebook',
182
+ frameType: 'laptop',
183
+ width: 1920,
184
+ height: 1080,
185
+ deviceScaleFactor: 2,
186
+ framedWidth: 3840,
187
+ framedHeight: 2160,
188
+ mobile: false,
189
+ },
190
+ {
191
+ name: 'android-xr',
192
+ label: 'Android XR 2D panel approximation',
193
+ outputDirectory: 'Android-XR',
194
+ frameType: 'xr',
195
+ width: 1920,
196
+ height: 1080,
197
+ deviceScaleFactor: 1,
198
+ mobile: false,
199
+ },
200
+ {
201
+ name: 'mac',
202
+ label: 'Mac-sized viewport',
203
+ outputDirectory: 'Mac',
204
+ frameType: 'laptop',
205
+ width: 1440,
206
+ height: 900,
207
+ deviceScaleFactor: 2,
208
+ mobile: false,
209
+ },
210
+ ];
211
+
212
+ function getConfiguredOrientation() {
213
+ const config = JSON.parse(fs.readFileSync(capacitorConfigPath, 'utf8'));
214
+ return String(config.android?.ORIENTATION || 'portrait').toLowerCase() === 'landscape'
215
+ ? 'landscape'
216
+ : 'portrait';
217
+ }
218
+
219
+ function orientProfile(profile, orientation) {
220
+ if (orientation !== 'landscape' || !profile.orientationSensitive) {
221
+ return profile;
222
+ }
223
+
224
+ return {
225
+ ...profile,
226
+ width: profile.height,
227
+ height: profile.width,
228
+ framedWidth: profile.framedHeight,
229
+ framedHeight: profile.framedWidth,
230
+ };
231
+ }
232
+
233
+ function runAdb(args, options = {}) {
234
+ const result = spawnSync('adb', args, {
235
+ encoding: 'utf8',
236
+ timeout: 30000,
237
+ windowsHide: true,
238
+ ...options,
239
+ });
240
+
241
+ if (result.error) {
242
+ if (result.error.code === 'ENOENT') {
243
+ throw new Error('ADB was not found. Add Android platform-tools to your PATH.');
244
+ }
245
+
246
+ if (result.error.code === 'ETIMEDOUT') {
247
+ throw new Error('ADB did not respond within 30 seconds. Check the device connection.');
248
+ }
249
+
250
+ throw result.error;
251
+ }
252
+
253
+ if (result.status !== 0) {
254
+ throw new Error(result.stderr.trim() || `ADB exited with code ${result.status}.`);
255
+ }
256
+
257
+ return result.stdout.trim();
258
+ }
259
+
260
+ function getConnectedDevice() {
261
+ const devices = runAdb(['devices'])
262
+ .split(/\r?\n/)
263
+ .map((line) => line.match(/^(\S+)\s+device$/))
264
+ .filter(Boolean)
265
+ .map((match) => match[1]);
266
+
267
+ if (!devices.length) {
268
+ throw new Error('no_connected_device');
269
+ }
270
+
271
+ return devices[0];
272
+ }
273
+
274
+ function getAppId() {
275
+ const config = JSON.parse(fs.readFileSync(capacitorConfigPath, 'utf8'));
276
+
277
+ if (!config.appId) {
278
+ throw new Error('capacitor.config.json does not contain an appId.');
279
+ }
280
+
281
+ return config.appId;
282
+ }
283
+
284
+ function getWebViewSocket(deviceId, appId) {
285
+ const processIds = runAdb(['-s', deviceId, 'shell', 'pidof', appId])
286
+ .split(/\s+/)
287
+ .filter(Boolean);
288
+
289
+ if (!processIds.length) {
290
+ throw new Error(`The app process is not running: ${appId}`);
291
+ }
292
+
293
+ const sockets = runAdb(['-s', deviceId, 'shell', 'cat', '/proc/net/unix']);
294
+
295
+ for (const processId of processIds) {
296
+ const socketName = `webview_devtools_remote_${processId}`;
297
+
298
+ if (sockets.includes(`@${socketName}`)) {
299
+ return socketName;
300
+ }
301
+ }
302
+
303
+ throw new Error(
304
+ 'The running app does not expose a debuggable WebView. Use a debug/live-reload build with WebView debugging enabled.',
305
+ );
306
+ }
307
+
308
+ function sanitizeName(input, fallback) {
309
+ const sanitized = input
310
+ .trim()
311
+ .replace(/[<>:"/\\|?*\u0000-\u001F]/g, '_')
312
+ .replace(/[. ]+$/g, '');
313
+
314
+ return sanitized || fallback;
315
+ }
316
+
317
+ function getSelectedOutputDirectories(selectedProfiles) {
318
+ return [...new Set(selectedProfiles.flatMap((profile) => ([
319
+ path.join(screenshotDirectory, profile.outputDirectory),
320
+ path.join(inFrameDirectory, profile.outputDirectory),
321
+ ])))];
322
+ }
323
+
324
+ function getNextNumberedFileName(selectedProfiles) {
325
+ const highestNumber = getSelectedOutputDirectories(selectedProfiles)
326
+ .filter((directory) => fs.existsSync(directory))
327
+ .reduce((highestAcrossDirectories, directory) => {
328
+ const highestInDirectory = fs.readdirSync(directory, { withFileTypes: true })
329
+ .filter((entry) => entry.isFile())
330
+ .reduce((highest, entry) => {
331
+ const match = entry.name.match(/^(\d+)\.png$/i);
332
+ return match ? Math.max(highest, Number(match[1])) : highest;
333
+ }, 0);
334
+
335
+ return Math.max(highestAcrossDirectories, highestInDirectory);
336
+ }, 0);
337
+
338
+ return `${highestNumber + 1}.png`;
339
+ }
340
+
341
+ function getUniqueFileName(prefix, selectedProfiles) {
342
+ const outputDirectories = getSelectedOutputDirectories(selectedProfiles);
343
+ let fileName = `${prefix}.png`;
344
+ let duplicateNumber = 2;
345
+
346
+ while (outputDirectories.some((directory) => (
347
+ fs.existsSync(path.join(directory, fileName))
348
+ ))) {
349
+ fileName = `${prefix}-${duplicateNumber}.png`;
350
+ duplicateNumber += 1;
351
+ }
352
+
353
+ return fileName;
354
+ }
355
+
356
+ function getScreenshotFileName(fileNameInput, selectedProfiles) {
357
+ const inputWithoutExtension = fileNameInput.trim().replace(/\.png$/i, '');
358
+
359
+ if (!inputWithoutExtension) {
360
+ return getNextNumberedFileName(selectedProfiles);
361
+ }
362
+
363
+ let prefix = sanitizeName(inputWithoutExtension, '');
364
+
365
+ if (!prefix) {
366
+ return getNextNumberedFileName(selectedProfiles);
367
+ }
368
+
369
+ if (/^(con|prn|aux|nul|com[1-9]|lpt[1-9])$/i.test(prefix)) {
370
+ prefix = `${prefix}_`;
371
+ }
372
+
373
+ return getUniqueFileName(prefix, selectedProfiles);
374
+ }
375
+
376
+ function parseArguments() {
377
+ const args = process.argv.slice(2);
378
+ const options = {
379
+ amazonAssets: false,
380
+ samsungAssets: false,
381
+ dryRun: false,
382
+ frameExisting: false,
383
+ help: false,
384
+ list: false,
385
+ prefix: '',
386
+ profiles: '',
387
+ };
388
+
389
+ for (let index = 0; index < args.length; index += 1) {
390
+ const argument = args[index];
391
+
392
+ if (argument === '--amazon-assets') {
393
+ options.amazonAssets = true;
394
+ } else if (argument === '--samsung-assets') {
395
+ options.samsungAssets = true;
396
+ } else if (argument === '--dry-run') {
397
+ options.dryRun = true;
398
+ } else if (argument === '--frame-existing') {
399
+ options.frameExisting = true;
400
+ } else if (argument === '--help' || argument === '-h') {
401
+ options.help = true;
402
+ } else if (argument === '--list') {
403
+ options.list = true;
404
+ } else if (argument === '--prefix') {
405
+ options.prefix = args[index + 1] || '';
406
+ index += 1;
407
+ } else if (argument === '--profiles') {
408
+ options.profiles = args[index + 1] || '';
409
+ index += 1;
410
+ } else {
411
+ throw new Error(`Unknown argument: ${argument}`);
412
+ }
413
+ }
414
+
415
+ return options;
416
+ }
417
+
418
+ function printProfiles() {
419
+ const orientation = getConfiguredOrientation();
420
+
421
+ for (const baseProfile of deviceProfiles) {
422
+ const profile = orientProfile(baseProfile, orientation);
423
+ const outputWidth = profile.width * profile.deviceScaleFactor;
424
+ const outputHeight = profile.height * profile.deviceScaleFactor;
425
+ const framedSize = profile.framedWidth
426
+ ? `, framed PNG ${profile.framedWidth}x${profile.framedHeight}`
427
+ : '';
428
+ console.log(
429
+ `${profile.name.padEnd(15)} ${profile.label} `
430
+ + `(folder ${profile.outputDirectory}, viewport ${profile.width}x${profile.height}, `
431
+ + `PNG ${outputWidth}x${outputHeight}${framedSize})`,
432
+ );
433
+ }
434
+ }
435
+
436
+ function printHelp() {
437
+ console.log('Capture the current live Android WebView at multiple responsive viewport sizes.');
438
+ console.log('');
439
+ console.log('Usage:');
440
+ console.log(' node take-screen-image.js');
441
+ console.log(' node take-screen-image.js --profiles iphone,ipad');
442
+ console.log(' node take-screen-image.js --prefix home --profiles android-phone,ipad');
443
+ console.log(' node take-screen-image.js --frame-existing');
444
+ console.log(' node take-screen-image.js --amazon-assets');
445
+ console.log(' node take-screen-image.js --samsung-assets');
446
+ console.log(' node take-screen-image.js --dry-run');
447
+ console.log(' node take-screen-image.js --list');
448
+ console.log('');
449
+ console.log('A normal capture automatically creates any missing Amazon, Samsung, and Play Store assets.');
450
+ }
451
+
452
+ function selectProfiles(input) {
453
+ const orientation = getConfiguredOrientation();
454
+ const names = input
455
+ .split(',')
456
+ .map((name) => name.trim().toLowerCase())
457
+ .filter(Boolean);
458
+
459
+ if (!names.length || names.includes('all')) {
460
+ return deviceProfiles.map((profile) => orientProfile(profile, orientation));
461
+ }
462
+
463
+ const selectedProfiles = names.map((name) => {
464
+ const profile = deviceProfiles.find((candidate) => (
465
+ candidate.name === name || candidate.aliases?.includes(name)
466
+ ));
467
+
468
+ if (!profile) {
469
+ throw new Error(`Unknown profile: ${name}. Run with --list to see valid profiles.`);
470
+ }
471
+
472
+ return profile;
473
+ });
474
+
475
+ return [...new Map(selectedProfiles.map((profile) => [profile.name, profile])).values()]
476
+ .map((profile) => orientProfile(profile, orientation));
477
+ }
478
+
479
+ class CdpClient {
480
+ constructor(webSocketUrl) {
481
+ this.nextId = 1;
482
+ this.pendingCommands = new Map();
483
+ this.socket = new WebSocket(webSocketUrl);
484
+ }
485
+
486
+ connect() {
487
+ return new Promise((resolve, reject) => {
488
+ const timeout = setTimeout(() => {
489
+ reject(new Error('Timed out while connecting to the app WebView.'));
490
+ }, 10000);
491
+
492
+ this.socket.addEventListener('open', () => {
493
+ clearTimeout(timeout);
494
+ resolve();
495
+ }, { once: true });
496
+
497
+ this.socket.addEventListener('error', () => {
498
+ clearTimeout(timeout);
499
+ reject(new Error('Could not connect to the app WebView debugging socket.'));
500
+ }, { once: true });
501
+
502
+ this.socket.addEventListener('message', (event) => {
503
+ const message = JSON.parse(event.data);
504
+
505
+ if (!message.id || !this.pendingCommands.has(message.id)) {
506
+ return;
507
+ }
508
+
509
+ const pendingCommand = this.pendingCommands.get(message.id);
510
+ this.pendingCommands.delete(message.id);
511
+
512
+ if (message.error) {
513
+ pendingCommand.reject(new Error(message.error.message));
514
+ } else {
515
+ pendingCommand.resolve(message.result);
516
+ }
517
+ });
518
+
519
+ this.socket.addEventListener('close', () => {
520
+ for (const pendingCommand of this.pendingCommands.values()) {
521
+ pendingCommand.reject(new Error('The app WebView debugging connection closed.'));
522
+ }
523
+
524
+ this.pendingCommands.clear();
525
+ });
526
+ });
527
+ }
528
+
529
+ send(method, params = {}) {
530
+ return new Promise((resolve, reject) => {
531
+ const id = this.nextId;
532
+ this.nextId += 1;
533
+ this.pendingCommands.set(id, { resolve, reject });
534
+ this.socket.send(JSON.stringify({ id, method, params }));
535
+ });
536
+ }
537
+
538
+ close() {
539
+ this.socket.close();
540
+ }
541
+ }
542
+
543
+ async function getWebViewTarget(localPort) {
544
+ const response = await fetch(`http://127.0.0.1:${localPort}/json/list`);
545
+
546
+ if (!response.ok) {
547
+ throw new Error(`WebView target discovery failed with HTTP ${response.status}.`);
548
+ }
549
+
550
+ const targets = await response.json();
551
+ const pageTargets = targets.filter((target) => (
552
+ target.type === 'page'
553
+ && target.webSocketDebuggerUrl
554
+ && target.url !== 'about:blank'
555
+ ));
556
+
557
+ const visibleTarget = pageTargets.find((target) => {
558
+ try {
559
+ return JSON.parse(target.description || '{}').visible !== false;
560
+ } catch {
561
+ return true;
562
+ }
563
+ });
564
+
565
+ const target = visibleTarget || pageTargets[0];
566
+
567
+ if (!target) {
568
+ throw new Error('No rendered page was found in the app WebView.');
569
+ }
570
+
571
+ return target;
572
+ }
573
+
574
+ async function getPageDetails(client) {
575
+ const evaluation = await client.send('Runtime.evaluate', {
576
+ expression: `JSON.stringify({
577
+ title: document.title,
578
+ url: location.href,
579
+ width: innerWidth,
580
+ height: innerHeight,
581
+ devicePixelRatio: devicePixelRatio,
582
+ readyState: document.readyState
583
+ })`,
584
+ returnByValue: true,
585
+ });
586
+
587
+ return JSON.parse(evaluation.result.value);
588
+ }
589
+
590
+ async function capturePageSnapshot(client) {
591
+ const evaluation = await client.send('Runtime.evaluate', {
592
+ expression: `(async () => {
593
+ const clonedRoot = document.documentElement.cloneNode(true);
594
+ const originalElements = [
595
+ document.documentElement,
596
+ ...document.documentElement.querySelectorAll('*'),
597
+ ];
598
+ const clonedElements = [clonedRoot, ...clonedRoot.querySelectorAll('*')];
599
+
600
+ const blobToDataUrl = (blob) => new Promise((resolve, reject) => {
601
+ const reader = new FileReader();
602
+ reader.onload = () => resolve(reader.result);
603
+ reader.onerror = () => reject(reader.error);
604
+ reader.readAsDataURL(blob);
605
+ });
606
+
607
+ const imageToDataUrl = (element) => {
608
+ const width = element.naturalWidth || element.clientWidth;
609
+ const height = element.naturalHeight || element.clientHeight;
610
+
611
+ if (!width || !height) return '';
612
+
613
+ const canvas = document.createElement('canvas');
614
+ canvas.width = width;
615
+ canvas.height = height;
616
+ canvas.getContext('2d').drawImage(element, 0, 0, width, height);
617
+ return canvas.toDataURL('image/png');
618
+ };
619
+
620
+ for (let index = 0; index < originalElements.length; index += 1) {
621
+ const original = originalElements[index];
622
+ const clone = clonedElements[index];
623
+
624
+ if (!clone) continue;
625
+
626
+ if (original.scrollTop || original.scrollLeft) {
627
+ clone.setAttribute('data-responsive-scroll-top', String(original.scrollTop));
628
+ clone.setAttribute('data-responsive-scroll-left', String(original.scrollLeft));
629
+ }
630
+
631
+ if (original.classList.contains('swiper-initialized')) {
632
+ clone.setAttribute('data-responsive-swiper-width', String(original.clientWidth));
633
+ clone.setAttribute('data-responsive-swiper-height', String(original.clientHeight));
634
+ }
635
+
636
+ if (original instanceof HTMLInputElement) {
637
+ clone.setAttribute('value', original.value);
638
+ clone.toggleAttribute('checked', original.checked);
639
+ } else if (original instanceof HTMLTextAreaElement) {
640
+ clone.textContent = original.value;
641
+ } else if (original instanceof HTMLSelectElement) {
642
+ for (let optionIndex = 0; optionIndex < original.options.length; optionIndex += 1) {
643
+ clone.options[optionIndex].selected = original.options[optionIndex].selected;
644
+ }
645
+ } else if (original instanceof HTMLCanvasElement) {
646
+ try {
647
+ clone.style.setProperty(
648
+ 'background-image',
649
+ 'url("' + original.toDataURL('image/png') + '")',
650
+ 'important',
651
+ );
652
+ clone.style.setProperty('background-position', 'center', 'important');
653
+ clone.style.setProperty('background-repeat', 'no-repeat', 'important');
654
+ clone.style.setProperty('background-size', '100% 100%', 'important');
655
+ } catch {
656
+ // Keep the canvas element when its pixels cannot be exported.
657
+ }
658
+ } else if (original instanceof HTMLVideoElement && original.readyState >= 2) {
659
+ try {
660
+ const replacement = document.createElement('img');
661
+ replacement.src = imageToDataUrl(original);
662
+ replacement.alt = original.getAttribute('aria-label') || '';
663
+ replacement.setAttribute('style', original.getAttribute('style') || '');
664
+ clone.replaceWith(replacement);
665
+ } catch {
666
+ clone.setAttribute('poster', original.poster || '');
667
+ }
668
+ } else if (original instanceof HTMLImageElement && original.complete) {
669
+ try {
670
+ clone.setAttribute('src', imageToDataUrl(original) || original.currentSrc || original.src);
671
+ clone.removeAttribute('srcset');
672
+ } catch {
673
+ clone.setAttribute('src', original.currentSrc || original.src);
674
+ }
675
+ }
676
+ }
677
+
678
+ const stylesheetLinks = [...document.querySelectorAll('link[rel="stylesheet"]')];
679
+ const clonedStylesheetLinks = [...clonedRoot.querySelectorAll('link[rel="stylesheet"]')];
680
+
681
+ await Promise.all(stylesheetLinks.map(async (link, index) => {
682
+ try {
683
+ const response = await fetch(link.href);
684
+ if (!response.ok) return;
685
+
686
+ const cssText = (await response.text()).replace(
687
+ /url\\(\\s*(['"]?)(?!data:|blob:|#)([^'"\\)]+)\\1\\s*\\)/gi,
688
+ (match, quote, resourceUrl) => {
689
+ try {
690
+ return 'url("' + new URL(resourceUrl.trim(), link.href).href + '")';
691
+ } catch {
692
+ return match;
693
+ }
694
+ },
695
+ );
696
+ const style = document.createElement('style');
697
+ style.textContent = cssText;
698
+ clonedStylesheetLinks[index].replaceWith(style);
699
+ } catch {
700
+ clonedStylesheetLinks[index].href = link.href;
701
+ }
702
+ }));
703
+
704
+ clonedRoot.querySelectorAll('script, link[rel="modulepreload"]').forEach((element) => element.remove());
705
+ clonedRoot.querySelectorAll('meta[http-equiv="Content-Security-Policy"]').forEach(
706
+ (element) => element.remove(),
707
+ );
708
+
709
+ const base = document.createElement('base');
710
+ base.href = location.href;
711
+ clonedRoot.querySelector('head').prepend(base);
712
+
713
+ const replayStyle = document.createElement('style');
714
+ replayStyle.textContent = [
715
+ '*,*::before,*::after{animation-play-state:paused!important;}',
716
+ '*{scrollbar-width:none!important;}',
717
+ '*::-webkit-scrollbar{width:0!important;height:0!important;display:none!important;}',
718
+ ].join('');
719
+ clonedRoot.querySelector('head').append(replayStyle);
720
+
721
+ return {
722
+ html: '<!doctype html>' + clonedRoot.outerHTML,
723
+ scrollY,
724
+ };
725
+ })()`,
726
+ awaitPromise: true,
727
+ returnByValue: true,
728
+ });
729
+
730
+ if (evaluation.exceptionDetails) {
731
+ throw new Error('The current WebView page could not be serialized.');
732
+ }
733
+
734
+ return evaluation.result.value;
735
+ }
736
+
737
+ function findBrowserExecutable() {
738
+ const executablePath = browserExecutablePaths.find((candidate) => (
739
+ fs.existsSync(candidate)
740
+ ));
741
+
742
+ if (!executablePath) {
743
+ throw new Error('Google Chrome or Microsoft Edge was not found on this computer.');
744
+ }
745
+
746
+ return executablePath;
747
+ }
748
+
749
+ function delay(milliseconds) {
750
+ return new Promise((resolve) => setTimeout(resolve, milliseconds));
751
+ }
752
+
753
+ async function launchReplayBrowser() {
754
+ fs.mkdirSync(temporaryDirectory, { recursive: true });
755
+
756
+ const profileDirectory = path.join(
757
+ temporaryDirectory,
758
+ `responsive-screenshot-chrome-${process.pid}-${Date.now()}`,
759
+ );
760
+ fs.mkdirSync(profileDirectory, { recursive: true });
761
+
762
+ const browserProcess = spawn(findBrowserExecutable(), [
763
+ '--headless=new',
764
+ '--remote-debugging-port=0',
765
+ `--user-data-dir=${profileDirectory}`,
766
+ '--no-first-run',
767
+ '--no-default-browser-check',
768
+ '--disable-background-networking',
769
+ '--remote-allow-origins=*',
770
+ 'about:blank',
771
+ ], {
772
+ stdio: 'ignore',
773
+ windowsHide: true,
774
+ });
775
+
776
+ const activePortPath = path.join(profileDirectory, 'DevToolsActivePort');
777
+ const deadline = Date.now() + 15000;
778
+
779
+ while (!fs.existsSync(activePortPath)) {
780
+ if (browserProcess.exitCode !== null) {
781
+ throw new Error('The local replay browser closed before it was ready.');
782
+ }
783
+
784
+ if (Date.now() >= deadline) {
785
+ browserProcess.kill();
786
+ throw new Error('Timed out while starting the local replay browser.');
787
+ }
788
+
789
+ await delay(100);
790
+ }
791
+
792
+ const [port] = fs.readFileSync(activePortPath, 'utf8').trim().split(/\r?\n/);
793
+ const versionResponse = await fetch(`http://127.0.0.1:${port}/json/version`);
794
+ const version = await versionResponse.json();
795
+ const client = new CdpClient(version.webSocketDebuggerUrl);
796
+ await client.connect();
797
+
798
+ return {
799
+ browserProcess,
800
+ client,
801
+ port,
802
+ profileDirectory,
803
+ };
804
+ }
805
+
806
+ async function createReplayPage(browser) {
807
+ const createdTarget = await browser.client.send('Target.createTarget', {
808
+ url: 'about:blank',
809
+ });
810
+ const deadline = Date.now() + 10000;
811
+
812
+ while (Date.now() < deadline) {
813
+ const response = await fetch(`http://127.0.0.1:${browser.port}/json/list`);
814
+ const targets = await response.json();
815
+ const target = targets.find((candidate) => candidate.id === createdTarget.targetId);
816
+
817
+ if (target && target.webSocketDebuggerUrl) {
818
+ const client = new CdpClient(target.webSocketDebuggerUrl);
819
+ await client.connect();
820
+ await client.send('Page.enable');
821
+ await client.send('Runtime.enable');
822
+ return client;
823
+ }
824
+
825
+ await delay(100);
826
+ }
827
+
828
+ throw new Error('The local replay page did not become available.');
829
+ }
830
+
831
+ function removeTemporaryBrowserProfile(profileDirectory) {
832
+ const resolvedTemporaryRoot = path.resolve(temporaryDirectory);
833
+ const resolvedProfileDirectory = path.resolve(profileDirectory);
834
+
835
+ if (!resolvedProfileDirectory.startsWith(`${resolvedTemporaryRoot}${path.sep}`)) {
836
+ throw new Error('Refusing to remove a browser profile outside agent-temp.');
837
+ }
838
+
839
+ fs.rmSync(resolvedProfileDirectory, { recursive: true, force: true });
840
+ }
841
+
842
+ async function closeReplayBrowser(browser) {
843
+ try {
844
+ await browser.client.send('Browser.close');
845
+ } catch {
846
+ // Closing the browser can close the protocol connection before it acknowledges.
847
+ }
848
+
849
+ const deadline = Date.now() + 3000;
850
+
851
+ while (browser.browserProcess.exitCode === null && Date.now() < deadline) {
852
+ await delay(100);
853
+ }
854
+
855
+ if (browser.browserProcess.exitCode === null) {
856
+ browser.browserProcess.kill();
857
+ await delay(200);
858
+ }
859
+
860
+ browser.client.close();
861
+
862
+ for (let attempt = 1; attempt <= 5; attempt += 1) {
863
+ try {
864
+ removeTemporaryBrowserProfile(browser.profileDirectory);
865
+ return;
866
+ } catch (error) {
867
+ if (attempt === 5) {
868
+ throw error;
869
+ }
870
+
871
+ await delay(200);
872
+ }
873
+ }
874
+ }
875
+
876
+ async function waitForReplayLayout(client, snapshot) {
877
+ const evaluation = await client.send('Runtime.evaluate', {
878
+ expression: `(async () => {
879
+ const assetWait = Promise.all([...document.images].map((image) => {
880
+ if (image.complete) return Promise.resolve();
881
+ return new Promise((resolve) => {
882
+ image.addEventListener('load', resolve, { once: true });
883
+ image.addEventListener('error', resolve, { once: true });
884
+ });
885
+ }));
886
+
887
+ await Promise.race([
888
+ Promise.all([document.fonts.ready, assetWait]),
889
+ new Promise((resolve) => setTimeout(resolve, 5000)),
890
+ ]);
891
+
892
+ document.querySelectorAll('[data-responsive-swiper-width]').forEach((swiper) => {
893
+ const wrapper = [...swiper.children].find((element) => (
894
+ element.classList.contains('swiper-wrapper')
895
+ ));
896
+
897
+ if (!wrapper) return;
898
+
899
+ const slides = [...wrapper.children].filter((element) => (
900
+ element.classList.contains('swiper-slide')
901
+ ));
902
+ const sourceWidth = Number(swiper.dataset.responsiveSwiperWidth);
903
+ const sourceHeight = Number(swiper.dataset.responsiveSwiperHeight);
904
+ const widthScale = sourceWidth > 0 ? swiper.clientWidth / sourceWidth : 1;
905
+ const heightScale = sourceHeight > 0 ? swiper.clientHeight / sourceHeight : 1;
906
+ const isVertical = swiper.classList.contains('swiper-vertical');
907
+
908
+ slides.forEach((slide) => {
909
+ if (!slide.dataset.responsiveSwiperWidth && slide.style.width) {
910
+ slide.dataset.responsiveSwiperWidth = String(parseFloat(slide.style.width));
911
+ }
912
+
913
+ if (!slide.dataset.responsiveSwiperHeight && slide.style.height) {
914
+ slide.dataset.responsiveSwiperHeight = String(parseFloat(slide.style.height));
915
+ }
916
+
917
+ const slideWidth = Number(slide.dataset.responsiveSwiperWidth);
918
+ const slideHeight = Number(slide.dataset.responsiveSwiperHeight);
919
+
920
+ if (Number.isFinite(slideWidth) && slideWidth > 0) {
921
+ slide.style.width = (slideWidth * widthScale) + 'px';
922
+ }
923
+
924
+ if (isVertical && Number.isFinite(slideHeight) && slideHeight > 0) {
925
+ slide.style.height = (slideHeight * heightScale) + 'px';
926
+ }
927
+ });
928
+
929
+ const activeSlide = slides.find((slide) => (
930
+ slide.classList.contains('swiper-slide-active')
931
+ )) || slides[0];
932
+
933
+ if (!activeSlide) return;
934
+
935
+ const offset = isVertical ? activeSlide.offsetTop : activeSlide.offsetLeft;
936
+ const direction = getComputedStyle(swiper).direction;
937
+ const horizontalOffset = direction === 'rtl' ? offset : -offset;
938
+ const verticalOffset = isVertical ? -offset : 0;
939
+
940
+ wrapper.style.transitionDuration = '0ms';
941
+ wrapper.style.transform = 'translate3d('
942
+ + (isVertical ? 0 : horizontalOffset) + 'px, '
943
+ + verticalOffset + 'px, 0px)';
944
+
945
+ if (swiper.classList.contains('swiper-autoheight')) {
946
+ wrapper.style.height = activeSlide.scrollHeight + 'px';
947
+ }
948
+ });
949
+
950
+ document.querySelectorAll('[data-responsive-scroll-top]').forEach((element) => {
951
+ element.scrollTop = Number(element.dataset.responsiveScrollTop);
952
+ element.scrollLeft = Number(element.dataset.responsiveScrollLeft);
953
+ });
954
+ scrollTo(0, ${Number(snapshot.scrollY) || 0});
955
+
956
+ await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
957
+
958
+ return {
959
+ documentWidth: Math.max(document.documentElement.scrollWidth, document.body.scrollWidth),
960
+ viewportWidth: innerWidth,
961
+ };
962
+ })()`,
963
+ awaitPromise: true,
964
+ returnByValue: true,
965
+ });
966
+
967
+ return evaluation.result.value;
968
+ }
969
+
970
+ function getFrameAdornment(frameType) {
971
+ if (frameType === 'iphone') {
972
+ return '<div class="dynamic-island"></div>';
973
+ }
974
+
975
+ if (frameType === 'android-phone') {
976
+ return '<div class="camera-dot"></div>';
977
+ }
978
+
979
+ if (frameType === 'tablet') {
980
+ return '<div class="tablet-camera"></div>';
981
+ }
982
+
983
+ if (frameType === 'tv' || frameType === 'apple-tv') {
984
+ return '<div class="tv-stand"></div>';
985
+ }
986
+
987
+ if (frameType === 'apple-watch') {
988
+ return '<div class="watch-crown"></div><div class="watch-button"></div>';
989
+ }
990
+
991
+ if (frameType === 'apple-vision-pro') {
992
+ return '<div class="vision-sensor vision-sensor-left"></div><div class="vision-sensor vision-sensor-right"></div>';
993
+ }
994
+
995
+ if (frameType === 'laptop') {
996
+ return '<div class="laptop-camera"></div><div class="laptop-base"></div>';
997
+ }
998
+
999
+ return '<div class="xr-status"></div>';
1000
+ }
1001
+
1002
+ function createDeviceFrameHtml(profile, screenshotBuffer) {
1003
+ const screenshotDataUrl = `data:image/png;base64,${screenshotBuffer.toString('base64')}`;
1004
+ const aspectRatio = `${profile.width} / ${profile.height}`;
1005
+
1006
+ return `<!doctype html>
1007
+ <html>
1008
+ <head>
1009
+ <meta charset="utf-8" />
1010
+ <style>
1011
+ * { box-sizing: border-box; }
1012
+ html, body { width: 100%; height: 100%; margin: 0; overflow: hidden; }
1013
+ body {
1014
+ display: grid;
1015
+ place-items: center;
1016
+ position: relative;
1017
+ background:
1018
+ radial-gradient(circle at 14% 18%, rgba(45, 213, 255, 0.24), transparent 30%),
1019
+ radial-gradient(circle at 86% 82%, rgba(196, 255, 61, 0.16), transparent 28%),
1020
+ linear-gradient(145deg, #111827 0%, #070b12 52%, #020409 100%);
1021
+ font-family: Arial, sans-serif;
1022
+ }
1023
+ body::before {
1024
+ content: '';
1025
+ position: absolute;
1026
+ inset: 0;
1027
+ opacity: 0.18;
1028
+ background-image:
1029
+ linear-gradient(rgba(255,255,255,0.045) 1px, transparent 1px),
1030
+ linear-gradient(90deg, rgba(255,255,255,0.045) 1px, transparent 1px);
1031
+ background-size: 7vw 7vw;
1032
+ mask-image: linear-gradient(to bottom, transparent, black 28%, black 72%, transparent);
1033
+ }
1034
+ .device {
1035
+ position: relative;
1036
+ z-index: 1;
1037
+ aspect-ratio: ${aspectRatio};
1038
+ background: linear-gradient(145deg, #566171 0%, #171c25 18%, #05070b 55%, #384252 100%);
1039
+ border: clamp(2px, 0.45vw, 9px) solid #727d8d;
1040
+ box-shadow:
1041
+ 0 4vh 9vh rgba(0, 0, 0, 0.68),
1042
+ 0 0 0 1px rgba(255, 255, 255, 0.18) inset,
1043
+ 0 0 5vh rgba(45, 213, 255, 0.12);
1044
+ }
1045
+ .screen {
1046
+ position: relative;
1047
+ width: 100%;
1048
+ height: 100%;
1049
+ overflow: hidden;
1050
+ background: #05070b;
1051
+ box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.95) inset;
1052
+ }
1053
+ .screen img {
1054
+ display: block;
1055
+ width: 100%;
1056
+ height: 100%;
1057
+ object-fit: cover;
1058
+ }
1059
+ .device.android-phone,
1060
+ .device.iphone {
1061
+ height: 87%;
1062
+ padding: 1.25%;
1063
+ border-radius: clamp(24px, 8vw, 70px);
1064
+ }
1065
+ .device.android-phone .screen,
1066
+ .device.iphone .screen {
1067
+ border-radius: clamp(19px, 6.7vw, 60px);
1068
+ }
1069
+ .camera-dot {
1070
+ position: absolute;
1071
+ z-index: 3;
1072
+ top: 2.1%;
1073
+ left: 50%;
1074
+ width: clamp(5px, 1.8vw, 16px);
1075
+ aspect-ratio: 1;
1076
+ transform: translateX(-50%);
1077
+ border-radius: 50%;
1078
+ background: radial-gradient(circle at 36% 34%, #406891, #080b10 46%, #000 72%);
1079
+ box-shadow: 0 0 0 2px #111827;
1080
+ }
1081
+ .dynamic-island {
1082
+ position: absolute;
1083
+ z-index: 3;
1084
+ top: 2.2%;
1085
+ left: 50%;
1086
+ width: 29%;
1087
+ height: 3.3%;
1088
+ transform: translateX(-50%);
1089
+ border-radius: 999px;
1090
+ background: #000;
1091
+ box-shadow: 0 0 0 1px rgba(255,255,255,0.05);
1092
+ }
1093
+ .device.tablet {
1094
+ height: 86%;
1095
+ padding: 1.05%;
1096
+ border-radius: clamp(18px, 3.2vw, 48px);
1097
+ }
1098
+ .device.tablet .screen {
1099
+ border-radius: clamp(13px, 2.5vw, 38px);
1100
+ }
1101
+ .device.apple-watch {
1102
+ height: 82%;
1103
+ padding: 2.2%;
1104
+ border: clamp(3px, 1.4vw, 7px) solid #8b929b;
1105
+ border-radius: 24% / 20%;
1106
+ background: linear-gradient(145deg, #d5d8dc, #363b43 28%, #11151b 72%, #9299a2);
1107
+ }
1108
+ .device.apple-watch .screen {
1109
+ border-radius: 20% / 17%;
1110
+ }
1111
+ .watch-crown {
1112
+ position: absolute;
1113
+ top: 24%;
1114
+ right: -5.5%;
1115
+ width: 5.5%;
1116
+ height: 15%;
1117
+ border-radius: 0 45% 45% 0;
1118
+ background: linear-gradient(90deg, #303640, #a8afb7 55%, #424851);
1119
+ box-shadow: 0 0.8vh 1.5vh rgba(0,0,0,0.45);
1120
+ }
1121
+ .watch-button {
1122
+ position: absolute;
1123
+ top: 43%;
1124
+ right: -3.5%;
1125
+ width: 3.5%;
1126
+ height: 11%;
1127
+ border-radius: 0 35% 35% 0;
1128
+ background: #626a74;
1129
+ }
1130
+ .device.apple-vision-pro {
1131
+ width: 82%;
1132
+ padding: 0.72%;
1133
+ border: clamp(4px, 0.45vw, 18px) solid rgba(201, 214, 224, 0.92);
1134
+ border-radius: 14% / 32%;
1135
+ background: linear-gradient(145deg, #dce5eb, #55616d 25%, #151b22 72%, #aebac3);
1136
+ box-shadow:
1137
+ 0 5vh 11vh rgba(0,0,0,0.68),
1138
+ 0 0 5vh rgba(180,225,255,0.2),
1139
+ 0 0 0 1px rgba(255,255,255,0.4) inset;
1140
+ }
1141
+ .device.apple-vision-pro .screen {
1142
+ border-radius: 13% / 29%;
1143
+ }
1144
+ .vision-sensor {
1145
+ position: absolute;
1146
+ z-index: 3;
1147
+ top: 48%;
1148
+ width: clamp(8px, 0.8vw, 30px);
1149
+ aspect-ratio: 1;
1150
+ border-radius: 50%;
1151
+ background: radial-gradient(circle at 38% 35%, #567793, #05080c 55%, #000 75%);
1152
+ box-shadow: 0 0 0 2px rgba(147,166,181,0.55);
1153
+ }
1154
+ .vision-sensor-left { left: 3.2%; }
1155
+ .vision-sensor-right { right: 3.2%; }
1156
+ .tablet-camera,
1157
+ .laptop-camera {
1158
+ position: absolute;
1159
+ z-index: 3;
1160
+ top: 0.48%;
1161
+ left: 50%;
1162
+ width: clamp(3px, 0.55vw, 10px);
1163
+ aspect-ratio: 1;
1164
+ transform: translateX(-50%);
1165
+ border-radius: 50%;
1166
+ background: #080b10;
1167
+ box-shadow: 0 0 0 1px #526071;
1168
+ }
1169
+ .device.tv,
1170
+ .device.apple-tv {
1171
+ width: 82%;
1172
+ padding: 0.62%;
1173
+ border-radius: clamp(8px, 1.1vw, 24px);
1174
+ transform: translateY(-4%);
1175
+ }
1176
+ .device.tv .screen,
1177
+ .device.apple-tv .screen { border-radius: clamp(4px, 0.55vw, 12px); }
1178
+ .tv-stand {
1179
+ position: absolute;
1180
+ z-index: -1;
1181
+ left: 50%;
1182
+ bottom: -13%;
1183
+ width: 24%;
1184
+ height: 13%;
1185
+ transform: translateX(-50%);
1186
+ background: linear-gradient(90deg, #11161f, #697383 50%, #11161f);
1187
+ clip-path: polygon(42% 0, 58% 0, 67% 76%, 94% 86%, 100% 100%, 0 100%, 6% 86%, 33% 76%);
1188
+ filter: drop-shadow(0 1.5vh 1.5vh rgba(0,0,0,0.55));
1189
+ }
1190
+ .device.laptop {
1191
+ width: 82%;
1192
+ padding: 0.75% 0.75% 1.15%;
1193
+ border-radius: clamp(8px, 1.2vw, 24px);
1194
+ transform: translateY(-3%);
1195
+ }
1196
+ .device.laptop .screen { border-radius: clamp(4px, 0.55vw, 12px); }
1197
+ .laptop-base {
1198
+ position: absolute;
1199
+ z-index: -1;
1200
+ left: 50%;
1201
+ bottom: -7.5%;
1202
+ width: 112%;
1203
+ height: 8%;
1204
+ transform: translateX(-50%);
1205
+ border-radius: 0 0 50% 50% / 0 0 70% 70%;
1206
+ background: linear-gradient(#697383, #202631 45%, #090c11);
1207
+ box-shadow: 0 1.5vh 2vh rgba(0,0,0,0.48);
1208
+ }
1209
+ .device.xr {
1210
+ width: 82%;
1211
+ padding: 0.58%;
1212
+ border: clamp(2px, 0.32vw, 7px) solid rgba(128, 230, 255, 0.9);
1213
+ border-radius: clamp(18px, 2.5vw, 48px);
1214
+ background: rgba(13, 22, 34, 0.82);
1215
+ box-shadow:
1216
+ 0 4vh 10vh rgba(0,0,0,0.65),
1217
+ 0 0 7vh rgba(45,213,255,0.34),
1218
+ 0 0 0 1px rgba(255,255,255,0.22) inset;
1219
+ }
1220
+ .device.xr .screen { border-radius: clamp(13px, 2vw, 38px); }
1221
+ .xr-status {
1222
+ position: absolute;
1223
+ top: -2.4%;
1224
+ left: 50%;
1225
+ width: 13%;
1226
+ height: 1.1%;
1227
+ transform: translateX(-50%);
1228
+ border-radius: 999px;
1229
+ background: #6fe8ff;
1230
+ box-shadow: 0 0 1.8vh #2dd5ff;
1231
+ }
1232
+ </style>
1233
+ </head>
1234
+ <body>
1235
+ <main class="device ${profile.frameType}">
1236
+ <div class="screen"><img src="${screenshotDataUrl}" /></div>
1237
+ ${getFrameAdornment(profile.frameType)}
1238
+ </main>
1239
+ </body>
1240
+ </html>`;
1241
+ }
1242
+
1243
+ function escapeHtml(value) {
1244
+ return String(value)
1245
+ .replace(/&/g, '&amp;')
1246
+ .replace(/</g, '&lt;')
1247
+ .replace(/>/g, '&gt;')
1248
+ .replace(/"/g, '&quot;')
1249
+ .replace(/'/g, '&#39;');
1250
+ }
1251
+
1252
+ function cleanAssetCopy(value, maximumLength, fallback = '') {
1253
+ const cleaned = String(value || '')
1254
+ .replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, ' ')
1255
+ .replace(/<style\b[^>]*>[\s\S]*?<\/style>/gi, ' ')
1256
+ .replace(/<[^>]+>/g, ' ')
1257
+ .replace(/&[a-zA-Z#0-9]+;/g, ' ')
1258
+ .replace(/[_|]+/g, ' ')
1259
+ .replace(/\s+/g, ' ')
1260
+ .trim();
1261
+ if (!cleaned) return fallback;
1262
+ if (cleaned.length <= maximumLength) return cleaned;
1263
+ const shortened = cleaned.slice(0, maximumLength + 1);
1264
+ const lastSpace = shortened.lastIndexOf(' ');
1265
+ return shortened.slice(0, lastSpace > maximumLength * 0.55 ? lastSpace : maximumLength).trim();
1266
+ }
1267
+
1268
+ function readJsonAssetSource(filePath) {
1269
+ try {
1270
+ return JSON.parse(fs.readFileSync(filePath, 'utf8'));
1271
+ } catch {
1272
+ return {};
1273
+ }
1274
+ }
1275
+
1276
+ function collectProjectUiPhrases() {
1277
+ const phrases = [];
1278
+ const pageDirectory = path.join(__dirname, 'src', 'pages');
1279
+ if (fs.existsSync(pageDirectory)) {
1280
+ const pageFiles = fs.readdirSync(pageDirectory)
1281
+ .filter((fileName) => /\.(?:f7|html)$/i.test(fileName))
1282
+ .sort()
1283
+ .slice(0, 40);
1284
+ const visibleElementPattern = /<(?:h[1-4]|button|label|option|a|li)\b[^>]*>([\s\S]*?)<\/(?:h[1-4]|button|label|option|a|li)>/gi;
1285
+ for (const fileName of pageFiles) {
1286
+ const source = fs.readFileSync(path.join(pageDirectory, fileName), 'utf8');
1287
+ let match;
1288
+ while ((match = visibleElementPattern.exec(source)) && phrases.length < 120) {
1289
+ const phrase = cleanAssetCopy(match[1], 52);
1290
+ if (phrase) phrases.push(phrase);
1291
+ }
1292
+ }
1293
+ }
1294
+ return phrases;
1295
+ }
1296
+
1297
+ function getStoreAssetCopy(config) {
1298
+ const configuredAppName = cleanAssetCopy(config.appName, 40, 'App');
1299
+ const appUniqueId = String(config.android?.APP_UNIQUE_ID ?? '').trim();
1300
+ const promoCopyPath = appUniqueId
1301
+ ? path.join(
1302
+ __dirname,
1303
+ 'Auto-AI-Video',
1304
+ `${appUniqueId}. ${sanitizeName(configuredAppName, 'App')}-promo-copy.json`,
1305
+ )
1306
+ : '';
1307
+ const promoCopy = promoCopyPath && fs.existsSync(promoCopyPath)
1308
+ ? readJsonAssetSource(promoCopyPath).copy || {}
1309
+ : {};
1310
+ const scenes = Array.isArray(promoCopy.scenes) ? promoCopy.scenes : [];
1311
+ const footerKeywords = Array.isArray(promoCopy.footer_keywords)
1312
+ ? promoCopy.footer_keywords
1313
+ : [];
1314
+ const manifestCandidates = [
1315
+ path.join(__dirname, 'src', 'manifest.webmanifest'),
1316
+ path.join(__dirname, 'public', 'manifest.webmanifest'),
1317
+ path.join(__dirname, 'manifest.webmanifest'),
1318
+ ];
1319
+ const manifestPath = manifestCandidates.find((candidate) => fs.existsSync(candidate));
1320
+ const manifest = manifestPath ? readJsonAssetSource(manifestPath) : {};
1321
+ const candidates = [
1322
+ ...footerKeywords,
1323
+ ...scenes.flatMap((scene) => [
1324
+ `${scene.title_line_1 || ''} ${scene.title_line_2 || ''}`,
1325
+ scene.kicker,
1326
+ scene.body,
1327
+ ]),
1328
+ ...(Array.isArray(manifest.categories) ? manifest.categories : []),
1329
+ manifest.description,
1330
+ ...collectProjectUiPhrases(),
1331
+ ];
1332
+ const uniquePhrases = [];
1333
+ const seen = new Set([configuredAppName.toLowerCase()]);
1334
+ for (const candidate of candidates) {
1335
+ const phrase = cleanAssetCopy(candidate, 52);
1336
+ const key = phrase.toLowerCase();
1337
+ if (phrase.length < 3 || seen.has(key)) continue;
1338
+ seen.add(key);
1339
+ uniquePhrases.push(phrase);
1340
+ }
1341
+
1342
+ const featureLabels = uniquePhrases
1343
+ .map((phrase) => cleanAssetCopy(phrase, 18))
1344
+ .filter((phrase, index, values) => (
1345
+ phrase.length >= 3
1346
+ && values.findIndex((value) => value.toLowerCase() === phrase.toLowerCase()) === index
1347
+ ))
1348
+ .slice(0, 9);
1349
+ while (featureLabels.length < 9) {
1350
+ featureLabels.push(`Feature ${String(featureLabels.length + 1).padStart(2, '0')}`);
1351
+ }
1352
+
1353
+ const firstScene = scenes[0] || {};
1354
+ const firstSceneTitle = cleanAssetCopy(
1355
+ `${firstScene.title_line_1 || ''} ${firstScene.title_line_2 || ''}`,
1356
+ 34,
1357
+ );
1358
+ const primaryPhrase = footerKeywords[0] || featureLabels[0];
1359
+ return {
1360
+ amazonEyebrow: cleanAssetCopy(primaryPhrase, 32, `Explore ${configuredAppName}`),
1361
+ amazonCallout: cleanAssetCopy(
1362
+ firstScene.body || manifest.description || uniquePhrases[0],
1363
+ 52,
1364
+ `Explore ${configuredAppName}`,
1365
+ ),
1366
+ featureLabels,
1367
+ samsungKicker: cleanAssetCopy(firstScene.kicker || primaryPhrase, 28, 'App features'),
1368
+ samsungAction: firstSceneTitle || cleanAssetCopy(primaryPhrase, 34, `Open ${configuredAppName}`),
1369
+ samsungBody: cleanAssetCopy(
1370
+ firstScene.body || manifest.description || uniquePhrases[1],
1371
+ 52,
1372
+ 'Explore the available features',
1373
+ ),
1374
+ };
1375
+ }
1376
+
1377
+ function createAmazonAssetHtml(asset, appName, iconBuffer, storeCopy) {
1378
+ const iconDataUrl = `data:image/png;base64,${iconBuffer.toString('base64')}`;
1379
+ const safeAppName = escapeHtml(appName);
1380
+ const safeEyebrow = escapeHtml(storeCopy.amazonEyebrow);
1381
+ const safeCallout = escapeHtml(storeCopy.amazonCallout);
1382
+ const featureMarkup = storeCopy.featureLabels.slice(0, 6)
1383
+ .map((label) => `<span>${escapeHtml(label)}</span>`)
1384
+ .join('');
1385
+ const logoMarkup = asset.type === 'featured-logo'
1386
+ ? `<main class="featured-logo"><img src="${iconDataUrl}" /><div><small>${safeEyebrow}</small><strong>${safeAppName}</strong></div></main>`
1387
+ : '';
1388
+ const iconMarkup = asset.type === 'icon'
1389
+ ? `<main class="icon-card"><img src="${iconDataUrl}" /><div><small>${safeCallout}</small><strong>${safeAppName}</strong></div></main>`
1390
+ : '';
1391
+ const backgroundMarkup = asset.type.includes('background')
1392
+ ? `<main class="background-art"><div class="pulse pulse-one"></div><div class="pulse pulse-two"></div><div class="feature-grid">${featureMarkup}</div></main>`
1393
+ : '';
1394
+
1395
+ return `<!doctype html>
1396
+ <html>
1397
+ <head>
1398
+ <meta charset="utf-8" />
1399
+ <style>
1400
+ * { box-sizing: border-box; }
1401
+ html, body { width: 100%; height: 100%; margin: 0; overflow: hidden; }
1402
+ body {
1403
+ position: relative;
1404
+ font-family: Arial, Helvetica, sans-serif;
1405
+ color: #f8fbff;
1406
+ background: transparent;
1407
+ }
1408
+ body.icon,
1409
+ body.background-image,
1410
+ body.featured-background {
1411
+ background:
1412
+ radial-gradient(circle at 16% 18%, rgba(52, 211, 235, 0.32), transparent 28%),
1413
+ radial-gradient(circle at 82% 82%, rgba(201, 255, 65, 0.24), transparent 30%),
1414
+ linear-gradient(135deg, #24113c 0%, #15152d 48%, #071725 100%);
1415
+ }
1416
+ body.icon::before,
1417
+ body.background-image::before,
1418
+ body.featured-background::before {
1419
+ content: '';
1420
+ position: absolute;
1421
+ inset: 0;
1422
+ opacity: 0.22;
1423
+ background-image:
1424
+ linear-gradient(rgba(255,255,255,0.07) 1px, transparent 1px),
1425
+ linear-gradient(90deg, rgba(255,255,255,0.07) 1px, transparent 1px);
1426
+ background-size: 72px 72px;
1427
+ }
1428
+ .icon-card {
1429
+ position: relative;
1430
+ z-index: 2;
1431
+ width: 100%;
1432
+ height: 100%;
1433
+ display: flex;
1434
+ align-items: center;
1435
+ justify-content: center;
1436
+ gap: 72px;
1437
+ padding: 72px 110px;
1438
+ }
1439
+ .icon-card img {
1440
+ width: 500px;
1441
+ height: 500px;
1442
+ object-fit: contain;
1443
+ border-radius: 104px;
1444
+ box-shadow: 0 38px 80px rgba(0,0,0,0.48), 0 0 70px rgba(52,211,235,0.2);
1445
+ }
1446
+ .icon-card div { display: flex; flex-direction: column; gap: 18px; }
1447
+ .icon-card small,
1448
+ .featured-logo small {
1449
+ color: #35d4eb;
1450
+ font-size: 24px;
1451
+ font-weight: 700;
1452
+ letter-spacing: 7px;
1453
+ }
1454
+ .icon-card strong { font-size: 86px; line-height: 0.96; max-width: 500px; }
1455
+ .featured-logo {
1456
+ width: 100%;
1457
+ height: 100%;
1458
+ display: flex;
1459
+ align-items: center;
1460
+ gap: 24px;
1461
+ padding: 20px 26px;
1462
+ color: #111827;
1463
+ }
1464
+ .featured-logo img {
1465
+ width: 210px;
1466
+ height: 210px;
1467
+ object-fit: contain;
1468
+ border-radius: 44px;
1469
+ }
1470
+ .featured-logo div { display: flex; min-width: 0; flex-direction: column; gap: 8px; }
1471
+ .featured-logo small { font-size: 14px; letter-spacing: 3px; }
1472
+ .featured-logo strong {
1473
+ max-width: 360px;
1474
+ overflow: hidden;
1475
+ color: #ffffff;
1476
+ font-size: 56px;
1477
+ line-height: 1;
1478
+ text-overflow: ellipsis;
1479
+ white-space: nowrap;
1480
+ text-shadow: 0 3px 12px rgba(0,0,0,0.75);
1481
+ }
1482
+ .background-art { position: absolute; inset: 0; }
1483
+ .pulse {
1484
+ position: absolute;
1485
+ border: 3px solid rgba(53,212,235,0.28);
1486
+ border-radius: 50%;
1487
+ box-shadow: 0 0 80px rgba(53,212,235,0.15);
1488
+ }
1489
+ .pulse-one { width: 520px; height: 520px; top: -180px; left: -110px; }
1490
+ .pulse-two { width: 620px; height: 620px; right: -180px; bottom: -310px; border-color: rgba(201,255,65,0.22); }
1491
+ .feature-grid {
1492
+ position: absolute;
1493
+ left: 8%;
1494
+ right: 8%;
1495
+ top: 50%;
1496
+ display: grid;
1497
+ grid-template-columns: repeat(2, minmax(0, 1fr));
1498
+ gap: 24px;
1499
+ transform: translateY(-50%);
1500
+ }
1501
+ .feature-grid span {
1502
+ display: flex;
1503
+ min-height: 108px;
1504
+ align-items: center;
1505
+ justify-content: center;
1506
+ padding: 20px;
1507
+ color: #f8fbff;
1508
+ border: 2px solid rgba(255,255,255,0.17);
1509
+ border-radius: 22px;
1510
+ background: linear-gradient(145deg, rgba(42,55,75,0.96), rgba(11,18,29,0.96));
1511
+ box-shadow: 0 20px 28px rgba(0,0,0,0.28);
1512
+ font-size: clamp(20px, 2vw, 34px);
1513
+ font-weight: 700;
1514
+ text-align: center;
1515
+ }
1516
+ .feature-grid span:nth-child(3n+1) { border-top: 7px solid #35d4eb; }
1517
+ .feature-grid span:nth-child(3n+2) { border-top: 7px solid #ff9f43; }
1518
+ .feature-grid span:nth-child(3n) { border-top: 7px solid #c9ff41; }
1519
+ body.featured-background .feature-grid { left: 5%; right: 5%; gap: 18px; }
1520
+ </style>
1521
+ </head>
1522
+ <body class="${asset.type}">
1523
+ ${iconMarkup}
1524
+ ${logoMarkup}
1525
+ ${backgroundMarkup}
1526
+ </body>
1527
+ </html>`;
1528
+ }
1529
+
1530
+ function createPlaystoreFeatureHtml(appName, iconBuffer, storeCopy) {
1531
+ const iconDataUrl = `data:image/png;base64,${iconBuffer.toString('base64')}`;
1532
+ const safeAppName = escapeHtml(appName);
1533
+ const safeCallout = escapeHtml(storeCopy.amazonCallout);
1534
+ const featureMarkup = storeCopy.featureLabels.slice(0, 5)
1535
+ .map((label, index) => `<span><i>0${index + 1}</i>${escapeHtml(label)}</span>`)
1536
+ .join('');
1537
+
1538
+ return `<!doctype html>
1539
+ <html>
1540
+ <head>
1541
+ <meta charset="utf-8" />
1542
+ <style>
1543
+ * { box-sizing: border-box; }
1544
+ html, body { width: 100%; height: 100%; margin: 0; overflow: hidden; }
1545
+ body {
1546
+ display: flex;
1547
+ align-items: center;
1548
+ padding: 42px 64px;
1549
+ color: #f7fbff;
1550
+ background: radial-gradient(circle at 84% 18%, rgba(53,212,235,0.3), transparent 31%), linear-gradient(120deg, #111729, #231039 58%, #071522);
1551
+ font-family: Arial, Helvetica, sans-serif;
1552
+ }
1553
+ main { display: flex; width: 100%; height: 100%; align-items: center; justify-content: space-between; gap: 48px; }
1554
+ .copy { max-width: 570px; }
1555
+ img { width: 128px; height: 128px; border-radius: 30px; object-fit: contain; }
1556
+ small { display: block; margin-top: 24px; color: #35d4eb; font-size: 18px; font-weight: 700; letter-spacing: 4px; text-transform: uppercase; }
1557
+ h1 { margin: 12px 0 18px; font-size: 64px; line-height: 0.98; }
1558
+ p { margin: 0; color: #b8c5d4; font-size: 23px; line-height: 1.35; }
1559
+ .features { display: grid; width: 340px; gap: 12px; }
1560
+ .features span { display: flex; align-items: center; gap: 16px; padding: 17px 20px; border: 1px solid rgba(255,255,255,0.16); border-radius: 15px; background: rgba(8,16,28,0.6); font-size: 18px; font-weight: 700; }
1561
+ .features i { color: #c9ff41; font-size: 13px; font-style: normal; }
1562
+ </style>
1563
+ </head>
1564
+ <body><main><section class="copy"><img src="${iconDataUrl}" /><small>Explore ${safeAppName}</small><h1>${safeAppName}</h1><p>${safeCallout}</p></section><section class="features">${featureMarkup}</section></main></body>
1565
+ </html>`;
1566
+ }
1567
+
1568
+ async function createPlaystoreAssets(client, skipExisting = false) {
1569
+ if (!fs.existsSync(amazonIconPath)) {
1570
+ throw new Error(`Play Store asset source icon was not found: ${amazonIconPath}`);
1571
+ }
1572
+
1573
+ const config = JSON.parse(fs.readFileSync(capacitorConfigPath, 'utf8'));
1574
+ const appName = config.appName || 'App';
1575
+ const storeCopy = getStoreAssetCopy(config);
1576
+ const iconBuffer = fs.readFileSync(amazonIconPath);
1577
+ fs.mkdirSync(playstoreAssetsDirectory, { recursive: true });
1578
+
1579
+ for (const asset of playstoreAssetDefinitions) {
1580
+ const outputPath = path.join(playstoreAssetsDirectory, asset.fileName);
1581
+ if (skipExisting && fs.existsSync(outputPath)) continue;
1582
+
1583
+ await client.send('Emulation.setDeviceMetricsOverride', {
1584
+ width: asset.width, height: asset.height, deviceScaleFactor: 1, mobile: false,
1585
+ screenWidth: asset.width, screenHeight: asset.height, positionX: 0, positionY: 0,
1586
+ });
1587
+ const frameTree = await client.send('Page.getFrameTree');
1588
+ await client.send('Page.setDocumentContent', {
1589
+ frameId: frameTree.frameTree.frame.id,
1590
+ html: createPlaystoreFeatureHtml(appName, iconBuffer, storeCopy),
1591
+ });
1592
+ await client.send('Runtime.evaluate', {
1593
+ expression: `Promise.all([...document.images].map((image) => image.complete ? Promise.resolve() : new Promise((resolve) => image.addEventListener('load', resolve, { once: true })))).then(() => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))))`,
1594
+ awaitPromise: true,
1595
+ returnByValue: true,
1596
+ });
1597
+ const screenshot = await client.send('Page.captureScreenshot', {
1598
+ format: 'png', fromSurface: true, captureBeyondViewport: false,
1599
+ });
1600
+ fs.writeFileSync(outputPath, Buffer.from(screenshot.data, 'base64'));
1601
+ console.log(`Saved Play Store feature ${asset.width}x${asset.height}: ${outputPath}`);
1602
+ }
1603
+ }
1604
+
1605
+ async function createAmazonAssets(client, skipExisting = false) {
1606
+ if (!fs.existsSync(amazonIconPath)) {
1607
+ throw new Error(`Amazon asset source icon was not found: ${amazonIconPath}`);
1608
+ }
1609
+
1610
+ const config = JSON.parse(fs.readFileSync(capacitorConfigPath, 'utf8'));
1611
+ const appName = config.appName || 'App';
1612
+ const storeCopy = getStoreAssetCopy(config);
1613
+ const iconBuffer = fs.readFileSync(amazonIconPath);
1614
+ fs.mkdirSync(amazonAssetsDirectory, { recursive: true });
1615
+
1616
+ for (const asset of amazonAssetDefinitions) {
1617
+ const outputPath = path.join(amazonAssetsDirectory, asset.fileName);
1618
+
1619
+ if (skipExisting && fs.existsSync(outputPath)) {
1620
+ continue;
1621
+ }
1622
+
1623
+ await client.send('Emulation.setDeviceMetricsOverride', {
1624
+ width: asset.width,
1625
+ height: asset.height,
1626
+ deviceScaleFactor: 1,
1627
+ mobile: false,
1628
+ screenWidth: asset.width,
1629
+ screenHeight: asset.height,
1630
+ positionX: 0,
1631
+ positionY: 0,
1632
+ });
1633
+
1634
+ const frameTree = await client.send('Page.getFrameTree');
1635
+ await client.send('Page.setDocumentContent', {
1636
+ frameId: frameTree.frameTree.frame.id,
1637
+ html: createAmazonAssetHtml(asset, appName, iconBuffer, storeCopy),
1638
+ });
1639
+ await client.send('Runtime.evaluate', {
1640
+ expression: `Promise.all([...document.images].map((image) => image.complete
1641
+ ? Promise.resolve()
1642
+ : new Promise((resolve) => {
1643
+ image.addEventListener('load', resolve, { once: true });
1644
+ image.addEventListener('error', resolve, { once: true });
1645
+ }))).then(() => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))))`,
1646
+ awaitPromise: true,
1647
+ returnByValue: true,
1648
+ });
1649
+
1650
+ const screenshot = await client.send('Page.captureScreenshot', {
1651
+ format: 'png',
1652
+ fromSurface: true,
1653
+ captureBeyondViewport: false,
1654
+ omitBackground: Boolean(asset.transparent),
1655
+ });
1656
+ fs.writeFileSync(outputPath, Buffer.from(screenshot.data, 'base64'));
1657
+ console.log(`Saved Amazon asset ${asset.width}x${asset.height}: ${outputPath}`);
1658
+ }
1659
+ }
1660
+
1661
+ function createSamsungAssetHtml(asset, appName, iconBuffer, storeCopy) {
1662
+ const iconDataUrl = `data:image/png;base64,${iconBuffer.toString('base64')}`;
1663
+ const safeAppName = escapeHtml(appName);
1664
+ const featureButtons = storeCopy.featureLabels.map((label, index) => (
1665
+ `<button><i>0${index + 1}</i><span>${escapeHtml(label.toUpperCase())}</span></button>`
1666
+ )).join('');
1667
+ const safeKicker = escapeHtml(storeCopy.samsungKicker.toUpperCase());
1668
+ const safeAction = escapeHtml(storeCopy.samsungAction.toUpperCase());
1669
+ const safeBody = escapeHtml(storeCopy.samsungBody);
1670
+ const content = asset.type === 'edge-screen'
1671
+ ? `<main class="edge-panel"><header><img src="${iconDataUrl}" /><strong>${safeAppName}</strong></header><section>${featureButtons}</section></main>`
1672
+ : `<main class="single-panel"><header><img src="${iconDataUrl}" /><div><small>${safeKicker}</small><strong>${safeAppName}</strong></div></header><div class="single-action"><span>+</span><strong>${safeAction}</strong><small>${safeBody}</small></div><div class="accent-bars"><i></i><i></i><i></i><i></i><i></i></div></main>`;
1673
+
1674
+ return `<!doctype html>
1675
+ <html>
1676
+ <head>
1677
+ <meta charset="utf-8" />
1678
+ <style>
1679
+ * { box-sizing: border-box; }
1680
+ html, body { width: 100%; height: 100%; margin: 0; overflow: hidden; }
1681
+ body {
1682
+ position: relative;
1683
+ color: #f7fbff;
1684
+ background:
1685
+ radial-gradient(circle at 50% 8%, rgba(53,212,235,0.26), transparent 24%),
1686
+ radial-gradient(circle at 50% 88%, rgba(201,255,65,0.16), transparent 25%),
1687
+ linear-gradient(180deg, #231039, #111729 48%, #071522);
1688
+ font-family: Arial, Helvetica, sans-serif;
1689
+ }
1690
+ body::before {
1691
+ content: '';
1692
+ position: absolute;
1693
+ inset: 0;
1694
+ opacity: 0.18;
1695
+ background: repeating-linear-gradient(0deg, transparent 0 79px, rgba(255,255,255,0.08) 80px);
1696
+ }
1697
+ main { position: relative; z-index: 1; width: 100%; height: 100%; }
1698
+ .edge-panel { display: flex; flex-direction: column; padding: 28px 12px; }
1699
+ .edge-panel header { display: flex; flex-direction: column; align-items: center; gap: 12px; min-height: 230px; }
1700
+ .edge-panel header img { width: 112px; height: 112px; border-radius: 25px; object-fit: contain; }
1701
+ .edge-panel header strong { max-width: 136px; overflow: hidden; font-size: 18px; text-overflow: ellipsis; white-space: nowrap; }
1702
+ .edge-panel section { display: grid; flex: 1; grid-template-rows: repeat(9, 1fr); gap: 13px; }
1703
+ .edge-panel button {
1704
+ display: flex;
1705
+ width: 100%;
1706
+ min-height: 0;
1707
+ flex-direction: column;
1708
+ align-items: center;
1709
+ justify-content: center;
1710
+ gap: 8px;
1711
+ color: #f7fbff;
1712
+ border: 1px solid rgba(255,255,255,0.16);
1713
+ border-top: 5px solid #35d4eb;
1714
+ border-radius: 18px;
1715
+ background: linear-gradient(145deg, rgba(41,55,76,0.98), rgba(10,17,29,0.98));
1716
+ box-shadow: 0 10px 18px rgba(0,0,0,0.3);
1717
+ }
1718
+ .edge-panel button:nth-child(3n+2) { border-top-color: #ff9f43; }
1719
+ .edge-panel button:nth-child(3n) { border-top-color: #c9ff41; }
1720
+ .edge-panel button i { color: #35d4eb; font-size: 14px; font-style: normal; font-weight: 700; }
1721
+ .edge-panel button span { font-size: 12px; font-weight: 700; letter-spacing: 1px; }
1722
+ .single-panel { display: flex; flex-direction: column; align-items: center; padding: 100px 44px; }
1723
+ .single-panel header { display: flex; width: 100%; align-items: center; gap: 24px; }
1724
+ .single-panel header img { width: 160px; height: 160px; border-radius: 36px; object-fit: contain; }
1725
+ .single-panel header div { display: flex; min-width: 0; flex-direction: column; gap: 9px; }
1726
+ .single-panel header small { color: #35d4eb; font-size: 17px; font-weight: 700; letter-spacing: 3px; }
1727
+ .single-panel header strong { overflow: hidden; font-size: 48px; text-overflow: ellipsis; white-space: nowrap; }
1728
+ .single-action {
1729
+ display: flex;
1730
+ width: 100%;
1731
+ height: 880px;
1732
+ margin-top: 380px;
1733
+ flex-direction: column;
1734
+ align-items: center;
1735
+ justify-content: center;
1736
+ border: 3px solid rgba(53,212,235,0.5);
1737
+ border-radius: 90px;
1738
+ background: linear-gradient(145deg, rgba(39,53,75,0.96), rgba(8,16,28,0.98));
1739
+ box-shadow: 0 50px 90px rgba(0,0,0,0.42), 0 0 75px rgba(53,212,235,0.16);
1740
+ }
1741
+ .single-action span {
1742
+ display: grid;
1743
+ width: 260px;
1744
+ height: 260px;
1745
+ place-items: center;
1746
+ color: #0d1721;
1747
+ border-radius: 50%;
1748
+ background: linear-gradient(145deg, #35d4eb, #c9ff41);
1749
+ box-shadow: 0 25px 55px rgba(53,212,235,0.3);
1750
+ font-size: 170px;
1751
+ font-weight: 200;
1752
+ line-height: 1;
1753
+ }
1754
+ .single-action strong { margin-top: 65px; font-size: 42px; letter-spacing: 5px; }
1755
+ .single-action small { margin-top: 18px; color: #a9b7c9; font-size: 24px; }
1756
+ .accent-bars { display: flex; align-items: center; gap: 18px; height: 300px; margin-top: auto; }
1757
+ .accent-bars i { width: 14px; height: 70px; border-radius: 999px; background: #35d4eb; }
1758
+ .accent-bars i:nth-child(2), .accent-bars i:nth-child(4) { height: 140px; background: #ff5964; }
1759
+ .accent-bars i:nth-child(3) { height: 210px; background: #c9ff41; }
1760
+ </style>
1761
+ </head>
1762
+ <body>${content}</body>
1763
+ </html>`;
1764
+ }
1765
+
1766
+ async function createSamsungAssets(client, skipExisting = false) {
1767
+ if (!fs.existsSync(amazonIconPath)) {
1768
+ throw new Error(`Samsung asset source icon was not found: ${amazonIconPath}`);
1769
+ }
1770
+
1771
+ const config = JSON.parse(fs.readFileSync(capacitorConfigPath, 'utf8'));
1772
+ const appName = config.appName || 'App';
1773
+ const storeCopy = getStoreAssetCopy(config);
1774
+ const iconBuffer = fs.readFileSync(amazonIconPath);
1775
+ const maximumFileSize = 1024 * 1024;
1776
+
1777
+ fs.mkdirSync(samsungAssetsDirectory, { recursive: true });
1778
+
1779
+ for (const asset of samsungAssetDefinitions) {
1780
+ const outputPath = path.join(samsungAssetsDirectory, asset.fileName);
1781
+
1782
+ if (skipExisting && fs.existsSync(outputPath)) {
1783
+ continue;
1784
+ }
1785
+
1786
+ await client.send('Emulation.setDeviceMetricsOverride', {
1787
+ width: asset.width,
1788
+ height: asset.height,
1789
+ deviceScaleFactor: 1,
1790
+ mobile: false,
1791
+ screenWidth: asset.width,
1792
+ screenHeight: asset.height,
1793
+ positionX: 0,
1794
+ positionY: 0,
1795
+ });
1796
+ const frameTree = await client.send('Page.getFrameTree');
1797
+ await client.send('Page.setDocumentContent', {
1798
+ frameId: frameTree.frameTree.frame.id,
1799
+ html: createSamsungAssetHtml(asset, appName, iconBuffer, storeCopy),
1800
+ });
1801
+ await client.send('Runtime.evaluate', {
1802
+ expression: `Promise.all([...document.images].map((image) => image.complete
1803
+ ? Promise.resolve()
1804
+ : new Promise((resolve) => image.addEventListener('load', resolve, { once: true }))))
1805
+ .then(() => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))))`,
1806
+ awaitPromise: true,
1807
+ returnByValue: true,
1808
+ });
1809
+ const screenshot = await client.send('Page.captureScreenshot', {
1810
+ format: 'png',
1811
+ fromSurface: true,
1812
+ captureBeyondViewport: false,
1813
+ });
1814
+ const outputBuffer = Buffer.from(screenshot.data, 'base64');
1815
+
1816
+ if (outputBuffer.length >= maximumFileSize) {
1817
+ throw new Error(
1818
+ `Samsung asset exceeds 1024 KB (${Math.ceil(outputBuffer.length / 1024)} KB): ${asset.fileName}`,
1819
+ );
1820
+ }
1821
+
1822
+ fs.writeFileSync(outputPath, outputBuffer);
1823
+ console.log(
1824
+ `Saved Samsung asset ${asset.width}x${asset.height} `
1825
+ + `(${Math.ceil(outputBuffer.length / 1024)} KB): ${outputPath}`,
1826
+ );
1827
+ }
1828
+ }
1829
+
1830
+ async function createMissingStoreAssets() {
1831
+ const hasMissingAmazonAssets = amazonAssetDefinitions.some((asset) => (
1832
+ !fs.existsSync(path.join(amazonAssetsDirectory, asset.fileName))
1833
+ ));
1834
+ const hasMissingSamsungAssets = samsungAssetDefinitions.some((asset) => (
1835
+ !fs.existsSync(path.join(samsungAssetsDirectory, asset.fileName))
1836
+ ));
1837
+ const hasMissingPlaystoreAssets = playstoreAssetDefinitions.some((asset) => (
1838
+ !fs.existsSync(path.join(playstoreAssetsDirectory, asset.fileName))
1839
+ ));
1840
+
1841
+ if (!hasMissingAmazonAssets && !hasMissingSamsungAssets && !hasMissingPlaystoreAssets) {
1842
+ console.log('Amazon, Samsung, and Play Store assets already exist.');
1843
+ return;
1844
+ }
1845
+
1846
+ let browser;
1847
+ let client;
1848
+
1849
+ try {
1850
+ browser = await launchReplayBrowser();
1851
+ client = await createReplayPage(browser);
1852
+
1853
+ if (hasMissingAmazonAssets) {
1854
+ await createAmazonAssets(client, true);
1855
+ }
1856
+
1857
+ if (hasMissingSamsungAssets) {
1858
+ await createSamsungAssets(client, true);
1859
+ }
1860
+ if (hasMissingPlaystoreAssets) {
1861
+ await createPlaystoreAssets(client, true);
1862
+ }
1863
+ } finally {
1864
+ if (client) client.close();
1865
+ if (browser) await closeReplayBrowser(browser);
1866
+ }
1867
+
1868
+ console.log('Missing Amazon, Samsung, and Play Store assets created.');
1869
+ }
1870
+
1871
+ async function captureFramedScreenshot(
1872
+ client,
1873
+ screenshotBuffer,
1874
+ profile,
1875
+ screenshotFileName,
1876
+ skipExisting = false,
1877
+ ) {
1878
+ const framedProfileDirectory = path.join(inFrameDirectory, profile.outputDirectory);
1879
+ const framedScreenshotPath = path.join(framedProfileDirectory, screenshotFileName);
1880
+
1881
+ if (skipExisting && fs.existsSync(framedScreenshotPath)) {
1882
+ console.log(`Skipped existing framed screenshot: ${framedScreenshotPath}`);
1883
+ return false;
1884
+ }
1885
+
1886
+ const hasCustomFrameSize = Boolean(profile.framedWidth && profile.framedHeight);
1887
+ const frameWidth = profile.framedWidth || profile.width;
1888
+ const frameHeight = profile.framedHeight || profile.height;
1889
+ const frameScaleFactor = hasCustomFrameSize ? 1 : profile.deviceScaleFactor;
1890
+
1891
+ await client.send('Emulation.setDeviceMetricsOverride', {
1892
+ width: frameWidth,
1893
+ height: frameHeight,
1894
+ deviceScaleFactor: frameScaleFactor,
1895
+ mobile: false,
1896
+ screenWidth: frameWidth,
1897
+ screenHeight: frameHeight,
1898
+ positionX: 0,
1899
+ positionY: 0,
1900
+ });
1901
+
1902
+ const frameTree = await client.send('Page.getFrameTree');
1903
+ await client.send('Page.setDocumentContent', {
1904
+ frameId: frameTree.frameTree.frame.id,
1905
+ html: createDeviceFrameHtml(profile, screenshotBuffer),
1906
+ });
1907
+ await client.send('Runtime.evaluate', {
1908
+ expression: `new Promise((resolve) => {
1909
+ const image = document.querySelector('img');
1910
+ const ready = () => requestAnimationFrame(() => requestAnimationFrame(resolve));
1911
+ if (image.complete) ready();
1912
+ else {
1913
+ image.addEventListener('load', ready, { once: true });
1914
+ image.addEventListener('error', ready, { once: true });
1915
+ }
1916
+ })`,
1917
+ awaitPromise: true,
1918
+ returnByValue: true,
1919
+ });
1920
+
1921
+ const framedScreenshot = await client.send('Page.captureScreenshot', {
1922
+ format: 'png',
1923
+ fromSurface: true,
1924
+ captureBeyondViewport: false,
1925
+ });
1926
+
1927
+ fs.mkdirSync(framedProfileDirectory, { recursive: true });
1928
+ fs.writeFileSync(
1929
+ framedScreenshotPath,
1930
+ Buffer.from(framedScreenshot.data, 'base64'),
1931
+ { flag: 'wx' },
1932
+ );
1933
+ console.log(`Saved framed ${profile.label}: ${framedScreenshotPath}`);
1934
+ return true;
1935
+ }
1936
+
1937
+ async function captureProfile(client, snapshot, profile, screenshotFileName) {
1938
+ await client.send('Emulation.setDeviceMetricsOverride', {
1939
+ width: profile.width,
1940
+ height: profile.height,
1941
+ deviceScaleFactor: profile.deviceScaleFactor,
1942
+ mobile: profile.mobile,
1943
+ screenWidth: profile.width,
1944
+ screenHeight: profile.height,
1945
+ positionX: 0,
1946
+ positionY: 0,
1947
+ });
1948
+
1949
+ const frameTree = await client.send('Page.getFrameTree');
1950
+ await client.send('Page.setDocumentContent', {
1951
+ frameId: frameTree.frameTree.frame.id,
1952
+ html: snapshot.html,
1953
+ });
1954
+ const replayLayout = await waitForReplayLayout(client, snapshot);
1955
+
1956
+ if (replayLayout.documentWidth > replayLayout.viewportWidth + 1) {
1957
+ console.warn(
1958
+ `Warning: ${profile.label} replay is ${replayLayout.documentWidth}px wide `
1959
+ + `inside a ${replayLayout.viewportWidth}px viewport.`,
1960
+ );
1961
+ }
1962
+
1963
+ const screenshot = await client.send('Page.captureScreenshot', {
1964
+ format: 'png',
1965
+ fromSurface: true,
1966
+ captureBeyondViewport: false,
1967
+ });
1968
+
1969
+ const profileDirectory = path.join(screenshotDirectory, profile.outputDirectory);
1970
+ const screenshotPath = path.join(profileDirectory, screenshotFileName);
1971
+ const screenshotBuffer = Buffer.from(screenshot.data, 'base64');
1972
+
1973
+ fs.mkdirSync(profileDirectory, { recursive: true });
1974
+ fs.writeFileSync(screenshotPath, screenshotBuffer, { flag: 'wx' });
1975
+ console.log(`Saved ${profile.label}: ${screenshotPath}`);
1976
+ await captureFramedScreenshot(
1977
+ client,
1978
+ screenshotBuffer,
1979
+ profile,
1980
+ screenshotFileName,
1981
+ );
1982
+ }
1983
+
1984
+ async function captureScreenshotSet(
1985
+ deviceClient,
1986
+ replayClient,
1987
+ selectedProfiles,
1988
+ fileNameInput,
1989
+ ) {
1990
+ console.log('Capturing the current rendered page and resolved native data...');
1991
+ const snapshot = await capturePageSnapshot(deviceClient);
1992
+ const screenshotFileName = getScreenshotFileName(fileNameInput, selectedProfiles);
1993
+
1994
+ for (const profile of selectedProfiles) {
1995
+ await captureProfile(replayClient, snapshot, profile, screenshotFileName);
1996
+ }
1997
+
1998
+ console.log(`Responsive screenshot set completed: ${screenshotFileName}`);
1999
+ }
2000
+
2001
+ async function frameExistingScreenshots(selectedProfiles) {
2002
+ let replayBrowser;
2003
+ let replayClient;
2004
+ let framedCount = 0;
2005
+ let failedCount = 0;
2006
+
2007
+ try {
2008
+ replayBrowser = await launchReplayBrowser();
2009
+ replayClient = await createReplayPage(replayBrowser);
2010
+
2011
+ for (const profile of selectedProfiles) {
2012
+ const profileDirectory = path.join(screenshotDirectory, profile.outputDirectory);
2013
+
2014
+ if (!fs.existsSync(profileDirectory)) {
2015
+ continue;
2016
+ }
2017
+
2018
+ const screenshotFiles = fs.readdirSync(profileDirectory, { withFileTypes: true })
2019
+ .filter((entry) => entry.isFile() && /\.png$/i.test(entry.name))
2020
+ .map((entry) => entry.name)
2021
+ .sort((first, second) => first.localeCompare(second, undefined, { numeric: true }));
2022
+
2023
+ for (const screenshotFileName of screenshotFiles) {
2024
+ const screenshotPath = path.join(profileDirectory, screenshotFileName);
2025
+
2026
+ try {
2027
+ const wasCreated = await captureFramedScreenshot(
2028
+ replayClient,
2029
+ fs.readFileSync(screenshotPath),
2030
+ profile,
2031
+ screenshotFileName,
2032
+ true,
2033
+ );
2034
+ if (wasCreated) framedCount += 1;
2035
+ } catch (error) {
2036
+ failedCount += 1;
2037
+ console.error(`Failed to frame ${screenshotPath}: ${error.message}`);
2038
+ }
2039
+ }
2040
+ }
2041
+ } finally {
2042
+ if (replayClient) {
2043
+ replayClient.close();
2044
+ }
2045
+
2046
+ if (replayBrowser) {
2047
+ await closeReplayBrowser(replayBrowser);
2048
+ }
2049
+ }
2050
+
2051
+ console.log(`Existing screenshot framing completed: ${framedCount} created.`);
2052
+
2053
+ if (failedCount) {
2054
+ throw new Error(`${failedCount} existing screenshot(s) could not be framed.`);
2055
+ }
2056
+ }
2057
+
2058
+ async function runInteractiveCaptureLoop(
2059
+ deviceClient,
2060
+ replayClient,
2061
+ selectedProfiles,
2062
+ ) {
2063
+ const terminal = readline.createInterface({
2064
+ input: process.stdin,
2065
+ output: process.stdout,
2066
+ prompt: 'Enter a filename, or press Enter for the next number: ',
2067
+ });
2068
+
2069
+ terminal.on('SIGINT', () => terminal.close());
2070
+
2071
+ console.log(`Screenshots will be saved in: ${screenshotDirectory}`);
2072
+ console.log('Press Ctrl+C to exit.');
2073
+ terminal.prompt();
2074
+
2075
+ for await (const fileNameInput of terminal) {
2076
+ try {
2077
+ await captureScreenshotSet(
2078
+ deviceClient,
2079
+ replayClient,
2080
+ selectedProfiles,
2081
+ fileNameInput,
2082
+ );
2083
+ } catch (error) {
2084
+ console.error(`Responsive screenshot failed: ${error.message}`);
2085
+ }
2086
+
2087
+ terminal.prompt();
2088
+ }
2089
+
2090
+ console.log('\nResponsive screenshot tool closed.');
2091
+ }
2092
+
2093
+ async function getInteractiveOptions(options) {
2094
+ if (
2095
+ !process.stdin.isTTY
2096
+ || options.dryRun
2097
+ || options.amazonAssets
2098
+ || options.samsungAssets
2099
+ ) {
2100
+ return options;
2101
+ }
2102
+
2103
+ const terminal = readline.createInterface({
2104
+ input: process.stdin,
2105
+ output: process.stdout,
2106
+ });
2107
+
2108
+ try {
2109
+ if (!options.profiles) {
2110
+ console.log('Available profiles:');
2111
+ printProfiles();
2112
+ options.profiles = await terminal.question(
2113
+ 'Profiles separated by commas, or press Enter for all: ',
2114
+ );
2115
+ }
2116
+ } finally {
2117
+ terminal.close();
2118
+ }
2119
+
2120
+ return options;
2121
+ }
2122
+
2123
+ async function main() {
2124
+ let options = parseArguments();
2125
+
2126
+ if (options.help) {
2127
+ printHelp();
2128
+ return;
2129
+ }
2130
+
2131
+ if (options.list) {
2132
+ printProfiles();
2133
+ return;
2134
+ }
2135
+
2136
+ options = await getInteractiveOptions(options);
2137
+
2138
+ if (options.amazonAssets) {
2139
+ let amazonBrowser;
2140
+ let amazonClient;
2141
+
2142
+ try {
2143
+ amazonBrowser = await launchReplayBrowser();
2144
+ amazonClient = await createReplayPage(amazonBrowser);
2145
+ await createAmazonAssets(amazonClient);
2146
+ } finally {
2147
+ if (amazonClient) amazonClient.close();
2148
+ if (amazonBrowser) await closeReplayBrowser(amazonBrowser);
2149
+ }
2150
+
2151
+ console.log(`Amazon asset creation completed: ${amazonAssetsDirectory}`);
2152
+ return;
2153
+ }
2154
+
2155
+ if (options.samsungAssets) {
2156
+ let samsungBrowser;
2157
+ let samsungClient;
2158
+
2159
+ try {
2160
+ samsungBrowser = await launchReplayBrowser();
2161
+ samsungClient = await createReplayPage(samsungBrowser);
2162
+ await createSamsungAssets(samsungClient);
2163
+ } finally {
2164
+ if (samsungClient) samsungClient.close();
2165
+ if (samsungBrowser) await closeReplayBrowser(samsungBrowser);
2166
+ }
2167
+
2168
+ console.log(`Samsung asset creation completed: ${samsungAssetsDirectory}`);
2169
+ return;
2170
+ }
2171
+
2172
+ if (!options.dryRun) {
2173
+ await createMissingStoreAssets();
2174
+ }
2175
+
2176
+ const selectedProfiles = selectProfiles(options.profiles);
2177
+
2178
+ if (options.frameExisting) {
2179
+ await frameExistingScreenshots(selectedProfiles);
2180
+ return;
2181
+ }
2182
+
2183
+ const interactiveMode = process.stdin.isTTY && !options.prefix && !options.dryRun;
2184
+ const deviceId = getConnectedDevice();
2185
+ const appId = getAppId();
2186
+ const socketName = getWebViewSocket(deviceId, appId);
2187
+ const localPort = runAdb([
2188
+ '-s',
2189
+ deviceId,
2190
+ 'forward',
2191
+ 'tcp:0',
2192
+ `localabstract:${socketName}`,
2193
+ ]);
2194
+
2195
+ let deviceClient;
2196
+ let replayBrowser;
2197
+ let replayClient;
2198
+
2199
+ try {
2200
+ const target = await getWebViewTarget(localPort);
2201
+ deviceClient = new CdpClient(target.webSocketDebuggerUrl);
2202
+ await deviceClient.connect();
2203
+ await deviceClient.send('Page.enable');
2204
+ await deviceClient.send('Runtime.enable');
2205
+
2206
+ const pageDetails = await getPageDetails(deviceClient);
2207
+ console.log(`Connected device: ${deviceId}`);
2208
+ console.log(`App: ${appId}`);
2209
+ console.log(`Current page: ${pageDetails.title || '(untitled)'}`);
2210
+ console.log(`Current URL: ${pageDetails.url}`);
2211
+ console.log(
2212
+ `Current viewport: ${pageDetails.width}x${pageDetails.height} `
2213
+ + `at ${pageDetails.devicePixelRatio}x (${pageDetails.readyState})`,
2214
+ );
2215
+
2216
+ if (options.dryRun) {
2217
+ console.log('Dry run completed. No screenshots were created.');
2218
+ return;
2219
+ }
2220
+
2221
+ fs.mkdirSync(screenshotDirectory, { recursive: true });
2222
+ replayBrowser = await launchReplayBrowser();
2223
+ replayClient = await createReplayPage(replayBrowser);
2224
+
2225
+ console.log(
2226
+ 'Apple, TV, Chromebook, Mac, and XR profiles are viewport approximations of the live Android WebView.',
2227
+ );
2228
+
2229
+ if (interactiveMode) {
2230
+ await runInteractiveCaptureLoop(
2231
+ deviceClient,
2232
+ replayClient,
2233
+ selectedProfiles,
2234
+ );
2235
+ } else {
2236
+ await captureScreenshotSet(
2237
+ deviceClient,
2238
+ replayClient,
2239
+ selectedProfiles,
2240
+ options.prefix,
2241
+ );
2242
+ }
2243
+ } finally {
2244
+ if (replayClient) {
2245
+ replayClient.close();
2246
+ }
2247
+
2248
+ if (replayBrowser) {
2249
+ await closeReplayBrowser(replayBrowser);
2250
+ }
2251
+
2252
+ if (deviceClient) {
2253
+ deviceClient.close();
2254
+ }
2255
+
2256
+ if (localPort) {
2257
+ try {
2258
+ runAdb(['-s', deviceId, 'forward', '--remove', `tcp:${localPort}`]);
2259
+ } catch {
2260
+ // ADB may already have removed the temporary forwarding rule.
2261
+ }
2262
+ }
2263
+ }
2264
+ }
2265
+
2266
+ main().catch((error) => {
2267
+ console.error(`Responsive screenshot failed: ${error.message}`);
2268
+ process.exitCode = 1;
2269
+ });