@craft-native/ios 0.0.304 → 0.0.328

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
@@ -1324,3 +1324,21 @@ and detents; Android sizes its native bottom panel to the selected detent
1324
1324
  without an extra dependency. Omitting presentationStyle keeps the existing
1325
1325
  full-screen overlay. A native sheet's close request still belongs to its
1326
1326
  owner, which sets visible to false.
1327
+
1328
+
1329
+ ### Controlled native paging and font families
1330
+
1331
+ A horizontal `ScrollView` with `pagingEnabled: true` and a numeric `pageIndex`
1332
+ uses viewport-sized direct children. The host advertises `nativePager` and emits
1333
+ `onPageSelected` with `{ index, count }` after initial layout, count changes and
1334
+ settled user selection. Updates to `pageIndex` select a page; removed pages clamp
1335
+ the index and resizing preserves it. Offscreen pages stay mounted but are hidden
1336
+ from native accessibility. `scrollEnabled: false` locks swiping while retaining
1337
+ controls in the selected page. Without `pageIndex`, scroll content keeps its
1338
+ ordinary intrinsic layout; this is an opt-in contract for a shared Pager.
1339
+
1340
+ Text `fontFamily` accepts a CSS fallback list. Bundled/custom fonts take priority
1341
+ when available. `monospace`/`ui-monospace`, `serif`/`ui-serif` and
1342
+ `sans-serif`/`ui-sans-serif`/`system-ui` select platform fonts, retaining requested
1343
+ weight, italic style and system text scaling. A missing custom face falls through
1344
+ to the next family rather than silently replacing a requested mono font with sans.
package/dist/cli.js CHANGED
@@ -6,6 +6,7 @@ import { parseArgs } from "util";
6
6
  import { resolve as resolve2 } from "path";
7
7
 
8
8
  // src/index.ts
9
+ import { gzipSync } from "zlib";
9
10
  import { cpSync, existsSync, mkdirSync, readFileSync, readdirSync as readdirSync2, rmSync, statSync, writeFileSync } from "fs";
10
11
  import { dirname as dirname2, extname, join as join3, resolve, sep as sep2 } from "path";
11
12
  var {$ } = globalThis.Bun;
@@ -117,6 +118,21 @@ import { dirname, join as join2 } from "path";
117
118
  var templates = join2(dirname(import.meta.dir), "templates/testing");
118
119
 
119
120
  // src/index.ts
121
+ function compressNativeSources(target) {
122
+ if (statSync(target).isDirectory()) {
123
+ for (const entry of readdirSync2(target))
124
+ compressNativeSources(join3(target, entry));
125
+ return;
126
+ }
127
+ if (!/\.(?:js|json)$/.test(target))
128
+ return;
129
+ const bytes = readFileSync(target);
130
+ if (bytes.length < 1024 || bytes[0] === 31 && bytes[1] === 139)
131
+ return;
132
+ const packed = gzipSync(bytes, { level: 9 });
133
+ if (packed.length < bytes.length)
134
+ writeFileSync(target, packed);
135
+ }
120
136
  function syncNativeFonts(fonts, output) {
121
137
  const destination = join3(output, "NativeFonts");
122
138
  const names = new Set;
@@ -315,11 +331,13 @@ function installNativeBundle(source, output) {
315
331
  rmSync(routes, { recursive: true, force: true });
316
332
  cpSync(source, routes, { recursive: true });
317
333
  }
334
+ compressNativeSources(routes);
318
335
  rmSync(single, { force: true });
319
336
  return;
320
337
  }
321
338
  if (resolve(source) !== resolve(single))
322
339
  cpSync(source, single);
340
+ compressNativeSources(single);
323
341
  rmSync(routes, { recursive: true, force: true });
324
342
  }
325
343
  function hasInstalledNativeBundle(output) {
@@ -1010,6 +1028,7 @@ function writeShell(output, config) {
1010
1028
  cpSync(join3(TEMPLATES_DIR, "CraftNativeUI.swift"), join3(output, "Sources", "CraftNativeUI.swift"));
1011
1029
  cpSync(join3(TEMPLATES_DIR, "CraftNativeServices.swift"), join3(output, "Sources", "CraftNativeServices.swift"));
1012
1030
  cpSync(join3(TEMPLATES_DIR, "CraftNativeFiles.swift"), join3(output, "Sources", "CraftNativeFiles.swift"));
1031
+ cpSync(join3(TEMPLATES_DIR, "CraftVoiceRecording.swift"), join3(output, "Sources", "CraftVoiceRecording.swift"));
1013
1032
  cpSync(join3(TEMPLATES_DIR, "CraftWebAudio.swift"), join3(output, "Sources", "CraftWebAudio.swift"));
1014
1033
  cpSync(join3(TEMPLATES_DIR, "CraftHybrid.swift"), join3(output, "Sources", "CraftHybrid.swift"));
1015
1034
  writeFileSync(join3(output, "Sources", "CraftCrashReporter.swift"), readFileSync(join3(TEMPLATES_DIR, "CraftCrashReporter.swift"), "utf-8").replace(/\{\{CRAFT_VERSION\}\}/g, CRAFT_VERSION));
package/dist/index.d.ts CHANGED
@@ -1,8 +1,5 @@
1
- /**
2
- * Craft iOS Builder
3
- *
4
- * Generates native iOS apps from web content using WKWebView.
5
- */
1
+ /** Store large native source assets compressed; the host decodes each once on demand. */
2
+ export declare function compressNativeSources(target: string): void;
6
3
  import { type CraftAppShortcut } from './app-shortcuts.js';
7
4
  /** Package custom fonts and their licenses; removed fonts cannot linger in the app. */
8
5
  export declare function syncNativeFonts(fonts: CraftConfig['nativeFonts'], output: string): string[];
package/dist/index.js CHANGED
@@ -1,5 +1,6 @@
1
1
  // @bun
2
2
  // src/index.ts
3
+ import { gzipSync } from "zlib";
3
4
  import { cpSync, existsSync, mkdirSync as mkdirSync2, readFileSync as readFileSync2, readdirSync as readdirSync2, rmSync as rmSync2, statSync, writeFileSync as writeFileSync2 } from "fs";
4
5
  import { dirname as dirname2, extname, join as join3, resolve, sep as sep2 } from "path";
5
6
  var {$ } = globalThis.Bun;
@@ -189,7 +190,12 @@ function serializeSimulatorSteps(steps) {
189
190
  };
190
191
  const line = (step) => {
191
192
  let arg = "";
192
- if ("paths" in step) {
193
+ if (step.command === "holdtab") {
194
+ const seconds = step.seconds ?? 6;
195
+ if (!step.text.trim() || step.text.includes("|") || number(seconds) < 2 || seconds > 10)
196
+ throw new Error("holdtab needs a nonempty tab name and 2\u201310 seconds");
197
+ arg = `${step.text} | ${seconds}`;
198
+ } else if ("paths" in step) {
193
199
  if (!step.paths.length || step.paths.length > 64)
194
200
  throw new Error("Expected 1\u201364 touch paths");
195
201
  for (const path of step.paths) {
@@ -301,6 +307,21 @@ function createNativeMutationValidator() {
301
307
  }
302
308
 
303
309
  // src/index.ts
310
+ function compressNativeSources(target) {
311
+ if (statSync(target).isDirectory()) {
312
+ for (const entry of readdirSync2(target))
313
+ compressNativeSources(join3(target, entry));
314
+ return;
315
+ }
316
+ if (!/\.(?:js|json)$/.test(target))
317
+ return;
318
+ const bytes = readFileSync2(target);
319
+ if (bytes.length < 1024 || bytes[0] === 31 && bytes[1] === 139)
320
+ return;
321
+ const packed = gzipSync(bytes, { level: 9 });
322
+ if (packed.length < bytes.length)
323
+ writeFileSync2(target, packed);
324
+ }
304
325
  function syncNativeFonts(fonts, output) {
305
326
  const destination = join3(output, "NativeFonts");
306
327
  const names = new Set;
@@ -502,11 +523,13 @@ function installNativeBundle(source, output) {
502
523
  rmSync2(routes, { recursive: true, force: true });
503
524
  cpSync(source, routes, { recursive: true });
504
525
  }
526
+ compressNativeSources(routes);
505
527
  rmSync2(single, { force: true });
506
528
  return;
507
529
  }
508
530
  if (resolve(source) !== resolve(single))
509
531
  cpSync(source, single);
532
+ compressNativeSources(single);
510
533
  rmSync2(routes, { recursive: true, force: true });
511
534
  }
512
535
  function hasInstalledNativeBundle(output) {
@@ -1210,6 +1233,7 @@ function writeShell(output, config) {
1210
1233
  cpSync(join3(TEMPLATES_DIR, "CraftNativeUI.swift"), join3(output, "Sources", "CraftNativeUI.swift"));
1211
1234
  cpSync(join3(TEMPLATES_DIR, "CraftNativeServices.swift"), join3(output, "Sources", "CraftNativeServices.swift"));
1212
1235
  cpSync(join3(TEMPLATES_DIR, "CraftNativeFiles.swift"), join3(output, "Sources", "CraftNativeFiles.swift"));
1236
+ cpSync(join3(TEMPLATES_DIR, "CraftVoiceRecording.swift"), join3(output, "Sources", "CraftVoiceRecording.swift"));
1213
1237
  cpSync(join3(TEMPLATES_DIR, "CraftWebAudio.swift"), join3(output, "Sources", "CraftWebAudio.swift"));
1214
1238
  cpSync(join3(TEMPLATES_DIR, "CraftHybrid.swift"), join3(output, "Sources", "CraftHybrid.swift"));
1215
1239
  writeFileSync2(join3(output, "Sources", "CraftCrashReporter.swift"), readFileSync2(join3(TEMPLATES_DIR, "CraftCrashReporter.swift"), "utf-8").replace(/\{\{CRAFT_VERSION\}\}/g, CRAFT_VERSION));
@@ -1580,6 +1604,7 @@ export {
1580
1604
  backgroundRefreshIdentifier,
1581
1605
  bootSimulator,
1582
1606
  build,
1607
+ compressNativeSources,
1583
1608
  createNativeMutationValidator,
1584
1609
  findApplicationSwiftSources,
1585
1610
  hasNativeRoutes,
package/dist/testing.d.ts CHANGED
@@ -61,6 +61,12 @@ export type SimulatorStep = {
61
61
  | {
62
62
  command: 'touches';
63
63
  paths: TimedTouchPoint[][];
64
+ }
65
+ /** Standard XCTest tab hold; independent screenshots must be captured before release. */
66
+ | {
67
+ command: 'holdtab';
68
+ text: string;
69
+ seconds?: number;
64
70
  } | {
65
71
  command: 'field';
66
72
  index: number;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@craft-native/ios",
3
- "version": "0.0.304",
3
+ "version": "0.0.328",
4
4
  "homepage": "https://github.com/craft-native/craft/tree/main/packages/ios#readme",
5
5
  "repository": {
6
6
  "type": "git",
@@ -2,20 +2,27 @@ import ActivityKit
2
2
 
3
3
  @available(iOS 16.1, *)
4
4
  struct CraftActivityAttributes: ActivityAttributes {
5
+ struct Transition: Codable, Hashable {
6
+ let at: Double
7
+ let detail: String
8
+ let duration: Double?
9
+ }
5
10
  struct ContentState: Codable, Hashable {
6
- let status: String
11
+ var status: String
7
12
  let distanceMeters: Double
8
13
  let durationSeconds: Double
9
- let progress: Double
14
+ var progress: Double
10
15
  var staleStatus: String?
11
16
  var detail: String?
12
17
  /// Epoch milliseconds. SwiftUI advances these timers without app updates.
18
+ var sessionStartedAt: Double?
13
19
  var timerStartedAt: Double?
14
20
  var timerEndsAt: Double?
15
21
  var paused: Bool?
16
22
  var remainingSeconds: Double?
17
23
  var heartRate: Double?
18
24
  var showsDistance: Bool?
25
+
19
26
  }
20
27
 
21
28
  let activityId: String
@@ -24,3 +31,23 @@ struct CraftActivityAttributes: ActivityAttributes {
24
31
  var deepLink: String?
25
32
  var tint: String?
26
33
  }
34
+
35
+
36
+ /// The whole plan stays in the app process. Only ContentState crosses
37
+ /// ActivityKit's 4 KB payload boundary, regardless of the workout's length.
38
+ @available(iOS 16.1, *)
39
+ struct CraftActivityTimeline {
40
+ var pending: [CraftActivityAttributes.Transition]
41
+
42
+ mutating func advance(_ state: inout CraftActivityAttributes.ContentState, to milliseconds: Double) {
43
+ let due = pending.prefix { $0.at <= milliseconds }
44
+ guard let next = due.last else { return }
45
+ pending.removeFirst(due.count)
46
+ state.detail = next.detail
47
+ state.timerStartedAt = next.duration == nil ? state.sessionStartedAt : next.at
48
+ state.timerEndsAt = next.duration.map { next.at + $0 * 1000 }
49
+ state.remainingSeconds = next.duration.map { max(0, $0 - (milliseconds - next.at) / 1000) }
50
+ state.progress = 0
51
+ if next.duration == 0 { state.status = next.detail; state.paused = true; state.progress = 1 }
52
+ }
53
+ }
@@ -766,6 +766,13 @@ final class CraftChrome: ObservableObject {
766
766
  @Published var tabAccessory: Tab?
767
767
  private weak var tabAccessoryOwner: AnyObject?
768
768
  @Published var selected: String?
769
+ /// The visible destination may be the accessory, independently of its owning stack.
770
+ @Published var visiblePath: String?
771
+ var accessorySelected: Bool {
772
+ guard let path = visiblePath, let accessory = tabAccessory?.id else { return false }
773
+ return path == accessory || path.hasPrefix(accessory + "/")
774
+ }
775
+ var highlightedTab: String? { accessorySelected ? nil : selected }
769
776
  let tabSwipeProgress = CraftTabSwipeProgress()
770
777
  private(set) var tabSwipePosition: CGFloat? {
771
778
  get { tabSwipeProgress.position }
@@ -1172,6 +1179,18 @@ final class CraftChromeRelay: NSObject, WKScriptMessageHandler {
1172
1179
 
1173
1180
  /// The floating tab bar: Liquid Glass on iOS 26 and later, a material capsule before.
1174
1181
  struct CraftTabBarView: View {
1182
+ @Environment(\.dynamicTypeSize) private var dynamicTypeSize
1183
+
1184
+ var body: some View {
1185
+ // ScaledMetric reads the environment of its owning view. Put the
1186
+ // metrics in a child so this compact-chrome cap reaches them too.
1187
+ CraftTabBarContent(viewerTextSize: dynamicTypeSize)
1188
+ .dynamicTypeSize(...DynamicTypeSize.xxxLarge)
1189
+ }
1190
+ }
1191
+
1192
+ private struct CraftTabBarContent: View {
1193
+ let viewerTextSize: DynamicTypeSize
1175
1194
  @ObservedObject private var chrome = CraftChrome.shared
1176
1195
  @Environment(\.accessibilityReduceMotion) private var reduceMotion
1177
1196
  @Environment(\.scenePhase) private var scenePhase
@@ -1183,7 +1202,7 @@ struct CraftTabBarView: View {
1183
1202
  @ScaledMetric(relativeTo: .caption2) private var badgeSize: CGFloat = 10
1184
1203
 
1185
1204
  var body: some View {
1186
- if chrome.tabBarVisible && !chrome.tabs.isEmpty {
1205
+ if !chrome.tabs.isEmpty {
1187
1206
  HStack(spacing: 8) {
1188
1207
  bar
1189
1208
  if let accessory = chrome.tabAccessory {
@@ -1191,8 +1210,13 @@ struct CraftTabBarView: View {
1191
1210
  surface {
1192
1211
  Image(systemName: accessory.symbol)
1193
1212
  .font(.system(size: iconSize))
1194
- .foregroundStyle(Color.primary)
1213
+ .foregroundStyle(chrome.accessorySelected ? selectedColor : Color.primary)
1195
1214
  .frame(width: 62, height: 62)
1215
+ .background {
1216
+ if chrome.accessorySelected {
1217
+ Circle().fill(selectedColor.opacity(0.14)).padding(4)
1218
+ }
1219
+ }
1196
1220
  }
1197
1221
  .contentShape(Rectangle())
1198
1222
  .overlay(alignment: .topTrailing) {
@@ -1207,9 +1231,17 @@ struct CraftTabBarView: View {
1207
1231
  }
1208
1232
  }
1209
1233
  .buttonStyle(.plain)
1234
+ .accessibilityShowsLargeContentViewer {
1235
+ Image(systemName: accessory.symbol)
1236
+ Text(accessory.accessibilityLabel ?? accessory.title)
1237
+ }
1238
+ // Keep accessibility intent for the viewer; displayed
1239
+ // sizes come from the bounded metrics owned above.
1240
+ .environment(\.dynamicTypeSize, viewerTextSize)
1210
1241
  .accessibilityLabel(accessory.accessibilityLabel ?? accessory.title)
1211
1242
  .accessibilityValue(accessory.badge ?? "")
1212
1243
  .accessibilityIdentifier("craft-tab-accessory")
1244
+ .accessibilityAddTraits(chrome.accessorySelected ? .isSelected : [])
1213
1245
  }
1214
1246
  }
1215
1247
  .padding(.horizontal, 16)
@@ -1219,9 +1251,14 @@ struct CraftTabBarView: View {
1219
1251
  .onAppear { report(proxy) }
1220
1252
  .onChange(of: proxy.frame(in: .global)) { _ in report(proxy) }
1221
1253
  })
1222
- .transition(reduceMotion ? .opacity : .move(edge: .bottom).combined(with: .opacity))
1254
+ // Keep the view mounted: a disappearing conditional inside a
1255
+ // child does not reliably animate in the UIKit navigation transaction.
1256
+ .offset(y: chrome.tabBarVisible || reduceMotion ? 0 : 120)
1257
+ .opacity(chrome.tabBarVisible ? 1 : 0)
1258
+ .allowsHitTesting(chrome.tabBarVisible)
1259
+ .accessibilityHidden(!chrome.tabBarVisible)
1260
+ .animation(.easeInOut(duration: reduceMotion ? 0.12 : 0.24), value: chrome.tabBarVisible)
1223
1261
  .ignoresSafeArea(.keyboard)
1224
- .dynamicTypeSize(...DynamicTypeSize.xxxLarge)
1225
1262
  }
1226
1263
  }
1227
1264
 
@@ -1260,7 +1297,7 @@ struct CraftTabBarView: View {
1260
1297
  private var items: some View {
1261
1298
  HStack(spacing: 0) {
1262
1299
  ForEach(chrome.tabs) { tab in
1263
- let isSelected = tab.id == chrome.selected
1300
+ let isSelected = tab.id == chrome.highlightedTab
1264
1301
  Button { chrome.tap(tab) } label: {
1265
1302
  VStack(spacing: 3) {
1266
1303
  ZStack(alignment: .topTrailing) {
@@ -1305,6 +1342,7 @@ struct CraftTabBarView: View {
1305
1342
  Image(systemName: symbol(tab.symbol, selected: isSelected))
1306
1343
  Text(tab.displayTitle)
1307
1344
  }
1345
+ .environment(\.dynamicTypeSize, viewerTextSize)
1308
1346
  .accessibilityIdentifier("craft-tab-\(tab.id)")
1309
1347
  .accessibilityLabel(tab.accessibilityLabel ?? tab.badge.map { "\(tab.displayTitle), \($0)" } ?? tab.displayTitle)
1310
1348
  .accessibilityAddTraits(isSelected ? [.isSelected, .isButton] : .isButton)
@@ -1312,7 +1350,7 @@ struct CraftTabBarView: View {
1312
1350
  }
1313
1351
  .background {
1314
1352
  CraftTabSelectionIndicator(
1315
- selected: chrome.tabs.firstIndex(where: { $0.id == chrome.selected }),
1353
+ selected: chrome.tabs.firstIndex(where: { $0.id == chrome.highlightedTab }),
1316
1354
  count: chrome.tabs.count,
1317
1355
  cornerRadius: chrome.tabSelectionCornerRadius
1318
1356
  )
@@ -8243,7 +8281,7 @@ struct CraftWebView: UIViewRepresentable {
8243
8281
  // MARK: - Live Activities
8244
8282
  private func liveActivity(method: String, args: [Any], callbackId: String?) {
8245
8283
  let token = armDeadline(Coordinator.liveActivityDeadline, callbackId: callbackId, error: "Live Activity request timed out")
8246
- CraftLiveActivities.perform(method: method, args: args, enabled: config.enableLiveActivities, appName: config.appName) { [weak self] result in
8284
+ CraftLiveActivities.perform(method: method, args: args, enabled: config.enableLiveActivities, appName: config.appName, healthEnabled: config.enableHealthKit) { [weak self] result in
8247
8285
  guard let self, self.claimDeadline(token) else { return }
8248
8286
  switch result {
8249
8287
  case .success(let value): self.resolveCallback(callbackId, result: value)
@@ -1011,6 +1011,17 @@ final class CraftHealth {
1011
1011
  }
1012
1012
  }
1013
1013
 
1014
+ /// Only the Live Activity that started this runtime can release it. HQ
1015
+ /// persists its own final result, so collecting here must not duplicate it.
1016
+ func discardActivityWorkout(expectedID: String) {
1017
+ guard #available(iOS 26.0, *), liveWorkoutID == expectedID,
1018
+ let session = liveWorkoutSession as? HKWorkoutSession,
1019
+ let builder = liveWorkoutBuilder as? HKLiveWorkoutBuilder else { return }
1020
+ session.end()
1021
+ builder.discardWorkout()
1022
+ clearLiveWorkout()
1023
+ }
1024
+
1014
1025
  private func clearLiveWorkout() {
1015
1026
  liveWorkoutSession = nil
1016
1027
  liveWorkoutBuilder = nil
@@ -1052,6 +1063,9 @@ final class CraftHealth {
1052
1063
  case "elliptical": return .elliptical
1053
1064
  case "strength": return .traditionalStrengthTraining
1054
1065
  case "hiit": return .highIntensityIntervalTraining
1066
+ case "flexibility": return .flexibility
1067
+ case "yoga": return .yoga
1068
+ case "other": return .other
1055
1069
  default: return nil
1056
1070
  }
1057
1071
  }
@@ -1664,6 +1664,7 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
1664
1664
  (nav.topViewController as? CraftNativeScreenController)?.screenVisibilityChanged(true)
1665
1665
  }
1666
1666
  currentTab = id
1667
+ if let top = nav.topViewController { CraftChrome.shared.visiblePath = nativePaths[ObjectIdentifier(top)] }
1667
1668
  for (key, other) in stacks { other.view.isHidden = key != id }
1668
1669
  nav.view.frame = view.bounds
1669
1670
  view.bringSubviewToFront(nav.view)
@@ -2042,6 +2043,9 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
2042
2043
  func navigationController(_ navigationController: UINavigationController, willShow viewController: UIViewController, animated: Bool) {
2043
2044
  lastNavigation = CACurrentMediaTime()
2044
2045
  if animated { CraftFramePacing.shared.begin("navigation") }
2046
+ if navigationController === visibleStack {
2047
+ CraftChrome.shared.visiblePath = nativePaths[ObjectIdentifier(viewController)]
2048
+ }
2045
2049
  // The page draws its own header, and a tab's root screen its own
2046
2050
  // title unless it asked for the bar; a pushed native screen gets the
2047
2051
  // bar and its Back.
@@ -2084,6 +2088,8 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
2084
2088
  hybrid.pageReset(to: rootPath)
2085
2089
  }
2086
2090
  guard navigationController === visibleStack else { return }
2091
+ // Also restore selection after a cancelled interactive pop.
2092
+ CraftChrome.shared.visiblePath = nativePaths[ObjectIdentifier(viewController)]
2087
2093
  if let slot = viewController as? CraftHybridWebSlot { adoptWeb(into: slot) } else { parkWeb() }
2088
2094
  }
2089
2095
 
@@ -1,10 +1,16 @@
1
1
  import ActivityKit
2
2
  import Foundation
3
+ import HealthKit
3
4
 
4
5
  /// ActivityKit belongs to the process, not a web view or a retained STX screen.
5
6
  /// A stable application activityId reattaches after a screen or app restart.
6
7
  enum CraftLiveActivities {
7
- static func perform(method: String, args: [Any], enabled: Bool, appName: String, completion: @escaping (Result<Any, CraftNativeActionError>) -> Void) {
8
+ private static var scheduled: [String: Task<Void, Never>] = [:]
9
+ private static var watching: [String: Task<Void, Never>] = [:]
10
+ private static var workoutOwner: (key: String, id: String)?
11
+ private static var startingWorkout: String?
12
+
13
+ static func perform(method: String, args: [Any], enabled: Bool, appName: String, healthEnabled: Bool = false, completion: @escaping (Result<Any, CraftNativeActionError>) -> Void) {
8
14
  guard enabled else {
9
15
  if method == "isAvailable" { completion(.success(false)); return }
10
16
  completion(.failure(CraftNativeActionError(code: "CAPABILITY_DISABLED", message: "Live Activities are disabled")))
@@ -33,11 +39,17 @@ enum CraftLiveActivities {
33
39
  let state = content(body)
34
40
  if let existing = activities.first(where: { $0.attributes.activityId == key }) {
35
41
  await existing.update(ActivityContent(state: state, staleDate: staleDate(body)))
42
+ watch(existing)
43
+ schedule(existing, plan: plan(body))
44
+ startWorkout(existing, body: body, enabled: healthEnabled)
36
45
  completion(.success(["id": existing.id]))
37
46
  return
38
47
  }
39
48
  let attributes = CraftActivityAttributes(activityId: key, title: String(title.prefix(160)), symbol: body["symbol"] as? String, deepLink: safeURL(body["deepLink"] as? String), tint: body["tint"] as? String)
40
49
  let activity = try Activity.request(attributes: attributes, content: ActivityContent(state: state, staleDate: staleDate(body)), pushType: nil)
50
+ watch(activity)
51
+ schedule(activity, plan: plan(body))
52
+ startWorkout(activity, body: body, enabled: healthEnabled)
41
53
  completion(.success(["id": activity.id]))
42
54
  case "update", "end":
43
55
  let id = args.first as? String
@@ -47,12 +59,21 @@ enum CraftLiveActivities {
47
59
  throw CraftNativeActionError(code: "NOT_FOUND", message: "No matching Live Activity is running")
48
60
  }
49
61
  let body = args.count > 1 ? args[1] as? [String: Any] ?? [:] : [:]
50
- let state = content(body, previous: activity.content.state)
62
+ let previous = activity.content.state
63
+ let state = content(body, previous: previous)
51
64
  if method == "end" {
65
+ watching.removeValue(forKey: activity.id)?.cancel()
66
+ scheduled.removeValue(forKey: activity.id)?.cancel()
67
+ stopWorkout(activity.attributes.activityId)
52
68
  await activity.end(ActivityContent(state: state, staleDate: nil), dismissalPolicy: .immediate)
53
69
  completion(.success(["ended": true]))
54
70
  } else {
55
71
  await activity.update(ActivityContent(state: state, staleDate: staleDate(body)))
72
+ schedule(activity, plan: plan(body))
73
+ if let owner = workoutOwner, owner.key == activity.attributes.activityId, previous.paused != state.paused {
74
+ if state.paused == true { CraftHealth.shared.pauseWorkout { _ in } }
75
+ else { CraftHealth.shared.resumeWorkout { _ in } }
76
+ }
56
77
  completion(.success(["updated": true]))
57
78
  }
58
79
  default: throw CraftNativeActionError(code: "UNKNOWN_ACTION", message: "Unsupported Live Activities method")
@@ -62,6 +83,88 @@ enum CraftLiveActivities {
62
83
  }
63
84
  }
64
85
 
86
+ /// Dismissing a Live Activity must release its own background workout
87
+ /// runtime as well, even though it no longer appears in the active list.
88
+ @available(iOS 16.2, *)
89
+ @MainActor private static func watch(_ activity: Activity<CraftActivityAttributes>) {
90
+ guard watching[activity.id] == nil else { return }
91
+ watching[activity.id] = Task { @MainActor in
92
+ for await state in activity.activityStateUpdates {
93
+ if Task.isCancelled { return }
94
+ if state == .ended || state == .dismissed {
95
+ scheduled.removeValue(forKey: activity.id)?.cancel()
96
+ stopWorkout(activity.attributes.activityId)
97
+ watching.removeValue(forKey: activity.id)
98
+ return
99
+ }
100
+ }
101
+ }
102
+ }
103
+
104
+ /// The real HealthKit workout owns background execution on iOS 26. It
105
+ /// collects while training; the caller's existing save path writes the
106
+ /// final workout once. Never stop an unrelated HealthKit session.
107
+ @available(iOS 16.2, *)
108
+ @MainActor private static func startWorkout(_ activity: Activity<CraftActivityAttributes>, body: [String: Any], enabled: Bool) {
109
+ guard enabled, #available(iOS 26.0, *), let type = body["workoutType"] as? String,
110
+ workoutOwner == nil, startingWorkout == nil,
111
+ HKHealthStore().authorizationStatus(for: .workoutType()) == .sharingAuthorized else { return }
112
+ let key = activity.attributes.activityId
113
+ startingWorkout = key
114
+ CraftHealth.shared.startWorkout(body: ["type": type]) { result in
115
+ Task { @MainActor in
116
+ startingWorkout = nil
117
+ guard case .success(let value) = result, let value = value as? [String: Any], let id = value["id"] as? String else { return }
118
+ guard activity.activityState == .active || activity.activityState == .stale else {
119
+ CraftHealth.shared.discardActivityWorkout(expectedID: id)
120
+ return
121
+ }
122
+ workoutOwner = (key, id)
123
+ if activity.content.state.paused == true { CraftHealth.shared.pauseWorkout { _ in } }
124
+ }
125
+ }
126
+ }
127
+
128
+ @MainActor private static func stopWorkout(_ key: String) {
129
+ guard let owner = workoutOwner, owner.key == key else { return }
130
+ workoutOwner = nil
131
+ CraftHealth.shared.discardActivityWorkout(expectedID: owner.id)
132
+ }
133
+
134
+ /// Step boundaries, independently of a retained screen or JS timer.
135
+ /// HealthKit, GPS and BLE use their documented background execution;
136
+ /// without permission iOS may suspend updates until the next app wake.
137
+ @available(iOS 16.2, *)
138
+ @MainActor private static func schedule(_ activity: Activity<CraftActivityAttributes>, plan: [CraftActivityAttributes.Transition]) {
139
+ scheduled.removeValue(forKey: activity.id)?.cancel()
140
+ guard activity.content.state.paused != true, !plan.isEmpty else { return }
141
+ scheduled[activity.id] = Task { @MainActor in
142
+ var timeline = CraftActivityTimeline(pending: plan)
143
+ while !Task.isCancelled, let next = timeline.pending.first {
144
+ let delay = max(0, next.at / 1000 - Date().timeIntervalSince1970)
145
+ do { try await Task.sleep(nanoseconds: UInt64(min(delay, 86400) * 1_000_000_000)) }
146
+ catch { return }
147
+ guard !Task.isCancelled, activity.activityState == .active || activity.activityState == .stale else { return }
148
+ var state = activity.content.state
149
+ timeline.advance(&state, to: Date().timeIntervalSince1970 * 1000)
150
+ if state.paused == true { stopWorkout(activity.attributes.activityId) }
151
+ await activity.update(ActivityContent(state: state, staleDate: timeline.pending.last.map { Date(timeIntervalSince1970: $0.at / 1000 + ($0.duration ?? 8 * 3600) + 1) }))
152
+ }
153
+ scheduled.removeValue(forKey: activity.id)
154
+ }
155
+ }
156
+
157
+ @available(iOS 16.1, *)
158
+ private static func plan(_ body: [String: Any]) -> [CraftActivityAttributes.Transition] {
159
+ let steps = (body["transitions"] as? [[String: Any]] ?? []).prefix(1000).compactMap { item -> CraftActivityAttributes.Transition? in
160
+ guard let at = finite(item, "at"), let detail = item["detail"] as? String else { return nil }
161
+ return .init(at: at, detail: String(detail.prefix(96)), duration: finite(item, "duration"))
162
+ }.sorted { $0.at < $1.at }
163
+ // An untimed exercise waits for the athlete. Never infer its completion.
164
+ if let stop = steps.firstIndex(where: { $0.duration == nil || $0.duration == 0 }) { return Array(steps.prefix(stop + 1)) }
165
+ return steps
166
+ }
167
+
65
168
  private static func safeURL(_ value: String?) -> String? {
66
169
  guard let value, let url = URL(string: value), let scheme = url.scheme?.lowercased(),
67
170
  !["javascript", "data", "file"].contains(scheme) else { return nil }
@@ -87,6 +190,7 @@ enum CraftLiveActivities {
87
190
  progress: min(finite(body, "progress", previous?.progress) ?? 0, 1),
88
191
  staleStatus: body["staleStatus"] as? String ?? previous?.staleStatus,
89
192
  detail: body["detail"] as? String ?? previous?.detail,
193
+ sessionStartedAt: finite(body, "sessionStartedAt", previous?.sessionStartedAt),
90
194
  timerStartedAt: finite(body, "timerStartedAt", previous?.timerStartedAt),
91
195
  timerEndsAt: finite(body, "timerEndsAt", previous?.timerEndsAt),
92
196
  paused: body["paused"] as? Bool ?? previous?.paused,
@@ -61,7 +61,7 @@ struct CraftLiveActivityWidget: Widget {
61
61
  } compactLeading: {
62
62
  activityIcon(context)
63
63
  } compactTrailing: {
64
- timer(context).monospacedDigit().frame(maxWidth: 64, alignment: .trailing)
64
+ timer(context).monospacedDigit().frame(width: 64, alignment: .trailing)
65
65
  } minimal: {
66
66
  activityIcon(context)
67
67
  }
@@ -43,6 +43,7 @@ enum CraftNativeActions {
43
43
  // `speech`, `audio` (background sound) and `eventStream` (a fetch read
44
44
  // as it arrives) need nothing the app has to declare.
45
45
  var values = ["device", "clipboard", "haptics", "storage", "lifecycle", "fetch", "dialog", "speech", "audio", "eventStream", "linking", "badge", "animation", "viewFocus", "viewMeasure", "viewReparent", "viewNonModalPresentation", "nativePlatformControls"]
46
+ values.append("nativePager")
46
47
  values.append("storageCompareAndSet")
47
48
  values.append("storageSetIfAbsent")
48
49
  values.append("viewScroll")
@@ -94,6 +95,7 @@ enum CraftNativeActions {
94
95
  // which the native Video element plays in place of the address.
95
96
  values.append("media")
96
97
  if config.enableCamera { values.append("camera") }
98
+ if config.enableAudioRecording { values.append("audioRecording") }
97
99
  // craft.crashReporter: the app's crash and error reporter (bughq).
98
100
  if config.crashReporting?.key.isEmpty == false { values.append("crashReporter") }
99
101
  return values
@@ -372,7 +374,7 @@ enum CraftNativeActions {
372
374
  }
373
375
  return
374
376
  case ("LiveActivities", _):
375
- CraftLiveActivities.perform(method: method, args: args, enabled: config.enableLiveActivities, appName: config.appName) { result in
377
+ CraftLiveActivities.perform(method: method, args: args, enabled: config.enableLiveActivities, appName: config.appName, healthEnabled: config.enableHealthKit) { result in
376
378
  complete(requestToken, result, completion)
377
379
  }
378
380
  return
@@ -554,6 +556,13 @@ enum CraftNativeActions {
554
556
  CraftNativeShare.share(options) { complete(requestToken, $0, completion) }
555
557
  }
556
558
  return
559
+ case ("AudioRecording", _):
560
+ guard config.enableAudioRecording else {
561
+ complete(requestToken, failure("CAPABILITY_DISABLED", "Audio recording is disabled"), completion)
562
+ return
563
+ }
564
+ CraftVoiceRecording.shared.perform(method: method, args: args) { result in complete(requestToken, result, completion) }
565
+ return
557
566
  case ("Speech", "speak"):
558
567
  // Settles when the words have been said (true) or were cut off
559
568
  // (false), as the page's `craft.speech.speak` does.
@@ -1,4 +1,5 @@
1
1
  import Foundation
2
+ import zlib
2
3
  import CoreFoundation
3
4
  import ImageIO
4
5
  import AVFoundation
@@ -2744,6 +2745,7 @@ final class CraftNativeCelebration {
2744
2745
  }
2745
2746
 
2746
2747
  private class CraftNativeFlowView: UIStackView {
2748
+ var pageViewport: CGSize? { didSet { if pageViewport != oldValue { invalidateMeasurements() } } }
2747
2749
  var celebration: CraftNativeCelebration?
2748
2750
  func configureCelebration(_ raw: Any?) {
2749
2751
  if raw != nil && celebration == nil { celebration = CraftNativeCelebration(view: self) }
@@ -2966,6 +2968,7 @@ private class CraftNativeFlowView: UIStackView {
2966
2968
  }
2967
2969
 
2968
2970
  func measure(width: CGFloat?, height: CGFloat?, maxWidth: CGFloat, maxHeight: CGFloat) -> CGSize {
2971
+ if let page = pageViewport { return CGSize(width: page.width * CGFloat(arrangedSubviews.filter { !$0.isHidden }.count), height: page.height) }
2969
2972
  let key = MeasureKey(width: width, height: height, maxWidth: maxWidth, maxHeight: maxHeight)
2970
2973
  if let cached = measurements[key] { return cached }
2971
2974
  let size = layout(width: width, height: height, maxWidth: maxWidth, maxHeight: maxHeight, place: false).size
@@ -2977,6 +2980,12 @@ private class CraftNativeFlowView: UIStackView {
2977
2980
  super.layoutSubviews()
2978
2981
  craftApplyCornerRadius()
2979
2982
  celebration?.layout()
2983
+ if let page = pageViewport {
2984
+ for (index, child) in arrangedSubviews.filter({ !$0.isHidden }).enumerated() {
2985
+ child.frame = CGRect(x: CGFloat(index) * page.width, y: 0, width: page.width, height: page.height)
2986
+ }
2987
+ return
2988
+ }
2980
2989
  let scale = window?.screen.scale ?? traitCollection.displayScale
2981
2990
  let result = layout(width: bounds.width, height: bounds.height, maxWidth: bounds.width, maxHeight: bounds.height, place: true)
2982
2991
  var moved = false
@@ -4344,6 +4353,46 @@ private final class CraftNativeScrollView: UIScrollView, UIGestureRecognizerDele
4344
4353
  private var laidOutSize: CGSize = .zero
4345
4354
  private let pullToRefresh = UIRefreshControl()
4346
4355
  private var refreshHandler: (() -> Void)?
4356
+ private var authoredPageIndex: Int?
4357
+ private var pendingPageIndex: Int?
4358
+ private var currentPageIndex = 0
4359
+ private var pageAnimating = false
4360
+ private var reportedPage: (Int, Int)?
4361
+ private var pageHandler: ((Int, Int) -> Void)?
4362
+
4363
+ /// Opt-in viewport-sized pages. Ordinary horizontal scroll strips retain
4364
+ /// their intrinsic widths; only a controlled pageIndex enables this mode.
4365
+ func configurePager(_ raw: Any?, onSelect: ((Int, Int) -> Void)?) {
4366
+ let next = craftNativeNumber(raw).flatMap { number -> Int? in
4367
+ let value = number.doubleValue
4368
+ return value.isFinite ? Int(min(Double(Int.max / 2), max(0, value))) : nil
4369
+ }
4370
+ if next != authoredPageIndex { pendingPageIndex = next; contentNeedsLayout = true }
4371
+ authoredPageIndex = next
4372
+ pageHandler = onSelect
4373
+ if next == nil { contentStack.pageViewport = nil; reportedPage = nil; pageAnimating = false }
4374
+ setNeedsLayout()
4375
+ }
4376
+
4377
+ func beginPageDrag() { pageAnimating = false }
4378
+
4379
+ func finishPageMotion() {
4380
+ pageAnimating = false
4381
+ DispatchQueue.main.async { [weak self] in self?.settlePage() }
4382
+ }
4383
+
4384
+ func settlePage() {
4385
+ guard authoredPageIndex != nil, bounds.width > 0, !pageAnimating,
4386
+ !isTracking, !isDragging, !isDecelerating else { return }
4387
+ let pages = contentStack.arrangedSubviews.filter { !$0.isHidden }
4388
+ let index = min(max(0, Int((contentOffset.x / bounds.width).rounded())), max(0, pages.count - 1))
4389
+ currentPageIndex = index
4390
+ for (i, child) in pages.enumerated() { child.accessibilityElementsHidden = i != index }
4391
+ if reportedPage?.0 != index || reportedPage?.1 != pages.count {
4392
+ reportedPage = (index, pages.count)
4393
+ pageHandler?(index, pages.count)
4394
+ }
4395
+ }
4347
4396
 
4348
4397
  override init(frame: CGRect) {
4349
4398
  super.init(frame: frame)
@@ -4384,6 +4433,11 @@ private final class CraftNativeScrollView: UIScrollView, UIGestureRecognizerDele
4384
4433
  /// The size of the content laid out across the visible width (or, sideways,
4385
4434
  /// height), which is also the scroll view's own size when nothing fixes it.
4386
4435
  func contentFitting(width: CGFloat?, height: CGFloat?) -> CGSize {
4436
+ if authoredPageIndex != nil && !isVertical {
4437
+ let pageWidth = max(0, width ?? bounds.width)
4438
+ let pageHeight = max(0, height ?? bounds.height)
4439
+ return CGSize(width: pageWidth * CGFloat(contentStack.arrangedSubviews.filter { !$0.isHidden }.count), height: pageHeight)
4440
+ }
4387
4441
  if isVertical {
4388
4442
  let contentWidth = max(0, (width ?? bounds.width) - adjustedContentInset.left - adjustedContentInset.right)
4389
4443
  return contentStack.measure(width: contentWidth, height: nil, maxWidth: contentWidth, maxHeight: CraftNativeFlexLayout.unbounded)
@@ -4406,7 +4460,9 @@ private final class CraftNativeScrollView: UIScrollView, UIGestureRecognizerDele
4406
4460
  craftApplyCornerRadius()
4407
4461
  if contentNeedsLayout || laidOutSize != bounds.size {
4408
4462
  contentNeedsLayout = false
4463
+ let resized = laidOutSize != bounds.size
4409
4464
  laidOutSize = bounds.size
4465
+ if authoredPageIndex != nil && !isVertical { contentStack.pageViewport = bounds.size }
4410
4466
  var size = contentFitting(width: bounds.width, height: bounds.height)
4411
4467
  if isVertical {
4412
4468
  size.width = max(0, bounds.width - adjustedContentInset.left - adjustedContentInset.right)
@@ -4416,12 +4472,31 @@ private final class CraftNativeScrollView: UIScrollView, UIGestureRecognizerDele
4416
4472
  let frame = CGRect(origin: .zero, size: size)
4417
4473
  if contentStack.frame != frame { contentStack.frame = frame }
4418
4474
  if contentSize != size { contentSize = size }
4475
+ if authoredPageIndex != nil && !isVertical {
4476
+ contentStack.layoutIfNeeded()
4477
+ let count = contentStack.arrangedSubviews.filter { !$0.isHidden }.count
4478
+ let index = min(max(0, pendingPageIndex ?? currentPageIndex), max(0, count - 1))
4479
+ let target = CGPoint(x: CGFloat(index) * bounds.width, y: 0)
4480
+ let moves = pendingPageIndex != nil || resized || index != currentPageIndex
4481
+ let animated = moves && target != contentOffset && !resized && !UIAccessibility.isReduceMotionEnabled
4482
+ if moves {
4483
+ pageAnimating = animated
4484
+ setContentOffset(target, animated: animated)
4485
+ }
4486
+ pendingPageIndex = nil
4487
+ currentPageIndex = index
4488
+ if !animated && !isTracking && !isDragging && !isDecelerating { settlePage() }
4489
+ }
4419
4490
  }
4420
4491
  // A scroll view lays out on every frame of a scroll: the sticky
4421
4492
  // headers follow it here, over content placed by this same pass.
4422
4493
  if !stickyIndices.isEmpty || pendingScrollTarget != nil { contentStack.layoutIfNeeded() }
4423
4494
  updateStickyHeaders()
4424
4495
  scrollToPendingTarget()
4496
+ // A nonanimated target changes the offset synchronously inside this
4497
+ // layout pass. Rebase the headers against that actual viewport, not
4498
+ // the viewport from before the target was applied.
4499
+ updateStickyHeaders()
4425
4500
  }
4426
4501
 
4427
4502
  // MARK: Sticky headers
@@ -4444,7 +4519,7 @@ private final class CraftNativeScrollView: UIScrollView, UIGestureRecognizerDele
4444
4519
  view.center.y - view.bounds.height / 2
4445
4520
  }
4446
4521
 
4447
- private func updateStickyHeaders() {
4522
+ fileprivate func updateStickyHeaders() {
4448
4523
  let children = contentStack.arrangedSubviews
4449
4524
  let headers = isVertical ? stickyIndices.compactMap { $0 < children.count ? children[$0] : nil }.filter { !$0.isHidden } : []
4450
4525
  for view in stickyViews where !headers.contains(where: { $0 === view }) {
@@ -4617,11 +4692,13 @@ private final class CraftNativeScrollView: UIScrollView, UIGestureRecognizerDele
4617
4692
  craftScrollGesture(gestureRecognizer, runsWith: otherGestureRecognizer)
4618
4693
  }
4619
4694
 
4620
- func setAxis(_ axis: NSLayoutConstraint.Axis) {
4695
+ func setAxis(_ axis: NSLayoutConstraint.Axis, resetBounce: Bool = true) {
4621
4696
  if contentStack.axis != axis { setContentNeedsLayout() }
4622
4697
  contentStack.axis = axis
4623
- alwaysBounceVertical = axis == .vertical
4624
- alwaysBounceHorizontal = axis == .horizontal
4698
+ if resetBounce {
4699
+ alwaysBounceVertical = axis == .vertical
4700
+ alwaysBounceHorizontal = axis == .horizontal
4701
+ }
4625
4702
  }
4626
4703
  }
4627
4704
 
@@ -5474,10 +5551,36 @@ enum CraftNativeBundle {
5474
5551
  return read(url)
5475
5552
  }
5476
5553
 
5554
+ /// gzip assets are immutable, decoded lazily and held in the same bounded cache.
5555
+ private static func unpack(_ bytes: Data) -> Data? {
5556
+ guard bytes.starts(with: [0x1f, 0x8b]) else { return bytes }
5557
+ return bytes.withUnsafeBytes { raw -> Data? in
5558
+ var stream = z_stream()
5559
+ guard inflateInit2_(&stream, 15 + 32, ZLIB_VERSION, Int32(MemoryLayout<z_stream>.size)) == Z_OK else { return nil }
5560
+ defer { inflateEnd(&stream) }
5561
+ stream.next_in = UnsafeMutablePointer<Bytef>(mutating: raw.bindMemory(to: Bytef.self).baseAddress)
5562
+ stream.avail_in = uInt(bytes.count)
5563
+ var result = Data()
5564
+ var chunk = [UInt8](repeating: 0, count: 32768)
5565
+ var status = Z_OK
5566
+ repeat {
5567
+ status = chunk.withUnsafeMutableBytes { output in
5568
+ stream.next_out = output.bindMemory(to: Bytef.self).baseAddress
5569
+ stream.avail_out = uInt(output.count)
5570
+ return inflate(&stream, Z_NO_FLUSH)
5571
+ }
5572
+ guard status == Z_OK || status == Z_STREAM_END else { return nil }
5573
+ result.append(contentsOf: chunk.prefix(chunk.count - Int(stream.avail_out)))
5574
+ guard result.count <= 64 << 20 else { return nil }
5575
+ } while status != Z_STREAM_END
5576
+ return result
5577
+ }
5578
+ }
5579
+
5477
5580
  private static func read(_ url: URL) -> (source: String, url: URL)? {
5478
5581
  let key = url.path as NSString
5479
5582
  if let cached = sources.object(forKey: key) { return (cached as String, url) }
5480
- guard let text = try? String(contentsOf: url, encoding: .utf8) else { return nil }
5583
+ guard let bytes = try? Data(contentsOf: url), let decoded = unpack(bytes), let text = String(data: decoded, encoding: .utf8) else { return nil }
5481
5584
  sources.setObject(text as NSString, forKey: key, cost: text.utf8.count)
5482
5585
  return (text, url)
5483
5586
  }
@@ -5624,6 +5727,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
5624
5727
  /// itself to be in front and acts on it: a sign-in guard on a prepared
5625
5728
  /// Record tab pushed Login over the Feed at launch.
5626
5729
  private var reportedVisible: Bool?
5730
+ private var voiceRecordingOwner: String?
5627
5731
  private let capabilityScope = UUID().uuidString
5628
5732
  private var pendingCapabilityRequests = Set<String>()
5629
5733
  private var pendingCapabilityDeadlines: [String: DispatchWorkItem] = [:]
@@ -5894,6 +5998,10 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
5894
5998
  private func disposeRuntime() {
5895
5999
  guard !runtimeDisposed else { return }
5896
6000
  runtimeDisposed = true
6001
+ if let owner = voiceRecordingOwner {
6002
+ voiceRecordingOwner = nil
6003
+ CraftVoiceRecording.shared.perform(method: "cancel", args: [owner]) { _ in }
6004
+ }
5897
6005
  frameWanted = false
5898
6006
  CraftNativeFrameClock.shared.cancel(self)
5899
6007
  disposeRenderedTree()
@@ -7598,6 +7706,24 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
7598
7706
  }
7599
7707
  let id = args.first as? String ?? ""
7600
7708
  switch (module, method) {
7709
+ case ("AudioRecording", _):
7710
+ guard config.enableAudioRecording else {
7711
+ reply(.failure(CraftNativeActionError(code: "CAPABILITY_DISABLED", message: "Audio recording is disabled")))
7712
+ return true
7713
+ }
7714
+ if method == "start" {
7715
+ guard voiceRecordingOwner == nil else {
7716
+ reply(.failure(CraftNativeActionError(code: "RECORDING_BUSY", message: "This screen already owns a recording")))
7717
+ return true
7718
+ }
7719
+ voiceRecordingOwner = id
7720
+ }
7721
+ if method == "cancel", voiceRecordingOwner == id { voiceRecordingOwner = nil }
7722
+ CraftVoiceRecording.shared.perform(method: method, args: args, isActive: { [weak self] in self?.runtimeDisposed == false }) { [weak self] result in
7723
+ if case .failure = result, self?.voiceRecordingOwner == id { self?.voiceRecordingOwner = nil }
7724
+ reply(result)
7725
+ }
7726
+ return true
7601
7727
  case ("View", "focus"):
7602
7728
  let options = args.first as? [String: Any]
7603
7729
  let nodeId = options?["nodeId"] as? String ?? (args.first as? String) ?? ""
@@ -8461,6 +8587,10 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
8461
8587
  scroll.isScrollEnabled = props["scrollEnabled"] as? Bool != false
8462
8588
  scroll.bounces = props["bounces"] as? Bool ?? true
8463
8589
  scroll.isPagingEnabled = props["pagingEnabled"] as? Bool ?? false
8590
+ let pageHandler = nonEmptyHandler(events["onPageSelected"])
8591
+ scroll.configurePager(props["pageIndex"], onSelect: pageHandler.map { handler in
8592
+ { [weak self] index, count in self?.send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": ["index": index, "count": count]]) }
8593
+ })
8464
8594
  scroll.keyboardDismissMode = keyboardDismissMode(props["keyboardDismissMode"])
8465
8595
  scroll.setKeyboardShouldPersistTaps(props["keyboardShouldPersistTaps"])
8466
8596
  scroll.showsVerticalScrollIndicator = props["showsVerticalScrollIndicator"] as? Bool != false
@@ -8492,6 +8622,10 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
8492
8622
  contentStyle.merge(override) { _, next in next }
8493
8623
  }
8494
8624
  configureStack(scroll.contentStack, style: contentStyle)
8625
+ // Content-style defaults are vertical; the scroll host's explicit
8626
+ // axis must survive that shared container configuration without
8627
+ // resetting the authored bounce options applied above.
8628
+ scroll.setAxis(direction, resetBounce: false)
8495
8629
  reconcileChildren(children.filter {
8496
8630
  guard let child = $0 as? [String: Any] else { return true }
8497
8631
  return canonicalType(child["type"] as? String ?? "") != "RefreshControl"
@@ -9712,10 +9846,28 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
9712
9846
  for: size,
9713
9847
  compatibleWith: traitCollection
9714
9848
  )
9715
- var font = (style["fontFamily"] as? String).flatMap { UIFont(name: $0, size: scaledSize) }
9716
- ?? .systemFont(ofSize: scaledSize, weight: weight)
9849
+ let families = (style["fontFamily"] as? String ?? "").split(separator: ",").map {
9850
+ $0.trimmingCharacters(in: .whitespacesAndNewlines).trimmingCharacters(in: CharacterSet(charactersIn: "\"'"))
9851
+ }
9852
+ let selectedFont = families.lazy.compactMap { family -> UIFont? in
9853
+ switch family.lowercased() {
9854
+ case "monospace", "ui-monospace": return .monospacedSystemFont(ofSize: scaledSize, weight: weight)
9855
+ case "serif", "ui-serif":
9856
+ let system = UIFont.systemFont(ofSize: scaledSize, weight: weight)
9857
+ return system.fontDescriptor.withDesign(.serif).map { UIFont(descriptor: $0, size: scaledSize) }
9858
+ case "sans-serif", "ui-sans-serif", "system-ui", "-apple-system": return .systemFont(ofSize: scaledSize, weight: weight)
9859
+ default:
9860
+ guard let custom = UIFont(name: family, size: scaledSize) else { return nil }
9861
+ let weighted = custom.fontDescriptor.addingAttributes([.traits: [UIFontDescriptor.TraitKey.weight: weight.rawValue]])
9862
+ var traits = custom.fontDescriptor.symbolicTraits
9863
+ if weight >= .semibold { traits.insert(.traitBold) } else { traits.remove(.traitBold) }
9864
+ let descriptor = weighted.withSymbolicTraits(traits) ?? weighted
9865
+ return UIFont(descriptor: descriptor, size: scaledSize)
9866
+ }
9867
+ }.first
9868
+ var font = selectedFont ?? .systemFont(ofSize: scaledSize, weight: weight)
9717
9869
  if style["fontStyle"] as? String == "italic",
9718
- let descriptor = font.fontDescriptor.withSymbolicTraits(.traitItalic) {
9870
+ let descriptor = font.fontDescriptor.withSymbolicTraits(font.fontDescriptor.symbolicTraits.union(.traitItalic)) {
9719
9871
  font = UIFont(descriptor: descriptor, size: scaledSize)
9720
9872
  }
9721
9873
  return font
@@ -10502,6 +10654,9 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
10502
10654
  }
10503
10655
 
10504
10656
  func scrollViewDidScroll(_ scrollView: UIScrollView) {
10657
+ // Sticky positioning is a native scroll behavior and must also run
10658
+ // without a JavaScript onScroll subscription, including animations.
10659
+ (scrollView as? CraftNativeScrollView)?.updateStickyHeaders()
10505
10660
  guard let handler = scrollHandlers[ObjectIdentifier(scrollView)] else { return }
10506
10661
  send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": scrollEvent(scrollView)])
10507
10662
  }
@@ -10510,12 +10665,13 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
10510
10665
 
10511
10666
  func scrollViewWillBeginDragging(_ scrollView: UIScrollView) {
10512
10667
  CraftFramePacing.shared.begin(pacingName)
10668
+ (scrollView as? CraftNativeScrollView)?.beginPageDrag()
10513
10669
  guard let handler = scrollBeginHandlers[ObjectIdentifier(scrollView)] else { return }
10514
10670
  send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": scrollEvent(scrollView)])
10515
10671
  }
10516
10672
 
10517
10673
  func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) {
10518
- if !decelerate { CraftFramePacing.shared.end(pacingName) }
10674
+ if !decelerate { CraftFramePacing.shared.end(pacingName); (scrollView as? CraftNativeScrollView)?.finishPageMotion() }
10519
10675
  guard let handler = scrollEndHandlers[ObjectIdentifier(scrollView)] else { return }
10520
10676
  send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": scrollEvent(scrollView)])
10521
10677
  }
@@ -10527,10 +10683,15 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
10527
10683
 
10528
10684
  func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
10529
10685
  CraftFramePacing.shared.end(pacingName)
10686
+ (scrollView as? CraftNativeScrollView)?.finishPageMotion()
10530
10687
  guard let handler = scrollMomentumEndHandlers[ObjectIdentifier(scrollView)] else { return }
10531
10688
  send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": scrollEvent(scrollView)])
10532
10689
  }
10533
10690
 
10691
+ func scrollViewDidEndScrollingAnimation(_ scrollView: UIScrollView) {
10692
+ (scrollView as? CraftNativeScrollView)?.finishPageMotion()
10693
+ }
10694
+
10534
10695
  private func scrollEvent(_ scrollView: UIScrollView) -> [String: Any] {
10535
10696
  [
10536
10697
  "contentOffset": ["x": scrollView.contentOffset.x, "y": scrollView.contentOffset.y],
@@ -0,0 +1,75 @@
1
+ import AVFoundation
2
+ import Foundation
3
+ import UIKit
4
+
5
+ /// One owner at a time, including the asynchronous permission prompt. A late
6
+ /// permission answer cannot start recording after its screen/account left.
7
+ final class CraftVoiceRecording {
8
+ static let shared = CraftVoiceRecording()
9
+ private var owner: String?
10
+ private var recorder: AVAudioRecorder?
11
+ private var file: URL?
12
+ private var backgroundObserver: NSObjectProtocol?
13
+
14
+ private init() {
15
+ backgroundObserver = NotificationCenter.default.addObserver(forName: UIApplication.didEnterBackgroundNotification, object: nil, queue: .main) { [weak self] _ in self?.clear() }
16
+ }
17
+
18
+ private func clear() {
19
+ owner = nil
20
+ recorder?.stop()
21
+ recorder = nil
22
+ if let file { CraftNativeFiles.remove(file.path) }
23
+ file = nil
24
+ try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)
25
+ }
26
+
27
+ func perform(method: String, args: [Any], isActive: @escaping () -> Bool = { true }, completion: @escaping (Result<Any, CraftNativeActionError>) -> Void) {
28
+ DispatchQueue.main.async { self.performOnMain(method: method, args: args, isActive: isActive, completion: completion) }
29
+ }
30
+
31
+ private func performOnMain(method: String, args: [Any], isActive: @escaping () -> Bool, completion: @escaping (Result<Any, CraftNativeActionError>) -> Void) {
32
+ func fail(_ code: String, _ message: String) { completion(.failure(CraftNativeActionError(code: code, message: message))) }
33
+ guard let key = args.first as? String, UUID(uuidString: key) != nil else { fail("INVALID_ARGUMENT", "A recording owner UUID is required"); return }
34
+ if method == "cancel" {
35
+ if owner == key { clear() }
36
+ completion(.success(true))
37
+ return
38
+ }
39
+ if method == "stop" {
40
+ guard owner == key, let file else { fail("RECORDING_NOT_FOUND", "This recording is no longer available"); return }
41
+ recorder?.stop()
42
+ recorder = nil
43
+ try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)
44
+ guard let audio = try? AVAudioFile(forReading: file), audio.length > 0 else { clear(); fail("EMPTY_RECORDING", "The recording was empty"); return }
45
+ let duration = Double(audio.length) / audio.processingFormat.sampleRate
46
+ completion(.success(CraftNativeFiles.describe(file, type: "audio/mp4", extra: ["durationSeconds": duration])))
47
+ return
48
+ }
49
+ guard method == "start" else { fail("UNKNOWN_ACTION", "Unknown voice recording action"); return }
50
+ guard isActive() else { fail("RECORDING_CANCELLED", "The requesting screen has closed"); return }
51
+ guard owner == nil else { fail("RECORDING_BUSY", "Another microphone session is still active"); return }
52
+ let options = args.count > 1 ? args[1] as? [String: Any] ?? [:] : [:]
53
+ let seconds = (options["maxSeconds"] as? NSNumber)?.doubleValue ?? 300
54
+ guard seconds.isFinite, seconds >= 1, seconds <= 300 else { fail("INVALID_ARGUMENT", "Recordings are limited to five minutes"); return }
55
+ owner = key
56
+ AVAudioSession.sharedInstance().requestRecordPermission { allowed in
57
+ DispatchQueue.main.async {
58
+ guard self.owner == key, isActive() else { if self.owner == key { self.clear() }; fail("RECORDING_CANCELLED", "Recording was cancelled"); return }
59
+ guard allowed else { self.clear(); fail("PERMISSION_DENIED", "Microphone permission was refused"); return }
60
+ do {
61
+ let session = AVAudioSession.sharedInstance()
62
+ try session.setCategory(.record, mode: .measurement)
63
+ try session.setActive(true)
64
+ let file = try CraftNativeFiles.newFile(named: "voice-feedback.m4a")
65
+ self.file = file
66
+ let recorder = try AVAudioRecorder(url: file, settings: [AVFormatIDKey: kAudioFormatMPEG4AAC, AVSampleRateKey: 16000, AVNumberOfChannelsKey: 1, AVEncoderBitRateKey: 32000])
67
+ self.recorder = recorder
68
+ guard recorder.record(forDuration: max(0.5, seconds - 0.25)) else { throw CraftNativeActionError(code: "RECORDING_FAILED", message: "The microphone could not start") }
69
+ completion(.success(true))
70
+ }
71
+ catch { self.clear(); fail("RECORDING_FAILED", "The microphone could not start") }
72
+ }
73
+ }
74
+ }
75
+ }
@@ -14,6 +14,12 @@ settings:
14
14
  CRAFT_APNS_ENVIRONMENT: development
15
15
  release:
16
16
  CRAFT_APNS_ENVIRONMENT: production
17
+ SWIFT_OPTIMIZATION_LEVEL: "-O"
18
+ SWIFT_COMPILATION_MODE: wholemodule
19
+ GCC_OPTIMIZATION_LEVEL: s
20
+ DEAD_CODE_STRIPPING: YES
21
+ STRIP_INSTALLED_PRODUCT: YES
22
+ COPY_PHASE_STRIP: YES
17
23
  targets:
18
24
  {{APP_NAME}}:
19
25
  type: application
@@ -22,6 +22,7 @@ import Darwin
22
22
  /// tapif <text> tap it when it is on screen, else carry on (the big timer's "Show the controls")
23
23
  /// tapid <identifier>
24
24
  /// longpressid <identifier> holds a hittable native element to open its edit menu
25
+ /// holdtab <name> [| secs] standard XCTest hold (2–10s, default 6), BEGIN/END timing receipt
25
26
  /// touches <JSON> timed normalized touch paths in one native dispatch
26
27
  /// tapxy <x> <y> normalised 0…1 of the screen
27
28
  /// tapexact <label> a button whose label is exactly this
@@ -381,6 +382,43 @@ final class DriverTests: XCTestCase {
381
382
  }
382
383
  target.press(forDuration: 1.2)
383
384
  note("longpressid \(arg): ok")
385
+ case "holdtab":
386
+ let parts = arg.components(separatedBy: "|").map { $0.trimmingCharacters(in: .whitespaces) }
387
+ guard parts.count <= 2, let name = parts.first, !name.isEmpty,
388
+ let duration = parts.count == 2 ? Double(parts[1]) : 6,
389
+ duration.isFinite, (2...10).contains(duration) else {
390
+ check(false, "holdtab needs a tab name and 2–10 seconds")
391
+ return
392
+ }
393
+ guard let button = poll(10, { tabButton(name) }) else {
394
+ check(false, "holdtab cannot find tab \(name)")
395
+ return
396
+ }
397
+ let frame = button.frame
398
+ let coordinate = button.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5))
399
+ let startedAt = Date().timeIntervalSince1970
400
+ let startedUptime = ProcessInfo.processInfo.systemUptime
401
+ // BEGIN arms the independent screenshot watcher. XCTest still
402
+ // performs its normal idle checks before synthesizing the press;
403
+ // the watcher must wait for that synthesis, not infer touch-down.
404
+ note("BEGIN holdtab \(name) | \(duration) | \(startedAt)")
405
+ fflush(nil)
406
+ coordinate.press(forDuration: duration)
407
+ let returnedAt = Date().timeIntervalSince1970
408
+ let returnedUptime = ProcessInfo.processInfo.systemUptime
409
+ note("END holdtab \(name) | \(returnedAt)")
410
+ let receipt: [String: Any] = [
411
+ "tab": name, "requestedSeconds": duration,
412
+ "input": "XCUICoordinate.press(forDuration:)",
413
+ "startedAt": startedAt, "returnedAt": returnedAt,
414
+ "startedUptime": startedUptime, "returnedUptime": returnedUptime,
415
+ "frame": ["x": frame.minX, "y": frame.minY, "width": frame.width, "height": frame.height],
416
+ "note": "Call times include XCTest idle checks; during-hold screenshots require an independent synthesis-timed capture.",
417
+ ]
418
+ if let data = try? JSONSerialization.data(withJSONObject: receipt, options: [.sortedKeys]) {
419
+ post("/save?name=holdtab-\(Int(startedUptime * 1000)).json", data)
420
+ }
421
+ check(true, "holdtab \(name): standard \(duration)s press returned")
384
422
  case "touches":
385
423
  do {
386
424
  guard let data = arg.data(using: .utf8),