@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.
- package/LICENSE +21 -0
- package/README.md +32 -0
- package/bin/keyboard-helper +0 -0
- package/bin/rotation-helper +0 -0
- package/bin/screencapture-helper +0 -0
- package/bin/touch-helper +0 -0
- package/dist/DeviceChromeLoader.d.ts +49 -0
- package/dist/DeviceChromeLoader.d.ts.map +1 -0
- package/dist/DeviceChromeLoader.js +680 -0
- package/dist/DeviceChromeLoader.js.map +1 -0
- package/dist/IOSAgent.d.ts +46 -0
- package/dist/IOSAgent.d.ts.map +1 -0
- package/dist/IOSAgent.js +478 -0
- package/dist/IOSAgent.js.map +1 -0
- package/dist/KeyCodeMap.d.ts +3 -0
- package/dist/KeyCodeMap.d.ts.map +1 -0
- package/dist/KeyCodeMap.js +42 -0
- package/dist/KeyCodeMap.js.map +1 -0
- package/dist/KeyboardHelperDaemon.d.ts +13 -0
- package/dist/KeyboardHelperDaemon.d.ts.map +1 -0
- package/dist/KeyboardHelperDaemon.js +96 -0
- package/dist/KeyboardHelperDaemon.js.map +1 -0
- package/dist/MjpegStreamer.d.ts +10 -0
- package/dist/MjpegStreamer.d.ts.map +1 -0
- package/dist/MjpegStreamer.js +40 -0
- package/dist/MjpegStreamer.js.map +1 -0
- package/dist/ScreenCaptureStreamer.d.ts +7 -0
- package/dist/ScreenCaptureStreamer.d.ts.map +1 -0
- package/dist/ScreenCaptureStreamer.js +92 -0
- package/dist/ScreenCaptureStreamer.js.map +1 -0
- package/dist/SimctlWrapper.d.ts +21 -0
- package/dist/SimctlWrapper.d.ts.map +1 -0
- package/dist/SimctlWrapper.js +154 -0
- package/dist/SimctlWrapper.js.map +1 -0
- package/dist/TouchHelper.d.ts +21 -0
- package/dist/TouchHelper.d.ts.map +1 -0
- package/dist/TouchHelper.js +110 -0
- package/dist/TouchHelper.js.map +1 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -0
- package/dist/simctl.d.ts +6 -0
- package/dist/simctl.d.ts.map +1 -0
- package/dist/simctl.js +64 -0
- package/dist/simctl.js.map +1 -0
- 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
|