craft-native 0.0.227 → 0.0.231

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.
@@ -4,9 +4,16 @@
4
4
  * Generates native Android apps from web content using WebView.
5
5
  */
6
6
  export { runKotlinFixture } from './testing.js';
7
+ /** Package custom fonts and their licenses; removed fonts cannot linger in the app. */
8
+ export declare function syncNativeFonts(fonts: CraftAndroidConfig['nativeFonts'], output: string): string[];
7
9
  /** Oldest Android API level supported by the generated Kotlin runtime. */
8
10
  export declare const ANDROID_MIN_SDK = 26;
9
11
  export interface CraftAndroidConfig {
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
  packageName: string;
12
19
  renderer?: 'web' | 'native';
@@ -134,6 +134,35 @@ tasks.register('runFixture', JavaExec) {
134
134
  }
135
135
 
136
136
  // ../android/src/index.ts
137
+ function syncNativeFonts(fonts, output) {
138
+ const destination = join2(output, "app/src/main/assets/NativeFonts");
139
+ const names = new Set;
140
+ const files = Object.entries(fonts ?? {}).map(([family, entry]) => {
141
+ if (!/^[A-Za-z0-9][A-Za-z0-9_.-]*$/.test(family))
142
+ throw new Error(`Invalid native font PostScript name: ${family}`);
143
+ if (names.has(family.toLowerCase()))
144
+ throw new Error(`Duplicate native font name: ${family}`);
145
+ names.add(family.toLowerCase());
146
+ if ([entry.src, entry.license].some((file) => file && resolve(file).startsWith(resolve(destination) + sep)))
147
+ throw new Error("Native font sources must be outside the generated NativeFonts directory");
148
+ const extension = extname(entry.src).toLowerCase();
149
+ if (![".ttf", ".otf"].includes(extension) || !existsSync(entry.src) || !statSync(entry.src).isFile())
150
+ throw new Error(`Native font ${family} must reference an existing TTF or OTF file: ${entry.src}`);
151
+ if (entry.license && (!existsSync(entry.license) || !statSync(entry.license).isFile()))
152
+ throw new Error(`Native font license not found: ${entry.license}`);
153
+ return { family, ...entry, filename: `${family}${extension}` };
154
+ });
155
+ rmSync(destination, { recursive: true, force: true });
156
+ if (!files.length)
157
+ return [];
158
+ mkdirSync(destination, { recursive: true });
159
+ for (const file of files) {
160
+ cpSync(file.src, join2(destination, file.filename));
161
+ if (file.license)
162
+ cpSync(file.license, join2(destination, `${file.family}.LICENSE.txt`));
163
+ }
164
+ return files.map((file) => `NativeFonts/${file.filename}`);
165
+ }
137
166
  var TEMPLATES_DIR = join2(dirname2(import.meta.dir), "templates");
138
167
  function resolveCraftAndroidVersion(startDir = import.meta.dir) {
139
168
  let directory = resolve(startDir);
@@ -884,6 +913,7 @@ zipStorePath=wrapper/dists
884
913
  </androidx.coordinatorlayout.widget.CoordinatorLayout>
885
914
  `;
886
915
  writeFileSync(join2(output, "app/src/main/res/layout/activity_main.xml"), activityMainXml);
916
+ syncNativeFonts(config.nativeFonts, output);
887
917
  const placeholderHtml = `<!DOCTYPE html>
888
918
  <html>
889
919
  <head>
@@ -995,6 +1025,7 @@ async function build(options) {
995
1025
  writeAndroidConfig(output, config);
996
1026
  console.log(` Synced: ${htmlPath} \u2192 assets/`);
997
1027
  }
1028
+ syncNativeFonts(config.nativeFonts, output);
998
1029
  if (!compile)
999
1030
  return;
1000
1031
  const gradlewPath = join2(output, "gradlew");
@@ -1066,6 +1097,7 @@ async function run(options) {
1066
1097
  }
1067
1098
  }
1068
1099
  export {
1100
+ syncNativeFonts,
1069
1101
  syncAndroidWebAssets,
1070
1102
  runKotlinFixture,
1071
1103
  run,
@@ -3810,6 +3810,11 @@ class MainActivity : AppCompatActivity() {
3810
3810
  private val imageCache by lazy { CraftNativeImageCache(File(cacheDir, "craft-native-images")) }
3811
3811
  /** Lays out each isolate's prelude (the synchronous stores) off the main thread. */
3812
3812
  private val preludeExecutor = Executors.newSingleThreadExecutor()
3813
+ private val bundledFonts: Map<String, Typeface> by lazy {
3814
+ (assets.list("NativeFonts") ?: emptyArray()).filter { it.endsWith(".ttf") || it.endsWith(".otf") }
3815
+ .associate { filename -> filename.substringBeforeLast('.') to Typeface.createFromAsset(assets, "NativeFonts/$filename") }
3816
+ }
3817
+
3813
3818
  private val imageSources = mutableMapOf<ImageView, String>()
3814
3819
  private val imageJobs = mutableMapOf<ImageView, Future<*>>()
3815
3820
  private val imageErrors = mutableMapOf<ImageView, String>()
@@ -6351,9 +6356,10 @@ class MainActivity : AppCompatActivity() {
6351
6356
  }
6352
6357
  val italic = craftNativeOptionalString(style.opt("fontStyle")) == "italic"
6353
6358
  val family = craftNativeOptionalString(style.opt("fontFamily"))?.takeIf { it.isNotBlank() }
6359
+ val base = family?.let { bundledFonts[it] } ?: Typeface.create(family, Typeface.NORMAL)
6354
6360
  view.typeface = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
6355
- Typeface.create(Typeface.create(family, Typeface.NORMAL), weight, italic)
6356
- } else Typeface.create(family, (if (weight >= 600) Typeface.BOLD else Typeface.NORMAL) or
6361
+ Typeface.create(base, weight, italic)
6362
+ } else Typeface.create(base, (if (weight >= 600) Typeface.BOLD else Typeface.NORMAL) or
6357
6363
  (if (italic) Typeface.ITALIC else Typeface.NORMAL))
6358
6364
  val textAlign = craftNativeOptionalString(style.opt("textAlign"))
6359
6365
  view.textAlignment = when (textAlign) {
package/dist/cli.js CHANGED
@@ -3424,7 +3424,7 @@ function pinCraftNativeDependency(packagePath, craftVersion) {
3424
3424
  `);
3425
3425
  }
3426
3426
  // package.json
3427
- var version = "0.0.227";
3427
+ var version = "0.0.231";
3428
3428
 
3429
3429
  // bin/cli.ts
3430
3430
  var spawnedFrom = process6.env[CRAFT_CLI_SPAWN_MARKER];
package/dist/index.cjs CHANGED
@@ -309,7 +309,7 @@ function craftBinaryNotFoundMessage(triedPath) {
309
309
  `);
310
310
  }
311
311
  // package.json
312
- var version = "0.0.227";
312
+ var version = "0.0.231";
313
313
 
314
314
  // src/sdk-version.ts
315
315
  var SDK_VERSION = version;
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.227";
60
+ var version = "0.0.231";
61
61
 
62
62
  // src/sdk-version.ts
63
63
  var SDK_VERSION = version;
@@ -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';
@@ -1,7 +1,7 @@
1
1
  // @bun
2
2
  // ../ios/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
  // ../ios/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
  // ../ios/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,
@@ -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;
@@ -627,6 +627,7 @@ final class CraftChrome: ObservableObject {
627
627
  @Published var tabs: [Tab] = []
628
628
  @Published var tabAccessory: Tab?
629
629
  @Published var selected: String?
630
+ @Published private(set) var tabSwipePosition: CGFloat?
630
631
  @Published var tabBarVisible = false
631
632
  @Published var splashVisible = true
632
633
  @Published var tint: UIColor?
@@ -640,6 +641,7 @@ final class CraftChrome: ObservableObject {
640
641
  private var pendingHide: DispatchWorkItem?
641
642
  private var splashDeadline: DispatchWorkItem?
642
643
  private var tabSwipePrevious: String?
644
+ private var tabSwipeTarget: String?
643
645
  /// The keyboard is up and covers the tab bar, so the page need not leave
644
646
  /// room for it: the web view ends at the keyboard (CraftHybrid's webHost).
645
647
  private var keyboardUp = false
@@ -821,12 +823,34 @@ final class CraftChrome: ObservableObject {
821
823
  // A page's pending selection report may disagree with the native
822
824
  // stack. Cancellation belongs to the stack the finger left.
823
825
  tabSwipePrevious = previous
824
- withAnimation(nil) { selected = id }
826
+ tabSwipeTarget = id
827
+ withAnimation(nil) {
828
+ tabSwipePosition = tabs.firstIndex(where: { $0.id == previous }).map { CGFloat($0) }
829
+ selected = id
830
+ }
831
+ }
832
+
833
+ /// The capsule shares the gesture's progress, rather than jumping to the
834
+ /// next item as soon as the pan recognizer begins.
835
+ func updateTabSwipe(progress: CGFloat) {
836
+ guard let previous = tabSwipePrevious, let target = tabSwipeTarget,
837
+ let from = tabs.firstIndex(where: { $0.id == previous }),
838
+ let to = tabs.firstIndex(where: { $0.id == target }) else { return }
839
+ let position = CGFloat(from) + CGFloat(to - from) * min(1, max(0, progress))
840
+ if tabSwipePosition != position { withAnimation(nil) { tabSwipePosition = position } }
841
+ }
842
+
843
+ func settleTabSwipe(completed: Bool, duration: TimeInterval) {
844
+ guard let id = completed ? tabSwipeTarget : tabSwipePrevious,
845
+ let index = tabs.firstIndex(where: { $0.id == id }) else { return }
846
+ withAnimation(Self.motion(.easeOut(duration: duration))) { tabSwipePosition = CGFloat(index) }
825
847
  }
826
848
 
827
849
  func cancelTabSwipe(restoringSelection: Bool = true) {
828
850
  guard let previous = tabSwipePrevious else { return }
829
851
  tabSwipePrevious = nil
852
+ tabSwipeTarget = nil
853
+ withAnimation(nil) { tabSwipePosition = nil }
830
854
  if restoringSelection {
831
855
  withAnimation(nil) { selected = previous }
832
856
  }
@@ -834,6 +858,8 @@ final class CraftChrome: ObservableObject {
834
858
 
835
859
  func commitTabSwipe(to id: String) {
836
860
  tabSwipePrevious = nil
861
+ tabSwipeTarget = nil
862
+ withAnimation(nil) { tabSwipePosition = nil }
837
863
  guard tabs.contains(where: { $0.id == id }) else { return }
838
864
  withAnimation(nil) { selected = id }
839
865
  UISelectionFeedbackGenerator().selectionChanged()
@@ -934,7 +960,6 @@ final class CraftChromeRelay: NSObject, WKScriptMessageHandler {
934
960
  /// The floating tab bar: Liquid Glass on iOS 26 and later, a material capsule before.
935
961
  struct CraftTabBarView: View {
936
962
  @ObservedObject private var chrome = CraftChrome.shared
937
- @Namespace private var selection
938
963
  @Environment(\.accessibilityReduceMotion) private var reduceMotion
939
964
  @Environment(\.scenePhase) private var scenePhase
940
965
  // Dynamic Type, relative to the styles UIKit's own bar uses. The bar is
@@ -1064,13 +1089,6 @@ struct CraftTabBarView: View {
1064
1089
  }
1065
1090
  .foregroundStyle(isSelected ? selectedColor : Color.primary)
1066
1091
  .frame(maxWidth: .infinity, minHeight: 54)
1067
- .background {
1068
- if isSelected {
1069
- Capsule()
1070
- .fill(Color.primary.opacity(0.09))
1071
- .matchedGeometryEffect(id: "selection", in: selection)
1072
- }
1073
- }
1074
1092
  .contentShape(Capsule())
1075
1093
  }
1076
1094
  .buttonStyle(.plain)
@@ -1083,6 +1101,20 @@ struct CraftTabBarView: View {
1083
1101
  .accessibilityAddTraits(isSelected ? [.isSelected, .isButton] : .isButton)
1084
1102
  }
1085
1103
  }
1104
+ .background {
1105
+ GeometryReader { geometry in
1106
+ if let position = chrome.tabSwipePosition
1107
+ ?? chrome.tabs.firstIndex(where: { $0.id == chrome.selected }).map({ CGFloat($0) }) {
1108
+ let width = geometry.size.width / CGFloat(max(1, chrome.tabs.count))
1109
+ Capsule()
1110
+ .fill(Color.primary.opacity(0.09))
1111
+ .frame(width: width, height: geometry.size.height)
1112
+ .offset(x: position * width)
1113
+ }
1114
+ }
1115
+ .allowsHitTesting(false)
1116
+ .accessibilityHidden(true)
1117
+ }
1086
1118
  }
1087
1119
 
1088
1120
  private var selectedColor: Color {
@@ -1104,6 +1104,11 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
1104
1104
  DispatchQueue.main.async { [weak self] in self?.hybrid.shellReady() }
1105
1105
  }
1106
1106
 
1107
+ override func viewDidAppear(_ animated: Bool) {
1108
+ super.viewDidAppear(animated)
1109
+ prepareAdjacentNativeTabs()
1110
+ }
1111
+
1107
1112
  private var webAttached = false
1108
1113
 
1109
1114
  private func installTabSwipeGestures() {
@@ -1152,9 +1157,10 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
1152
1157
  let speed = velocity * direction
1153
1158
  let complete = recognizer.state == .ended && speed > -700
1154
1159
  && (progress >= max(32, view.bounds.width * 0.28) || speed > 700)
1155
- finishInteractiveTabSwipe(completed: complete) { [weak self] in
1160
+ finishInteractiveTabSwipe(completed: complete, velocity: velocity) { [weak self] in
1156
1161
  self?.activeTabSwipeTarget = nil
1157
1162
  self?.tabSwipeSettling = false
1163
+ self?.prepareAdjacentNativeTabs()
1158
1164
  guard complete else {
1159
1165
  CraftChrome.shared.cancelTabSwipe()
1160
1166
  return
@@ -1224,9 +1230,10 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
1224
1230
  : max(0, min(width, translation))
1225
1231
  target.view.frame.origin.x = tabSwipeFrom + offset
1226
1232
  current.view.frame.origin.x = offset
1233
+ CraftChrome.shared.updateTabSwipe(progress: abs(offset) / width)
1227
1234
  }
1228
1235
 
1229
- private func finishInteractiveTabSwipe(completed: Bool, completion: @escaping () -> Void) {
1236
+ private func finishInteractiveTabSwipe(completed: Bool, velocity: CGFloat, completion: @escaping () -> Void) {
1230
1237
  guard let targetId = activeTabSwipeTarget, let current = visibleStack,
1231
1238
  let target = stacks[targetId] else { completion(); return }
1232
1239
  let width = view.bounds.width
@@ -1235,7 +1242,12 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
1235
1242
  let settlingDuration = completed ? 0.22 : 0.18
1236
1243
  let duration = CraftChrome.motion(.easeOut(duration: settlingDuration)) == nil ? 0 : settlingDuration
1237
1244
  let generation = tabSwipeGeneration
1238
- UIView.animate(withDuration: duration, delay: 0, options: [.curveEaseOut, .beginFromCurrentState], animations: {
1245
+ CraftChrome.shared.settleTabSwipe(completed: completed, duration: duration)
1246
+ let remaining = end - target.view.frame.minX
1247
+ let initialVelocity = abs(remaining) > 1 ? max(0, min(12, velocity / remaining)) : 0
1248
+ UIView.animate(withDuration: duration, delay: 0, usingSpringWithDamping: 1,
1249
+ initialSpringVelocity: initialVelocity,
1250
+ options: [.beginFromCurrentState, .allowUserInteraction], animations: {
1239
1251
  target.view.frame.origin.x = end
1240
1252
  current.view.frame.origin.x = completed ? -direction * width : 0
1241
1253
  }, completion: { _ in
@@ -1449,6 +1461,7 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
1449
1461
  for (key, other) in stacks { other.view.isHidden = key != id }
1450
1462
  nav.view.frame = view.bounds
1451
1463
  view.bringSubviewToFront(nav.view)
1464
+ prepareAdjacentNativeTabs()
1452
1465
  if let slot = nav.topViewController as? CraftHybridWebSlot, slot.isViewLoaded {
1453
1466
  adoptWeb(into: slot)
1454
1467
  } else if !(nav.topViewController is CraftHybridWebSlot) {
@@ -1456,6 +1469,31 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
1456
1469
  }
1457
1470
  }
1458
1471
 
1472
+ /// Prepare one neighbour after the first frame, never inside the finger's
1473
+ /// tracking run-loop mode. A first swipe then uses a laid-out native root
1474
+ /// rather than compiling, measuring and fetching at gesture start.
1475
+ private func prepareAdjacentNativeTabs() {
1476
+ let owner = currentTab
1477
+ let timer = Timer(timeInterval: 0.15, repeats: false) { [weak self] _ in
1478
+ guard let self, self.currentTab == owner, self.activeTabSwipeTarget == nil,
1479
+ self.view.window != nil,
1480
+ let index = self.hybrid.tabIds.firstIndex(of: owner) else { return }
1481
+ let tabs = self.hybrid.tabIds
1482
+ for neighbour in [index + 1, index - 1] where tabs.indices.contains(neighbour) {
1483
+ let id = tabs[neighbour]
1484
+ guard self.stacks[id] == nil, self.hybrid.routes.match(id) != nil else { continue }
1485
+ let nav = self.makeStack(tab: id, rootPath: id)
1486
+ nav.view.isHidden = true
1487
+ nav.view.layoutIfNeeded()
1488
+ (nav.topViewController as? CraftNativeScreenController)?.screenVisibilityChanged(false)
1489
+ (self.visibleStack?.topViewController as? CraftNativeScreenController)?.screenVisibilityChanged(true)
1490
+ self.prepareAdjacentNativeTabs()
1491
+ break
1492
+ }
1493
+ }
1494
+ RunLoop.main.add(timer, forMode: .default)
1495
+ }
1496
+
1459
1497
  private func cancelPendingTab() {
1460
1498
  pendingTab?.deadline.cancel()
1461
1499
  pendingTab = nil
@@ -1436,6 +1436,16 @@ enum CraftSnapshots {
1436
1436
  private static let lock = NSLock()
1437
1437
  /// Writes not on disk yet, so a read right after a write sees it.
1438
1438
  private static var pending: [String: String?] = [:]
1439
+ private static var revisions: [String: UInt64] = [:]
1440
+ private static var revision: UInt64 = 0
1441
+ // Screens read shared headers many times while evaluating bindings. Keep
1442
+ // settled JSON in memory instead of reopening the same file on the UI thread.
1443
+ private static let cache: NSCache<NSString, NSString> = {
1444
+ let cache = NSCache<NSString, NSString>()
1445
+ cache.countLimit = 32
1446
+ cache.totalCostLimit = 8 * 1024 * 1024
1447
+ return cache
1448
+ }()
1439
1449
 
1440
1450
  static var directory: URL? {
1441
1451
  FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first?
@@ -1458,11 +1468,13 @@ enum CraftSnapshots {
1458
1468
  static func read(_ name: String) -> String? {
1459
1469
  guard let url = url(for: name) else { return nil }
1460
1470
  lock.lock()
1461
- let waiting = pending[name]
1462
- lock.unlock()
1463
- if let waiting { return waiting }
1471
+ defer { lock.unlock() }
1472
+ if let waiting = pending[name] { return waiting }
1473
+ if let cached = cache.object(forKey: name as NSString) { return cached as String }
1464
1474
  guard let data = try? Data(contentsOf: url) else { return nil }
1465
- return String(decoding: data, as: UTF8.self)
1475
+ let json = String(decoding: data, as: UTF8.self)
1476
+ cache.setObject(json as NSString, forKey: name as NSString, cost: data.count)
1477
+ return json
1466
1478
  }
1467
1479
 
1468
1480
  /// Replaces the snapshot with JSON text (nil deletes it) off the main
@@ -1477,7 +1489,11 @@ enum CraftSnapshots {
1477
1489
  return
1478
1490
  }
1479
1491
  lock.lock()
1492
+ revision &+= 1
1493
+ let mine = revision
1494
+ revisions[name] = mine
1480
1495
  pending[name] = .some(json)
1496
+ cache.removeObject(forKey: name as NSString)
1481
1497
  lock.unlock()
1482
1498
  queue.async {
1483
1499
  var ok = true
@@ -1492,7 +1508,13 @@ enum CraftSnapshots {
1492
1508
  ok = false
1493
1509
  }
1494
1510
  lock.lock()
1495
- if let current = pending[name], current == json { pending.removeValue(forKey: name) }
1511
+ if revisions[name] == mine {
1512
+ revisions.removeValue(forKey: name)
1513
+ pending.removeValue(forKey: name)
1514
+ if ok, let json {
1515
+ cache.setObject(json as NSString, forKey: name as NSString, cost: json.utf8.count)
1516
+ }
1517
+ }
1496
1518
  lock.unlock()
1497
1519
  DispatchQueue.main.async {
1498
1520
  if ok { NotificationCenter.default.post(name: changed, object: nil, userInfo: ["name": name]) }
@@ -4224,7 +4224,14 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
4224
4224
  screenRequestCancellers.removeAll()
4225
4225
  }
4226
4226
 
4227
+ private var appliedTraitDefaults: String?
4228
+
4227
4229
  private func refreshTraitDefaults() {
4230
+ // Tab navigation is not a theme change. Reapplying the entire tree
4231
+ // discards every measurement just as an interactive transition starts.
4232
+ let traits = "\(colorScheme):\(traitCollection.preferredContentSizeCategory.rawValue):\(UIAccessibility.isReduceTransparencyEnabled)"
4233
+ guard traits != appliedTraitDefaults else { return }
4234
+ appliedTraitDefaults = traits
4228
4235
  view.backgroundColor = screenBackgroundColor ?? config.resolvedBackgroundColor
4229
4236
  applyNavigationBarAppearance()
4230
4237
  if let document = mutationDocument.node("root") {
@@ -20,10 +20,13 @@
20
20
  <string>{{VERSION}}</string>
21
21
  <key>CFBundleVersion</key>
22
22
  <string>{{BUILD_NUMBER}}</string>
23
+ <key>CADisableMinimumFrameDurationOnPhone</key>
24
+ <true/>
23
25
  <key>LSRequiresIPhoneOS</key>
24
26
  <true/>
25
27
  <key>ITSAppUsesNonExemptEncryption</key>
26
28
  <{{NON_EXEMPT_ENCRYPTION}}/>
29
+ {{NATIVE_FONTS}}
27
30
  <key>UILaunchScreen</key>
28
31
  <dict>
29
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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "craft-native",
3
- "version": "0.0.227",
3
+ "version": "0.0.231",
4
4
  "type": "module",
5
5
  "description": "Build desktop apps with web languages - TypeScript SDK for Craft",
6
6
  "author": "Chris Breuer <chris@stacksjs.org>",