simdeck 0.1.0

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.
@@ -0,0 +1,1438 @@
1
+ #!/usr/bin/env node
2
+ import fs from "node:fs";
3
+ import crypto from "node:crypto";
4
+ import path from "node:path";
5
+ import process from "node:process";
6
+ import net from "node:net";
7
+ import { spawnSync } from "node:child_process";
8
+
9
+ const DEFAULT_BUNDLE_ID = "dev.simdeck.PreviewHost";
10
+ const DEFAULT_MIN_IOS = "15.0";
11
+ const RELOAD_PORT_START = 47440;
12
+ const RELOAD_PORT_LIMIT = 16;
13
+ const RELOAD_PROTOCOL_PREFIX = "SIMDECK_PREVIEW_RELOAD ";
14
+ const RELOAD_BYTES_PROTOCOL_PREFIX = "SIMDECK_PREVIEW_RELOAD_B64 ";
15
+
16
+ main().catch((error) => {
17
+ console.error(error instanceof Error ? error.message : String(error));
18
+ process.exit(1);
19
+ });
20
+
21
+ async function main() {
22
+ const args = parseArgs(process.argv.slice(2));
23
+ if (args.help || !args.file) {
24
+ printUsage();
25
+ process.exit(args.help ? 0 : 2);
26
+ }
27
+
28
+ const sourceFile = path.resolve(String(args.file));
29
+ const udid = String(args.udid ?? findBootedSimulatorUDID());
30
+ if (!udid) {
31
+ throw new Error(
32
+ "No simulator UDID supplied and no booted simulator was found.",
33
+ );
34
+ }
35
+
36
+ const buildRoot = path.resolve(
37
+ String(args.buildRoot ?? path.join(".simdeck-preview", "build")),
38
+ );
39
+ const bundleId = String(args.bundleId ?? DEFAULT_BUNDLE_ID);
40
+ const minIos = String(args.minIos ?? DEFAULT_MIN_IOS);
41
+ const targetArch = String(
42
+ args.arch ?? (process.arch === "arm64" ? "arm64" : "x86_64"),
43
+ );
44
+ const sdkPath = runText("xcrun", [
45
+ "--sdk",
46
+ "iphonesimulator",
47
+ "--show-sdk-path",
48
+ ]).trim();
49
+ const context = {
50
+ buildRoot,
51
+ bundleId,
52
+ minIos,
53
+ sdkPath,
54
+ target: `${targetArch}-apple-ios${minIos}-simulator`,
55
+ udid,
56
+ };
57
+ fs.mkdirSync(buildRoot, { recursive: true });
58
+ const xcode = resolveXcodeContext(args, context);
59
+
60
+ const host = buildHostApp(context, Boolean(args.rebuildHost));
61
+ const shouldInstallHost =
62
+ host.rebuilt || Boolean(xcode && !args.skipXcodeBuild);
63
+ if (xcode && shouldInstallHost) {
64
+ overlayXcodeAppBundle(host.appPath, xcode);
65
+ }
66
+ installAndLaunchHost(context, host.appPath, shouldInstallHost);
67
+ await reloadPreview(context, sourceFile, args, xcode);
68
+
69
+ if (args.watch) {
70
+ console.log(`[simdeck-preview] watching ${sourceFile}`);
71
+ watchFiles(
72
+ [
73
+ sourceFile,
74
+ ...arrayArg(args.extraSwift).map((item) => path.resolve(item)),
75
+ ],
76
+ () => {
77
+ reloadPreview(context, sourceFile, args, xcode).catch((error) => {
78
+ console.error(`[simdeck-preview] reload failed: ${error.message}`);
79
+ });
80
+ },
81
+ );
82
+ process.stdin.resume();
83
+ }
84
+ }
85
+
86
+ function parseArgs(argv) {
87
+ const args = {};
88
+ for (let index = 0; index < argv.length; index += 1) {
89
+ const arg = argv[index];
90
+ if (arg === "--help" || arg === "-h") {
91
+ args.help = true;
92
+ } else if (arg === "--watch" || arg === "-w") {
93
+ args.watch = true;
94
+ } else if (arg === "--rebuild-host") {
95
+ args.rebuildHost = true;
96
+ } else if (arg === "--skip-xcode-build") {
97
+ args.skipXcodeBuild = true;
98
+ } else if (arg === "--skip-codesign") {
99
+ args.skipCodesign = true;
100
+ } else if (arg === "--profile") {
101
+ args.profile = true;
102
+ } else if (arg === "--split-compile") {
103
+ args.splitCompile = true;
104
+ } else if (arg.startsWith("--")) {
105
+ const key = arg
106
+ .slice(2)
107
+ .replace(/-([a-z])/g, (_, char) => char.toUpperCase());
108
+ const next = argv[index + 1];
109
+ if (!next || next.startsWith("--")) {
110
+ throw new Error(`Missing value for ${arg}.`);
111
+ }
112
+ index += 1;
113
+ if (key === "extraSwift" || key === "swiftcArg") {
114
+ args[key] = [...arrayArg(args[key]), next];
115
+ } else {
116
+ args[key] = next;
117
+ }
118
+ } else if (!args.file) {
119
+ args.file = arg;
120
+ } else {
121
+ args.extraSwift = [...arrayArg(args.extraSwift), arg];
122
+ }
123
+ }
124
+ return args;
125
+ }
126
+
127
+ function printUsage() {
128
+ console.log(`Usage:
129
+ node scripts/experimental/swiftui-preview.mjs --udid <sim> --file <Preview.swift> [options]
130
+
131
+ Options:
132
+ --preview <name-or-index> Select a #Preview block. Defaults to the first one.
133
+ --watch, -w Recompile and dlopen a new payload after file changes.
134
+ --extra-swift <file> Include another Swift file. Can be repeated.
135
+ --swiftc-arg <arg> Pass an extra argument to swiftc. Can be repeated.
136
+ --workspace <path> Use an Xcode workspace for compatibility mode.
137
+ --project <path> Use an Xcode project for compatibility mode.
138
+ --scheme <name> Xcode scheme to build for compatibility mode.
139
+ --configuration <name> Xcode configuration. Default: Debug.
140
+ --derived-data-path <path> DerivedData path for Xcode builds.
141
+ --skip-xcode-build Reuse existing Xcode build artifacts.
142
+ --skip-codesign Do not ad-hoc sign reload dylibs.
143
+ --profile Print reload-stage timings.
144
+ --split-compile Cache the preview source as a testable Swift module.
145
+ --bundle-id <id> Host bundle id. Default: ${DEFAULT_BUNDLE_ID}
146
+ --build-root <path> Build/cache directory. Default: .simdeck-preview/build
147
+ --rebuild-host Rebuild and reinstall the stable host app.
148
+
149
+ This is intentionally experimental. It extracts simple #Preview { ... } bodies,
150
+ builds them into a versioned simulator dylib, and asks a tiny host app to dlopen
151
+ the new dylib without reinstalling the host.
152
+
153
+ When --workspace/--project and --scheme are supplied, the runner builds the real
154
+ app target once, copies its resources/frameworks into the host, and links reload
155
+ dylibs against the target's Xcode-built debug dylib. That is the faster Xcode-ish
156
+ path: reloads compile the edited preview source plus a tiny wrapper, not the full
157
+ app target.`);
158
+ }
159
+
160
+ function buildHostApp(context, rebuildHost) {
161
+ const appPath = path.join(context.buildRoot, "SimDeckPreviewHost.app");
162
+ const executable = "SimDeckPreviewHost";
163
+ const executablePath = path.join(appPath, executable);
164
+ if (!rebuildHost && fs.existsSync(executablePath)) {
165
+ return { appPath, rebuilt: false };
166
+ }
167
+
168
+ fs.rmSync(appPath, { recursive: true, force: true });
169
+ fs.mkdirSync(appPath, { recursive: true });
170
+ fs.writeFileSync(
171
+ path.join(appPath, "Info.plist"),
172
+ `<?xml version="1.0" encoding="UTF-8"?>
173
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
174
+ <plist version="1.0">
175
+ <dict>
176
+ <key>CFBundleDevelopmentRegion</key><string>en</string>
177
+ <key>CFBundleExecutable</key><string>${executable}</string>
178
+ <key>CFBundleIdentifier</key><string>${context.bundleId}</string>
179
+ <key>CFBundleInfoDictionaryVersion</key><string>6.0</string>
180
+ <key>CFBundleName</key><string>SimDeckPreview</string>
181
+ <key>CFBundlePackageType</key><string>APPL</string>
182
+ <key>CFBundleShortVersionString</key><string>1.0</string>
183
+ <key>CFBundleVersion</key><string>1</string>
184
+ <key>LSRequiresIPhoneOS</key><true/>
185
+ <key>MinimumOSVersion</key><string>${context.minIos}</string>
186
+ <key>UIDeviceFamily</key><array><integer>1</integer><integer>2</integer></array>
187
+ <key>CFBundleURLTypes</key>
188
+ <array>
189
+ <dict>
190
+ <key>CFBundleURLName</key><string>SimDeckPreview</string>
191
+ <key>CFBundleURLSchemes</key><array><string>simdeck-preview</string></array>
192
+ </dict>
193
+ </array>
194
+ </dict>
195
+ </plist>
196
+ `,
197
+ );
198
+
199
+ const mainPath = path.join(context.buildRoot, "SimDeckPreviewHost.swift");
200
+ fs.writeFileSync(mainPath, hostSource());
201
+ run("xcrun", [
202
+ "--sdk",
203
+ "iphonesimulator",
204
+ "swiftc",
205
+ "-target",
206
+ context.target,
207
+ "-sdk",
208
+ context.sdkPath,
209
+ "-parse-as-library",
210
+ "-Onone",
211
+ "-framework",
212
+ "SwiftUI",
213
+ "-framework",
214
+ "UIKit",
215
+ mainPath,
216
+ "-o",
217
+ executablePath,
218
+ ]);
219
+ console.log(`[simdeck-preview] built host ${appPath}`);
220
+ return { appPath, rebuilt: true };
221
+ }
222
+
223
+ function installAndLaunchHost(context, appPath, shouldInstall) {
224
+ if (!shouldInstall) {
225
+ return;
226
+ }
227
+ run("xcrun", ["simctl", "install", context.udid, appPath]);
228
+ run("xcrun", ["simctl", "launch", context.udid, context.bundleId], {
229
+ allowFailure: true,
230
+ });
231
+ }
232
+
233
+ async function reloadPreview(context, sourceFile, args, xcode) {
234
+ const started = Date.now();
235
+ const timings = [];
236
+ const timed = (label, action) => {
237
+ const stageStarted = Date.now();
238
+ const result = action();
239
+ timings.push([label, Date.now() - stageStarted]);
240
+ return result;
241
+ };
242
+ const timedAsync = async (label, action) => {
243
+ const stageStarted = Date.now();
244
+ const result = await action();
245
+ timings.push([label, Date.now() - stageStarted]);
246
+ return result;
247
+ };
248
+ const source = timed("read source", () =>
249
+ fs.readFileSync(sourceFile, "utf8"),
250
+ );
251
+ const previews = timed("extract previews", () => extractPreviews(source));
252
+ if (previews.length === 0) {
253
+ throw new Error(`No #Preview blocks found in ${sourceFile}.`);
254
+ }
255
+ const preview = timed("select preview", () =>
256
+ selectPreview(previews, args.preview),
257
+ );
258
+ const stamp = `${Date.now()}-${process.pid}`;
259
+ const payloadDir = path.join(context.buildRoot, "payloads", stamp);
260
+ timed("prepare payload dir", () =>
261
+ fs.mkdirSync(payloadDir, { recursive: true }),
262
+ );
263
+
264
+ const sanitizedPath = path.join(payloadDir, "PreviewSource.swift");
265
+ const wrapperPath = path.join(payloadDir, "SimDeckPreviewPayload.swift");
266
+ const dylibPath = path.join(
267
+ payloadDir,
268
+ `SimDeckPreviewPayload-${stamp}.dylib`,
269
+ );
270
+ const sanitizedSource = timed("sanitize source", () =>
271
+ sanitizePreviewSource(
272
+ removeRanges(
273
+ source,
274
+ previews.map((item) => item.range),
275
+ ),
276
+ xcode?.moduleName,
277
+ ),
278
+ );
279
+ const sourceModule = args.splitCompile
280
+ ? timed("resolve source cache", () =>
281
+ previewSourceModule(context, sanitizedSource, args, xcode),
282
+ )
283
+ : null;
284
+ timed("write generated swift", () => {
285
+ fs.writeFileSync(sanitizedPath, sanitizedSource);
286
+ fs.writeFileSync(
287
+ wrapperPath,
288
+ payloadWrapperSource(
289
+ preview.body,
290
+ xcode?.moduleName,
291
+ sourceModule?.moduleName,
292
+ ),
293
+ );
294
+ });
295
+
296
+ if (sourceModule) {
297
+ timed("swiftc source module", () =>
298
+ compilePreviewSourceModule(
299
+ context,
300
+ sanitizedPath,
301
+ sourceModule,
302
+ args,
303
+ xcode,
304
+ ),
305
+ );
306
+ }
307
+
308
+ const compileArgs = sourceModule
309
+ ? splitWrapperCompileArgs(
310
+ context,
311
+ stamp,
312
+ wrapperPath,
313
+ dylibPath,
314
+ sourceModule,
315
+ args,
316
+ xcode,
317
+ sourceFile,
318
+ )
319
+ : monolithicCompileArgs(
320
+ context,
321
+ stamp,
322
+ sanitizedPath,
323
+ wrapperPath,
324
+ dylibPath,
325
+ args,
326
+ xcode,
327
+ sourceFile,
328
+ );
329
+ timed("swiftc emit dylib", () => run("xcrun", compileArgs));
330
+ if (!args.skipCodesign) {
331
+ timed("codesign", () =>
332
+ run("codesign", ["-s", "-", "-f", dylibPath], { allowFailure: true }),
333
+ );
334
+ }
335
+
336
+ const sentBytes = await timedAsync("tcp reload bytes", () =>
337
+ sendTcpReloadBytes(dylibPath),
338
+ );
339
+ if (
340
+ !sentBytes &&
341
+ !(await timedAsync("tcp reload path", () =>
342
+ sendTcpReloadPath(context, dylibPath),
343
+ ))
344
+ ) {
345
+ const containerPath = timed("simctl get container", () =>
346
+ appContainerPath(context),
347
+ );
348
+ const documentsDir = path.join(containerPath, "Documents");
349
+ fs.mkdirSync(documentsDir, { recursive: true });
350
+ const installedDylibPath = path.join(
351
+ documentsDir,
352
+ path.basename(dylibPath),
353
+ );
354
+ timed("copy dylib", () => fs.copyFileSync(dylibPath, installedDylibPath));
355
+ const reloadUrl = `simdeck-preview://reload?path=${encodeURIComponent(installedDylibPath)}`;
356
+ timed("simctl openurl", () =>
357
+ run("xcrun", ["simctl", "openurl", context.udid, reloadUrl]),
358
+ );
359
+ }
360
+
361
+ const elapsed = Date.now() - started;
362
+ const label = preview.name ? `"${preview.name}"` : `#${preview.index + 1}`;
363
+ console.log(`[simdeck-preview] reloaded preview ${label} in ${elapsed}ms`);
364
+ console.log(
365
+ `[simdeck-preview] SimDeck UI should show bundle ${context.bundleId} on ${context.udid}`,
366
+ );
367
+ if (args.profile) {
368
+ const summary = timings.map(([label, ms]) => `${label}=${ms}ms`).join(" ");
369
+ console.log(`[simdeck-preview] profile ${summary}`);
370
+ }
371
+ }
372
+
373
+ function monolithicCompileArgs(
374
+ context,
375
+ stamp,
376
+ sanitizedPath,
377
+ wrapperPath,
378
+ dylibPath,
379
+ args,
380
+ xcode,
381
+ sourceFile,
382
+ ) {
383
+ return [
384
+ "--sdk",
385
+ "iphonesimulator",
386
+ "swiftc",
387
+ "-target",
388
+ context.target,
389
+ "-sdk",
390
+ context.sdkPath,
391
+ "-parse-as-library",
392
+ "-Onone",
393
+ "-emit-library",
394
+ "-module-name",
395
+ `SimDeckPreviewPayload_${stamp.replaceAll("-", "_")}`,
396
+ "-framework",
397
+ "SwiftUI",
398
+ "-framework",
399
+ "UIKit",
400
+ ...xcodeSwiftcArgs(xcode),
401
+ sanitizedPath,
402
+ ...arrayArg(args.extraSwift).map((item) => path.resolve(item)),
403
+ wrapperPath,
404
+ ...xcodeObjectFiles(xcode, sourceFile),
405
+ ...arrayArg(args.swiftcArg),
406
+ "-o",
407
+ dylibPath,
408
+ ];
409
+ }
410
+
411
+ function splitWrapperCompileArgs(
412
+ context,
413
+ stamp,
414
+ wrapperPath,
415
+ dylibPath,
416
+ sourceModule,
417
+ args,
418
+ xcode,
419
+ sourceFile,
420
+ ) {
421
+ return [
422
+ "--sdk",
423
+ "iphonesimulator",
424
+ "swiftc",
425
+ "-target",
426
+ context.target,
427
+ "-sdk",
428
+ context.sdkPath,
429
+ "-parse-as-library",
430
+ "-Onone",
431
+ "-emit-library",
432
+ "-module-name",
433
+ `SimDeckPreviewPayload_${stamp.replaceAll("-", "_")}`,
434
+ "-I",
435
+ sourceModule.directory,
436
+ "-framework",
437
+ "SwiftUI",
438
+ "-framework",
439
+ "UIKit",
440
+ ...xcodeSwiftcArgs(xcode),
441
+ wrapperPath,
442
+ sourceModule.objectPath,
443
+ ...xcodeObjectFiles(xcode, sourceFile),
444
+ ...arrayArg(args.swiftcArg),
445
+ "-o",
446
+ dylibPath,
447
+ ];
448
+ }
449
+
450
+ function previewSourceModule(context, sanitizedSource, args, xcode) {
451
+ const extraSwift = arrayArg(args.extraSwift);
452
+ if (extraSwift.length > 0) {
453
+ console.warn(
454
+ "[simdeck-preview] warning: --split-compile currently falls back when --extra-swift is used.",
455
+ );
456
+ return null;
457
+ }
458
+ const swiftcArgs = [...xcodeSwiftcArgs(xcode), ...arrayArg(args.swiftcArg)];
459
+ const hash = crypto
460
+ .createHash("sha256")
461
+ .update(context.target)
462
+ .update("\0")
463
+ .update(context.sdkPath)
464
+ .update("\0")
465
+ .update(xcode?.moduleName ?? "")
466
+ .update("\0")
467
+ .update(swiftcArgs.join("\0"))
468
+ .update("\0")
469
+ .update(sanitizedSource)
470
+ .digest("hex")
471
+ .slice(0, 16);
472
+ const directory = path.join(context.buildRoot, "source-cache", hash);
473
+ return {
474
+ directory,
475
+ moduleName: `SimDeckPreviewSource_${hash}`,
476
+ modulePath: path.join(
477
+ directory,
478
+ `SimDeckPreviewSource_${hash}.swiftmodule`,
479
+ ),
480
+ objectPath: path.join(directory, `SimDeckPreviewSource_${hash}.o`),
481
+ };
482
+ }
483
+
484
+ function compilePreviewSourceModule(
485
+ context,
486
+ sanitizedPath,
487
+ sourceModule,
488
+ args,
489
+ xcode,
490
+ ) {
491
+ if (
492
+ fs.existsSync(sourceModule.objectPath) &&
493
+ fs.existsSync(sourceModule.modulePath)
494
+ ) {
495
+ return;
496
+ }
497
+ fs.mkdirSync(sourceModule.directory, { recursive: true });
498
+ run("xcrun", [
499
+ "--sdk",
500
+ "iphonesimulator",
501
+ "swiftc",
502
+ "-target",
503
+ context.target,
504
+ "-sdk",
505
+ context.sdkPath,
506
+ "-parse-as-library",
507
+ "-Onone",
508
+ "-enable-testing",
509
+ "-emit-module",
510
+ "-emit-module-path",
511
+ sourceModule.modulePath,
512
+ "-emit-object",
513
+ "-module-name",
514
+ sourceModule.moduleName,
515
+ "-framework",
516
+ "SwiftUI",
517
+ "-framework",
518
+ "UIKit",
519
+ ...xcodeSwiftcArgs(xcode),
520
+ sanitizedPath,
521
+ ...arrayArg(args.swiftcArg),
522
+ "-o",
523
+ sourceModule.objectPath,
524
+ ]);
525
+ }
526
+
527
+ async function sendTcpReloadPath(context, dylibPath) {
528
+ const containerPath = appContainerPath(context);
529
+ const documentsDir = path.join(containerPath, "Documents");
530
+ fs.mkdirSync(documentsDir, { recursive: true });
531
+ const installedDylibPath = path.join(documentsDir, path.basename(dylibPath));
532
+ fs.copyFileSync(dylibPath, installedDylibPath);
533
+ for (let offset = 0; offset < RELOAD_PORT_LIMIT; offset += 1) {
534
+ const port = RELOAD_PORT_START + offset;
535
+ if (await sendTcpReloadToPort(port, installedDylibPath)) {
536
+ return true;
537
+ }
538
+ }
539
+ return false;
540
+ }
541
+
542
+ async function sendTcpReloadBytes(dylibPath) {
543
+ const base64 = fs.readFileSync(dylibPath).toString("base64");
544
+ const payload = `${RELOAD_BYTES_PROTOCOL_PREFIX}${path.basename(dylibPath)} ${base64}\n`;
545
+ for (let offset = 0; offset < RELOAD_PORT_LIMIT; offset += 1) {
546
+ const port = RELOAD_PORT_START + offset;
547
+ if (await sendTcpMessageToPort(port, payload)) {
548
+ return true;
549
+ }
550
+ }
551
+ return false;
552
+ }
553
+
554
+ function sendTcpReloadToPort(port, dylibPath) {
555
+ return sendTcpMessageToPort(port, `${RELOAD_PROTOCOL_PREFIX}${dylibPath}\n`);
556
+ }
557
+
558
+ function sendTcpMessageToPort(port, message) {
559
+ return new Promise((resolve) => {
560
+ const socket = net.createConnection({ host: "127.0.0.1", port });
561
+ let settled = false;
562
+ let response = "";
563
+ const settle = (value) => {
564
+ if (settled) {
565
+ return;
566
+ }
567
+ settled = true;
568
+ clearTimeout(timer);
569
+ socket.destroy();
570
+ resolve(value);
571
+ };
572
+ const timer = setTimeout(() => {
573
+ settle(false);
574
+ }, 500);
575
+ socket.once("connect", () => {
576
+ socket.end(message);
577
+ });
578
+ socket.on("data", (data) => {
579
+ response += data.toString("utf8");
580
+ if (response.trim().startsWith("OK")) {
581
+ settle(true);
582
+ } else if (response.trim().startsWith("ERROR")) {
583
+ settle(false);
584
+ }
585
+ });
586
+ socket.once("error", () => {
587
+ settle(false);
588
+ });
589
+ socket.once("close", () => {
590
+ settle(response.trim().startsWith("OK"));
591
+ });
592
+ });
593
+ }
594
+
595
+ function appContainerPath(context) {
596
+ if (context.containerPath) {
597
+ return context.containerPath;
598
+ }
599
+ context.containerPath = runText("xcrun", [
600
+ "simctl",
601
+ "get_app_container",
602
+ context.udid,
603
+ context.bundleId,
604
+ "data",
605
+ ]).trim();
606
+ return context.containerPath;
607
+ }
608
+
609
+ function resolveXcodeContext(args, context) {
610
+ if (!args.workspace && !args.project && !args.scheme) {
611
+ return null;
612
+ }
613
+ if (!args.scheme) {
614
+ throw new Error(
615
+ "--scheme is required when using --workspace or --project.",
616
+ );
617
+ }
618
+
619
+ const derivedDataPath = path.resolve(
620
+ String(args.derivedDataPath ?? path.join(context.buildRoot, "DerivedData")),
621
+ );
622
+ const cacheIdentity = xcodeCacheIdentity(args, derivedDataPath);
623
+ if (args.skipXcodeBuild) {
624
+ const cached = readCachedXcodeContext(context, cacheIdentity);
625
+ if (cached) {
626
+ return cached;
627
+ }
628
+ }
629
+ const buildArgs = xcodebuildBaseArgs(args, derivedDataPath);
630
+ if (!args.skipXcodeBuild) {
631
+ const started = Date.now();
632
+ run("xcodebuild", [
633
+ ...buildArgs,
634
+ "build",
635
+ "CODE_SIGNING_ALLOWED=NO",
636
+ "ENABLE_TESTABILITY=YES",
637
+ ]);
638
+ console.log(
639
+ `[simdeck-preview] xcodebuild warm build completed in ${Date.now() - started}ms`,
640
+ );
641
+ }
642
+
643
+ const settings = readXcodeBuildSettings(buildArgs, args.target);
644
+ const buildSettings = settings.buildSettings ?? settings;
645
+ const arch = context.target.split("-")[0];
646
+ const appPath = findBuiltAppPath(buildSettings);
647
+ const debugDylibPath = findXcodeDebugDylib(appPath, buildSettings);
648
+ const xcode = {
649
+ appPath,
650
+ arch,
651
+ buildSettings,
652
+ debugDylibPath,
653
+ moduleName:
654
+ buildSettings.PRODUCT_MODULE_NAME || buildSettings.SWIFT_MODULE_NAME,
655
+ objectFiles: debugDylibPath
656
+ ? []
657
+ : collectXcodeObjectFiles(buildSettings, arch),
658
+ };
659
+ writeCachedXcodeContext(context, cacheIdentity, xcode);
660
+ return xcode;
661
+ }
662
+
663
+ function xcodeContextCachePath(context) {
664
+ return path.join(context.buildRoot, "xcode-context-cache.json");
665
+ }
666
+
667
+ function xcodeCacheIdentity(args, derivedDataPath) {
668
+ return {
669
+ configuration: String(args.configuration ?? "Debug"),
670
+ derivedDataPath,
671
+ destination: String(args.destination ?? "generic/platform=iOS Simulator"),
672
+ project: args.project ? path.resolve(String(args.project)) : "",
673
+ scheme: String(args.scheme),
674
+ target: String(args.target ?? ""),
675
+ workspace: args.workspace ? path.resolve(String(args.workspace)) : "",
676
+ };
677
+ }
678
+
679
+ function readCachedXcodeContext(context, identity) {
680
+ const cachePath = xcodeContextCachePath(context);
681
+ if (!fs.existsSync(cachePath)) {
682
+ return null;
683
+ }
684
+ try {
685
+ const cached = JSON.parse(fs.readFileSync(cachePath, "utf8"));
686
+ if (JSON.stringify(cached.identity) !== JSON.stringify(identity)) {
687
+ return null;
688
+ }
689
+ const xcode = cached.xcode;
690
+ if (!xcode?.appPath || !fs.existsSync(xcode.appPath)) {
691
+ return null;
692
+ }
693
+ if (xcode.debugDylibPath && !fs.existsSync(xcode.debugDylibPath)) {
694
+ return null;
695
+ }
696
+ return xcode;
697
+ } catch {
698
+ return null;
699
+ }
700
+ }
701
+
702
+ function writeCachedXcodeContext(context, identity, xcode) {
703
+ fs.writeFileSync(
704
+ xcodeContextCachePath(context),
705
+ JSON.stringify({ identity, xcode }, null, 2),
706
+ );
707
+ }
708
+
709
+ function xcodebuildBaseArgs(args, derivedDataPath) {
710
+ const buildArgs = [];
711
+ if (args.workspace) {
712
+ buildArgs.push("-workspace", path.resolve(String(args.workspace)));
713
+ } else if (args.project) {
714
+ buildArgs.push("-project", path.resolve(String(args.project)));
715
+ }
716
+ buildArgs.push("-scheme", String(args.scheme));
717
+ buildArgs.push("-configuration", String(args.configuration ?? "Debug"));
718
+ buildArgs.push(
719
+ "-destination",
720
+ String(args.destination ?? "generic/platform=iOS Simulator"),
721
+ );
722
+ buildArgs.push("-derivedDataPath", derivedDataPath);
723
+ return buildArgs;
724
+ }
725
+
726
+ function readXcodeBuildSettings(buildArgs, targetName) {
727
+ const output = runText("xcodebuild", [
728
+ ...buildArgs,
729
+ "-showBuildSettings",
730
+ "-json",
731
+ ]);
732
+ const settings = JSON.parse(output);
733
+ if (!Array.isArray(settings) || settings.length === 0) {
734
+ throw new Error(
735
+ "xcodebuild -showBuildSettings returned no target settings.",
736
+ );
737
+ }
738
+ if (targetName) {
739
+ const match = settings.find((item) => item.target === targetName);
740
+ if (match) {
741
+ return match;
742
+ }
743
+ }
744
+ return (
745
+ settings.find((item) => item.buildSettings?.PRODUCT_MODULE_NAME) ??
746
+ settings[0]
747
+ );
748
+ }
749
+
750
+ function findBuiltAppPath(settings) {
751
+ const wrapperName = settings.WRAPPER_NAME;
752
+ const candidates = [
753
+ settings.TARGET_BUILD_DIR && wrapperName
754
+ ? path.join(settings.TARGET_BUILD_DIR, wrapperName)
755
+ : "",
756
+ settings.CODESIGNING_FOLDER_PATH,
757
+ settings.BUILT_PRODUCTS_DIR && wrapperName
758
+ ? path.join(settings.BUILT_PRODUCTS_DIR, wrapperName)
759
+ : "",
760
+ ].filter(Boolean);
761
+ const appPath = candidates.find(
762
+ (candidate) => candidate.endsWith(".app") && fs.existsSync(candidate),
763
+ );
764
+ if (!appPath) {
765
+ throw new Error(
766
+ `Unable to locate built .app for Xcode target. Tried: ${candidates.join(", ")}`,
767
+ );
768
+ }
769
+ return appPath;
770
+ }
771
+
772
+ function findXcodeDebugDylib(appPath, settings) {
773
+ const moduleName = settings.PRODUCT_MODULE_NAME || settings.SWIFT_MODULE_NAME;
774
+ const candidates = [
775
+ moduleName ? path.join(appPath, `${moduleName}.debug.dylib`) : "",
776
+ ...fs
777
+ .readdirSync(appPath)
778
+ .filter((entry) => entry.endsWith(".debug.dylib"))
779
+ .map((entry) => path.join(appPath, entry)),
780
+ ].filter(Boolean);
781
+ return candidates.find((candidate) => fs.existsSync(candidate)) ?? "";
782
+ }
783
+
784
+ function overlayXcodeAppBundle(hostAppPath, xcode) {
785
+ const sourceApp = xcode.appPath;
786
+ for (const entry of fs.readdirSync(sourceApp, { withFileTypes: true })) {
787
+ if (
788
+ entry.name === "Info.plist" ||
789
+ entry.name === "_CodeSignature" ||
790
+ entry.name === "PkgInfo" ||
791
+ entry.name.endsWith(".app") ||
792
+ entry.name === path.basename(sourceApp, ".app")
793
+ ) {
794
+ continue;
795
+ }
796
+ copyRecursive(
797
+ path.join(sourceApp, entry.name),
798
+ path.join(hostAppPath, entry.name),
799
+ );
800
+ }
801
+ console.log(
802
+ `[simdeck-preview] overlaid resources/frameworks from ${sourceApp}`,
803
+ );
804
+ }
805
+
806
+ function copyRecursive(source, destination) {
807
+ fs.rmSync(destination, { recursive: true, force: true });
808
+ fs.cpSync(source, destination, { recursive: true, dereference: false });
809
+ }
810
+
811
+ function xcodeSwiftcArgs(xcode) {
812
+ if (!xcode) {
813
+ return [];
814
+ }
815
+ const settings = xcode.buildSettings;
816
+ const args = [];
817
+ pushSearchArgs(args, "-I", [
818
+ settings.SWIFT_INCLUDE_PATHS,
819
+ settings.BUILT_PRODUCTS_DIR,
820
+ settings.TARGET_BUILD_DIR,
821
+ settings.CONFIGURATION_BUILD_DIR,
822
+ ]);
823
+ pushSearchArgs(args, "-F", [
824
+ settings.FRAMEWORK_SEARCH_PATHS,
825
+ settings.BUILT_PRODUCTS_DIR,
826
+ settings.TARGET_BUILD_DIR,
827
+ settings.CONFIGURATION_BUILD_DIR,
828
+ ]);
829
+ pushSearchArgs(args, "-L", [
830
+ settings.LIBRARY_SEARCH_PATHS,
831
+ settings.BUILT_PRODUCTS_DIR,
832
+ settings.TARGET_BUILD_DIR,
833
+ settings.CONFIGURATION_BUILD_DIR,
834
+ ]);
835
+ for (const condition of splitBuildSetting(
836
+ settings.SWIFT_ACTIVE_COMPILATION_CONDITIONS,
837
+ )) {
838
+ if (condition && condition !== "$(inherited)") {
839
+ args.push("-D", condition);
840
+ }
841
+ }
842
+ const bridgingHeader = settings.SWIFT_OBJC_BRIDGING_HEADER;
843
+ if (bridgingHeader && fs.existsSync(bridgingHeader)) {
844
+ args.push("-import-objc-header", bridgingHeader);
845
+ }
846
+ const moduleCache =
847
+ settings.CLANG_MODULE_CACHE_PATH || settings.MODULE_CACHE_DIR;
848
+ if (moduleCache) {
849
+ args.push("-module-cache-path", moduleCache);
850
+ }
851
+ args.push(...splitBuildSetting(settings.OTHER_SWIFT_FLAGS));
852
+ if (xcode.debugDylibPath) {
853
+ args.push(
854
+ "-Xlinker",
855
+ "-rpath",
856
+ "-Xlinker",
857
+ "@executable_path",
858
+ "-Xlinker",
859
+ "-rpath",
860
+ "-Xlinker",
861
+ "@executable_path/Frameworks",
862
+ );
863
+ }
864
+ return args.filter((item) => item !== "$(inherited)");
865
+ }
866
+
867
+ function xcodeObjectFiles(xcode, editedSourceFile) {
868
+ if (!xcode) {
869
+ return [];
870
+ }
871
+ if (xcode.debugDylibPath) {
872
+ return [xcode.debugDylibPath];
873
+ }
874
+ const editedBaseName = path.basename(
875
+ editedSourceFile,
876
+ path.extname(editedSourceFile),
877
+ );
878
+ return xcode.objectFiles.filter((file) => {
879
+ const name = path.basename(file, ".o");
880
+ return name !== editedBaseName && !name.startsWith(`${editedBaseName}.`);
881
+ });
882
+ }
883
+
884
+ function collectXcodeObjectFiles(settings, arch) {
885
+ const roots = [
886
+ settings.OBJECT_FILE_DIR_normal,
887
+ settings.TARGET_TEMP_DIR,
888
+ settings.OBJECT_FILE_DIR,
889
+ ].filter(Boolean);
890
+ const files = new Set();
891
+ for (const root of roots) {
892
+ if (fs.existsSync(root)) {
893
+ for (const file of walkFiles(root)) {
894
+ if (file.endsWith(".o") && objectFileMatchesArchPath(file, arch)) {
895
+ files.add(file);
896
+ }
897
+ }
898
+ }
899
+ }
900
+ const list = [...files].filter(
901
+ (file) => !file.includes("SimDeckPreviewPayload"),
902
+ );
903
+ if (list.length === 0) {
904
+ console.warn(
905
+ "[simdeck-preview] warning: no Xcode object files found; compatibility will be limited.",
906
+ );
907
+ } else {
908
+ console.log(
909
+ `[simdeck-preview] found ${list.length} Xcode object files for fallback linking`,
910
+ );
911
+ }
912
+ return list;
913
+ }
914
+
915
+ function objectFileMatchesArchPath(file, arch) {
916
+ const normalized = file.split(path.sep).join("/");
917
+ return (
918
+ normalized.includes(`/Objects-normal/${arch}/`) ||
919
+ !normalized.includes("/Objects-normal/")
920
+ );
921
+ }
922
+
923
+ function* walkFiles(root) {
924
+ for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
925
+ const fullPath = path.join(root, entry.name);
926
+ if (entry.isDirectory()) {
927
+ yield* walkFiles(fullPath);
928
+ } else if (entry.isFile()) {
929
+ yield fullPath;
930
+ }
931
+ }
932
+ }
933
+
934
+ function pushSearchArgs(args, flag, values) {
935
+ for (const value of values) {
936
+ for (const item of splitBuildSetting(value)) {
937
+ if (item && item !== "$(inherited)" && fs.existsSync(item)) {
938
+ args.push(flag, item);
939
+ }
940
+ }
941
+ }
942
+ }
943
+
944
+ function findBootedSimulatorUDID() {
945
+ const data = JSON.parse(
946
+ runText("xcrun", ["simctl", "list", "devices", "booted", "-j"]),
947
+ );
948
+ for (const runtimes of Object.values(data.devices ?? {})) {
949
+ for (const device of runtimes) {
950
+ if (device.state === "Booted") {
951
+ return device.udid;
952
+ }
953
+ }
954
+ }
955
+ return "";
956
+ }
957
+
958
+ function extractPreviews(source) {
959
+ const previews = [];
960
+ let searchFrom = 0;
961
+ while (true) {
962
+ const macroIndex = source.indexOf("#Preview", searchFrom);
963
+ if (macroIndex < 0) {
964
+ break;
965
+ }
966
+ let cursor = macroIndex + "#Preview".length;
967
+ cursor = skipWhitespace(source, cursor);
968
+ let name = "";
969
+ if (source[cursor] === "(") {
970
+ const parens = readBalanced(source, cursor, "(", ")");
971
+ name = firstStringLiteral(parens.text) ?? "";
972
+ cursor = skipWhitespace(source, parens.end + 1);
973
+ }
974
+ if (source[cursor] !== "{") {
975
+ searchFrom = cursor + 1;
976
+ continue;
977
+ }
978
+ const body = readBalanced(source, cursor, "{", "}");
979
+ previews.push({
980
+ body: body.text.trim(),
981
+ index: previews.length,
982
+ name,
983
+ range: [macroIndex, body.end + 1],
984
+ });
985
+ searchFrom = body.end + 1;
986
+ }
987
+ return previews;
988
+ }
989
+
990
+ function selectPreview(previews, selector) {
991
+ if (selector == null || selector === "") {
992
+ return previews[0];
993
+ }
994
+ const numeric = Number(selector);
995
+ if (Number.isInteger(numeric)) {
996
+ const match = previews[numeric - 1] ?? previews[numeric];
997
+ if (match) {
998
+ return match;
999
+ }
1000
+ }
1001
+ const named = previews.find((item) => item.name === selector);
1002
+ if (!named) {
1003
+ throw new Error(
1004
+ `No preview matched ${JSON.stringify(selector)}. Available: ${previews
1005
+ .map((item) => item.name || `#${item.index + 1}`)
1006
+ .join(", ")}`,
1007
+ );
1008
+ }
1009
+ return named;
1010
+ }
1011
+
1012
+ function removeRanges(source, ranges) {
1013
+ let output = "";
1014
+ let cursor = 0;
1015
+ for (const [start, end] of ranges.sort((a, b) => a[0] - b[0])) {
1016
+ output += source.slice(cursor, start);
1017
+ cursor = end;
1018
+ }
1019
+ output += source.slice(cursor);
1020
+ return output;
1021
+ }
1022
+
1023
+ function sanitizePreviewSource(source, moduleName) {
1024
+ const stripped = source.replace(
1025
+ /(^|\n)(\s*)@main\b/g,
1026
+ "$1$2// @main stripped by SimDeck preview runner",
1027
+ );
1028
+ return xcodeImportPrelude(moduleName) + stripped;
1029
+ }
1030
+
1031
+ function xcodeImportPrelude(moduleName) {
1032
+ if (!moduleName) {
1033
+ return "";
1034
+ }
1035
+ return `#if DEBUG
1036
+ @testable import ${moduleName}
1037
+ #else
1038
+ import ${moduleName}
1039
+ #endif
1040
+
1041
+ `;
1042
+ }
1043
+
1044
+ function testableImportPrelude(moduleName) {
1045
+ if (!moduleName) {
1046
+ return "";
1047
+ }
1048
+ return `@testable import ${moduleName}
1049
+
1050
+ `;
1051
+ }
1052
+
1053
+ function readBalanced(source, start, open, close) {
1054
+ let depth = 0;
1055
+ let textStart = start + 1;
1056
+ for (let index = start; index < source.length; index += 1) {
1057
+ const char = source[index];
1058
+ if (char === '"' || char === "'") {
1059
+ index = skipQuoted(source, index);
1060
+ continue;
1061
+ }
1062
+ if (char === "/" && source[index + 1] === "/") {
1063
+ index = skipLineComment(source, index);
1064
+ continue;
1065
+ }
1066
+ if (char === "/" && source[index + 1] === "*") {
1067
+ index = skipBlockComment(source, index);
1068
+ continue;
1069
+ }
1070
+ if (char === open) {
1071
+ depth += 1;
1072
+ } else if (char === close) {
1073
+ depth -= 1;
1074
+ if (depth === 0) {
1075
+ return { end: index, text: source.slice(textStart, index) };
1076
+ }
1077
+ }
1078
+ }
1079
+ throw new Error(`Unbalanced ${open}${close} block near offset ${start}.`);
1080
+ }
1081
+
1082
+ function firstStringLiteral(text) {
1083
+ const match = text.match(/"((?:\\"|[^"])*)"/);
1084
+ return match ? match[1].replace(/\\"/g, '"') : null;
1085
+ }
1086
+
1087
+ function skipWhitespace(source, index) {
1088
+ while (/\s/.test(source[index] ?? "")) {
1089
+ index += 1;
1090
+ }
1091
+ return index;
1092
+ }
1093
+
1094
+ function skipQuoted(source, start) {
1095
+ const quote = source[start];
1096
+ for (let index = start + 1; index < source.length; index += 1) {
1097
+ if (source[index] === "\\") {
1098
+ index += 1;
1099
+ continue;
1100
+ }
1101
+ if (source[index] === quote) {
1102
+ return index;
1103
+ }
1104
+ }
1105
+ return source.length - 1;
1106
+ }
1107
+
1108
+ function skipLineComment(source, start) {
1109
+ const next = source.indexOf("\n", start + 2);
1110
+ return next < 0 ? source.length - 1 : next;
1111
+ }
1112
+
1113
+ function skipBlockComment(source, start) {
1114
+ const next = source.indexOf("*/", start + 2);
1115
+ return next < 0 ? source.length - 1 : next + 1;
1116
+ }
1117
+
1118
+ function watchFiles(files, callback) {
1119
+ let timer = null;
1120
+ for (const file of files) {
1121
+ fs.watchFile(file, { interval: 250 }, () => {
1122
+ clearTimeout(timer);
1123
+ timer = setTimeout(callback, 120);
1124
+ });
1125
+ }
1126
+ }
1127
+
1128
+ function payloadWrapperSource(previewBody, moduleName, sourceModuleName) {
1129
+ return `import SwiftUI
1130
+ import UIKit
1131
+ ${sourceModuleName ? "" : xcodeImportPrelude(moduleName)}
1132
+ ${testableImportPrelude(sourceModuleName)}
1133
+
1134
+ @_cdecl("simdeck_make_preview_view_controller")
1135
+ public func simdeck_make_preview_view_controller() -> UnsafeMutableRawPointer {
1136
+ let rootView = AnyView({
1137
+ ${indent(previewBody, 8)}
1138
+ }())
1139
+ let controller = UIHostingController(rootView: rootView)
1140
+ controller.view.backgroundColor = .systemBackground
1141
+ return Unmanaged.passRetained(controller).toOpaque()
1142
+ }
1143
+ `;
1144
+ }
1145
+
1146
+ function hostSource() {
1147
+ return `import Darwin
1148
+ import Foundation
1149
+ import Network
1150
+ import SwiftUI
1151
+ import UIKit
1152
+
1153
+ private typealias PreviewFactory = @convention(c) () -> UnsafeMutableRawPointer
1154
+ private let simDeckPreviewReloadPortStart: UInt16 = ${RELOAD_PORT_START}
1155
+ private let simDeckPreviewReloadPortLimit: UInt16 = ${RELOAD_PORT_LIMIT}
1156
+ private let simDeckPreviewReloadPrefix = "${RELOAD_PROTOCOL_PREFIX}"
1157
+ private let simDeckPreviewReloadBytesPrefix = "${RELOAD_BYTES_PROTOCOL_PREFIX}"
1158
+
1159
+ @MainActor
1160
+ final class PreviewStore: ObservableObject {
1161
+ @Published var controller: UIViewController?
1162
+ @Published var status = "Waiting for SimDeck preview dylib..."
1163
+ private var handles: [UnsafeMutableRawPointer] = []
1164
+ private var listener: NWListener?
1165
+
1166
+ init() {
1167
+ startReloadListener()
1168
+ }
1169
+
1170
+ @discardableResult
1171
+ func load(path rawPath: String) -> Bool {
1172
+ let path = rawPath.removingPercentEncoding ?? rawPath
1173
+ guard FileManager.default.fileExists(atPath: path) else {
1174
+ status = "Missing dylib: \\(path)"
1175
+ return false
1176
+ }
1177
+ guard let handle = dlopen(path, RTLD_NOW | RTLD_LOCAL) else {
1178
+ status = String(cString: dlerror())
1179
+ return false
1180
+ }
1181
+ guard let symbol = dlsym(handle, "simdeck_make_preview_view_controller") else {
1182
+ status = "Missing simdeck_make_preview_view_controller"
1183
+ return false
1184
+ }
1185
+ let factory = unsafeBitCast(symbol, to: PreviewFactory.self)
1186
+ let pointer = factory()
1187
+ let nextController = Unmanaged<UIViewController>.fromOpaque(pointer).takeRetainedValue()
1188
+ nextController.view.backgroundColor = .systemBackground
1189
+ handles.append(handle)
1190
+ controller = nextController
1191
+ status = "Loaded \\((path as NSString).lastPathComponent)"
1192
+ return true
1193
+ }
1194
+
1195
+ private func startReloadListener() {
1196
+ for offset in 0..<simDeckPreviewReloadPortLimit {
1197
+ guard let port = NWEndpoint.Port(rawValue: simDeckPreviewReloadPortStart + offset) else {
1198
+ continue
1199
+ }
1200
+ do {
1201
+ let listener = try NWListener(using: .tcp, on: port)
1202
+ listener.newConnectionHandler = { [weak self] connection in
1203
+ self?.accept(connection)
1204
+ }
1205
+ listener.start(queue: DispatchQueue(label: "dev.simdeck.preview.reload"))
1206
+ self.listener = listener
1207
+ status = "Waiting for preview reload on TCP \\(port.rawValue)..."
1208
+ return
1209
+ } catch {
1210
+ continue
1211
+ }
1212
+ }
1213
+ status = "Waiting for SimDeck preview URL reload..."
1214
+ }
1215
+
1216
+ private nonisolated func accept(_ connection: NWConnection) {
1217
+ connection.start(queue: DispatchQueue(label: "dev.simdeck.preview.reload.connection"))
1218
+ receive(connection, buffer: Data())
1219
+ }
1220
+
1221
+ private nonisolated func receive(_ connection: NWConnection, buffer: Data) {
1222
+ connection.receive(minimumIncompleteLength: 1, maximumLength: 64 * 1024) { [weak self] data, _, isComplete, error in
1223
+ var nextBuffer = buffer
1224
+ if let data {
1225
+ nextBuffer.append(data)
1226
+ }
1227
+ if isComplete || error != nil {
1228
+ self?.handleReloadMessage(nextBuffer, connection: connection)
1229
+ } else {
1230
+ self?.receive(connection, buffer: nextBuffer)
1231
+ }
1232
+ }
1233
+ }
1234
+
1235
+ private nonisolated func handleReloadMessage(_ data: Data, connection: NWConnection) {
1236
+ guard let message = String(data: data, encoding: .utf8) else {
1237
+ sendResponse("ERROR", on: connection)
1238
+ return
1239
+ }
1240
+ let trimmed = message.trimmingCharacters(in: .whitespacesAndNewlines)
1241
+ if trimmed.hasPrefix(simDeckPreviewReloadBytesPrefix) {
1242
+ let body = trimmed.dropFirst(simDeckPreviewReloadBytesPrefix.count)
1243
+ guard let separator = body.firstIndex(of: " ") else {
1244
+ sendResponse("ERROR", on: connection)
1245
+ return
1246
+ }
1247
+ let filename = String(body[..<separator])
1248
+ let encoded = String(body[body.index(after: separator)...])
1249
+ guard let dylibData = Data(base64Encoded: encoded) else {
1250
+ sendResponse("ERROR", on: connection)
1251
+ return
1252
+ }
1253
+ Task { @MainActor [weak self] in
1254
+ var loaded = false
1255
+ do {
1256
+ let documents = try FileManager.default.url(
1257
+ for: .documentDirectory,
1258
+ in: .userDomainMask,
1259
+ appropriateFor: nil,
1260
+ create: true
1261
+ )
1262
+ let safeName = (filename as NSString).lastPathComponent
1263
+ let destination = documents.appendingPathComponent(safeName)
1264
+ try dylibData.write(to: destination, options: .atomic)
1265
+ loaded = self?.load(path: destination.path) ?? false
1266
+ } catch {
1267
+ self?.status = "Reload write failed: \\(error.localizedDescription)"
1268
+ }
1269
+ self?.sendResponse(loaded ? "OK" : "ERROR", on: connection)
1270
+ }
1271
+ return
1272
+ }
1273
+ guard trimmed.hasPrefix(simDeckPreviewReloadPrefix) else {
1274
+ sendResponse("ERROR", on: connection)
1275
+ return
1276
+ }
1277
+ let rawPath = String(trimmed.dropFirst(simDeckPreviewReloadPrefix.count))
1278
+ Task { @MainActor [weak self] in
1279
+ let loaded = self?.load(path: rawPath) ?? false
1280
+ self?.sendResponse(loaded ? "OK" : "ERROR", on: connection)
1281
+ }
1282
+ }
1283
+
1284
+ private nonisolated func sendResponse(_ text: String, on connection: NWConnection) {
1285
+ connection.send(content: Data("\\(text)\\n".utf8), completion: .contentProcessed { _ in
1286
+ connection.cancel()
1287
+ })
1288
+ }
1289
+ }
1290
+
1291
+ struct PreviewHostRoot: View {
1292
+ @StateObject private var store = PreviewStore()
1293
+
1294
+ var body: some View {
1295
+ ZStack {
1296
+ if let controller = store.controller {
1297
+ ControllerContainer(controller: controller)
1298
+ .ignoresSafeArea()
1299
+ } else {
1300
+ VStack(spacing: 12) {
1301
+ ProgressView()
1302
+ Text(store.status)
1303
+ .font(.callout)
1304
+ .foregroundStyle(.secondary)
1305
+ .multilineTextAlignment(.center)
1306
+ .padding(.horizontal, 24)
1307
+ }
1308
+ }
1309
+ }
1310
+ .onOpenURL { url in
1311
+ guard url.host == "reload",
1312
+ let components = URLComponents(url: url, resolvingAgainstBaseURL: false),
1313
+ let path = components.queryItems?.first(where: { $0.name == "path" })?.value
1314
+ else {
1315
+ return
1316
+ }
1317
+ store.load(path: path)
1318
+ }
1319
+ }
1320
+ }
1321
+
1322
+ struct ControllerContainer: UIViewControllerRepresentable {
1323
+ let controller: UIViewController
1324
+
1325
+ func makeUIViewController(context: Context) -> ContainerViewController {
1326
+ let container = ContainerViewController()
1327
+ container.set(controller)
1328
+ return container
1329
+ }
1330
+
1331
+ func updateUIViewController(_ uiViewController: ContainerViewController, context: Context) {
1332
+ uiViewController.set(controller)
1333
+ }
1334
+ }
1335
+
1336
+ final class ContainerViewController: UIViewController {
1337
+ private var current: UIViewController?
1338
+
1339
+ func set(_ next: UIViewController) {
1340
+ guard current !== next else {
1341
+ return
1342
+ }
1343
+ current?.willMove(toParent: nil)
1344
+ current?.view.removeFromSuperview()
1345
+ current?.removeFromParent()
1346
+
1347
+ addChild(next)
1348
+ next.view.frame = view.bounds
1349
+ next.view.autoresizingMask = [.flexibleWidth, .flexibleHeight]
1350
+ view.addSubview(next.view)
1351
+ next.didMove(toParent: self)
1352
+ current = next
1353
+ }
1354
+ }
1355
+
1356
+ @main
1357
+ struct SimDeckPreviewHostApp: App {
1358
+ var body: some Scene {
1359
+ WindowGroup {
1360
+ PreviewHostRoot()
1361
+ }
1362
+ }
1363
+ }
1364
+ `;
1365
+ }
1366
+
1367
+ function indent(text, spaces) {
1368
+ const prefix = " ".repeat(spaces);
1369
+ return text
1370
+ .split("\n")
1371
+ .map((line) => `${prefix}${line}`)
1372
+ .join("\n");
1373
+ }
1374
+
1375
+ function arrayArg(value) {
1376
+ if (value == null) {
1377
+ return [];
1378
+ }
1379
+ return Array.isArray(value) ? value : [value];
1380
+ }
1381
+
1382
+ function splitBuildSetting(value) {
1383
+ if (!value) {
1384
+ return [];
1385
+ }
1386
+ const text = String(value);
1387
+ const items = [];
1388
+ let current = "";
1389
+ let quote = "";
1390
+ for (let index = 0; index < text.length; index += 1) {
1391
+ const char = text[index];
1392
+ if (quote) {
1393
+ if (char === "\\") {
1394
+ current += text[index + 1] ?? "";
1395
+ index += 1;
1396
+ } else if (char === quote) {
1397
+ quote = "";
1398
+ } else {
1399
+ current += char;
1400
+ }
1401
+ continue;
1402
+ }
1403
+ if (char === '"' || char === "'") {
1404
+ quote = char;
1405
+ } else if (/\s/.test(char)) {
1406
+ if (current) {
1407
+ items.push(current);
1408
+ current = "";
1409
+ }
1410
+ } else {
1411
+ current += char;
1412
+ }
1413
+ }
1414
+ if (current) {
1415
+ items.push(current);
1416
+ }
1417
+ return items;
1418
+ }
1419
+
1420
+ function run(command, args, options = {}) {
1421
+ const result = spawnSync(command, args, {
1422
+ cwd: process.cwd(),
1423
+ encoding: "utf8",
1424
+ stdio: options.capture ? "pipe" : "inherit",
1425
+ });
1426
+ if (result.status !== 0 && !options.allowFailure) {
1427
+ const details = options.capture
1428
+ ? `\n${result.stderr || result.stdout}`
1429
+ : "";
1430
+ throw new Error(`${command} ${args.join(" ")} failed.${details}`);
1431
+ }
1432
+ return result;
1433
+ }
1434
+
1435
+ function runText(command, args) {
1436
+ const result = run(command, args, { capture: true });
1437
+ return result.stdout;
1438
+ }