@craft-native/ios 0.0.369 → 0.0.374

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/dist/index.js CHANGED
@@ -1,8 +1,114 @@
1
1
  // @bun
2
2
  // src/index.ts
3
3
  import { gzipSync } from "zlib";
4
- import { cpSync, existsSync, mkdirSync as mkdirSync2, readFileSync as readFileSync2, readdirSync as readdirSync2, rmSync as rmSync2, statSync, writeFileSync as writeFileSync2 } from "fs";
5
- import { dirname as dirname2, extname, join as join3, resolve, sep as sep2 } from "path";
4
+
5
+ // src/runtime-profile.ts
6
+ import { createHash } from "crypto";
7
+ import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "fs";
8
+ import { tmpdir } from "os";
9
+ import { join, resolve } from "path";
10
+ var iosPermissionNames = ["speech", "microphone", "camera", "photos", "location", "contacts", "calendar", "bluetooth", "motion", "biometric", "music", "health", "nfc"];
11
+ function iosPermissions(config) {
12
+ const enabled = {
13
+ speech: config.enableSpeechRecognition,
14
+ microphone: config.enableSpeechRecognition || config.enableAudioRecording || config.enableVideoRecording,
15
+ camera: config.enableCamera || config.enableVideoRecording || config.enableQRScanner || config.enableAR,
16
+ photos: config.enableCamera || config.enableVideoRecording,
17
+ location: config.enableGeolocation || config.enableBackgroundLocation,
18
+ contacts: config.enableContacts,
19
+ calendar: config.enableCalendar,
20
+ bluetooth: config.enableBluetooth || config.enableHeartRate,
21
+ motion: config.enableMotionSensors,
22
+ biometric: config.enableBiometric,
23
+ music: config.enableAppleMusic,
24
+ health: config.enableHealthKit,
25
+ nfc: config.enableNFC
26
+ };
27
+ return iosPermissionNames.filter((name) => enabled[name] === true);
28
+ }
29
+ function iosPermissionMask(config) {
30
+ return iosPermissions(config).reduce((mask, name) => mask | 1 << iosPermissionNames.indexOf(name), 0);
31
+ }
32
+ function renderPermissionSource(source, config) {
33
+ const enabled = new Set(iosPermissions(config));
34
+ return source.replace(/#if CRAFT_(?:PERMISSION_([A-Z]+)|APPLE_(MUSIC))\b/g, (_match, permission, music) => {
35
+ const name = music ? "music" : permission.toLowerCase();
36
+ if (!iosPermissionNames.includes(name))
37
+ throw new Error(`Unknown Swift permission gate: ${name}`);
38
+ return `#if ${enabled.has(name) ? "true" : "false"}`;
39
+ });
40
+ }
41
+ var archives = ["libcraft-ios.a", "libcraft-ios-simulator-arm64.a", "libcraft-ios-simulator-x64.a"];
42
+ var digest = (path) => createHash("sha256").update(readFileSync(path)).digest("hex");
43
+ function verifyRuntimeProfile(directory, config) {
44
+ if (!existsSync(join(directory, "ios-permissions.json")))
45
+ throw new Error("Unprofiled Zig runtime: use runtimeSource to build archives for this app");
46
+ const profile = JSON.parse(readFileSync(join(directory, "ios-permissions.json"), "utf8"));
47
+ if (profile.version !== 1 || profile.mask !== iosPermissionMask(config))
48
+ throw new Error("Zig runtime permission profile does not match craft.config.json; rebuild it for this app");
49
+ if (!existsSync(join(directory, archives[0])) || !archives.slice(1).some((name) => existsSync(join(directory, name))))
50
+ throw new Error("Zig runtime must include device and simulator archives");
51
+ for (const name of archives) {
52
+ if (!existsSync(join(directory, name)) && !profile.sha256?.[name])
53
+ continue;
54
+ if (!existsSync(join(directory, name)) || profile.sha256?.[name] !== digest(join(directory, name)))
55
+ throw new Error(`Zig runtime archive identity mismatch: ${name}; rebuild it for this app`);
56
+ }
57
+ }
58
+ function writeInstalledProfile(directory, config, source) {
59
+ const paths = ["device/libcraft-ios.a", "simulator/libcraft-ios.a"];
60
+ writeFileSync(join(directory, "profile.json"), JSON.stringify({
61
+ version: 1,
62
+ mask: iosPermissionMask(config),
63
+ source,
64
+ sha256: Object.fromEntries(paths.map((path) => [path, digest(join(directory, path))]))
65
+ }, null, 2));
66
+ }
67
+ function verifyInstalledProfile(directory, config) {
68
+ if (!existsSync(join(directory, "profile.json")))
69
+ throw new Error("Unprofiled Zig runtime: regenerate this project with runtimeSource before omitting permission descriptions");
70
+ const profile = JSON.parse(readFileSync(join(directory, "profile.json"), "utf8"));
71
+ if (profile.version !== 1 || profile.mask !== iosPermissionMask(config))
72
+ throw new Error("Installed Zig runtime permission profile is stale; rebuild it for craft.config.json");
73
+ for (const path of ["device/libcraft-ios.a", "simulator/libcraft-ios.a"]) {
74
+ if (!existsSync(join(directory, path)) || profile.sha256?.[path] !== digest(join(directory, path)))
75
+ throw new Error(`Installed Zig runtime identity mismatch: ${path}; rebuild it for this app`);
76
+ }
77
+ }
78
+ async function withAppRuntime(source, config, use) {
79
+ const root = resolve(source);
80
+ const manifest = readFileSync(join(root, "build.zig.zon"), "utf8");
81
+ const version = /\.minimum_zig_version\s*=\s*"([^"]+)"/.exec(manifest)?.[1];
82
+ if (!version)
83
+ throw new Error("Craft Zig source must declare its pinned minimum_zig_version");
84
+ const zig = process.env.CRAFT_ZIG || "zig";
85
+ const actual = Bun.spawnSync([zig, "version"], { stdout: "pipe", stderr: "pipe" });
86
+ if (actual.exitCode !== 0 || actual.stdout.toString().trim() !== version)
87
+ throw new Error(`Per-app iOS runtime requires Zig ${version}; set CRAFT_ZIG to that executable`);
88
+ const output = mkdtempSync(join(tmpdir(), "craft-ios-runtime-"));
89
+ try {
90
+ const names = iosPermissions(config);
91
+ const command = [zig, "build", "build-ios-all", `-Dios-permissions=${names.join(",") || "none"}`, "-Djs-runtime=false", "-Doptimize=ReleaseFast", "--prefix", output];
92
+ const child = Bun.spawn(command, { cwd: root, stdout: "inherit", stderr: "inherit" });
93
+ if (await child.exited !== 0)
94
+ throw new Error("Per-app iOS Zig runtime build failed; installed runtime was not replaced");
95
+ const directory = join(output, "lib");
96
+ const profile = JSON.parse(readFileSync(join(directory, "ios-permissions.json"), "utf8"));
97
+ if (profile.version !== 1 || profile.mask !== iosPermissionMask(config))
98
+ throw new Error("Craft Zig source did not build the requested iOS permission profile");
99
+ profile.sha256 = Object.fromEntries(archives.map((name) => [name, digest(join(directory, name))]));
100
+ writeFileSync(join(directory, "ios-permissions.json"), `${JSON.stringify(profile, null, 2)}
101
+ `);
102
+ verifyRuntimeProfile(directory, config);
103
+ return await use(directory);
104
+ } finally {
105
+ rmSync(output, { recursive: true, force: true });
106
+ }
107
+ }
108
+
109
+ // src/index.ts
110
+ import { cpSync, existsSync as existsSync2, mkdirSync as mkdirSync2, mkdtempSync as mkdtempSync3, readFileSync as readFileSync3, readdirSync as readdirSync2, renameSync, rmSync as rmSync3, statSync, writeFileSync as writeFileSync3 } from "fs";
111
+ import { dirname as dirname2, extname, join as join4, resolve as resolve2, sep as sep2 } from "path";
6
112
  var {$ } = globalThis.Bun;
7
113
 
8
114
  // src/app-shortcuts.ts
@@ -61,7 +167,7 @@ ${entries}
61
167
 
62
168
  // src/source-audit.ts
63
169
  import { readdirSync } from "fs";
64
- import { join, relative, sep } from "path";
170
+ import { join as join2, relative, sep } from "path";
65
171
  var defaultIgnoredPaths = [
66
172
  ".git",
67
173
  "node_modules",
@@ -81,7 +187,7 @@ function isIgnored(path, ignoredPaths) {
81
187
  }
82
188
  function visit(root, current, ignoredPaths, found) {
83
189
  for (const entry of readdirSync(current, { withFileTypes: true })) {
84
- const absolute = join(current, entry.name);
190
+ const absolute = join2(current, entry.name);
85
191
  const relativePath = relative(root, absolute);
86
192
  if (isIgnored(relativePath, ignoredPaths))
87
193
  continue;
@@ -108,11 +214,11 @@ ${sources.map((path) => `- ${path}`).join(`
108
214
  }
109
215
  }
110
216
  // src/testing.ts
111
- import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "fs";
112
- import { tmpdir } from "os";
113
- import { dirname, join as join2 } from "path";
217
+ import { mkdirSync, mkdtempSync as mkdtempSync2, readFileSync as readFileSync2, rmSync as rmSync2, writeFileSync as writeFileSync2 } from "fs";
218
+ import { tmpdir as tmpdir2 } from "os";
219
+ import { dirname, join as join3 } from "path";
114
220
  import { spawnSync } from "child_process";
115
- var templates = join2(dirname(import.meta.dir), "templates/testing");
221
+ var templates = join3(dirname(import.meta.dir), "templates/testing");
116
222
  function bundleIdentifier(value) {
117
223
  if (!/^[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)+$/.test(value))
118
224
  throw new Error(`Invalid bundle identifier: ${value}`);
@@ -155,26 +261,26 @@ function initSimulatorDriver(options) {
155
261
  ["CraftTimedInput.m", "UITests/CraftTimedInput.m"],
156
262
  ["project.yml.template", "project.yml"]
157
263
  ]) {
158
- const path = join2(output, destination);
264
+ const path = join3(output, destination);
159
265
  mkdirSync(dirname(path), { recursive: true });
160
- const content = readFileSync(join2(templates, source), "utf8").replace(/\{\{([A-Z_]+)\}\}/g, (_, key) => replacements[key]);
266
+ const content = readFileSync2(join3(templates, source), "utf8").replace(/\{\{([A-Z_]+)\}\}/g, (_, key) => replacements[key]);
161
267
  let previous;
162
268
  try {
163
- previous = readFileSync(path, "utf8");
269
+ previous = readFileSync2(path, "utf8");
164
270
  } catch {}
165
271
  if (previous !== content)
166
- writeFileSync(path, content);
272
+ writeFileSync2(path, content);
167
273
  }
168
- const script = join2(output, "UITests/journey.txt");
274
+ const script = join3(output, "UITests/journey.txt");
169
275
  if (options.script === undefined)
170
- rmSync(script, { force: true });
276
+ rmSync2(script, { force: true });
171
277
  else {
172
278
  let previous;
173
279
  try {
174
- previous = readFileSync(script, "utf8");
280
+ previous = readFileSync2(script, "utf8");
175
281
  } catch {}
176
282
  if (previous !== options.script)
177
- writeFileSync(script, options.script);
283
+ writeFileSync2(script, options.script);
178
284
  }
179
285
  }
180
286
  function serializeSimulatorSteps(steps) {
@@ -267,16 +373,16 @@ function serializeSimulatorSteps(steps) {
267
373
  `;
268
374
  }
269
375
  function createNativeMutationValidator() {
270
- const output = mkdtempSync(join2(tmpdir(), `craft-native-mutation-p${process.pid}-`));
376
+ const output = mkdtempSync2(join3(tmpdir2(), `craft-native-mutation-p${process.pid}-`));
271
377
  const removeOutput = () => {
272
378
  process.off("exit", removeOutput);
273
- rmSync(output, { recursive: true, force: true });
379
+ rmSync2(output, { recursive: true, force: true });
274
380
  };
275
381
  process.on("exit", removeOutput);
276
- const binary = join2(output, "validate");
382
+ const binary = join3(output, "validate");
277
383
  try {
278
- writeFileSync(join2(output, "main.swift"), readFileSync(join2(templates, "MutationValidator.swift")));
279
- const build = Bun.spawnSync(["swiftc", join2(templates, "../CraftNativeMutation.swift"), join2(output, "main.swift"), "-o", binary]);
384
+ writeFileSync2(join3(output, "main.swift"), readFileSync2(join3(templates, "MutationValidator.swift")));
385
+ const build = Bun.spawnSync(["swiftc", join3(templates, "../CraftNativeMutation.swift"), join3(output, "main.swift"), "-o", binary]);
280
386
  if (build.exitCode !== 0)
281
387
  throw new Error(build.stderr.toString());
282
388
  } catch (error) {
@@ -288,14 +394,14 @@ function createNativeMutationValidator() {
288
394
  validate(messages) {
289
395
  if (disposed)
290
396
  throw new Error("The native mutation validator has been disposed");
291
- const input = join2(output, "messages.json");
292
- writeFileSync(input, JSON.stringify(messages));
293
- const report = join2(output, "result.txt");
294
- rmSync(report, { force: true });
397
+ const input = join3(output, "messages.json");
398
+ writeFileSync2(input, JSON.stringify(messages));
399
+ const report = join3(output, "result.txt");
400
+ rmSync2(report, { force: true });
295
401
  const result = spawnSync(binary, [input, report], { encoding: "utf8" });
296
402
  let stdout = result.stdout ?? "";
297
403
  try {
298
- stdout = readFileSync(report, "utf8");
404
+ stdout = readFileSync2(report, "utf8");
299
405
  } catch {}
300
406
  return { exitCode: result.status ?? 1, stdout, stderr: result.stderr || result.error?.message || (result.signal ? `Validator terminated by ${result.signal}` : "") };
301
407
  },
@@ -310,20 +416,20 @@ function createNativeMutationValidator() {
310
416
  function compressNativeSources(target) {
311
417
  if (statSync(target).isDirectory()) {
312
418
  for (const entry of readdirSync2(target))
313
- compressNativeSources(join3(target, entry));
419
+ compressNativeSources(join4(target, entry));
314
420
  return;
315
421
  }
316
422
  if (!/\.(?:js|json)$/.test(target))
317
423
  return;
318
- const bytes = readFileSync2(target);
424
+ const bytes = readFileSync3(target);
319
425
  if (bytes.length < 1024 || bytes[0] === 31 && bytes[1] === 139)
320
426
  return;
321
427
  const packed = gzipSync(bytes, { level: 9 });
322
428
  if (packed.length < bytes.length)
323
- writeFileSync2(target, packed);
429
+ writeFileSync3(target, packed);
324
430
  }
325
431
  function syncNativeFonts(fonts, output) {
326
- const destination = join3(output, "NativeFonts");
432
+ const destination = join4(output, "NativeFonts");
327
433
  const names = new Set;
328
434
  const files = Object.entries(fonts ?? {}).map(([family, entry]) => {
329
435
  if (!/^[A-Za-z0-9][A-Za-z0-9_.-]*$/.test(family))
@@ -331,32 +437,32 @@ function syncNativeFonts(fonts, output) {
331
437
  if (names.has(family.toLowerCase()))
332
438
  throw new Error(`Duplicate native font name: ${family}`);
333
439
  names.add(family.toLowerCase());
334
- if ([entry.src, entry.license].some((file) => file && resolve(file).startsWith(resolve(destination) + sep2)))
440
+ if ([entry.src, entry.license].some((file) => file && resolve2(file).startsWith(resolve2(destination) + sep2)))
335
441
  throw new Error("Native font sources must be outside the generated NativeFonts directory");
336
442
  const extension = extname(entry.src).toLowerCase();
337
- if (![".ttf", ".otf"].includes(extension) || !existsSync(entry.src) || !statSync(entry.src).isFile())
443
+ if (![".ttf", ".otf"].includes(extension) || !existsSync2(entry.src) || !statSync(entry.src).isFile())
338
444
  throw new Error(`Native font ${family} must reference an existing TTF or OTF file: ${entry.src}`);
339
- if (entry.license && (!existsSync(entry.license) || !statSync(entry.license).isFile()))
445
+ if (entry.license && (!existsSync2(entry.license) || !statSync(entry.license).isFile()))
340
446
  throw new Error(`Native font license not found: ${entry.license}`);
341
447
  return { family, ...entry, filename: `${family}${extension}` };
342
448
  });
343
- rmSync2(destination, { recursive: true, force: true });
449
+ rmSync3(destination, { recursive: true, force: true });
344
450
  if (!files.length)
345
451
  return [];
346
452
  mkdirSync2(destination, { recursive: true });
347
453
  for (const file of files) {
348
- cpSync(file.src, join3(destination, file.filename));
454
+ cpSync(file.src, join4(destination, file.filename));
349
455
  if (file.license)
350
- cpSync(file.license, join3(destination, `${file.family}.LICENSE.txt`));
456
+ cpSync(file.license, join4(destination, `${file.family}.LICENSE.txt`));
351
457
  }
352
458
  return files.map((file) => `NativeFonts/${file.filename}`);
353
459
  }
354
- var TEMPLATES_DIR = join3(dirname2(import.meta.dir), "templates");
460
+ var TEMPLATES_DIR = join4(dirname2(import.meta.dir), "templates");
355
461
  function resolveCraftVersion(startDir = import.meta.dir) {
356
- let directory = resolve(startDir);
462
+ let directory = resolve2(startDir);
357
463
  while (true) {
358
464
  try {
359
- const packageJSON = JSON.parse(readFileSync2(join3(directory, "package.json"), "utf8"));
465
+ const packageJSON = JSON.parse(readFileSync3(join4(directory, "package.json"), "utf8"));
360
466
  if ((packageJSON.name === "@craft-native/ios" || packageJSON.name === "craft-native") && typeof packageJSON.version === "string" && packageJSON.version.length > 0)
361
467
  return packageJSON.version;
362
468
  } catch {}
@@ -511,29 +617,29 @@ var nativeRouteBundles = true;
511
617
  var nativeRouteRuntime = true;
512
618
  var nativeMessages = true;
513
619
  function installNativeBundle(source, output) {
514
- if (!existsSync(source))
620
+ if (!existsSync2(source))
515
621
  throw new Error(`Native bundle not found: ${source}`);
516
- const single = join3(output, "dist", "native-screen.js");
517
- const routes = join3(output, "dist", "native-screens");
622
+ const single = join4(output, "dist", "native-screen.js");
623
+ const routes = join4(output, "dist", "native-screens");
518
624
  mkdirSync2(dirname2(single), { recursive: true });
519
625
  if (statSync(source).isDirectory()) {
520
626
  if (!readdirSync2(source).some((name) => name.endsWith(".js")))
521
627
  throw new Error(`Native route bundles not found in ${source}`);
522
- if (resolve(source) !== resolve(routes)) {
523
- rmSync2(routes, { recursive: true, force: true });
628
+ if (resolve2(source) !== resolve2(routes)) {
629
+ rmSync3(routes, { recursive: true, force: true });
524
630
  cpSync(source, routes, { recursive: true });
525
631
  }
526
632
  compressNativeSources(routes);
527
- rmSync2(single, { force: true });
633
+ rmSync3(single, { force: true });
528
634
  return;
529
635
  }
530
- if (resolve(source) !== resolve(single))
636
+ if (resolve2(source) !== resolve2(single))
531
637
  cpSync(source, single);
532
638
  compressNativeSources(single);
533
- rmSync2(routes, { recursive: true, force: true });
639
+ rmSync3(routes, { recursive: true, force: true });
534
640
  }
535
641
  function hasInstalledNativeBundle(output) {
536
- return existsSync(join3(output, "dist", "native-screen.js")) || existsSync(join3(output, "dist", "native-screens"));
642
+ return existsSync2(join4(output, "dist", "native-screen.js")) || existsSync2(join4(output, "dist", "native-screens"));
537
643
  }
538
644
  function runPostGenerateHook(hook, output, phase = "build") {
539
645
  if (!hook)
@@ -639,23 +745,10 @@ function renderAppearance(config) {
639
745
  return { interfaceStyle: "Light", statusBarStyle: "UIStatusBarStyleDarkContent" };
640
746
  return { interfaceStyle: "Dark", statusBarStyle: "UIStatusBarStyleLightContent" };
641
747
  }
642
- var LINKED_PURPOSES = {
643
- NSSpeechRecognitionUsageDescription: "speech recognition",
644
- NSMicrophoneUsageDescription: "the microphone",
645
- NSCameraUsageDescription: "the camera",
646
- NSPhotoLibraryUsageDescription: "your photo library",
647
- NSLocationWhenInUseUsageDescription: "your location",
648
- NSContactsUsageDescription: "your contacts",
649
- NSCalendarsUsageDescription: "your calendar",
650
- NSBluetoothAlwaysUsageDescription: "Bluetooth",
651
- NSMotionUsageDescription: "motion data",
652
- NSFaceIDUsageDescription: "Face ID",
653
- NSAppleMusicUsageDescription: "your music"
654
- };
655
748
  function renderUsageDescriptions(config) {
656
749
  const entries = [
657
750
  [config.enableSpeechRecognition, "NSSpeechRecognitionUsageDescription", `${config.appName} uses speech recognition for voice input.`],
658
- [config.enableSpeechRecognition || config.enableAudioRecording, "NSMicrophoneUsageDescription", `${config.appName} uses the microphone to record audio.`],
751
+ [config.enableSpeechRecognition || config.enableAudioRecording || config.enableVideoRecording, "NSMicrophoneUsageDescription", `${config.appName} uses the microphone to record audio.`],
659
752
  [config.enableCamera || config.enableVideoRecording || config.enableQRScanner || config.enableAR, "NSCameraUsageDescription", `${config.appName} uses the camera for photos, video, scanning, and augmented reality.`],
660
753
  [config.enableCamera || config.enableVideoRecording, "NSPhotoLibraryUsageDescription", `${config.appName} lets you choose photos and videos from your library.`],
661
754
  [config.enableGeolocation || config.enableBackgroundLocation, "NSLocationWhenInUseUsageDescription", `${config.appName} uses your location while you record an activity.`],
@@ -678,8 +771,7 @@ function renderUsageDescriptions(config) {
678
771
  return [key, custom];
679
772
  if (enabled)
680
773
  return [key, value];
681
- const linked = LINKED_PURPOSES[key];
682
- return linked ? [key, `${config.appName} asks for ${linked} only in a feature that needs it.`] : null;
774
+ return null;
683
775
  }).filter((entry) => entry !== null).map(([key, value]) => plistString(key, value)).join(`
684
776
  `);
685
777
  }
@@ -844,9 +936,11 @@ function renderRuntimeSettings() {
844
936
  `);
845
937
  }
846
938
  function renderFeatureSettings(config) {
847
- const conditions = [];
939
+ const conditions = iosPermissions(config).filter((name) => name !== "music").map((name) => `CRAFT_PERMISSION_${name.toUpperCase()}`);
848
940
  if (config.enableScheduledWorkouts)
849
941
  conditions.push("CRAFT_SCHEDULED_WORKOUTS");
942
+ if (config.enableAppleMusic)
943
+ conditions.push("CRAFT_APPLE_MUSIC");
850
944
  if (!conditions.length)
851
945
  return "";
852
946
  return ` SWIFT_ACTIVE_COMPILATION_CONDITIONS: "$(inherited) ${conditions.join(" ")}"`;
@@ -921,7 +1015,7 @@ function pngHasAlpha(bytes) {
921
1015
  return false;
922
1016
  }
923
1017
  function writeOpaqueIcon(source, target) {
924
- const bytes = new Uint8Array(readFileSync2(source));
1018
+ const bytes = new Uint8Array(readFileSync3(source));
925
1019
  if (!pngHasAlpha(bytes) || process.platform !== "darwin") {
926
1020
  cpSync(source, target);
927
1021
  return;
@@ -930,27 +1024,27 @@ function writeOpaqueIcon(source, target) {
930
1024
  const run = (args) => Bun.spawnSync(["sips", ...args], { stdout: "ignore", stderr: "pipe" });
931
1025
  const toJpeg = run(["-s", "format", "jpeg", "-s", "formatOptions", "100", source, "--out", flattened]);
932
1026
  const toPng = toJpeg.exitCode === 0 ? run(["-s", "format", "png", flattened, "--out", target]) : toJpeg;
933
- rmSync2(flattened, { force: true });
1027
+ rmSync3(flattened, { force: true });
934
1028
  if (toPng.exitCode !== 0) {
935
1029
  cpSync(source, target);
936
1030
  console.warn(`[craft] Could not remove the app icon's alpha channel; App Store Connect will refuse it: ${toPng.stderr.toString().trim()}`);
937
1031
  }
938
1032
  }
939
1033
  function renderAssetCatalog(output, config) {
940
- const catalog = join3(output, "Assets.xcassets");
941
- const appIcon = join3(catalog, "AppIcon.appiconset");
942
- const launchBackground = join3(catalog, "LaunchBackground.colorset");
1034
+ const catalog = join4(output, "Assets.xcassets");
1035
+ const appIcon = join4(catalog, "AppIcon.appiconset");
1036
+ const launchBackground = join4(catalog, "LaunchBackground.colorset");
943
1037
  mkdirSync2(appIcon, { recursive: true });
944
1038
  mkdirSync2(launchBackground, { recursive: true });
945
- writeFileSync2(join3(catalog, "Contents.json"), `${JSON.stringify({ info: { author: "xcode", version: 1 } }, null, 2)}
1039
+ writeFileSync3(join4(catalog, "Contents.json"), `${JSON.stringify({ info: { author: "xcode", version: 1 } }, null, 2)}
946
1040
  `);
947
1041
  const iconFilename = config.appIconPath ? "AppIcon-1024.png" : undefined;
948
1042
  if (config.appIconPath) {
949
- if (!existsSync(config.appIconPath))
1043
+ if (!existsSync2(config.appIconPath))
950
1044
  throw new Error(`App icon not found: ${config.appIconPath}`);
951
- writeOpaqueIcon(config.appIconPath, join3(appIcon, iconFilename));
1045
+ writeOpaqueIcon(config.appIconPath, join4(appIcon, iconFilename));
952
1046
  }
953
- writeFileSync2(join3(appIcon, "Contents.json"), `${JSON.stringify({
1047
+ writeFileSync3(join4(appIcon, "Contents.json"), `${JSON.stringify({
954
1048
  images: iconFilename ? [{
955
1049
  filename: iconFilename,
956
1050
  idiom: "universal",
@@ -977,7 +1071,7 @@ function renderAssetCatalog(output, config) {
977
1071
  idiom: "universal"
978
1072
  });
979
1073
  }
980
- writeFileSync2(join3(launchBackground, "Contents.json"), `${JSON.stringify({
1074
+ writeFileSync3(join4(launchBackground, "Contents.json"), `${JSON.stringify({
981
1075
  colors,
982
1076
  info: { author: "xcode", version: 1 }
983
1077
  }, null, 2)}
@@ -986,8 +1080,8 @@ function renderAssetCatalog(output, config) {
986
1080
  }
987
1081
  var LAUNCH_LOGO = "LaunchLogo";
988
1082
  function renderLaunchLogo(catalog, config) {
989
- const imageset = join3(catalog, `${LAUNCH_LOGO}.imageset`);
990
- rmSync2(imageset, { recursive: true, force: true });
1083
+ const imageset = join4(catalog, `${LAUNCH_LOGO}.imageset`);
1084
+ rmSync3(imageset, { recursive: true, force: true });
991
1085
  if (!config.splashImagePath)
992
1086
  return;
993
1087
  mkdirSync2(imageset, { recursive: true });
@@ -996,20 +1090,20 @@ function renderLaunchLogo(catalog, config) {
996
1090
  for (const [source, dark] of [[config.splashImagePath, false], [config.splashImagePathDark, true]]) {
997
1091
  if (!source)
998
1092
  continue;
999
- if (!existsSync(source))
1093
+ if (!existsSync2(source))
1000
1094
  throw new Error(`Splash image not found: ${source}`);
1001
1095
  const extension = source.split(".").pop().toLowerCase();
1002
1096
  if (extension === "svg" || extension === "pdf")
1003
1097
  vector = true;
1004
1098
  const filename = `${LAUNCH_LOGO}${dark ? "-dark" : ""}.${extension}`;
1005
- cpSync(source, join3(imageset, filename));
1099
+ cpSync(source, join4(imageset, filename));
1006
1100
  images.push({
1007
1101
  ...dark ? { appearances: [{ appearance: "luminosity", value: "dark" }] } : {},
1008
1102
  filename,
1009
1103
  idiom: "universal"
1010
1104
  });
1011
1105
  }
1012
- writeFileSync2(join3(imageset, "Contents.json"), `${JSON.stringify({
1106
+ writeFileSync3(join4(imageset, "Contents.json"), `${JSON.stringify({
1013
1107
  images,
1014
1108
  info: { author: "xcode", version: 1 },
1015
1109
  ...vector ? { properties: { "preserves-vector-representation": true } } : {}
@@ -1120,18 +1214,18 @@ function renderOfflinePage(config) {
1120
1214
  `;
1121
1215
  }
1122
1216
  function syncWebAssets(source, output) {
1123
- const sourcePath = resolve(source);
1124
- if (!existsSync(sourcePath))
1217
+ const sourcePath = resolve2(source);
1218
+ if (!existsSync2(sourcePath))
1125
1219
  throw new Error(`Web asset path not found: ${source}`);
1126
- const distDir = join3(output, "dist");
1127
- rmSync2(distDir, { recursive: true, force: true });
1220
+ const distDir = join4(output, "dist");
1221
+ rmSync3(distDir, { recursive: true, force: true });
1128
1222
  mkdirSync2(distDir, { recursive: true });
1129
1223
  if (statSync(sourcePath).isDirectory()) {
1130
1224
  cpSync(sourcePath, distDir, { recursive: true });
1131
1225
  } else {
1132
- cpSync(sourcePath, join3(distDir, "index.html"));
1226
+ cpSync(sourcePath, join4(distDir, "index.html"));
1133
1227
  }
1134
- if (!existsSync(join3(distDir, "index.html"))) {
1228
+ if (!existsSync2(join4(distDir, "index.html"))) {
1135
1229
  throw new Error(`Web asset directory must contain index.html: ${source}`);
1136
1230
  }
1137
1231
  }
@@ -1141,7 +1235,7 @@ function resolveRuntimeDir(override) {
1141
1235
  const dir = override ?? process.env.CRAFT_IOS_RUNTIME;
1142
1236
  if (!dir)
1143
1237
  return null;
1144
- if (!existsSync(dir)) {
1238
+ if (!existsSync2(dir)) {
1145
1239
  const source = override === undefined ? "CRAFT_IOS_RUNTIME points at" : "runtimeDir is";
1146
1240
  throw new Error(`${source} ${dir}, which does not exist.`);
1147
1241
  }
@@ -1151,43 +1245,71 @@ var RUNTIME_ARCHIVES = {
1151
1245
  device: ["libcraft-ios.a"],
1152
1246
  simulator: ["libcraft-ios-simulator-arm64.a", "libcraft-ios-simulator-x64.a"]
1153
1247
  };
1154
- async function installRuntime(output, runtimeDir) {
1248
+ async function installRuntime(output, runtimeDir, config) {
1249
+ if (config)
1250
+ verifyRuntimeProfile(runtimeDir, config);
1155
1251
  const resolved = Object.entries(RUNTIME_ARCHIVES).map(([sdk, archives]) => {
1156
- const present = archives.filter((a) => existsSync(join3(runtimeDir, a)));
1252
+ const present = archives.filter((a) => existsSync2(join4(runtimeDir, a)));
1157
1253
  if (present.length === 0) {
1158
1254
  throw new Error(`${runtimeDir} has none of ${archives.join(", ")}. ` + `Run \`zig build build-ios-all\` in packages/zig and point at its zig-out/lib.`);
1159
1255
  }
1160
1256
  return { sdk, archives, present };
1161
1257
  });
1162
- const dest = join3(output, "Runtime");
1163
- rmSync2(dest, { recursive: true, force: true });
1164
- for (const { sdk, archives, present } of resolved) {
1165
- const sdkDir = join3(dest, sdk);
1166
- mkdirSync2(sdkDir, { recursive: true });
1167
- const target = join3(sdkDir, "libcraft-ios.a");
1168
- if (present.length === 1) {
1169
- const missing = archives.filter((a) => !present.includes(a));
1170
- if (missing.length > 0) {
1171
- console.warn(` \u26A0 ${sdk}: only ${present[0]} was found; ${missing.join(", ")} is missing. ` + `The generated project will not link on the other architecture.`);
1258
+ const installed = join4(output, "Runtime");
1259
+ const staging = mkdtempSync3(join4(output, ".craft-runtime-"));
1260
+ const dest = join4(staging, "next");
1261
+ const previous = join4(staging, "previous");
1262
+ try {
1263
+ for (const { sdk, archives, present } of resolved) {
1264
+ const sdkDir = join4(dest, sdk);
1265
+ mkdirSync2(sdkDir, { recursive: true });
1266
+ const target = join4(sdkDir, "libcraft-ios.a");
1267
+ if (present.length === 1) {
1268
+ const missing = archives.filter((a) => !present.includes(a));
1269
+ if (missing.length > 0) {
1270
+ console.warn(` \u26A0 ${sdk}: only ${present[0]} was found; ${missing.join(", ")} is missing. ` + `The generated project will not link on the other architecture.`);
1271
+ }
1272
+ cpSync(join4(runtimeDir, present[0]), target);
1273
+ } else {
1274
+ await $`lipo -create ${present.map((a) => join4(runtimeDir, a))} -output ${target}`.quiet();
1172
1275
  }
1173
- cpSync(join3(runtimeDir, present[0]), target);
1174
- } else {
1175
- await $`lipo -create ${present.map((a) => join3(runtimeDir, a))} -output ${target}`.quiet();
1176
1276
  }
1277
+ if (config)
1278
+ writeInstalledProfile(dest, config);
1279
+ if (existsSync2(installed))
1280
+ renameSync(installed, previous);
1281
+ try {
1282
+ renameSync(dest, installed);
1283
+ } catch (error) {
1284
+ if (existsSync2(previous))
1285
+ renameSync(previous, installed);
1286
+ throw error;
1287
+ }
1288
+ return true;
1289
+ } finally {
1290
+ rmSync3(staging, { recursive: true, force: true });
1177
1291
  }
1178
- return true;
1179
1292
  }
1180
- async function refreshRuntime(output, override) {
1181
- if (!existsSync(join3(output, "Runtime")))
1293
+ async function refreshRuntime(output, config, override) {
1294
+ if (!existsSync2(join4(output, "Runtime")))
1182
1295
  return;
1183
1296
  const dir = resolveRuntimeDir(override);
1184
1297
  if (!dir) {
1298
+ verifyInstalledProfile(join4(output, "Runtime"), config);
1185
1299
  console.log(" Keeping the Zig runtime installed at init (no runtime directory configured)");
1186
1300
  return;
1187
1301
  }
1188
- await installRuntime(output, dir);
1302
+ await installRuntime(output, dir, config);
1189
1303
  console.log(" Refreshed the Zig runtime from", dir);
1190
1304
  }
1305
+ async function installAppRuntime(output, source, config) {
1306
+ return withAppRuntime(source, config, async (directory) => {
1307
+ verifyRuntimeProfile(directory, config);
1308
+ await installRuntime(output, directory, config);
1309
+ writeInstalledProfile(join4(output, "Runtime"), config, resolve2(source));
1310
+ return true;
1311
+ });
1312
+ }
1191
1313
  function productName(name) {
1192
1314
  const words = name.split(/[^A-Za-z0-9]+/).filter(Boolean);
1193
1315
  const joined = words.map((word) => word[0].toUpperCase() + word.slice(1)).join("");
@@ -1196,8 +1318,10 @@ function productName(name) {
1196
1318
  return /^\d/.test(joined) ? `App${joined}` : joined;
1197
1319
  }
1198
1320
  function restoreShell(output) {
1199
- const file = join3(output, "craft.config.json");
1200
- const config = JSON.parse(readFileSync2(file, "utf8"));
1321
+ const file = join4(output, "craft.config.json");
1322
+ const config = JSON.parse(readFileSync3(file, "utf8"));
1323
+ if (existsSync2(join4(output, "Runtime")))
1324
+ verifyInstalledProfile(join4(output, "Runtime"), config);
1201
1325
  const cloudBuild = process.env.CI_BUILD_NUMBER;
1202
1326
  if (cloudBuild !== undefined) {
1203
1327
  if (!/^[1-9]\d*$/.test(cloudBuild) || cloudBuild.trim() !== cloudBuild)
@@ -1205,51 +1329,70 @@ function restoreShell(output) {
1205
1329
  config.buildNumber = cloudBuild;
1206
1330
  }
1207
1331
  writeShell(output, config);
1332
+ refreshUsageDescriptions(output, config);
1208
1333
  if (cloudBuild !== undefined)
1209
- writeFileSync2(file, JSON.stringify(config, null, 2));
1334
+ writeFileSync3(file, JSON.stringify(config, null, 2));
1210
1335
  }
1211
1336
  function writeShell(output, config) {
1212
1337
  if (!config.appName || !/^[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)+$/.test(config.bundleId))
1213
1338
  throw new Error("The iOS shell needs an appName and a valid bundleId in craft.config.json.");
1339
+ const writeSource = (path, source) => {
1340
+ const rendered = renderPermissionSource(source, config);
1341
+ if (!existsSync2(path) || readFileSync3(path, "utf8") !== rendered)
1342
+ writeFileSync3(path, rendered);
1343
+ };
1214
1344
  const name = productName(config.appName);
1215
1345
  const shortcutSource = appShortcutsSwift(config.appShortcuts, config.urlSchemes);
1216
- mkdirSync2(join3(output, "Sources"), { recursive: true });
1217
- mkdirSync2(join3(output, "Shared"), { recursive: true });
1218
- const swiftTemplate = readFileSync2(join3(TEMPLATES_DIR, "CraftApp.swift"), "utf-8");
1346
+ mkdirSync2(join4(output, "Sources"), { recursive: true });
1347
+ mkdirSync2(join4(output, "Shared"), { recursive: true });
1348
+ const swiftTemplate = readFileSync3(join4(TEMPLATES_DIR, "CraftApp.swift"), "utf-8");
1219
1349
  const swiftSource = swiftTemplate.replace(/CraftApp/g, `${name}App`).replace(/\{\{BUNDLE_ID\}\}/g, config.bundleId).replace(/\{\{CRAFT_VERSION\}\}/g, CRAFT_VERSION);
1220
- writeFileSync2(join3(output, "Sources", `${name}App.swift`), swiftSource);
1221
- writeFileSync2(join3(output, "Sources", "CraftAppShortcuts.swift"), shortcutSource);
1222
- cpSync(join3(TEMPLATES_DIR, "CraftNativeScreen.swift"), join3(output, "Sources", "CraftNativeScreen.swift"));
1223
- cpSync(join3(TEMPLATES_DIR, "CraftNativeFlatList.swift"), join3(output, "Sources", "CraftNativeFlatList.swift"));
1224
- cpSync(join3(TEMPLATES_DIR, "CraftNativeActions.swift"), join3(output, "Sources", "CraftNativeActions.swift"));
1225
- cpSync(join3(TEMPLATES_DIR, "CraftHealth.swift"), join3(output, "Sources", "CraftHealth.swift"));
1226
- cpSync(join3(TEMPLATES_DIR, "CraftMusic.swift"), join3(output, "Sources", "CraftMusic.swift"));
1227
- cpSync(join3(TEMPLATES_DIR, "CraftBluetooth.swift"), join3(output, "Sources", "CraftBluetooth.swift"));
1228
- cpSync(join3(TEMPLATES_DIR, "CraftHeartRate.swift"), join3(output, "Sources", "CraftHeartRate.swift"));
1229
- cpSync(join3(TEMPLATES_DIR, "CraftRecordingArchive.swift"), join3(output, "Sources", "CraftRecordingArchive.swift"));
1230
- cpSync(join3(TEMPLATES_DIR, "CraftPersistentStorage.swift"), join3(output, "Sources", "CraftPersistentStorage.swift"));
1231
- cpSync(join3(TEMPLATES_DIR, "CraftScheduledWorkouts.swift"), join3(output, "Sources", "CraftScheduledWorkouts.swift"));
1232
- cpSync(join3(TEMPLATES_DIR, "CraftNativeMutation.swift"), join3(output, "Sources", "CraftNativeMutation.swift"));
1233
- cpSync(join3(TEMPLATES_DIR, "CraftNativeUI.swift"), join3(output, "Sources", "CraftNativeUI.swift"));
1234
- cpSync(join3(TEMPLATES_DIR, "CraftNativeServices.swift"), join3(output, "Sources", "CraftNativeServices.swift"));
1235
- cpSync(join3(TEMPLATES_DIR, "CraftNativeFiles.swift"), join3(output, "Sources", "CraftNativeFiles.swift"));
1236
- cpSync(join3(TEMPLATES_DIR, "CraftVoiceRecording.swift"), join3(output, "Sources", "CraftVoiceRecording.swift"));
1237
- cpSync(join3(TEMPLATES_DIR, "CraftWebAudio.swift"), join3(output, "Sources", "CraftWebAudio.swift"));
1238
- cpSync(join3(TEMPLATES_DIR, "CraftHybrid.swift"), join3(output, "Sources", "CraftHybrid.swift"));
1239
- writeFileSync2(join3(output, "Sources", "CraftCrashReporter.swift"), readFileSync2(join3(TEMPLATES_DIR, "CraftCrashReporter.swift"), "utf-8").replace(/\{\{CRAFT_VERSION\}\}/g, CRAFT_VERSION));
1240
- cpSync(join3(TEMPLATES_DIR, "CraftLiveActivities.swift"), join3(output, "Sources", "CraftLiveActivities.swift"));
1241
- cpSync(join3(TEMPLATES_DIR, "CraftActivityAttributes.swift"), join3(output, "Shared", "CraftActivityAttributes.swift"));
1350
+ writeSource(join4(output, "Sources", `${name}App.swift`), swiftSource);
1351
+ writeSource(join4(output, "Sources", "CraftAppShortcuts.swift"), shortcutSource);
1352
+ writeSource(join4(output, "Sources", "CraftNativeScreen.swift"), readFileSync3(join4(TEMPLATES_DIR, "CraftNativeScreen.swift"), "utf8"));
1353
+ writeSource(join4(output, "Sources", "CraftNativeFlatList.swift"), readFileSync3(join4(TEMPLATES_DIR, "CraftNativeFlatList.swift"), "utf8"));
1354
+ writeSource(join4(output, "Sources", "CraftNativeActions.swift"), readFileSync3(join4(TEMPLATES_DIR, "CraftNativeActions.swift"), "utf8"));
1355
+ writeSource(join4(output, "Sources", "CraftHealth.swift"), readFileSync3(join4(TEMPLATES_DIR, "CraftHealth.swift"), "utf8"));
1356
+ writeSource(join4(output, "Sources", "CraftMusic.swift"), readFileSync3(join4(TEMPLATES_DIR, "CraftMusic.swift"), "utf8"));
1357
+ writeSource(join4(output, "Sources", "CraftBluetooth.swift"), readFileSync3(join4(TEMPLATES_DIR, "CraftBluetooth.swift"), "utf8"));
1358
+ writeSource(join4(output, "Sources", "CraftHeartRate.swift"), readFileSync3(join4(TEMPLATES_DIR, "CraftHeartRate.swift"), "utf8"));
1359
+ writeSource(join4(output, "Sources", "CraftRecordingArchive.swift"), readFileSync3(join4(TEMPLATES_DIR, "CraftRecordingArchive.swift"), "utf8"));
1360
+ writeSource(join4(output, "Sources", "CraftPersistentStorage.swift"), readFileSync3(join4(TEMPLATES_DIR, "CraftPersistentStorage.swift"), "utf8"));
1361
+ writeSource(join4(output, "Sources", "CraftScheduledWorkouts.swift"), readFileSync3(join4(TEMPLATES_DIR, "CraftScheduledWorkouts.swift"), "utf8"));
1362
+ writeSource(join4(output, "Sources", "CraftNativeMutation.swift"), readFileSync3(join4(TEMPLATES_DIR, "CraftNativeMutation.swift"), "utf8"));
1363
+ writeSource(join4(output, "Sources", "CraftNativeUI.swift"), readFileSync3(join4(TEMPLATES_DIR, "CraftNativeUI.swift"), "utf8"));
1364
+ writeSource(join4(output, "Sources", "CraftNativeServices.swift"), readFileSync3(join4(TEMPLATES_DIR, "CraftNativeServices.swift"), "utf8"));
1365
+ writeSource(join4(output, "Sources", "CraftNativeFiles.swift"), readFileSync3(join4(TEMPLATES_DIR, "CraftNativeFiles.swift"), "utf8"));
1366
+ writeSource(join4(output, "Sources", "CraftVoiceRecording.swift"), readFileSync3(join4(TEMPLATES_DIR, "CraftVoiceRecording.swift"), "utf8"));
1367
+ writeSource(join4(output, "Sources", "CraftWebAudio.swift"), readFileSync3(join4(TEMPLATES_DIR, "CraftWebAudio.swift"), "utf8"));
1368
+ writeSource(join4(output, "Sources", "CraftHybrid.swift"), readFileSync3(join4(TEMPLATES_DIR, "CraftHybrid.swift"), "utf8"));
1369
+ writeSource(join4(output, "Sources", "CraftCrashReporter.swift"), readFileSync3(join4(TEMPLATES_DIR, "CraftCrashReporter.swift"), "utf-8").replace(/\{\{CRAFT_VERSION\}\}/g, CRAFT_VERSION));
1370
+ writeSource(join4(output, "Sources", "CraftLiveActivities.swift"), readFileSync3(join4(TEMPLATES_DIR, "CraftLiveActivities.swift"), "utf8"));
1371
+ writeSource(join4(output, "Shared", "CraftActivityAttributes.swift"), readFileSync3(join4(TEMPLATES_DIR, "CraftActivityAttributes.swift"), "utf8"));
1242
1372
  for (const [enabled, folder, template, filename] of [
1243
1373
  [config.enableLiveActivities, "WidgetExtension", "CraftLiveActivityWidget.swift.template", `${name}LiveActivity.swift`],
1244
1374
  [config.enableWatchApp, "WatchExtension", "CraftWatchApp.swift.template", `${name}WatchApp.swift`]
1245
1375
  ]) {
1246
1376
  if (!enabled)
1247
1377
  continue;
1248
- mkdirSync2(join3(output, folder), { recursive: true });
1249
- const source = readFileSync2(join3(TEMPLATES_DIR, template), "utf8").replace(/\{\{APP_NAME\}\}/g, name).replace(/\{\{HEALTH_KIT_ENABLED\}\}/g, config.enableHealthKit ? "true" : "false");
1250
- writeFileSync2(join3(output, folder, filename), source);
1378
+ mkdirSync2(join4(output, folder), { recursive: true });
1379
+ const source = readFileSync3(join4(TEMPLATES_DIR, template), "utf8").replace(/\{\{APP_NAME\}\}/g, name).replace(/\{\{HEALTH_KIT_ENABLED\}\}/g, config.enableHealthKit ? "true" : "false");
1380
+ writeSource(join4(output, folder, filename), source);
1251
1381
  }
1252
1382
  }
1383
+ function refreshUsageDescriptions(output, config) {
1384
+ const path = join4(output, "Info.plist");
1385
+ if (!existsSync2(path))
1386
+ return;
1387
+ const managed = /\s*<key>(?:NS(?:SpeechRecognition|Microphone|Camera|PhotoLibrary|LocationWhenInUse|LocationAlwaysAndWhenInUse|Contacts|Calendars|BluetoothAlways|Motion|LocalNetwork|HealthShare|HealthUpdate|FaceID|AppleMusic)|NFCReader)UsageDescription<\/key>\s*<string>[\s\S]*?<\/string>/g;
1388
+ const plist = readFileSync3(path, "utf8").replace(managed, "");
1389
+ const end = plist.lastIndexOf("</dict>");
1390
+ if (end < 0)
1391
+ throw new Error("Info.plist must contain a root dictionary");
1392
+ writeFileSync3(path, `${plist.slice(0, end).trimEnd()}
1393
+ ${renderUsageDescriptions(config)}
1394
+ ${plist.slice(end)}`);
1395
+ }
1253
1396
  async function init(options) {
1254
1397
  const { name: displayName, bundleId, teamId, output } = options;
1255
1398
  const name = productName(displayName);
@@ -1257,11 +1400,11 @@ async function init(options) {
1257
1400
  \u26A1 Initializing Craft iOS project: ${displayName}`);
1258
1401
  console.log(` Output: ${output}
1259
1402
  `);
1260
- const dirs = [output, join3(output, "Sources"), join3(output, "Shared"), join3(output, "dist")];
1403
+ const dirs = [output, join4(output, "Sources"), join4(output, "Shared"), join4(output, "dist")];
1261
1404
  if (options.config?.enableWatchApp)
1262
- dirs.push(join3(output, "WatchApp"), join3(output, "WatchExtension"));
1405
+ dirs.push(join4(output, "WatchApp"), join4(output, "WatchExtension"));
1263
1406
  for (const dir of dirs) {
1264
- if (!existsSync(dir)) {
1407
+ if (!existsSync2(dir)) {
1265
1408
  mkdirSync2(dir, { recursive: true });
1266
1409
  }
1267
1410
  }
@@ -1291,10 +1434,20 @@ async function init(options) {
1291
1434
  `));
1292
1435
  if (Object.keys(config.shareStorage ?? {}).length)
1293
1436
  config.enableSecureStorage = true;
1294
- writeFileSync2(join3(output, "craft.config.json"), JSON.stringify(config, null, 2));
1437
+ const runtimeDir = resolveRuntimeDir(options.runtimeDir);
1438
+ const runtimeSource = options.runtimeSource === null || options.runtimeDir === null ? undefined : options.runtimeSource ?? process.env.CRAFT_IOS_RUNTIME_SOURCE;
1439
+ if (runtimeSource && runtimeDir)
1440
+ throw new Error("Choose runtimeSource or runtimeDir, not both");
1441
+ const hasRuntime = runtimeSource ? await installAppRuntime(output, runtimeSource, config) : runtimeDir ? await installRuntime(output, runtimeDir, config) : false;
1442
+ if (hasRuntime) {
1443
+ console.log(" Linked the Zig runtime from", runtimeSource ?? runtimeDir);
1444
+ } else {
1445
+ rmSync3(join4(output, "Runtime"), { recursive: true, force: true });
1446
+ }
1447
+ writeFileSync3(join4(output, "craft.config.json"), JSON.stringify(config, null, 2));
1295
1448
  writeShell(output, config);
1296
1449
  const nativeFontFiles = syncNativeFonts(config.nativeFonts, output);
1297
- const infoPlistTemplate = readFileSync2(join3(TEMPLATES_DIR, "Info.plist.template"), "utf-8");
1450
+ const infoPlistTemplate = readFileSync3(join4(TEMPLATES_DIR, "Info.plist.template"), "utf-8");
1298
1451
  const infoPlist = infoPlistTemplate.replace(/\{\{APP_NAME\}\}/g, xmlEscape(displayName)).replace(/\{\{BUNDLE_ID\}\}/g, finalBundleId).replace(/\{\{VERSION\}\}/g, config.version || "1.0.0").replace(/\{\{BUILD_NUMBER\}\}/g, config.buildNumber || "1").replace(/\{\{UI_STYLE\}\}/g, renderAppearance(config).interfaceStyle).replace(/\{\{STATUS_BAR_STYLE\}\}/g, renderAppearance(config).statusBarStyle).replace(/\{\{ORIENTATIONS\}\}/g, renderOrientations(config)).replace(/\{\{USAGE_DESCRIPTIONS\}\}/g, renderUsageDescriptions(config)).replace(/\{\{URL_TYPES\}\}/g, renderUrlTypes(config)).replace(/\{\{APP_BOUND_DOMAINS\}\}/g, renderAppBoundDomains(config)).replace(/\{\{BACKGROUND_MODES\}\}/g, renderBackgroundModes(config)).replace(/\{\{BACKGROUND_TASK_IDENTIFIERS\}\}/g, renderBackgroundTaskIdentifiers(config)).replace(/\{\{LAUNCH_SCREEN\}\}/g, renderLaunchScreen(config)).replace(/\{\{NATIVE_FONTS\}\}/g, nativeFontFiles.length ? ` <key>UIAppFonts</key>
1299
1452
  <array>
1300
1453
  ${plistArray(nativeFontFiles, 2)}
@@ -1302,8 +1455,8 @@ ${plistArray(nativeFontFiles, 2)}
1302
1455
  <true/>
1303
1456
  <key>NSSupportsLiveActivitiesFrequentUpdates</key>
1304
1457
  <true/>` : "");
1305
- writeFileSync2(join3(output, "Info.plist"), infoPlist);
1306
- const projectYmlTemplate = readFileSync2(join3(TEMPLATES_DIR, "project.yml.template"), "utf-8");
1458
+ writeFileSync3(join4(output, "Info.plist"), infoPlist);
1459
+ const projectYmlTemplate = readFileSync3(join4(TEMPLATES_DIR, "project.yml.template"), "utf-8");
1307
1460
  const nativeDependencies = [];
1308
1461
  if (config.enableLiveActivities)
1309
1462
  nativeDependencies.push(` - target: ${name}LiveActivity`);
@@ -1358,38 +1511,31 @@ ${plistArray(nativeFontFiles, 2)}
1358
1511
  APPLICATION_EXTENSION_API_ONLY: YES
1359
1512
  SKIP_INSTALL: YES`);
1360
1513
  }
1361
- const runtimeDir = resolveRuntimeDir(options.runtimeDir);
1362
- const hasRuntime = runtimeDir ? await installRuntime(output, runtimeDir) : false;
1363
- if (hasRuntime) {
1364
- console.log(" Linked the Zig runtime from", runtimeDir);
1365
- } else {
1366
- rmSync2(join3(output, "Runtime"), { recursive: true, force: true });
1367
- }
1368
1514
  const projectYml = projectYmlTemplate.replace(/\{\{NATIVE_FONT_RESOURCES\}\}/g, nativeFontFiles.length ? ` - path: NativeFonts
1369
1515
  type: folder
1370
1516
  buildPhase: resources` : "").replace(/\{\{CRAFT_RUNTIME_SETTINGS\}\}/g, hasRuntime ? renderRuntimeSettings() : "").replace(/\{\{FEATURE_SETTINGS\}\}/g, renderFeatureSettings(config)).replace(/\{\{APP_NAME\}\}/g, name).replace(/\{\{BUNDLE_ID\}\}/g, finalBundleId).replace(/\{\{BUNDLE_ID_PREFIX\}\}/g, bundleIdPrefix).replace(/\{\{VERSION\}\}/g, config.version || "1.0.0").replace(/\{\{BUILD_NUMBER\}\}/g, config.buildNumber || "1").replace(/\{\{IOS_VERSION\}\}/g, config.iosVersion || "15.0").replace(/\{\{DEVICE_FAMILIES\}\}/g, renderDeviceFamilies(config)).replace(/\{\{TEAM_ID\}\}/g, teamId || "").replace(/\{\{NATIVE_DEPENDENCIES\}\}/g, nativeDependencies.length ? ` dependencies:
1371
1517
  ${nativeDependencies.join(`
1372
1518
  `)}` : "").replace(/\{\{NATIVE_TARGETS\}\}/g, nativeTargets.join(`
1373
1519
  `));
1374
- writeFileSync2(join3(output, "project.yml"), projectYml);
1375
- writeFileSync2(join3(output, "Craft.entitlements"), renderEntitlements(config));
1376
- writeFileSync2(join3(output, "PrivacyInfo.xcprivacy"), renderPrivacyManifest(config));
1520
+ writeFileSync3(join4(output, "project.yml"), projectYml);
1521
+ writeFileSync3(join4(output, "Craft.entitlements"), renderEntitlements(config));
1522
+ writeFileSync3(join4(output, "PrivacyInfo.xcprivacy"), renderPrivacyManifest(config));
1377
1523
  renderAssetCatalog(output, config);
1378
1524
  if (config.enableLiveActivities) {
1379
- mkdirSync2(join3(output, "WidgetExtension"), { recursive: true });
1380
- const widgetInfo = readFileSync2(join3(TEMPLATES_DIR, "WidgetExtension.Info.plist"), "utf8");
1381
- writeFileSync2(join3(output, "WidgetExtension", "Info.plist"), widgetInfo);
1525
+ mkdirSync2(join4(output, "WidgetExtension"), { recursive: true });
1526
+ const widgetInfo = readFileSync3(join4(TEMPLATES_DIR, "WidgetExtension.Info.plist"), "utf8");
1527
+ writeFileSync3(join4(output, "WidgetExtension", "Info.plist"), widgetInfo);
1382
1528
  }
1383
1529
  if (config.enableWatchApp) {
1384
- const watchInfo = readFileSync2(join3(TEMPLATES_DIR, "WatchApp.Info.plist.template"), "utf8").replace(/\{\{APP_NAME\}\}/g, xmlEscape(displayName)).replace(/\{\{BUNDLE_ID\}\}/g, finalBundleId);
1385
- writeFileSync2(join3(output, "WatchApp", "Info.plist"), watchInfo);
1386
- writeFileSync2(join3(output, "WatchApp", "Watch.entitlements"), renderWatchEntitlements(config));
1387
- const watchExtensionInfo = readFileSync2(join3(TEMPLATES_DIR, "WatchExtension.Info.plist.template"), "utf8").replace(/\{\{APP_NAME\}\}/g, xmlEscape(displayName)).replace(/\{\{BUNDLE_ID\}\}/g, finalBundleId).replace(/\{\{HEALTH_USAGE_DESCRIPTIONS\}\}/g, config.enableHealthKit ? `${plistString("NSHealthShareUsageDescription", `${displayName} reads health data you choose to share.`)}
1530
+ const watchInfo = readFileSync3(join4(TEMPLATES_DIR, "WatchApp.Info.plist.template"), "utf8").replace(/\{\{APP_NAME\}\}/g, xmlEscape(displayName)).replace(/\{\{BUNDLE_ID\}\}/g, finalBundleId);
1531
+ writeFileSync3(join4(output, "WatchApp", "Info.plist"), watchInfo);
1532
+ writeFileSync3(join4(output, "WatchApp", "Watch.entitlements"), renderWatchEntitlements(config));
1533
+ const watchExtensionInfo = readFileSync3(join4(TEMPLATES_DIR, "WatchExtension.Info.plist.template"), "utf8").replace(/\{\{APP_NAME\}\}/g, xmlEscape(displayName)).replace(/\{\{BUNDLE_ID\}\}/g, finalBundleId).replace(/\{\{HEALTH_USAGE_DESCRIPTIONS\}\}/g, config.enableHealthKit ? `${plistString("NSHealthShareUsageDescription", `${displayName} reads health data you choose to share.`)}
1388
1534
  ${plistString("NSHealthUpdateUsageDescription", `${displayName} writes health data only with your permission.`)}` : "");
1389
- writeFileSync2(join3(output, "WatchExtension", "Info.plist"), watchExtensionInfo);
1390
- writeFileSync2(join3(output, "WatchExtension", "Watch.entitlements"), renderWatchEntitlements(config));
1535
+ writeFileSync3(join4(output, "WatchExtension", "Info.plist"), watchExtensionInfo);
1536
+ writeFileSync3(join4(output, "WatchExtension", "Watch.entitlements"), renderWatchEntitlements(config));
1391
1537
  }
1392
- writeFileSync2(join3(output, "dist", "index.html"), renderOfflinePage(config));
1538
+ writeFileSync3(join4(output, "dist", "index.html"), renderOfflinePage(config));
1393
1539
  if (usesNativeBundle(config)) {
1394
1540
  if (config.nativeBundle)
1395
1541
  installNativeBundle(config.nativeBundle, output);
@@ -1416,11 +1562,11 @@ async function build(options) {
1416
1562
  const { htmlPath, nativeBundlePath, devServer, output, generateProject = true } = options;
1417
1563
  console.log(`
1418
1564
  \uD83D\uDCE6 Building Craft iOS project...`);
1419
- const configPath = join3(output, "craft.config.json");
1420
- if (!existsSync(configPath)) {
1565
+ const configPath = join4(output, "craft.config.json");
1566
+ if (!existsSync2(configPath)) {
1421
1567
  throw new Error(`No craft.config.json found in ${output}. Run 'craft ios init' first.`);
1422
1568
  }
1423
- const config = JSON.parse(readFileSync2(configPath, "utf-8"));
1569
+ const config = JSON.parse(readFileSync3(configPath, "utf-8"));
1424
1570
  if (config.renderer !== undefined && config.renderer !== "web" && config.renderer !== "native") {
1425
1571
  throw new Error(`Unknown iOS renderer: ${config.renderer}. Expected web or native.`);
1426
1572
  }
@@ -1445,7 +1591,7 @@ async function build(options) {
1445
1591
  config.devServerURL = devServer;
1446
1592
  const origin = new URL(devServer).origin;
1447
1593
  config.trustedOrigins = [...new Set([...config.trustedOrigins ?? [], origin])];
1448
- writeFileSync2(configPath, JSON.stringify(config, null, 2));
1594
+ writeFileSync3(configPath, JSON.stringify(config, null, 2));
1449
1595
  console.log(` Dev server: ${devServer}`);
1450
1596
  }
1451
1597
  if (htmlPath) {
@@ -1461,7 +1607,20 @@ async function build(options) {
1461
1607
  console.warn(" \u26A0 nativeScreens is set but there is no native bundle: every path opens in the web view.");
1462
1608
  }
1463
1609
  }
1464
- await refreshRuntime(output, options.runtimeDir);
1610
+ const profilePath = join4(output, "Runtime", "profile.json");
1611
+ const savedProfile = existsSync2(profilePath) ? JSON.parse(readFileSync3(profilePath, "utf8")) : undefined;
1612
+ const runtimeSource = options.runtimeSource === null || options.runtimeDir === null ? undefined : options.runtimeSource ?? process.env.CRAFT_IOS_RUNTIME_SOURCE ?? savedProfile?.source;
1613
+ if (runtimeSource && existsSync2(join4(output, "Runtime"))) {
1614
+ if (options.runtimeDir)
1615
+ throw new Error("Choose runtimeSource or runtimeDir, not both");
1616
+ await installAppRuntime(output, runtimeSource, config);
1617
+ } else {
1618
+ if (savedProfile && savedProfile.mask !== iosPermissionMask(config))
1619
+ throw new Error("Installed Zig runtime permission profile is stale; provide runtimeSource to rebuild it");
1620
+ await refreshRuntime(output, config, options.runtimeDir);
1621
+ }
1622
+ writeShell(output, config);
1623
+ refreshUsageDescriptions(output, config);
1465
1624
  if (!generateProject)
1466
1625
  return;
1467
1626
  try {
@@ -1488,7 +1647,7 @@ async function open(options) {
1488
1647
  if (!xcodeproj) {
1489
1648
  throw new Error(`No Xcode project found in ${output}. Run 'craft ios build' first.`);
1490
1649
  }
1491
- const projectPath = join3(output, xcodeproj);
1650
+ const projectPath = join4(output, xcodeproj);
1492
1651
  console.log(`\uD83D\uDE80 Opening ${xcodeproj}...`);
1493
1652
  await $`open ${projectPath}`;
1494
1653
  }
@@ -1542,8 +1701,8 @@ async function showSimulator() {
1542
1701
  const selected = await $`xcode-select -p`.quiet().nothrow();
1543
1702
  const developer = selected.exitCode === 0 ? selected.stdout.toString().trim() : "";
1544
1703
  if (developer) {
1545
- const app = join3(developer, "Applications", "Simulator.app");
1546
- if (existsSync(app)) {
1704
+ const app = join4(developer, "Applications", "Simulator.app");
1705
+ if (existsSync2(app)) {
1547
1706
  await $`open ${app}`.quiet().nothrow();
1548
1707
  return;
1549
1708
  }
@@ -1558,12 +1717,12 @@ async function run(options) {
1558
1717
  if (!xcodeproj) {
1559
1718
  throw new Error(`No Xcode project found in ${output}`);
1560
1719
  }
1561
- const projectPath = join3(output, xcodeproj);
1720
+ const projectPath = join4(output, xcodeproj);
1562
1721
  const appName = xcodeproj.replace(".xcodeproj", "");
1563
- const configPath = join3(output, "craft.config.json");
1564
- if (!existsSync(configPath))
1722
+ const configPath = join4(output, "craft.config.json");
1723
+ if (!existsSync2(configPath))
1565
1724
  throw new Error(`No craft.config.json found in ${output}. Run 'craft-ios init' first.`);
1566
- const config = JSON.parse(readFileSync2(configPath, "utf-8"));
1725
+ const config = JSON.parse(readFileSync3(configPath, "utf-8"));
1567
1726
  if (simulator) {
1568
1727
  console.log("\uD83D\uDCF1 Building and running on simulator...");
1569
1728
  const device = await pickSimulator();
@@ -1573,10 +1732,10 @@ async function run(options) {
1573
1732
  console.log(` Device: ${device.name} (${device.runtime})`);
1574
1733
  try {
1575
1734
  await bootSimulator(device);
1576
- const derivedData = join3(output, "build");
1577
- const product = join3(derivedData, "Build", "Products", "Debug-iphonesimulator", `${appName}.app`);
1735
+ const derivedData = join4(output, "build");
1736
+ const product = join4(derivedData, "Build", "Products", "Debug-iphonesimulator", `${appName}.app`);
1578
1737
  await $`xcodebuild -project ${projectPath} -scheme ${appName} -configuration Debug -destination ${`id=${device.udid}`} -derivedDataPath ${derivedData} build`;
1579
- if (!existsSync(product)) {
1738
+ if (!existsSync2(product)) {
1580
1739
  throw new Error(`xcodebuild reported success but ${product} does not exist. ` + "The scheme may build a different product name than the project.");
1581
1740
  }
1582
1741
  await $`xcrun simctl install ${device.udid} ${product}`;