craft-native 0.0.236 → 0.0.238

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/README.md +71 -0
  2. package/dist/android/src/emulator.d.ts +23 -0
  3. package/dist/android/src/index.d.ts +7 -1
  4. package/dist/android/src/index.js +280 -98
  5. package/dist/android/templates/CraftBridge.kt.template +65 -54
  6. package/dist/android/templates/CraftNative.kt.template +27 -0
  7. package/dist/android/templates/CraftNativeCapabilities.kt.template +55 -6
  8. package/dist/android/templates/CraftNativeServices.kt.template +45 -35
  9. package/dist/android/templates/CraftNativeShell.kt.template +103 -20
  10. package/dist/android/templates/LocationRecordingService.kt.template +252 -46
  11. package/dist/android/templates/MainActivityNative.kt.template +163 -5
  12. package/dist/android-adb.d.ts +2 -0
  13. package/dist/api/index.d.ts +1 -1
  14. package/dist/api/mobile.d.ts +49 -14
  15. package/dist/browser/storage.d.ts +3 -0
  16. package/dist/browser.js +163 -51
  17. package/dist/cli.js +1 -1
  18. package/dist/components/index.d.ts +33 -0
  19. package/dist/index.cjs +126 -12
  20. package/dist/index.js +126 -12
  21. package/dist/ios/src/app-shortcuts.d.ts +11 -0
  22. package/dist/ios/src/index.d.ts +7 -0
  23. package/dist/ios/src/index.js +65 -1
  24. package/dist/ios/src/testing.d.ts +2 -0
  25. package/dist/ios/templates/CraftApp.swift +392 -88
  26. package/dist/ios/templates/CraftHeartRate.swift +141 -44
  27. package/dist/ios/templates/CraftNativeActions.swift +46 -48
  28. package/dist/ios/templates/CraftNativeScreen.swift +177 -10
  29. package/dist/ios/templates/CraftPersistentStorage.swift +99 -0
  30. package/dist/ios/templates/CraftRecordingArchive.swift +85 -0
  31. package/dist/ios/templates/testing/DriverTests.swift.template +8 -1
  32. package/dist/mobile-testing.js +30 -4
  33. package/dist/mobile.js +169 -17
  34. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -57,7 +57,7 @@ function craftBinaryNotFoundMessage(triedPath) {
57
57
  `);
58
58
  }
59
59
  // package.json
60
- var version = "0.0.236";
60
+ var version = "0.0.238";
61
61
 
62
62
  // src/sdk-version.ts
63
63
  var SDK_VERSION = version;
@@ -4981,6 +4981,92 @@ var secureStorage = {
4981
4981
  Object.keys(localStorage).filter((k) => k.startsWith("secure_")).forEach((k) => localStorage.removeItem(k));
4982
4982
  }
4983
4983
  };
4984
+ function storageHost(method) {
4985
+ const host2 = getCraftRoot()?.storage;
4986
+ if (typeof host2?.[method] !== "function")
4987
+ throw Object.assign(new Error("This host does not provide native storage"), { code: "STORAGE_UNSUPPORTED" });
4988
+ return host2;
4989
+ }
4990
+ var storage2 = {
4991
+ supportsSetIfAbsent() {
4992
+ const root = getCraftRoot();
4993
+ const caps = root?.capabilities;
4994
+ const supported = Array.isArray(caps) ? caps.includes("storageSetIfAbsent") : caps?.storageSetIfAbsent === true;
4995
+ return supported && typeof root?.storage?.setIfAbsent === "function";
4996
+ },
4997
+ supportsCompareAndSet() {
4998
+ const root = getCraftRoot();
4999
+ const caps = root?.capabilities;
5000
+ const supported = Array.isArray(caps) ? caps.includes("storageCompareAndSet") : caps?.storageCompareAndSet === true;
5001
+ return supported && typeof root?.storage?.compareAndSet === "function";
5002
+ },
5003
+ async get(key) {
5004
+ return storageHost("get").get(key);
5005
+ },
5006
+ async set(key, value) {
5007
+ return storageHost("set").set(key, value);
5008
+ },
5009
+ async remove(key) {
5010
+ return storageHost("remove").remove(key);
5011
+ },
5012
+ async clear() {
5013
+ return storageHost("clear").clear();
5014
+ },
5015
+ async keys() {
5016
+ return storageHost("keys").keys();
5017
+ },
5018
+ async setIfAbsent(key, value) {
5019
+ if (!storage2.supportsSetIfAbsent())
5020
+ throw Object.assign(new Error("This app build cannot atomically register an intent"), { code: "STORAGE_SET_IF_ABSENT_UNSUPPORTED" });
5021
+ if (typeof key !== "string" || !key.trim() || JSON.stringify(value) === undefined)
5022
+ throw Object.assign(new Error("setIfAbsent needs a key and JSON value"), { code: "INVALID_ARGUMENT" });
5023
+ return storageHost("setIfAbsent").setIfAbsent(key, value);
5024
+ },
5025
+ async compareAndSet(key, expectedValue, nextValue) {
5026
+ if (!storage2.supportsCompareAndSet())
5027
+ throw Object.assign(new Error("This app build cannot atomically checkpoint existing storage"), { code: "STORAGE_COMPARE_AND_SET_UNSUPPORTED" });
5028
+ if (typeof key !== "string" || !key.trim() || JSON.stringify(expectedValue) === undefined || JSON.stringify(nextValue) === undefined)
5029
+ throw Object.assign(new Error("compareAndSet needs a key and JSON values"), { code: "INVALID_ARGUMENT" });
5030
+ return storageHost("compareAndSet").compareAndSet(key, expectedValue, nextValue);
5031
+ }
5032
+ };
5033
+ function recordingOwnershipSupported(namespace) {
5034
+ const root = getCraftRoot();
5035
+ const flag = namespace === "location" ? "locationRecordingOwnership" : "heartRateRecordingOwnership";
5036
+ const caps = root?.capabilities;
5037
+ const advertised = Array.isArray(caps) ? caps.includes(flag) : caps?.[flag] === true;
5038
+ return advertised && ["pauseRecording", "resumeRecording", "stopRecording", "readRecording", "getRecordingState", "acknowledgeRecording"].every((method) => typeof root?.[namespace]?.[method] === "function");
5039
+ }
5040
+ function recordingIntentSupported(namespace) {
5041
+ const root = getCraftRoot();
5042
+ const flag = namespace === "location" ? "locationRecordingIntentIdentity" : "heartRateRecordingIntentIdentity";
5043
+ const caps = root?.capabilities;
5044
+ const advertised = Array.isArray(caps) ? caps.includes(flag) : caps?.[flag] === true;
5045
+ return advertised && recordingOwnershipSupported(namespace) && typeof root?.[namespace]?.startRecording === "function";
5046
+ }
5047
+ function validateRecordingStart(namespace, options) {
5048
+ if (!Object.prototype.hasOwnProperty.call(options, "recordingId"))
5049
+ return;
5050
+ if (typeof options.recordingId !== "string" || !options.recordingId.trim())
5051
+ throw Object.assign(new Error("recordingId must be a nonempty recording ID"), { code: "INVALID_ARGUMENT" });
5052
+ if (!recordingIntentSupported(namespace))
5053
+ throw Object.assign(new Error("This app build cannot start the persisted recording intent"), { code: "RECORDING_INTENT_IDENTITY_UNSUPPORTED" });
5054
+ }
5055
+ function verifyRecordingStart(options, result) {
5056
+ if (options.recordingId !== undefined && result.id !== options.recordingId)
5057
+ throw Object.assign(new Error("The native recording does not match the persisted intent"), { code: "RECORDING_CONFLICT" });
5058
+ return result;
5059
+ }
5060
+ function validateRecordingOwnership(namespace, options) {
5061
+ if (!options || !Object.prototype.hasOwnProperty.call(options, "expectedId"))
5062
+ return;
5063
+ if (typeof options.expectedId !== "string" || !options.expectedId.trim())
5064
+ throw Object.assign(new Error("expectedId must be a nonempty recording ID"), { code: "INVALID_ARGUMENT" });
5065
+ if ((getCraftRoot() || namespace === "heartRate") && !recordingOwnershipSupported(namespace))
5066
+ throw Object.assign(new Error("This app build cannot enforce recording ownership"), { code: "RECORDING_OWNERSHIP_UNSUPPORTED" });
5067
+ if (namespace === "location" && !getCraftRoot() && options.expectedId !== webLocationRecording.id)
5068
+ throw Object.assign(new Error("The active location recording has changed"), { code: "RECORDING_CONFLICT" });
5069
+ }
4984
5070
  var webLocationRecording = {
4985
5071
  id: null,
4986
5072
  active: false,
@@ -4990,6 +5076,8 @@ var webLocationRecording = {
4990
5076
  };
4991
5077
  var webLocationRecordingWatchId = null;
4992
5078
  var location = {
5079
+ supportsRecordingOwnership: () => recordingOwnershipSupported("location"),
5080
+ supportsRecordingIntentIdentity: () => recordingIntentSupported("location"),
4993
5081
  async getCurrentPosition(options = {}) {
4994
5082
  const craft = getCraftMobile();
4995
5083
  if (typeof window !== "undefined" && craft?.location) {
@@ -5038,9 +5126,12 @@ var location = {
5038
5126
  navigator.geolocation.clearWatch(watchId);
5039
5127
  },
5040
5128
  async startRecording(options = {}) {
5129
+ validateRecordingStart("location", options);
5041
5130
  const craft = getCraftMobile();
5042
5131
  if (typeof window !== "undefined" && craft?.location?.startRecording)
5043
- return craft.location.startRecording(options);
5132
+ return verifyRecordingStart(options, await craft.location.startRecording(options));
5133
+ if (webLocationRecording.active)
5134
+ throw Object.assign(new Error("A location recording is already active"), { code: "RECORDING_ALREADY_ACTIVE" });
5044
5135
  if (webLocationRecordingWatchId !== null)
5045
5136
  navigator.geolocation.clearWatch(webLocationRecordingWatchId);
5046
5137
  webLocationRecording = {
@@ -5056,24 +5147,27 @@ var location = {
5056
5147
  }, options);
5057
5148
  return { ...webLocationRecording, sampleCount: 0 };
5058
5149
  },
5059
- async pauseRecording() {
5150
+ async pauseRecording(options) {
5151
+ validateRecordingOwnership("location", options);
5060
5152
  const craft = getCraftMobile();
5061
5153
  if (typeof window !== "undefined" && craft?.location?.pauseRecording)
5062
- return craft.location.pauseRecording();
5154
+ return craft.location.pauseRecording(options);
5063
5155
  webLocationRecording.paused = true;
5064
5156
  return { ...webLocationRecording, sampleCount: webLocationRecording.locations.length };
5065
5157
  },
5066
- async resumeRecording() {
5158
+ async resumeRecording(options) {
5159
+ validateRecordingOwnership("location", options);
5067
5160
  const craft = getCraftMobile();
5068
5161
  if (typeof window !== "undefined" && craft?.location?.resumeRecording)
5069
- return craft.location.resumeRecording();
5162
+ return craft.location.resumeRecording(options);
5070
5163
  webLocationRecording.paused = false;
5071
5164
  return { ...webLocationRecording, sampleCount: webLocationRecording.locations.length };
5072
5165
  },
5073
- async stopRecording() {
5166
+ async stopRecording(options) {
5167
+ validateRecordingOwnership("location", options);
5074
5168
  const craft = getCraftMobile();
5075
5169
  if (typeof window !== "undefined" && craft?.location?.stopRecording)
5076
- return craft.location.stopRecording();
5170
+ return craft.location.stopRecording(options);
5077
5171
  if (webLocationRecordingWatchId !== null) {
5078
5172
  location.clearWatch(webLocationRecordingWatchId);
5079
5173
  webLocationRecordingWatchId = null;
@@ -5082,16 +5176,30 @@ var location = {
5082
5176
  webLocationRecording.paused = false;
5083
5177
  return { ...webLocationRecording, locations: [...webLocationRecording.locations] };
5084
5178
  },
5085
- async getRecordingState() {
5179
+ async getRecordingState(options) {
5180
+ validateRecordingOwnership("location", options);
5086
5181
  const craft = getCraftMobile();
5087
5182
  if (typeof window !== "undefined" && craft?.location?.getRecordingState)
5088
- return craft.location.getRecordingState();
5183
+ return craft.location.getRecordingState(options);
5089
5184
  return { ...webLocationRecording, sampleCount: webLocationRecording.locations.length };
5090
5185
  },
5091
- async readRecording() {
5186
+ async acknowledgeRecording(options) {
5187
+ if (!options || !Object.prototype.hasOwnProperty.call(options, "expectedId"))
5188
+ throw Object.assign(new Error("Acknowledgement needs expectedId"), { code: "INVALID_ARGUMENT" });
5189
+ validateRecordingOwnership("location", options);
5190
+ const craft = getCraftMobile();
5191
+ if (craft?.location?.acknowledgeRecording)
5192
+ return craft.location.acknowledgeRecording(options);
5193
+ if (webLocationRecording.active)
5194
+ throw Object.assign(new Error("The recording is still active"), { code: "RECORDING_ALREADY_ACTIVE" });
5195
+ webLocationRecording = { id: null, active: false, paused: false, startedAt: null, locations: [] };
5196
+ return true;
5197
+ },
5198
+ async readRecording(options) {
5199
+ validateRecordingOwnership("location", options);
5092
5200
  const craft = getCraftMobile();
5093
5201
  if (typeof window !== "undefined" && craft?.location?.readRecording)
5094
- return craft.location.readRecording();
5202
+ return craft.location.readRecording(options);
5095
5203
  return [...webLocationRecording.locations];
5096
5204
  }
5097
5205
  };
@@ -5213,6 +5321,11 @@ function onCraftEvent(eventName, callback) {
5213
5321
  globalThis.addEventListener(eventName, listener);
5214
5322
  return () => globalThis.removeEventListener(eventName, listener);
5215
5323
  }
5324
+ function getCraftRoot() {
5325
+ if (typeof window === "undefined")
5326
+ return;
5327
+ return window.craft;
5328
+ }
5216
5329
  function detectPlatform2() {
5217
5330
  if (typeof window === "undefined")
5218
5331
  return "linux";
@@ -11296,6 +11409,7 @@ export {
11296
11409
  styles,
11297
11410
  style,
11298
11411
  storeKit,
11412
+ storage2 as storage,
11299
11413
  stat,
11300
11414
  startupTask,
11301
11415
  stageManager,
@@ -0,0 +1,11 @@
1
+ export interface CraftAppShortcut {
2
+ id: string;
3
+ title: string;
4
+ subtitle?: string;
5
+ symbol: string;
6
+ url: string;
7
+ /** Each phrase includes exactly one `{app}` token for Siri's app name. */
8
+ phrases: string[];
9
+ }
10
+ /** Build-owned App Intents survive shell restoration and need no app Swift. */
11
+ export declare function appShortcutsSwift(shortcuts?: CraftAppShortcut[], schemes?: string[]): string;
@@ -3,12 +3,14 @@
3
3
  *
4
4
  * Generates native iOS apps from web content using WKWebView.
5
5
  */
6
+ import { type CraftAppShortcut } from './app-shortcuts.js';
6
7
  /** Package custom fonts and their licenses; removed fonts cannot linger in the app. */
7
8
  export declare function syncNativeFonts(fonts: CraftConfig['nativeFonts'], output: string): string[];
8
9
  /** This package's version, which the app names in its user agent (`Craft/<version>`). */
9
10
  export declare function resolveCraftVersion(startDir?: string): string;
10
11
  export type CraftAppearance = 'light' | 'dark' | 'system';
11
12
  export interface CraftConfig {
13
+ appShortcuts?: CraftAppShortcut[];
12
14
  /** Bundled font files keyed by their PostScript font name, used as native fontFamily. */
13
15
  nativeFonts?: Record<string, {
14
16
  src: string;
@@ -272,6 +274,11 @@ export interface CraftTab {
272
274
  title: string;
273
275
  /** An SF Symbol name. */
274
276
  symbol?: string;
277
+ /** Native selected-tab icon overrides; retained pushed screens keep their owning tab's policy. */
278
+ selectedSymbol?: string;
279
+ selectedIconTint?: string;
280
+ /** False suppresses the legacy pulse while selected; omission uses the legacy policy. */
281
+ selectedIconPulse?: boolean;
275
282
  }
276
283
  /** `/a//b/?q#h` → `/a/b`, the way the shell and the page compare paths. Null for a relative path. */
277
284
  export declare function normalizeNativePath(raw: string): string | null;
@@ -4,6 +4,60 @@ import { cpSync, existsSync, mkdirSync as mkdirSync2, readFileSync as readFileSy
4
4
  import { dirname as dirname2, extname, join as join3, resolve, sep as sep2 } from "path";
5
5
  var {$ } = globalThis.Bun;
6
6
 
7
+ // ../ios/src/app-shortcuts.ts
8
+ function literal(value) {
9
+ return `"${value.replace(/\\/g, "\\\\").replace(/"/g, "\\\"").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t")}"`;
10
+ }
11
+ function appShortcutsSwift(shortcuts = [], schemes = []) {
12
+ if (shortcuts.length > 10)
13
+ throw new Error("iOS supports at most ten app shortcuts");
14
+ const ids = new Set;
15
+ for (const shortcut of shortcuts) {
16
+ if (!/^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/.test(shortcut.id) || ids.has(shortcut.id))
17
+ throw new Error(`Invalid or duplicate app shortcut id: ${shortcut.id}`);
18
+ ids.add(shortcut.id);
19
+ const scheme = /^([a-z][a-z0-9+.-]*):\/\//i.exec(shortcut.url)?.[1];
20
+ if (!scheme || !schemes.includes(scheme) || /[\s\\]/.test(shortcut.url))
21
+ throw new Error(`App shortcut ${shortcut.id} needs a registered app URL scheme`);
22
+ if (!shortcut.title?.trim() || !shortcut.symbol?.trim() || !shortcut.phrases?.length || shortcut.phrases.some((phrase) => phrase.split("{app}").length !== 2))
23
+ throw new Error(`App shortcut ${shortcut.id} needs a title, symbol and phrases with exactly one {app}`);
24
+ }
25
+ const name = (id) => `CraftShortcut${id.split("-").map((part) => part[0].toUpperCase() + part.slice(1)).join("")}Intent`;
26
+ const intents = shortcuts.map((shortcut) => `@available(iOS 16.4, *)
27
+ struct ${name(shortcut.id)}: AppIntent {
28
+ static var title: LocalizedStringResource = ${literal(shortcut.title)}
29
+ static var description = IntentDescription(${literal(shortcut.subtitle ?? shortcut.title)})
30
+ static var openAppWhenRun: Bool = true
31
+ @MainActor
32
+ func perform() async throws -> some IntentResult {
33
+ if let url = URL(string: ${literal(shortcut.url)}) { await UIApplication.shared.open(url) }
34
+ return .result()
35
+ }
36
+ }`).join(`
37
+
38
+ `);
39
+ const entries = shortcuts.map((shortcut) => ` AppShortcut(
40
+ intent: ${name(shortcut.id)}(),
41
+ phrases: [${shortcut.phrases.map((phrase) => literal(phrase).replace("{app}", "\\(.applicationName)")).join(", ")}],
42
+ shortTitle: ${literal(shortcut.title)},
43
+ systemImageName: ${literal(shortcut.symbol)}
44
+ )`).join(`
45
+ `);
46
+ return `// Generated by Craft from appShortcuts. Restored from the package at build time.
47
+ import AppIntents
48
+ import UIKit
49
+
50
+ ${intents}
51
+ ${shortcuts.length ? `
52
+ @available(iOS 16.4, *)
53
+ struct CraftConfiguredAppShortcuts: AppShortcutsProvider {
54
+ static var appShortcuts: [AppShortcut] {
55
+ ${entries}
56
+ }
57
+ }
58
+ ` : ""}`;
59
+ }
60
+
7
61
  // ../ios/src/source-audit.ts
8
62
  import { readdirSync } from "fs";
9
63
  import { join, relative, sep } from "path";
@@ -79,13 +133,19 @@ function initSimulatorDriver(options) {
79
133
  throw new Error("The simulator driver requires iOS 17.0 or newer");
80
134
  if (!["http:", "https:"].includes(new URL(control).protocol))
81
135
  throw new Error("controlUrl must use HTTP or HTTPS");
136
+ const tabIdentifiers = Object.entries(options.tabIdentifiers ?? {});
137
+ for (const [name2, identifier] of tabIdentifiers) {
138
+ if (!name2.trim() || !identifier.trim())
139
+ throw new Error("Tab names and native identifiers must be nonempty");
140
+ }
82
141
  const replacements = {
83
142
  PROJECT_NAME: name,
84
143
  BUNDLE_PREFIX: prefix,
85
144
  IOS_VERSION: version,
86
145
  BUNDLE_ID: swiftString(bundleId),
87
146
  CONTROL_URL: swiftString(control),
88
- TABS: `[${(options.tabs ?? []).map(swiftString).join(", ")}]`
147
+ TABS: `[${(options.tabs ?? []).map(swiftString).join(", ")}]`,
148
+ TAB_IDENTIFIERS: tabIdentifiers.length ? `[${tabIdentifiers.map(([name2, identifier]) => `${swiftString(name2)}: ${swiftString(identifier)}`).join(", ")}]` : "[:]"
89
149
  };
90
150
  for (const [source, destination] of [
91
151
  ["HostApp.swift", "Host/HostApp.swift"],
@@ -1073,11 +1133,13 @@ function writeShell(output, config) {
1073
1133
  if (!config.appName || !/^[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)+$/.test(config.bundleId))
1074
1134
  throw new Error("The iOS shell needs an appName and a valid bundleId in craft.config.json.");
1075
1135
  const name = productName(config.appName);
1136
+ const shortcutSource = appShortcutsSwift(config.appShortcuts, config.urlSchemes);
1076
1137
  mkdirSync2(join3(output, "Sources"), { recursive: true });
1077
1138
  mkdirSync2(join3(output, "Shared"), { recursive: true });
1078
1139
  const swiftTemplate = readFileSync2(join3(TEMPLATES_DIR, "CraftApp.swift"), "utf-8");
1079
1140
  const swiftSource = swiftTemplate.replace(/CraftApp/g, `${name}App`).replace(/\{\{BUNDLE_ID\}\}/g, config.bundleId).replace(/\{\{CRAFT_VERSION\}\}/g, CRAFT_VERSION);
1080
1141
  writeFileSync2(join3(output, "Sources", `${name}App.swift`), swiftSource);
1142
+ writeFileSync2(join3(output, "Sources", "CraftAppShortcuts.swift"), shortcutSource);
1081
1143
  cpSync(join3(TEMPLATES_DIR, "CraftNativeScreen.swift"), join3(output, "Sources", "CraftNativeScreen.swift"));
1082
1144
  cpSync(join3(TEMPLATES_DIR, "CraftNativeFlatList.swift"), join3(output, "Sources", "CraftNativeFlatList.swift"));
1083
1145
  cpSync(join3(TEMPLATES_DIR, "CraftNativeActions.swift"), join3(output, "Sources", "CraftNativeActions.swift"));
@@ -1085,6 +1147,8 @@ function writeShell(output, config) {
1085
1147
  cpSync(join3(TEMPLATES_DIR, "CraftMusic.swift"), join3(output, "Sources", "CraftMusic.swift"));
1086
1148
  cpSync(join3(TEMPLATES_DIR, "CraftBluetooth.swift"), join3(output, "Sources", "CraftBluetooth.swift"));
1087
1149
  cpSync(join3(TEMPLATES_DIR, "CraftHeartRate.swift"), join3(output, "Sources", "CraftHeartRate.swift"));
1150
+ cpSync(join3(TEMPLATES_DIR, "CraftRecordingArchive.swift"), join3(output, "Sources", "CraftRecordingArchive.swift"));
1151
+ cpSync(join3(TEMPLATES_DIR, "CraftPersistentStorage.swift"), join3(output, "Sources", "CraftPersistentStorage.swift"));
1088
1152
  cpSync(join3(TEMPLATES_DIR, "CraftScheduledWorkouts.swift"), join3(output, "Sources", "CraftScheduledWorkouts.swift"));
1089
1153
  cpSync(join3(TEMPLATES_DIR, "CraftNativeMutation.swift"), join3(output, "Sources", "CraftNativeMutation.swift"));
1090
1154
  cpSync(join3(TEMPLATES_DIR, "CraftNativeUI.swift"), join3(output, "Sources", "CraftNativeUI.swift"));
@@ -7,6 +7,8 @@ export interface SimulatorDriverOptions {
7
7
  deploymentTarget?: string;
8
8
  controlUrl?: string;
9
9
  tabs?: readonly string[];
10
+ /** Stable native accessibility identifiers for tabs whose labels can change. */
11
+ tabIdentifiers?: Readonly<Record<string, string>>;
10
12
  /** Bundle a wire-protocol journey and save native XCTest attachments. No control server is needed on physical devices. */
11
13
  script?: string;
12
14
  }