@craft-native/ios 0.0.228 → 0.0.232

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/README.md CHANGED
@@ -216,6 +216,29 @@ does not change placement semantics.
216
216
  Reverse flex directions also retain wrapped line order on the cross axis while
217
217
  reversing each line's main-axis children, matching Android's native host.
218
218
 
219
+ For rapid or interrupted input, `touches` submits multiple touch paths in one
220
+ native XCTest event record. Each point has `at` in seconds and either normalized
221
+ `x`/`y` coordinates or a `tab` name resolved to its native button center before
222
+ submission. For example, two taps 80 ms apart:
223
+
224
+ ```ts
225
+ serializeSimulatorSteps([{ command: 'touches', paths: [
226
+ [{ tab: 'Today', at: 0 }, { tab: 'Today', at: 0.025 }],
227
+ [{ tab: 'Progress', at: 0.08 }, { tab: 'Progress', at: 0.105 }],
228
+ ] }])
229
+ ```
230
+
231
+ Unlike separate coordinate taps, the event record preserves timing between
232
+ paths without waiting for app quiescence. Save the scheduled paths and verify
233
+ the resulting selection and native content. Timing schedules are not FPS
234
+ measurements. The Objective-C bridge belongs only to the test target and uses
235
+ runtime-checked private XCTest event APIs; unsupported toolchains fail the
236
+ journey rather than silently switching to synchronized input. Nothing is linked
237
+ into the installed app. CI also delivers a 24-tap schedule to an independent
238
+ native receiver and verifies every callback and the final selection; source
239
+ serialization and successful compilation alone do not establish delivery. Native input semantics follow the [event-record API
240
+ used by XCTest automation](https://appium.github.io/appium-xcuitest-driver/latest/guides/input-events/).
241
+
219
242
  For multiple native screens, configure named `.stx` routes and an initial screen in `native.config.json` (see [`fixtures/native-navigation`](./fixtures/native-navigation)). Compile without a positional file: `stx native compile --format bundle --output ./screen.js`. A screen can call `craft.navigation.push('details', { id: 7 })`, `.replace('home')`, or `.back()`; the destination reads `craft.route.params`. Craft uses a native navigation stack, so the back button and edge-swipe reveal the existing controller and retain its JavaScript and input state. `bun packages/ios/scripts/test-native-navigation.ts` compiles the fixture and runs its simulator test; the default WebView renderer is unchanged.
220
243
 
221
244
  Hybrid web-to-web pushes use the same shared WebView across stack entries. The maintained [`fixtures/hybrid-navigation`](./fixtures/hybrid-navigation) fixture samples the outgoing page during the UIKit transition and is run by CI with:
@@ -1165,3 +1188,18 @@ This will:
1165
1188
  ## License
1166
1189
 
1167
1190
  MIT
1191
+
1192
+ ### Custom native fonts
1193
+
1194
+ Set `nativeFonts` to bundle TTF/OTF files and their licenses. Keys are the font's
1195
+ PostScript name (for example `Allura-Regular`); use that same name in a native
1196
+ Text node's `fontFamily` style. Paths are local files, resolved before packaging.
1197
+
1198
+ ```ts
1199
+ nativeFonts: {
1200
+ 'Allura-Regular': { src: '/app/fonts/Allura-Regular.ttf', license: '/app/fonts/OFL.txt' },
1201
+ }
1202
+ ```
1203
+
1204
+ The iOS project registers the files through `UIAppFonts`. Android loads bundled
1205
+ typefaces from assets. Both work offline; a missing file fails the build.
package/dist/cli.js CHANGED
@@ -7,7 +7,7 @@ import { resolve as resolve2 } from "path";
7
7
 
8
8
  // src/index.ts
9
9
  import { cpSync, existsSync, mkdirSync, readFileSync, readdirSync as readdirSync2, rmSync, statSync, writeFileSync } from "fs";
10
- import { dirname as dirname2, join as join3, resolve } from "path";
10
+ import { dirname as dirname2, extname, join as join3, resolve, sep as sep2 } from "path";
11
11
  var {$ } = globalThis.Bun;
12
12
 
13
13
  // src/source-audit.ts
@@ -63,6 +63,35 @@ import { dirname, join as join2 } from "path";
63
63
  var templates = join2(dirname(import.meta.dir), "templates/testing");
64
64
 
65
65
  // src/index.ts
66
+ function syncNativeFonts(fonts, output) {
67
+ const destination = join3(output, "NativeFonts");
68
+ const names = new Set;
69
+ const files = Object.entries(fonts ?? {}).map(([family, entry]) => {
70
+ if (!/^[A-Za-z0-9][A-Za-z0-9_.-]*$/.test(family))
71
+ throw new Error(`Invalid native font PostScript name: ${family}`);
72
+ if (names.has(family.toLowerCase()))
73
+ throw new Error(`Duplicate native font name: ${family}`);
74
+ names.add(family.toLowerCase());
75
+ if ([entry.src, entry.license].some((file) => file && resolve(file).startsWith(resolve(destination) + sep2)))
76
+ throw new Error("Native font sources must be outside the generated NativeFonts directory");
77
+ const extension = extname(entry.src).toLowerCase();
78
+ if (![".ttf", ".otf"].includes(extension) || !existsSync(entry.src) || !statSync(entry.src).isFile())
79
+ throw new Error(`Native font ${family} must reference an existing TTF or OTF file: ${entry.src}`);
80
+ if (entry.license && (!existsSync(entry.license) || !statSync(entry.license).isFile()))
81
+ throw new Error(`Native font license not found: ${entry.license}`);
82
+ return { family, ...entry, filename: `${family}${extension}` };
83
+ });
84
+ rmSync(destination, { recursive: true, force: true });
85
+ if (!files.length)
86
+ return [];
87
+ mkdirSync(destination, { recursive: true });
88
+ for (const file of files) {
89
+ cpSync(file.src, join3(destination, file.filename));
90
+ if (file.license)
91
+ cpSync(file.license, join3(destination, `${file.family}.LICENSE.txt`));
92
+ }
93
+ return files.map((file) => `NativeFonts/${file.filename}`);
94
+ }
66
95
  var TEMPLATES_DIR = join3(dirname2(import.meta.dir), "templates");
67
96
  function resolveCraftVersion(startDir = import.meta.dir) {
68
97
  let directory = resolve(startDir);
@@ -932,8 +961,12 @@ async function init(options) {
932
961
  config.enableSecureStorage = true;
933
962
  writeFileSync(join3(output, "craft.config.json"), JSON.stringify(config, null, 2));
934
963
  writeShell(output, config);
964
+ const nativeFontFiles = syncNativeFonts(config.nativeFonts, output);
935
965
  const infoPlistTemplate = readFileSync(join3(TEMPLATES_DIR, "Info.plist.template"), "utf-8");
936
- 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(/\{\{NON_EXEMPT_ENCRYPTION\}\}/g, config.usesNonExemptEncryption ? "true" : "false").replace(/\{\{LIVE_ACTIVITY_SUPPORT\}\}/g, config.enableLiveActivities ? ` <key>NSSupportsLiveActivities</key>
966
+ 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>
967
+ <array>
968
+ ${plistArray(nativeFontFiles, 2)}
969
+ </array>` : "").replace(/\{\{NON_EXEMPT_ENCRYPTION\}\}/g, config.usesNonExemptEncryption ? "true" : "false").replace(/\{\{LIVE_ACTIVITY_SUPPORT\}\}/g, config.enableLiveActivities ? ` <key>NSSupportsLiveActivities</key>
937
970
  <true/>
938
971
  <key>NSSupportsLiveActivitiesFrequentUpdates</key>
939
972
  <true/>` : "");
@@ -1000,7 +1033,9 @@ async function init(options) {
1000
1033
  } else {
1001
1034
  rmSync(join3(output, "Runtime"), { recursive: true, force: true });
1002
1035
  }
1003
- const projectYml = projectYmlTemplate.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:
1036
+ const projectYml = projectYmlTemplate.replace(/\{\{NATIVE_FONT_RESOURCES\}\}/g, nativeFontFiles.length ? ` - path: NativeFonts
1037
+ type: folder
1038
+ 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:
1004
1039
  ${nativeDependencies.join(`
1005
1040
  `)}` : "").replace(/\{\{NATIVE_TARGETS\}\}/g, nativeTargets.join(`
1006
1041
  `));
package/dist/index.d.ts CHANGED
@@ -3,10 +3,17 @@
3
3
  *
4
4
  * Generates native iOS apps from web content using WKWebView.
5
5
  */
6
+ /** Package custom fonts and their licenses; removed fonts cannot linger in the app. */
7
+ export declare function syncNativeFonts(fonts: CraftConfig['nativeFonts'], output: string): string[];
6
8
  /** This package's version, which the app names in its user agent (`Craft/<version>`). */
7
9
  export declare function resolveCraftVersion(startDir?: string): string;
8
10
  export type CraftAppearance = 'light' | 'dark' | 'system';
9
11
  export interface CraftConfig {
12
+ /** Bundled font files keyed by their PostScript font name, used as native fontFamily. */
13
+ nativeFonts?: Record<string, {
14
+ src: string;
15
+ license?: string;
16
+ }>;
10
17
  appName: string;
11
18
  bundleId: string;
12
19
  /** Opt in to UIKit + JavaScriptCore instead of the default WebView. */
@@ -563,4 +570,4 @@ export declare function bootSimulator(device: SimulatorDevice): Promise<void>;
563
570
  export declare function showSimulator(): Promise<void>;
564
571
  export declare function run(options: RunOptions): Promise<void>;
565
572
  export { createNativeMutationValidator, initSimulatorDriver, serializeSimulatorSteps } from './testing.js';
566
- export type { NativeMutationValidator, NativeValidationResult, SimulatorDriverOptions, SimulatorStep } from './testing.js';
573
+ export type { NativeMutationValidator, NativeValidationResult, SimulatorDriverOptions, SimulatorStep, TimedTouchPoint } from './testing.js';
package/dist/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  // @bun
2
2
  // src/index.ts
3
3
  import { cpSync, existsSync, mkdirSync as mkdirSync2, readFileSync as readFileSync2, readdirSync as readdirSync2, rmSync as rmSync2, statSync, writeFileSync as writeFileSync2 } from "fs";
4
- import { dirname as dirname2, join as join3, resolve } from "path";
4
+ import { dirname as dirname2, extname, join as join3, resolve, sep as sep2 } from "path";
5
5
  var {$ } = globalThis.Bun;
6
6
 
7
7
  // src/source-audit.ts
@@ -90,6 +90,8 @@ function initSimulatorDriver(options) {
90
90
  for (const [source, destination] of [
91
91
  ["HostApp.swift", "Host/HostApp.swift"],
92
92
  ["DriverTests.swift.template", "UITests/DriverTests.swift"],
93
+ ["CraftTimedInput.h", "UITests/CraftTimedInput.h"],
94
+ ["CraftTimedInput.m", "UITests/CraftTimedInput.m"],
93
95
  ["project.yml.template", "project.yml"]
94
96
  ]) {
95
97
  const path = join2(output, destination);
@@ -127,7 +129,23 @@ function serializeSimulatorSteps(steps) {
127
129
  };
128
130
  const line = (step) => {
129
131
  let arg = "";
130
- if ("step" in step)
132
+ if ("paths" in step) {
133
+ if (!step.paths.length || step.paths.length > 64)
134
+ throw new Error("Expected 1\u201364 touch paths");
135
+ for (const path of step.paths) {
136
+ if (path.length < 2 || path.length > 64)
137
+ throw new Error("Expected 2\u201364 points per touch");
138
+ let previous = -1;
139
+ for (const point of path) {
140
+ if ("tab" in point ? !point.tab.trim() || /[\r\n]/.test(point.tab) : number(point.x) > 1 || number(point.y) > 1)
141
+ throw new Error("Touch targets must be tab names or normalized coordinates");
142
+ if (number(point.at) > 12 || point.at <= previous)
143
+ throw new Error("Touch coordinates must be normalized and times strictly increase within 12 seconds");
144
+ previous = point.at;
145
+ }
146
+ }
147
+ arg = JSON.stringify(step.paths);
148
+ } else if ("step" in step)
131
149
  arg = `${token(step.id)} ${line(step.step)}`;
132
150
  else if ("seconds" in step && !("text" in step))
133
151
  arg = String(number(step.seconds));
@@ -215,6 +233,35 @@ function createNativeMutationValidator() {
215
233
  }
216
234
 
217
235
  // src/index.ts
236
+ function syncNativeFonts(fonts, output) {
237
+ const destination = join3(output, "NativeFonts");
238
+ const names = new Set;
239
+ const files = Object.entries(fonts ?? {}).map(([family, entry]) => {
240
+ if (!/^[A-Za-z0-9][A-Za-z0-9_.-]*$/.test(family))
241
+ throw new Error(`Invalid native font PostScript name: ${family}`);
242
+ if (names.has(family.toLowerCase()))
243
+ throw new Error(`Duplicate native font name: ${family}`);
244
+ names.add(family.toLowerCase());
245
+ if ([entry.src, entry.license].some((file) => file && resolve(file).startsWith(resolve(destination) + sep2)))
246
+ throw new Error("Native font sources must be outside the generated NativeFonts directory");
247
+ const extension = extname(entry.src).toLowerCase();
248
+ if (![".ttf", ".otf"].includes(extension) || !existsSync(entry.src) || !statSync(entry.src).isFile())
249
+ throw new Error(`Native font ${family} must reference an existing TTF or OTF file: ${entry.src}`);
250
+ if (entry.license && (!existsSync(entry.license) || !statSync(entry.license).isFile()))
251
+ throw new Error(`Native font license not found: ${entry.license}`);
252
+ return { family, ...entry, filename: `${family}${extension}` };
253
+ });
254
+ rmSync2(destination, { recursive: true, force: true });
255
+ if (!files.length)
256
+ return [];
257
+ mkdirSync2(destination, { recursive: true });
258
+ for (const file of files) {
259
+ cpSync(file.src, join3(destination, file.filename));
260
+ if (file.license)
261
+ cpSync(file.license, join3(destination, `${file.family}.LICENSE.txt`));
262
+ }
263
+ return files.map((file) => `NativeFonts/${file.filename}`);
264
+ }
218
265
  var TEMPLATES_DIR = join3(dirname2(import.meta.dir), "templates");
219
266
  function resolveCraftVersion(startDir = import.meta.dir) {
220
267
  let directory = resolve(startDir);
@@ -1088,8 +1135,12 @@ async function init(options) {
1088
1135
  config.enableSecureStorage = true;
1089
1136
  writeFileSync2(join3(output, "craft.config.json"), JSON.stringify(config, null, 2));
1090
1137
  writeShell(output, config);
1138
+ const nativeFontFiles = syncNativeFonts(config.nativeFonts, output);
1091
1139
  const infoPlistTemplate = readFileSync2(join3(TEMPLATES_DIR, "Info.plist.template"), "utf-8");
1092
- 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(/\{\{NON_EXEMPT_ENCRYPTION\}\}/g, config.usesNonExemptEncryption ? "true" : "false").replace(/\{\{LIVE_ACTIVITY_SUPPORT\}\}/g, config.enableLiveActivities ? ` <key>NSSupportsLiveActivities</key>
1140
+ 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>
1141
+ <array>
1142
+ ${plistArray(nativeFontFiles, 2)}
1143
+ </array>` : "").replace(/\{\{NON_EXEMPT_ENCRYPTION\}\}/g, config.usesNonExemptEncryption ? "true" : "false").replace(/\{\{LIVE_ACTIVITY_SUPPORT\}\}/g, config.enableLiveActivities ? ` <key>NSSupportsLiveActivities</key>
1093
1144
  <true/>
1094
1145
  <key>NSSupportsLiveActivitiesFrequentUpdates</key>
1095
1146
  <true/>` : "");
@@ -1156,7 +1207,9 @@ async function init(options) {
1156
1207
  } else {
1157
1208
  rmSync2(join3(output, "Runtime"), { recursive: true, force: true });
1158
1209
  }
1159
- const projectYml = projectYmlTemplate.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:
1210
+ const projectYml = projectYmlTemplate.replace(/\{\{NATIVE_FONT_RESOURCES\}\}/g, nativeFontFiles.length ? ` - path: NativeFonts
1211
+ type: folder
1212
+ 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:
1160
1213
  ${nativeDependencies.join(`
1161
1214
  `)}` : "").replace(/\{\{NATIVE_TARGETS\}\}/g, nativeTargets.join(`
1162
1215
  `));
@@ -1387,6 +1440,7 @@ async function run(options) {
1387
1440
  export {
1388
1441
  usesNativeBundle,
1389
1442
  syncWebAssets,
1443
+ syncNativeFonts,
1390
1444
  showSimulator,
1391
1445
  serializeSimulatorSteps,
1392
1446
  run,
package/dist/testing.d.ts CHANGED
@@ -12,6 +12,14 @@ export interface SimulatorDriverOptions {
12
12
  }
13
13
  /** Generate the reusable XCUITest shell from package templates. No app-owned Swift. */
14
14
  export declare function initSimulatorDriver(options: SimulatorDriverOptions): void;
15
+ export type TimedTouchPoint = {
16
+ x: number;
17
+ y: number;
18
+ at: number;
19
+ } | {
20
+ tab: string;
21
+ at: number;
22
+ };
15
23
  export type SimulatorStep = {
16
24
  command: 'launch' | 'activate' | 'terminate' | 'home' | 'back' | 'return' | 'unlabelled' | 'notifications';
17
25
  } | {
@@ -46,6 +54,11 @@ export type SimulatorStep = {
46
54
  command: 'tapxy';
47
55
  x: number;
48
56
  y: number;
57
+ }
58
+ /** One native event record, without quiescence between paths. Times are seconds. */
59
+ | {
60
+ command: 'touches';
61
+ paths: TimedTouchPoint[][];
49
62
  } | {
50
63
  command: 'field';
51
64
  index: number;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@craft-native/ios",
3
- "version": "0.0.228",
3
+ "version": "0.0.232",
4
4
  "homepage": "https://github.com/craft-native/craft/tree/main/packages/ios#readme",
5
5
  "repository": {
6
6
  "type": "git",
@@ -6837,17 +6837,21 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
6837
6837
  ) {
6838
6838
  let id = ObjectIdentifier(textView)
6839
6839
  textView.delegate = self
6840
- textView.textColor = color(style["color"]) ?? .label
6841
- textView.font = textFont(style, default: .systemFont(ofSize: 16))
6842
- textView.textAlignment = textAlignment(style["textAlign"])
6843
- textView.tintColor = color(props["selectionColor"])
6844
- textView.keyboardType = keyboardType(props["keyboardType"])
6845
- textView.returnKeyType = returnKeyType(props["returnKeyType"])
6846
- textView.autocorrectionType = props["autoCorrect"] as? Bool == false ? .no : .default
6847
- textView.autocapitalizationType = capitalizationType(props["autoCapitalize"])
6848
- textView.isSecureTextEntry = props["secureTextEntry"] as? Bool == true
6840
+ // UIKit owns marked text until the keyboard commits it. Reapplying
6841
+ // typography or input traits during composition can commit that draft.
6842
+ if textView.markedTextRange == nil {
6843
+ textView.textColor = color(style["color"]) ?? .label
6844
+ textView.font = textFont(style, default: .systemFont(ofSize: 16))
6845
+ textView.textAlignment = textAlignment(style["textAlign"])
6846
+ textView.tintColor = color(props["selectionColor"])
6847
+ textView.keyboardType = keyboardType(props["keyboardType"])
6848
+ textView.returnKeyType = returnKeyType(props["returnKeyType"])
6849
+ textView.autocorrectionType = props["autoCorrect"] as? Bool == false ? .no : .default
6850
+ textView.autocapitalizationType = capitalizationType(props["autoCapitalize"])
6851
+ textView.isSecureTextEntry = props["secureTextEntry"] as? Bool == true
6852
+ applyAutofill(to: textView, props: props)
6853
+ }
6849
6854
  textView.isEditable = props["editable"] as? Bool != false
6850
- applyAutofill(to: textView, props: props)
6851
6855
  // The style's padding is where the text starts, as in a web textarea.
6852
6856
  let padding = paddingInsets(style)
6853
6857
  if let craftTextView = textView as? CraftNativeTextView {
@@ -6875,12 +6879,17 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
6875
6879
  craftTextView.placeholderFont = textView.font
6876
6880
  }
6877
6881
  updateTextView(textView, value: (props["value"] as? String) ?? defaultValue)
6878
- let selectedRange = textView.selectedRange
6879
- let attributes = inputTextAttributes(style, font: textView.font ?? .systemFont(ofSize: 16), alignment: textView.textAlignment)
6880
- textView.typingAttributes = attributes
6881
- if !textView.text.isEmpty {
6882
- textView.attributedText = NSAttributedString(string: textView.text, attributes: attributes)
6883
- textView.selectedRange = selectedRange
6882
+ if textView.markedTextRange == nil {
6883
+ let attributes = inputTextAttributes(style, font: textView.font ?? .systemFont(ofSize: 16), alignment: textView.textAlignment)
6884
+ let styled = NSAttributedString(string: textView.text ?? "", attributes: attributes)
6885
+ if !textView.text.isEmpty, textView.attributedText?.isEqual(to: styled) != true {
6886
+ let selectedRange = textView.selectedRange
6887
+ textView.attributedText = styled
6888
+ textView.selectedRange = selectedRange
6889
+ }
6890
+ if !(textView.typingAttributes as NSDictionary).isEqual(attributes as NSDictionary) {
6891
+ textView.typingAttributes = attributes
6892
+ }
6884
6893
  }
6885
6894
  (textView as? CraftNativeTextView)?.refreshPlaceholder()
6886
6895
  updateHandler(nonEmptyHandler(events["onChange"]) ?? nonEmptyHandler(events["onChangeText"]), for: textView)
@@ -26,6 +26,7 @@
26
26
  <true/>
27
27
  <key>ITSAppUsesNonExemptEncryption</key>
28
28
  <{{NON_EXEMPT_ENCRYPTION}}/>
29
+ {{NATIVE_FONTS}}
29
30
  <key>UILaunchScreen</key>
30
31
  <dict>
31
32
  {{LAUNCH_SCREEN}}
@@ -27,6 +27,7 @@ targets:
27
27
  - path: craft.config.json
28
28
  optional: true
29
29
  buildPhase: resources
30
+ {{NATIVE_FONT_RESOURCES}}
30
31
  - path: Assets.xcassets
31
32
  buildPhase: resources
32
33
  - path: PrivacyInfo.xcprivacy
@@ -0,0 +1,11 @@
1
+ #import <Foundation/Foundation.h>
2
+ #import <CoreGraphics/CoreGraphics.h>
3
+
4
+ NS_ASSUME_NONNULL_BEGIN
5
+ /// Test-runner-only native events. Never linked into a shipped application.
6
+ @interface CraftTimedInput : NSObject
7
+ + (BOOL)synthesizePaths:(NSArray<NSArray<NSDictionary<NSString *, NSNumber *> *> *> *)paths
8
+ frame:(CGRect)frame
9
+ error:(NSError * _Nullable * _Nullable)error NS_SWIFT_NAME(synthesize(paths:frame:));
10
+ @end
11
+ NS_ASSUME_NONNULL_END
@@ -0,0 +1,75 @@
1
+ #import "CraftTimedInput.h"
2
+ #import <UIKit/UIKit.h>
3
+ #import <math.h>
4
+
5
+ // XCTest's own touch-record API is private. Look up classes and check every
6
+ // selector at runtime; unsupported Xcode versions fail rather than skipping or
7
+ // falling back to serialized taps. These declarations create no class linkage.
8
+ @interface NSObject (CraftTestingTouchRecords)
9
+ - (id)initForTouchAtPoint:(CGPoint)point offset:(double)offset;
10
+ - (void)moveToPoint:(CGPoint)point atOffset:(double)offset;
11
+ - (void)liftUpAtOffset:(double)offset;
12
+ - (id)initWithName:(NSString *)name interfaceOrientation:(NSInteger)orientation;
13
+ - (void)addPointerEventPath:(id)path;
14
+ - (BOOL)synthesizeWithError:(NSError **)error;
15
+ @end
16
+
17
+ @implementation CraftTimedInput
18
+ + (BOOL)fail:(NSString *)message error:(NSError **)error {
19
+ if (error) *error = [NSError errorWithDomain:@"CraftTimedInput" code:1
20
+ userInfo:@{NSLocalizedDescriptionKey: message}];
21
+ return NO;
22
+ }
23
+
24
+ + (BOOL)synthesizePaths:(NSArray *)paths frame:(CGRect)frame error:(NSError **)error {
25
+ if (![paths isKindOfClass:NSArray.class] || paths.count == 0 || paths.count > 64
26
+ || CGRectIsEmpty(frame) || !isfinite(frame.size.width) || !isfinite(frame.size.height))
27
+ return [self fail:@"Expected 1–64 timed touch paths and a visible app frame" error:error];
28
+ // Validate the entire record before any native event is submitted.
29
+ for (id path in paths) {
30
+ if (![path isKindOfClass:NSArray.class] || [path count] < 2 || [path count] > 64)
31
+ return [self fail:@"Each touch needs 2–64 points" error:error];
32
+ double previous = -1;
33
+ for (id point in path) {
34
+ if (![point isKindOfClass:NSDictionary.class]) return [self fail:@"Invalid touch point" error:error];
35
+ for (NSString *key in @[@"x", @"y", @"at"]) {
36
+ id value = point[key];
37
+ if (![value isKindOfClass:NSNumber.class] || !isfinite([value doubleValue]))
38
+ return [self fail:@"Touch coordinates and times must be finite numbers" error:error];
39
+ }
40
+ double x = [point[@"x"] doubleValue], y = [point[@"y"] doubleValue], at = [point[@"at"] doubleValue];
41
+ if (x < 0 || x > 1 || y < 0 || y > 1 || at < 0 || at > 12 || at <= previous)
42
+ return [self fail:@"Touch coordinates must be normalized; times must increase within 12 seconds" error:error];
43
+ previous = at;
44
+ }
45
+ }
46
+ Class recordClass = NSClassFromString(@"XCSynthesizedEventRecord");
47
+ Class pathClass = NSClassFromString(@"XCPointerEventPath");
48
+ for (NSString *name in @[@"initWithName:interfaceOrientation:", @"addPointerEventPath:", @"synthesizeWithError:"])
49
+ if (![recordClass instancesRespondToSelector:NSSelectorFromString(name)])
50
+ return [self fail:@"XCTest does not support timed native event records" error:error];
51
+ for (NSString *name in @[@"initForTouchAtPoint:offset:", @"moveToPoint:atOffset:", @"liftUpAtOffset:"])
52
+ if (![pathClass instancesRespondToSelector:NSSelectorFromString(name)])
53
+ return [self fail:@"XCTest does not support timed native touch paths" error:error];
54
+ id record = [[recordClass alloc] initWithName:@"Craft timed native input" interfaceOrientation:UIInterfaceOrientationPortrait];
55
+ if (!record) return [self fail:@"XCTest refused a native event record" error:error];
56
+ for (NSArray *points in paths) {
57
+ NSDictionary *first = points.firstObject;
58
+ CGPoint start = CGPointMake(frame.origin.x + [first[@"x"] doubleValue] * frame.size.width,
59
+ frame.origin.y + [first[@"y"] doubleValue] * frame.size.height);
60
+ id path = [[pathClass alloc] initForTouchAtPoint:start offset:[first[@"at"] doubleValue]];
61
+ if (!path) return [self fail:@"XCTest refused a native touch path" error:error];
62
+ for (NSUInteger index = 1; index < points.count; index++) {
63
+ NSDictionary *point = points[index];
64
+ CGPoint location = CGPointMake(frame.origin.x + [point[@"x"] doubleValue] * frame.size.width,
65
+ frame.origin.y + [point[@"y"] doubleValue] * frame.size.height);
66
+ [path moveToPoint:location atOffset:[point[@"at"] doubleValue]];
67
+ }
68
+ [path liftUpAtOffset:[points.lastObject[@"at"] doubleValue]];
69
+ [record addPointerEventPath:path];
70
+ }
71
+ // One dispatch retains the native offsets between different fingers/taps.
72
+ // Individual XCUICoordinate.tap() calls would wait for app quiescence.
73
+ return [record synthesizeWithError:error];
74
+ }
75
+ @end
@@ -1,4 +1,5 @@
1
1
  import XCTest
2
+ import Darwin
2
3
 
3
4
  /// Drives the installed app from a script the Mac serves (netswitch.ts
4
5
  /// `/script`), one command a line:
@@ -20,6 +21,7 @@ import XCTest
20
21
  /// tap <text> first hittable element whose label contains text
21
22
  /// tapif <text> tap it when it is on screen, else carry on (the big timer's "Show the controls")
22
23
  /// tapid <identifier>
24
+ /// touches <JSON> timed normalized touch paths in one native dispatch
23
25
  /// tapxy <x> <y> normalised 0…1 of the screen
24
26
  /// tapexact <label> a button whose label is exactly this
25
27
  /// back the navigation bar's back button, else an edge swipe
@@ -288,6 +290,52 @@ final class DriverTests: XCTestCase {
288
290
  let target = identified(arg)
289
291
  if target.waitForExistence(timeout: 10) { press(target); note("tapid \(arg): ok") }
290
292
  else { XCTFail("Element not found"); note("FAIL tapid \(arg): NOT FOUND") }
293
+ case "touches":
294
+ do {
295
+ guard let data = arg.data(using: .utf8),
296
+ let rawPaths = try JSONSerialization.jsonObject(with: data) as? [[[String: Any]]] else {
297
+ check(false, "touches requires JSON arrays of x/y/at or tab/at points")
298
+ return
299
+ }
300
+ let frame = app.frame
301
+ var tabLocations: [String: CGPoint] = [:]
302
+ var paths: [[[String: NSNumber]]] = []
303
+ for rawPath in rawPaths {
304
+ var path: [[String: NSNumber]] = []
305
+ for rawPoint in rawPath {
306
+ var point = rawPoint
307
+ if let name = point.removeValue(forKey: "tab") as? String {
308
+ if tabLocations[name] == nil {
309
+ guard let button = tabButton(name), frame.width > 0, frame.height > 0 else {
310
+ check(false, "Timed native input cannot find tab \(name)")
311
+ return
312
+ }
313
+ let rect = button.frame
314
+ tabLocations[name] = CGPoint(x: (rect.midX - frame.minX) / frame.width,
315
+ y: (rect.midY - frame.minY) / frame.height)
316
+ }
317
+ point["x"] = tabLocations[name]!.x
318
+ point["y"] = tabLocations[name]!.y
319
+ }
320
+ guard let numbers = point as? [String: NSNumber] else {
321
+ check(false, "Timed touch points must contain numeric coordinates and times")
322
+ return
323
+ }
324
+ path.append(numbers)
325
+ }
326
+ paths.append(path)
327
+ }
328
+ let scheduled = try JSONSerialization.data(withJSONObject: paths, options: [.sortedKeys])
329
+ let started = ProcessInfo.processInfo.systemUptime
330
+ if (paths.flatMap { $0 }.compactMap { $0["at"]?.doubleValue }.max() ?? 0) >= 2 {
331
+ note("BEGIN held timed native input")
332
+ fflush(nil)
333
+ }
334
+ try CraftTimedInput.synthesize(paths: paths, frame: frame)
335
+ let elapsed = ProcessInfo.processInfo.systemUptime - started
336
+ post("/save?name=timed-input-\(Int(started * 1000)).json", scheduled)
337
+ check(true, "timed native input: \(paths.count) paths, \(elapsed)s wall time")
338
+ } catch { check(false, "timed native input: \(error)") }
291
339
  case "tapxy":
292
340
  let xy = arg.split(separator: " ").compactMap { Double($0) }
293
341
  if xy.count == 2 && xy.allSatisfy({ $0 >= 0 && $0 <= 1 }) {
@@ -19,6 +19,7 @@ targets:
19
19
  platform: iOS
20
20
  sources: [UITests]
21
21
  settings:
22
+ SWIFT_OBJC_BRIDGING_HEADER: UITests/CraftTimedInput.h
22
23
  TEST_TARGET_NAME: {{PROJECT_NAME}}Host
23
24
  PRODUCT_BUNDLE_IDENTIFIER: {{BUNDLE_PREFIX}}.uitests
24
25
  GENERATE_INFOPLIST_FILE: YES