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.
- package/LICENSE +13 -0
- package/README.md +240 -0
- package/bin/simdeck.mjs +50 -0
- package/build/simdeck-bin +0 -0
- package/client/dist/assets/index-BL9Mcd6u.js +9 -0
- package/client/dist/assets/index-Cu4TL413.css +1 -0
- package/client/dist/assets/simulatorStream.worker-CH72C_tF.js +22 -0
- package/client/dist/index.html +28 -0
- package/package.json +82 -0
- package/packages/simdeck-test/dist/index.d.ts +75 -0
- package/packages/simdeck-test/dist/index.js +376 -0
- package/scripts/experimental/swiftui-preview.mjs +1438 -0
- package/scripts/postinstall.mjs +42 -0
|
@@ -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
|
+
}
|