@tapflowio/ios-agent 0.1.0-alpha.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +32 -0
  3. package/bin/keyboard-helper +0 -0
  4. package/bin/rotation-helper +0 -0
  5. package/bin/screencapture-helper +0 -0
  6. package/bin/touch-helper +0 -0
  7. package/dist/DeviceChromeLoader.d.ts +49 -0
  8. package/dist/DeviceChromeLoader.d.ts.map +1 -0
  9. package/dist/DeviceChromeLoader.js +680 -0
  10. package/dist/DeviceChromeLoader.js.map +1 -0
  11. package/dist/IOSAgent.d.ts +46 -0
  12. package/dist/IOSAgent.d.ts.map +1 -0
  13. package/dist/IOSAgent.js +478 -0
  14. package/dist/IOSAgent.js.map +1 -0
  15. package/dist/KeyCodeMap.d.ts +3 -0
  16. package/dist/KeyCodeMap.d.ts.map +1 -0
  17. package/dist/KeyCodeMap.js +42 -0
  18. package/dist/KeyCodeMap.js.map +1 -0
  19. package/dist/KeyboardHelperDaemon.d.ts +13 -0
  20. package/dist/KeyboardHelperDaemon.d.ts.map +1 -0
  21. package/dist/KeyboardHelperDaemon.js +96 -0
  22. package/dist/KeyboardHelperDaemon.js.map +1 -0
  23. package/dist/MjpegStreamer.d.ts +10 -0
  24. package/dist/MjpegStreamer.d.ts.map +1 -0
  25. package/dist/MjpegStreamer.js +40 -0
  26. package/dist/MjpegStreamer.js.map +1 -0
  27. package/dist/ScreenCaptureStreamer.d.ts +7 -0
  28. package/dist/ScreenCaptureStreamer.d.ts.map +1 -0
  29. package/dist/ScreenCaptureStreamer.js +92 -0
  30. package/dist/ScreenCaptureStreamer.js.map +1 -0
  31. package/dist/SimctlWrapper.d.ts +21 -0
  32. package/dist/SimctlWrapper.d.ts.map +1 -0
  33. package/dist/SimctlWrapper.js +154 -0
  34. package/dist/SimctlWrapper.js.map +1 -0
  35. package/dist/TouchHelper.d.ts +21 -0
  36. package/dist/TouchHelper.d.ts.map +1 -0
  37. package/dist/TouchHelper.js +110 -0
  38. package/dist/TouchHelper.js.map +1 -0
  39. package/dist/index.d.ts +6 -0
  40. package/dist/index.d.ts.map +1 -0
  41. package/dist/index.js +5 -0
  42. package/dist/index.js.map +1 -0
  43. package/dist/simctl.d.ts +6 -0
  44. package/dist/simctl.d.ts.map +1 -0
  45. package/dist/simctl.js +64 -0
  46. package/dist/simctl.js.map +1 -0
  47. package/package.json +66 -0
@@ -0,0 +1,680 @@
1
+ // Button layout logic (computeButtonLayout) is derived from baguette (Apache-2.0):
2
+ // https://github.com/tddworks/baguette
3
+ import { execFileSync } from 'child_process';
4
+ import { existsSync, readFileSync, writeFileSync, statSync } from 'fs';
5
+ import { join } from 'path';
6
+ import { tmpdir } from 'os';
7
+ const CHROME_MAP_PATH = '/Library/Developer/DeviceKit/chrome_map.plist';
8
+ const CHROME_DIR = '/Library/Developer/DeviceKit/Chrome';
9
+ const PROFILES_DIR = '/Library/Developer/CoreSimulator/Profiles/DeviceTypes';
10
+ // ---------------------------------------------------------------------------
11
+ // Utilities
12
+ // ---------------------------------------------------------------------------
13
+ function readPlistAsJson(filePath) {
14
+ const json = execFileSync('plutil', ['-convert', 'json', '-o', '-', filePath]);
15
+ return JSON.parse(json.toString());
16
+ }
17
+ function getSipsSize(filePath) {
18
+ const out = execFileSync('sips', ['-g', 'pixelWidth', '-g', 'pixelHeight', filePath]).toString();
19
+ const w = out.match(/pixelWidth:\s*([\d.]+)/);
20
+ const h = out.match(/pixelHeight:\s*([\d.]+)/);
21
+ return { width: Math.round(parseFloat(w[1])), height: Math.round(parseFloat(h[1])) };
22
+ }
23
+ // Mirrors baguette's LiveChromes.computeMargins + buttonTopLeft.
24
+ // compositeW/H = device body dimensions in 1× PDF pts (before button margin expansion).
25
+ // For composite PDF: pdfSize.width / pdfSize.height.
26
+ // For nine-slice: leftWidth+screenW+rightWidth / topHeight+screenH+bottomHeight.
27
+ // leftWidth/rightWidth: bezel sizes; top/bottom-anchor x offsets are measured from
28
+ // the screen edge (after bezel), so we add leftWidth for leading and subtract
29
+ // rightWidth for trailing to convert to canvas coordinates.
30
+ function computeButtonLayout(inputs, resourcesDir, compositeW, compositeH, scale = 2, leftWidth = 0, rightWidth = 0) {
31
+ const margins = { left: 0, top: 0, right: 0, bottom: 0 };
32
+ const infos = [];
33
+ for (const inp of inputs) {
34
+ if (!inp.offsets)
35
+ continue;
36
+ const pdfPath = join(resourcesDir, `${inp.image}.pdf`);
37
+ if (!existsSync(pdfPath))
38
+ continue;
39
+ try {
40
+ const size = getSipsSize(pdfPath);
41
+ const roll = inp.offsets.rollover ?? inp.offsets.normal;
42
+ infos.push({ input: inp, w: size.width, h: size.height, roll });
43
+ }
44
+ catch { /* skip unmeasurable assets */ }
45
+ }
46
+ // Pass 1 — margins (baguette's computeMargins, rollover offset)
47
+ for (const { input: inp, w, h, roll } of infos) {
48
+ switch (inp.anchor ?? 'left') {
49
+ case 'left':
50
+ margins.left = Math.max(margins.left, Math.max(w - roll.x, 0));
51
+ break;
52
+ case 'right':
53
+ margins.right = Math.max(margins.right, Math.max(w + roll.x, 0));
54
+ break;
55
+ case 'top':
56
+ margins.top = Math.max(margins.top, Math.max(-(roll.y - h / 2), 0));
57
+ break;
58
+ // bottom-anchored buttons with negative y sit inside the bezel — no canvas expansion needed
59
+ case 'bottom':
60
+ margins.bottom = Math.max(margins.bottom, Math.max(roll.y + h / 2, 0));
61
+ break;
62
+ }
63
+ }
64
+ // Pass 2 — button top-left positions and normalOffset centers (baguette's buttonTopLeft)
65
+ const drawData = [];
66
+ const buttons = [];
67
+ const pressedData = [];
68
+ for (const { input: inp, w, h, roll } of infos) {
69
+ const mL = margins.left;
70
+ const mT = margins.top;
71
+ let centerX;
72
+ let topY;
73
+ switch (inp.anchor ?? 'left') {
74
+ case 'left':
75
+ centerX = mL + roll.x;
76
+ topY = mT + roll.y;
77
+ break;
78
+ case 'right':
79
+ centerX = mL + compositeW + roll.x;
80
+ topY = mT + roll.y;
81
+ break;
82
+ case 'bottom': {
83
+ const align = inp.align ?? 'leading';
84
+ centerX = align === 'center' ? mL + compositeW / 2 + roll.x
85
+ : align === 'trailing' ? mL + compositeW + roll.x
86
+ : mL + roll.x;
87
+ topY = mT + compositeH + roll.y;
88
+ break;
89
+ }
90
+ case 'top': {
91
+ const align = inp.align ?? 'leading';
92
+ centerX = align === 'center' ? mL + compositeW / 2 + roll.x
93
+ : align === 'trailing' ? mL + (compositeW - rightWidth) + roll.x
94
+ : mL + leftWidth + roll.x - w / 2;
95
+ topY = mT + roll.y;
96
+ break;
97
+ }
98
+ default:
99
+ centerX = mL + roll.x;
100
+ topY = mT + roll.y;
101
+ }
102
+ const btnTopLeftX = centerX - w / 2;
103
+ // 'top' anchor: roll.y is center Y (button protrudes above device top); convert to top-left.
104
+ // Other anchors: roll.y is already the top-left Y.
105
+ const btnTopLeftY = (inp.anchor === 'top') ? topY - h / 2 : topY;
106
+ drawData.push({
107
+ pdfPath: join(resourcesDir, `${inp.image}.pdf`),
108
+ topLeftX: btnTopLeftX,
109
+ topLeftY: btnTopLeftY,
110
+ onTop: inp.onTop ?? false,
111
+ });
112
+ // imageDown: pressed state PDF at the same position as the normal button
113
+ let pressed = null;
114
+ if (inp.imageDown) {
115
+ const downPath = join(resourcesDir, `${inp.imageDown}.pdf`);
116
+ if (existsSync(downPath)) {
117
+ try {
118
+ const downSize = getSipsSize(downPath);
119
+ pressed = {
120
+ pdfPath: downPath,
121
+ topLeftX: btnTopLeftX,
122
+ topLeftY: btnTopLeftY,
123
+ pdfW: downSize.width,
124
+ pdfH: downSize.height,
125
+ };
126
+ }
127
+ catch { /* skip unmeasurable pressed asset */ }
128
+ }
129
+ }
130
+ pressedData.push(pressed);
131
+ // normalOffset: button center in expanded 2× composite px for hit-testing
132
+ const nx = inp.offsets.normal.x;
133
+ const ny = inp.offsets.normal.y;
134
+ let normalCX;
135
+ let normalCY;
136
+ switch (inp.anchor ?? 'left') {
137
+ case 'right':
138
+ normalCX = mL + compositeW + nx;
139
+ normalCY = mT + ny;
140
+ break;
141
+ case 'bottom': {
142
+ const align = inp.align ?? 'leading';
143
+ normalCX = align === 'center' ? mL + compositeW / 2 + nx
144
+ : align === 'trailing' ? mL + compositeW + nx
145
+ : mL + nx;
146
+ normalCY = mT + compositeH + ny + h / 2;
147
+ break;
148
+ }
149
+ case 'top': {
150
+ const align = inp.align ?? 'leading';
151
+ normalCX = align === 'center' ? mL + compositeW / 2 + nx
152
+ : align === 'trailing' ? mL + (compositeW - rightWidth) + nx
153
+ : mL + leftWidth + nx - w / 2;
154
+ normalCY = mT + ny - h / 2;
155
+ break;
156
+ }
157
+ default: // left / fallback — y is top edge
158
+ normalCX = mL + nx;
159
+ normalCY = mT + ny;
160
+ }
161
+ buttons.push({
162
+ name: inp.name,
163
+ accessibilityTitle: inp.accessibilityTitle ?? inp.name,
164
+ anchor: inp.anchor ?? 'left',
165
+ onTop: inp.onTop ?? false,
166
+ normalOffset: {
167
+ x: Math.round(normalCX * scale),
168
+ y: Math.round(normalCY * scale),
169
+ },
170
+ // rolloverOffset.x = centerX (from roll offsets).
171
+ // rolloverOffset.y: for top anchor, rollover extends the button further above the frame,
172
+ // so store the rollover top-edge Y (= btnTopLeftY) not the normal top-edge Y.
173
+ // For left/right/bottom anchors Y is the same at normal and rollover positions.
174
+ rolloverOffset: {
175
+ x: Math.round(centerX * scale),
176
+ y: Math.round(((inp.anchor === 'top') ? btnTopLeftY : normalCY) * scale),
177
+ },
178
+ buttonW: Math.round(w * scale),
179
+ buttonH: Math.round(h * scale),
180
+ usagePage: inp.usagePage ?? 0,
181
+ usage: inp.usage ?? 0,
182
+ });
183
+ }
184
+ return { margins, drawData, buttons, pressedData };
185
+ }
186
+ // ---------------------------------------------------------------------------
187
+ // Single PDF → PNG at 2× (used for pressed-state button images)
188
+ // ---------------------------------------------------------------------------
189
+ function renderPdfToPng(pdfPath, outPath) {
190
+ const SCRIPT = `
191
+ import Foundation
192
+ import CoreGraphics
193
+ import ImageIO
194
+
195
+ let src = CommandLine.arguments[1]
196
+ let dst = CommandLine.arguments[2]
197
+ let scale: CGFloat = 2
198
+
199
+ guard let doc = CGPDFDocument(URL(fileURLWithPath: src) as CFURL),
200
+ let page = doc.page(at: 1) else { exit(1) }
201
+
202
+ let box = page.getBoxRect(.mediaBox)
203
+ let w = max(1, Int(ceil(box.width * scale)))
204
+ let h = max(1, Int(ceil(box.height * scale)))
205
+
206
+ let ctx = CGContext(
207
+ data: nil, width: w, height: h,
208
+ bitsPerComponent: 8, bytesPerRow: 0,
209
+ space: CGColorSpaceCreateDeviceRGB(),
210
+ bitmapInfo: CGBitmapInfo(rawValue: CGImageAlphaInfo.premultipliedLast.rawValue).rawValue
211
+ )!
212
+ ctx.scaleBy(x: scale, y: scale)
213
+ ctx.drawPDFPage(page)
214
+
215
+ let img = ctx.makeImage()!
216
+ let dest = CGImageDestinationCreateWithURL(URL(fileURLWithPath: dst) as CFURL, "public.png" as CFString, 1, nil)!
217
+ CGImageDestinationAddImage(dest, img, nil)
218
+ CGImageDestinationFinalize(dest)
219
+ `;
220
+ const scriptPath = join(tmpdir(), 'tapflow-pdf-to-png.swift');
221
+ writeFileSync(scriptPath, SCRIPT);
222
+ execFileSync('swift', [scriptPath, pdfPath, outPath]);
223
+ }
224
+ // ---------------------------------------------------------------------------
225
+ // Frame PNG rendering — composite PDF path
226
+ // Renders composite PDF + physical button PDFs into one PNG at 2×.
227
+ // ---------------------------------------------------------------------------
228
+ function renderFramePng(compositePdf, outPath, margins, buttons) {
229
+ const behind = buttons.filter(b => !b.onTop);
230
+ const onTop = buttons.filter(b => b.onTop);
231
+ function btnLiteral(b) {
232
+ return `(path: ${JSON.stringify(b.pdfPath)}, x: ${b.topLeftX}, y: ${b.topLeftY})`;
233
+ }
234
+ const SCRIPT = `
235
+ import Foundation
236
+ import CoreGraphics
237
+ import ImageIO
238
+
239
+ let args = CommandLine.arguments
240
+ let src = args[1]
241
+ let dst = args[2]
242
+ let scale: CGFloat = 2
243
+
244
+ let mL: CGFloat = ${margins.left}
245
+ let mT: CGFloat = ${margins.top}
246
+ let mR: CGFloat = ${margins.right}
247
+ let mB: CGFloat = ${margins.bottom}
248
+
249
+ // (pdf path, topLeftX in 1× expanded-canvas pts, topLeftY in 1× expanded-canvas pts)
250
+ let behindBtns: [(path: String, x: CGFloat, y: CGFloat)] = [
251
+ ${behind.map(btnLiteral).join(',\n ')}
252
+ ]
253
+ let onTopBtns: [(path: String, x: CGFloat, y: CGFloat)] = [
254
+ ${onTop.map(btnLiteral).join(',\n ')}
255
+ ]
256
+
257
+ let pdf = CGPDFDocument(URL(fileURLWithPath: src) as CFURL)!
258
+ let page = pdf.page(at: 1)!
259
+ let box = page.getBoxRect(.mediaBox)
260
+
261
+ let cW = box.width
262
+ let cH = box.height
263
+ let canW = Int((cW + mL + mR) * scale)
264
+ let canH = Int((cH + mT + mB) * scale)
265
+
266
+ let ctx = CGContext(
267
+ data: nil, width: canW, height: canH,
268
+ bitsPerComponent: 8, bytesPerRow: 0,
269
+ space: CGColorSpaceCreateDeviceRGB(),
270
+ bitmapInfo: CGBitmapInfo(rawValue: CGImageAlphaInfo.premultipliedLast.rawValue).rawValue
271
+ )!
272
+
273
+ func drawPage(_ pg: CGPDFPage, tlX: CGFloat, tlY: CGFloat) {
274
+ let b = pg.getBoxRect(.mediaBox)
275
+ ctx.saveGState()
276
+ ctx.translateBy(
277
+ x: tlX * scale,
278
+ y: CGFloat(canH) - (tlY + b.height) * scale
279
+ )
280
+ ctx.scaleBy(x: scale, y: scale)
281
+ ctx.drawPDFPage(pg)
282
+ ctx.restoreGState()
283
+ }
284
+
285
+ func loadButton(_ entry: (path: String, x: CGFloat, y: CGFloat)) {
286
+ guard let d = CGPDFDocument(URL(fileURLWithPath: entry.path) as CFURL),
287
+ let p = d.page(at: 1) else { return }
288
+ drawPage(p, tlX: entry.x, tlY: entry.y)
289
+ }
290
+
291
+ for btn in behindBtns { loadButton(btn) }
292
+ drawPage(page, tlX: mL, tlY: mT)
293
+ for btn in onTopBtns { loadButton(btn) }
294
+
295
+ let img = ctx.makeImage()!
296
+ let dest = CGImageDestinationCreateWithURL(URL(fileURLWithPath: dst) as CFURL, "public.png" as CFString, 1, nil)!
297
+ CGImageDestinationAddImage(dest, img, nil)
298
+ CGImageDestinationFinalize(dest)
299
+ `;
300
+ const scriptPath = join(tmpdir(), 'tapflow-frame-png.swift');
301
+ writeFileSync(scriptPath, SCRIPT);
302
+ execFileSync('swift', [scriptPath, compositePdf, outPath]);
303
+ }
304
+ // ---------------------------------------------------------------------------
305
+ // Frame PNG rendering — nine-slice path
306
+ // Rasterizes 8 PDF slices to bitmaps, composes them around the screen hole,
307
+ // then draws physical button PDFs. Matches baguette's compose9Slice approach.
308
+ // ---------------------------------------------------------------------------
309
+ function renderNineSlicePng(slicePaths, outPath, leftWidth, rightWidth, topHeight, bottomHeight, cornerW, cornerH, screenW, screenH, margins, buttons) {
310
+ const behind = buttons.filter(b => !b.onTop);
311
+ const onTop = buttons.filter(b => b.onTop);
312
+ function btnLiteral(b) {
313
+ return `(path: ${JSON.stringify(b.pdfPath)}, x: ${b.topLeftX}, y: ${b.topLeftY})`;
314
+ }
315
+ const SCRIPT = `
316
+ import Foundation
317
+ import CoreGraphics
318
+ import ImageIO
319
+
320
+ // Device body dims (bezel + screen hole) in 1× pts
321
+ let leftW: CGFloat = ${leftWidth}
322
+ let rightW: CGFloat = ${rightWidth}
323
+ let topH: CGFloat = ${topHeight}
324
+ let botH: CGFloat = ${bottomHeight}
325
+ let cornerW: CGFloat = ${cornerW}
326
+ let cornerH: CGFloat = ${cornerH}
327
+ let screenW: CGFloat = ${screenW}
328
+ let screenH: CGFloat = ${screenH}
329
+ let mL: CGFloat = ${margins.left}
330
+ let mT: CGFloat = ${margins.top}
331
+ let mR: CGFloat = ${margins.right}
332
+ let mB: CGFloat = ${margins.bottom}
333
+ let scale: CGFloat = 2
334
+ let dst = CommandLine.arguments[1]
335
+
336
+ // Device body (bezel + screen area)
337
+ let bodyW = leftW + screenW + rightW // = leftWidth + screenW + rightWidth
338
+ let bodyH = topH + screenH + botH
339
+
340
+ // Middle section (area between corners, where edge strips stretch)
341
+ let midW = bodyW - cornerW * 2
342
+ let midH = bodyH - cornerH * 2
343
+
344
+ // Total canvas including button margins
345
+ let canW = Int((mL + bodyW + mR) * scale)
346
+ let canH = Int((mT + bodyH + mB) * scale)
347
+
348
+ let ctx = CGContext(
349
+ data: nil, width: canW, height: canH,
350
+ bitsPerComponent: 8, bytesPerRow: 0,
351
+ space: CGColorSpaceCreateDeviceRGB(),
352
+ bitmapInfo: CGBitmapInfo(rawValue: CGImageAlphaInfo.premultipliedLast.rawValue).rawValue
353
+ )!
354
+
355
+ // Rasterize PDF to CGImage at 2×.
356
+ // drawPDFPage cannot non-uniformly scale PDF pages; rasterize first, then ctx.draw() stretches.
357
+ func rasterize(_ path: String) -> CGImage? {
358
+ guard let doc = CGPDFDocument(URL(fileURLWithPath: path) as CFURL),
359
+ let page = doc.page(at: 1) else { return nil }
360
+ let box = page.getBoxRect(.mediaBox)
361
+ let w = max(1, Int(ceil(box.width * scale)))
362
+ let h = max(1, Int(ceil(box.height * scale)))
363
+ guard let rc = CGContext(
364
+ data: nil, width: w, height: h,
365
+ bitsPerComponent: 8, bytesPerRow: 0,
366
+ space: CGColorSpaceCreateDeviceRGB(),
367
+ bitmapInfo: CGBitmapInfo(rawValue: CGImageAlphaInfo.premultipliedLast.rawValue).rawValue)
368
+ else { return nil }
369
+ rc.scaleBy(x: scale, y: scale)
370
+ rc.drawPDFPage(page)
371
+ return rc.makeImage()
372
+ }
373
+
374
+ // Draw bitmap at (tlX, tlY) in 1× top-left pts, stretched to (w, h).
375
+ func drawImg(_ img: CGImage, tlX: CGFloat, tlY: CGFloat, w: CGFloat, h: CGFloat) {
376
+ ctx.draw(img, in: CGRect(
377
+ x: tlX * scale,
378
+ y: CGFloat(canH) - (tlY + h) * scale,
379
+ width: w * scale,
380
+ height: h * scale
381
+ ))
382
+ }
383
+
384
+ // Draw PDF at (tlX, tlY) in 1× pts — used for buttons (no stretching needed).
385
+ func drawPDF(_ path: String, tlX: CGFloat, tlY: CGFloat) {
386
+ guard let doc = CGPDFDocument(URL(fileURLWithPath: path) as CFURL),
387
+ let page = doc.page(at: 1) else { return }
388
+ let b = page.getBoxRect(.mediaBox)
389
+ ctx.saveGState()
390
+ ctx.translateBy(x: tlX * scale, y: CGFloat(canH) - (tlY + b.height) * scale)
391
+ ctx.scaleBy(x: scale, y: scale)
392
+ ctx.drawPDFPage(page)
393
+ ctx.restoreGState()
394
+ }
395
+
396
+ let imgTL = rasterize(${JSON.stringify(slicePaths.topLeft)})
397
+ let imgT = rasterize(${JSON.stringify(slicePaths.top)})
398
+ let imgTR = rasterize(${JSON.stringify(slicePaths.topRight)})
399
+ let imgR = rasterize(${JSON.stringify(slicePaths.right)})
400
+ let imgBR = rasterize(${JSON.stringify(slicePaths.bottomRight)})
401
+ let imgB = rasterize(${JSON.stringify(slicePaths.bottom)})
402
+ let imgBL = rasterize(${JSON.stringify(slicePaths.bottomLeft)})
403
+ let imgL = rasterize(${JSON.stringify(slicePaths.left)})
404
+
405
+ let behindBtns: [(path: String, x: CGFloat, y: CGFloat)] = [
406
+ ${behind.map(btnLiteral).join(',\n ')}
407
+ ]
408
+ let onTopBtns: [(path: String, x: CGFloat, y: CGFloat)] = [
409
+ ${onTop.map(btnLiteral).join(',\n ')}
410
+ ]
411
+
412
+ for btn in behindBtns { drawPDF(btn.path, tlX: btn.x, tlY: btn.y) }
413
+
414
+ // Device body starts at (mL, mT) in expanded canvas.
415
+ // Corners are at natural size; edge strips are stretched to fill the gap between corners.
416
+ // Screen hole (mL+leftW, mT+topH, screenW, screenH) is left transparent.
417
+ let bx = mL; let by = mT
418
+
419
+ // Top row
420
+ if let img = imgTL { drawImg(img, tlX: bx, tlY: by, w: cornerW, h: cornerH) }
421
+ if let img = imgT { drawImg(img, tlX: bx + cornerW, tlY: by, w: midW, h: cornerH) }
422
+ if let img = imgTR { drawImg(img, tlX: bx + bodyW - cornerW, tlY: by, w: cornerW, h: cornerH) }
423
+
424
+ // Middle row — corners overlap the screen area but their inner zone is transparent
425
+ if let img = imgL { drawImg(img, tlX: bx, tlY: by + cornerH, w: cornerW, h: midH) }
426
+ if let img = imgR { drawImg(img, tlX: bx + bodyW - cornerW, tlY: by + cornerH, w: cornerW, h: midH) }
427
+
428
+ // Bottom row
429
+ if let img = imgBL { drawImg(img, tlX: bx, tlY: by + bodyH - cornerH, w: cornerW, h: cornerH) }
430
+ if let img = imgB { drawImg(img, tlX: bx + cornerW, tlY: by + bodyH - cornerH, w: midW, h: cornerH) }
431
+ if let img = imgBR { drawImg(img, tlX: bx + bodyW - cornerW, tlY: by + bodyH - cornerH, w: cornerW, h: cornerH) }
432
+
433
+ for btn in onTopBtns { drawPDF(btn.path, tlX: btn.x, tlY: btn.y) }
434
+
435
+ let outImg = ctx.makeImage()!
436
+ let dest = CGImageDestinationCreateWithURL(URL(fileURLWithPath: dst) as CFURL, "public.png" as CFString, 1, nil)!
437
+ CGImageDestinationAddImage(dest, outImg, nil)
438
+ CGImageDestinationFinalize(dest)
439
+ `;
440
+ const scriptPath = join(tmpdir(), 'tapflow-frame-nineslice.swift');
441
+ writeFileSync(scriptPath, SCRIPT);
442
+ execFileSync('swift', [scriptPath, outPath]);
443
+ }
444
+ // ---------------------------------------------------------------------------
445
+ // Model identifier and profile lookups
446
+ // ---------------------------------------------------------------------------
447
+ function modelIdentifierForType(typeIdentifier) {
448
+ try {
449
+ const out = execFileSync('xcrun', ['simctl', 'list', 'devicetypes', '-j']);
450
+ const types = JSON.parse(out.toString())['devicetypes'];
451
+ return types.find(t => t.identifier === typeIdentifier)?.modelIdentifier ?? null;
452
+ }
453
+ catch {
454
+ return null;
455
+ }
456
+ }
457
+ // Reads mainScreenWidth/Height/Scale from CoreSimulator profile.plist.
458
+ // Returns logical point dimensions (physical px ÷ scale).
459
+ function loadProfileScreenSize(typeIdentifier) {
460
+ try {
461
+ const out = execFileSync('xcrun', ['simctl', 'list', 'devicetypes', '-j']);
462
+ const types = JSON.parse(out.toString())['devicetypes'];
463
+ const name = types.find(t => t.identifier === typeIdentifier)?.name;
464
+ if (!name)
465
+ return null;
466
+ const plistPath = join(PROFILES_DIR, `${name}.simdevicetype`, 'Contents', 'Resources', 'profile.plist');
467
+ if (!existsSync(plistPath))
468
+ return null;
469
+ const data = readPlistAsJson(plistPath);
470
+ const w = data.mainScreenWidth;
471
+ const h = data.mainScreenHeight;
472
+ const s = data.mainScreenScale;
473
+ if (!w || !h || !s || s <= 0)
474
+ return null;
475
+ return { width: Math.round(w / s), height: Math.round(h / s) };
476
+ }
477
+ catch {
478
+ return null;
479
+ }
480
+ }
481
+ // ---------------------------------------------------------------------------
482
+ // Button PNG attachment — renders each button's normal PDF separately for CSS overlay
483
+ // ---------------------------------------------------------------------------
484
+ function attachButtonPngs(buttons, drawData, chromeName) {
485
+ for (let i = 0; i < buttons.length; i++) {
486
+ if (i >= drawData.length)
487
+ continue;
488
+ const pdfPath = drawData[i].pdfPath;
489
+ const cacheKey = `tapflow-btn-normal-${chromeName}-${i}.png`;
490
+ const outPath = join(tmpdir(), cacheKey);
491
+ try {
492
+ if (!existsSync(outPath) || statSync(pdfPath).mtimeMs > statSync(outPath).mtimeMs) {
493
+ renderPdfToPng(pdfPath, outPath);
494
+ }
495
+ buttons[i].buttonPng = readFileSync(outPath).toString('base64');
496
+ }
497
+ catch { /* skip if rendering fails */ }
498
+ }
499
+ }
500
+ // ---------------------------------------------------------------------------
501
+ // Pressed PNG attachment — renders imageDown PDFs and attaches to buttons[]
502
+ // ---------------------------------------------------------------------------
503
+ function attachPressedPngs(buttons, pressedData, chromeName, scale) {
504
+ for (let i = 0; i < buttons.length; i++) {
505
+ const pd = pressedData[i];
506
+ if (!pd)
507
+ continue;
508
+ const cacheKey = `tapflow-btn-pressed-${chromeName}-${i}.png`;
509
+ const outPath = join(tmpdir(), cacheKey);
510
+ try {
511
+ if (!existsSync(outPath) || statSync(pd.pdfPath).mtimeMs > statSync(outPath).mtimeMs) {
512
+ renderPdfToPng(pd.pdfPath, outPath);
513
+ }
514
+ buttons[i].pressedPng = readFileSync(outPath).toString('base64');
515
+ buttons[i].pressedRect = {
516
+ x: Math.round(pd.topLeftX * scale),
517
+ y: Math.round(pd.topLeftY * scale),
518
+ width: Math.round(pd.pdfW * scale),
519
+ height: Math.round(pd.pdfH * scale),
520
+ };
521
+ }
522
+ catch { /* skip if rendering fails */ }
523
+ }
524
+ }
525
+ // ---------------------------------------------------------------------------
526
+ // DeviceChromeLoader
527
+ // ---------------------------------------------------------------------------
528
+ export class DeviceChromeLoader {
529
+ load(typeIdentifier) {
530
+ try {
531
+ const modelId = modelIdentifierForType(typeIdentifier);
532
+ if (!modelId)
533
+ return null;
534
+ const chromeMap = readPlistAsJson(CHROME_MAP_PATH);
535
+ const entry = chromeMap[modelId];
536
+ if (!entry)
537
+ return null;
538
+ const chromeName = entry.ChromeIdentifier.split('.').pop();
539
+ const resourcesDir = join(CHROME_DIR, `${chromeName}.devicechrome`, 'Contents', 'Resources');
540
+ const chromeJsonPath = join(resourcesDir, 'chrome.json');
541
+ if (!existsSync(chromeJsonPath))
542
+ return null;
543
+ const chromeJson = JSON.parse(readFileSync(chromeJsonPath, 'utf-8'));
544
+ const { leftWidth, rightWidth, topHeight, bottomHeight } = chromeJson.images.sizing;
545
+ const dp = chromeJson.images.devicePadding ?? {};
546
+ const paddingLeft = dp.left ?? 0;
547
+ const paddingRight = dp.right ?? 0;
548
+ const paddingTop = dp.top ?? 0;
549
+ const paddingBottom = dp.bottom ?? 0;
550
+ const outerRadius = chromeJson.paths?.simpleOutsideBorder?.cornerRadiusX ?? 0;
551
+ const rawInputs = chromeJson.inputs ?? [];
552
+ const scale = 2;
553
+ // -----------------------------------------------------------------------
554
+ // Path A — composite PDF (all post-2020 iPhones)
555
+ // -----------------------------------------------------------------------
556
+ const compositePdf = join(resourcesDir, 'PhoneComposite.pdf');
557
+ if (existsSync(compositePdf)) {
558
+ const pdfSize = getSipsSize(compositePdf);
559
+ const screenW = pdfSize.width - leftWidth - rightWidth;
560
+ const screenH = pdfSize.height - topHeight - bottomHeight;
561
+ const bezelInset = Math.max(leftWidth, topHeight);
562
+ const screenCornerRadius1x = Math.max(0, outerRadius - bezelInset);
563
+ const { margins: btnM, drawData, buttons, pressedData } = computeButtonLayout(rawInputs, resourcesDir, pdfSize.width, pdfSize.height, scale, leftWidth, rightWidth);
564
+ const expandedW = pdfSize.width + btnM.left + btnM.right;
565
+ const expandedH = pdfSize.height + btnM.top + btnM.bottom;
566
+ // v3: buttons excluded from framePng — rendered separately as CSS-animated overlays
567
+ const framePath = join(tmpdir(), `tapflow-frame-v3-${chromeName}.png`);
568
+ if (!existsSync(framePath) || statSync(compositePdf).mtimeMs > statSync(framePath).mtimeMs) {
569
+ renderFramePng(compositePdf, framePath, btnM, []);
570
+ }
571
+ attachButtonPngs(buttons, drawData, chromeName);
572
+ attachPressedPngs(buttons, pressedData, chromeName, scale);
573
+ const screenRect = {
574
+ x: Math.round((leftWidth + btnM.left) * scale),
575
+ y: Math.round((topHeight + btnM.top) * scale),
576
+ width: Math.round(screenW * scale),
577
+ height: Math.round(screenH * scale),
578
+ };
579
+ return {
580
+ framePng: readFileSync(framePath).toString('base64'),
581
+ bezelWidth: Math.round((pdfSize.width - paddingLeft - paddingRight) * scale),
582
+ bezelHeight: Math.round((pdfSize.height - paddingTop - paddingBottom) * scale),
583
+ compositeWidth: Math.round(expandedW * scale),
584
+ compositeHeight: Math.round(expandedH * scale),
585
+ padding: {
586
+ left: Math.round(paddingLeft * scale),
587
+ right: Math.round(paddingRight * scale),
588
+ top: Math.round(paddingTop * scale),
589
+ bottom: Math.round(paddingBottom * scale),
590
+ },
591
+ screenRect,
592
+ screenCornerRadius: Math.round(screenCornerRadius1x * scale),
593
+ logicalWidth: Math.round(screenW),
594
+ logicalHeight: Math.round(screenH),
595
+ buttons,
596
+ };
597
+ }
598
+ // -----------------------------------------------------------------------
599
+ // Path B — nine-slice chrome (iPhone SE / older home-button devices)
600
+ // Slices: topLeft/top/topRight/right/bottomRight/bottom/bottomLeft/left
601
+ // -----------------------------------------------------------------------
602
+ const imgs = chromeJson.images;
603
+ if (!imgs.topLeft || !imgs.top || !imgs.topRight || !imgs.right ||
604
+ !imgs.bottomRight || !imgs.bottom || !imgs.bottomLeft || !imgs.left) {
605
+ return null;
606
+ }
607
+ const slicePaths = {
608
+ topLeft: join(resourcesDir, `${imgs.topLeft}.pdf`),
609
+ top: join(resourcesDir, `${imgs.top}.pdf`),
610
+ topRight: join(resourcesDir, `${imgs.topRight}.pdf`),
611
+ right: join(resourcesDir, `${imgs.right}.pdf`),
612
+ bottomRight: join(resourcesDir, `${imgs.bottomRight}.pdf`),
613
+ bottom: join(resourcesDir, `${imgs.bottom}.pdf`),
614
+ bottomLeft: join(resourcesDir, `${imgs.bottomLeft}.pdf`),
615
+ left: join(resourcesDir, `${imgs.left}.pdf`),
616
+ };
617
+ // Ensure all slice PDFs exist
618
+ if (!Object.values(slicePaths).every(existsSync))
619
+ return null;
620
+ // Corner PDF dimensions define the actual bezel insets used in composition.
621
+ // The sizing.leftWidth/rightWidth in chrome.json is the VISIBLE bezel width
622
+ // (interior of the corner), not the full corner PDF dimensions.
623
+ const cornerSize = getSipsSize(slicePaths.topLeft);
624
+ const cornerW = cornerSize.width;
625
+ const cornerH = cornerSize.height; // should equal topHeight from sizing
626
+ // Get logical screen dimensions from CoreSimulator profile.plist
627
+ const screenSize = loadProfileScreenSize(typeIdentifier);
628
+ if (!screenSize)
629
+ return null;
630
+ const screenW = screenSize.width;
631
+ const screenH = screenSize.height;
632
+ // Device body = bezel insets + screen (baguette: canvasSize = insets + innerSize)
633
+ const compositeW = leftWidth + screenW + rightWidth;
634
+ const compositeH = topHeight + screenH + bottomHeight;
635
+ const bezelInset = Math.max(leftWidth, topHeight);
636
+ const screenCornerRadius1x = Math.max(0, outerRadius - bezelInset);
637
+ const { margins: btnM, drawData, buttons, pressedData } = computeButtonLayout(rawInputs, resourcesDir, compositeW, compositeH, scale, leftWidth, rightWidth);
638
+ const expandedW = compositeW + btnM.left + btnM.right;
639
+ const expandedH = compositeH + btnM.top + btnM.bottom;
640
+ // nb = no-buttons: buttons excluded from framePng, rendered separately as CSS-animated overlays
641
+ const framePath = join(tmpdir(), `tapflow-frame-nineslice-nb-${chromeName}-${screenW}x${screenH}-c${Math.round(expandedW)}x${Math.round(expandedH)}.png`);
642
+ const needsRender = !existsSync(framePath)
643
+ || statSync(slicePaths.topLeft).mtimeMs > statSync(framePath).mtimeMs;
644
+ if (needsRender) {
645
+ renderNineSlicePng(slicePaths, framePath, leftWidth, rightWidth, topHeight, bottomHeight, cornerW, cornerH, screenW, screenH, btnM, []);
646
+ }
647
+ attachButtonPngs(buttons, drawData, chromeName);
648
+ attachPressedPngs(buttons, pressedData, chromeName, scale);
649
+ // Screen hole: starts at sizing insets within the device body
650
+ const screenRect = {
651
+ x: Math.round((btnM.left + leftWidth) * scale),
652
+ y: Math.round((btnM.top + topHeight) * scale),
653
+ width: Math.round(screenW * scale),
654
+ height: Math.round(screenH * scale),
655
+ };
656
+ return {
657
+ framePng: readFileSync(framePath).toString('base64'),
658
+ bezelWidth: Math.round((compositeW - paddingLeft - paddingRight) * scale),
659
+ bezelHeight: Math.round((compositeH - paddingTop - paddingBottom) * scale),
660
+ compositeWidth: Math.round(expandedW * scale),
661
+ compositeHeight: Math.round(expandedH * scale),
662
+ padding: {
663
+ left: Math.round(paddingLeft * scale),
664
+ right: Math.round(paddingRight * scale),
665
+ top: Math.round(paddingTop * scale),
666
+ bottom: Math.round(paddingBottom * scale),
667
+ },
668
+ screenRect,
669
+ screenCornerRadius: Math.round(screenCornerRadius1x * scale),
670
+ logicalWidth: screenW,
671
+ logicalHeight: screenH,
672
+ buttons,
673
+ };
674
+ }
675
+ catch {
676
+ return null;
677
+ }
678
+ }
679
+ }
680
+ //# sourceMappingURL=DeviceChromeLoader.js.map