craft-native 0.0.158 → 0.0.161

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.
@@ -1238,9 +1238,13 @@ export declare const pushNotifications: {
1238
1238
  */
1239
1239
  onReceive(callback: (data: Record<string, unknown>) => void): () => void;
1240
1240
  };
1241
- export type HealthDataType = 'steps' | 'heartRate' | 'activeEnergy' | 'distance' | 'workouts' | 'restingHeartRate' | 'heartRateVariability' | 'bodyMass' | 'sleep';
1241
+ export type HealthDataType = 'steps' | 'heartRate' | 'activeEnergy' | 'distance' | 'workouts' | 'restingHeartRate' | 'heartRateVariability' | 'bodyMass' | 'sleep'
1242
+ /** Food logged by any app, per day: kcal eaten, then grams of protein, carbohydrate and fat. Read only. */
1243
+ | 'dietaryEnergy' | 'dietaryProtein' | 'dietaryCarbohydrates' | 'dietaryFat'
1244
+ /** Mindful minutes (meditation, breathing): read with getMindfulSessions, written with saveMindfulSession. */
1245
+ | 'mindful';
1242
1246
  /** The types with one value per day. `sleep` is hours asleep, counted on the day you woke. */
1243
- export type HealthDailyType = Exclude<HealthDataType, 'workouts'>;
1247
+ export type HealthDailyType = Exclude<HealthDataType, 'workouts' | 'mindful'>;
1244
1248
  export interface HealthAuthorizationOptions {
1245
1249
  /** `false` asks to read only. Default: also allowed to save workouts. */
1246
1250
  write?: boolean;
@@ -1348,8 +1352,49 @@ export interface HealthSleepInterval {
1348
1352
  sourceName: string;
1349
1353
  deviceName?: string;
1350
1354
  }
1351
- /** The health reads a host may or may not have, depending on the app build it runs in. */
1352
- export type HealthReadMethod = 'getWorkouts' | 'getDailyStatistics' | 'getHeartRateSamples' | 'getSleepIntervals';
1355
+ /** A meditation or breathing session to record as mindful minutes. */
1356
+ export interface HealthMindfulSession {
1357
+ /**
1358
+ * The app's own id for the session (up to 128 characters), kept as the
1359
+ * sample's external UUID. Saving the same id again keeps the first sample.
1360
+ */
1361
+ id: string;
1362
+ /** Epoch milliseconds. */
1363
+ startDate: number;
1364
+ /** Epoch milliseconds, after `startDate` and at most a day later. */
1365
+ endDate: number;
1366
+ }
1367
+ export interface HealthMindfulSessionResult {
1368
+ /** HealthKit's UUID for the sample. */
1369
+ id: string;
1370
+ /** The id it was saved with. */
1371
+ externalId: string;
1372
+ /** True when this id was saved before and that sample was kept. */
1373
+ existing: boolean;
1374
+ }
1375
+ export interface HealthMindfulSessionQuery extends HealthSampleQuery {
1376
+ /** At most this many, oldest first. Defaults to 1000, capped at 5000. Start defaults to 30 days ago. */
1377
+ limit?: number;
1378
+ }
1379
+ /** A mindful session in Apple Health, from any app that writes them. */
1380
+ export interface HealthMindfulSample {
1381
+ /** HealthKit's UUID. */
1382
+ id: string;
1383
+ /** Epoch milliseconds. */
1384
+ startDate: number;
1385
+ /** Epoch milliseconds. */
1386
+ endDate: number;
1387
+ durationSeconds: number;
1388
+ sourceName: string;
1389
+ sourceBundleId: string;
1390
+ /** True when this app wrote it. */
1391
+ ownApp: boolean;
1392
+ /** The id the writing app saved it with, when it gave one. */
1393
+ externalId?: string;
1394
+ deviceName?: string;
1395
+ }
1396
+ /** The health reads and writes a host may or may not have, depending on the app build it runs in. */
1397
+ export type HealthReadMethod = 'getWorkouts' | 'getDailyStatistics' | 'getHeartRateSamples' | 'getSleepIntervals' | 'saveMindfulSession' | 'getMindfulSessions';
1353
1398
  export declare const health: {
1354
1399
  /**
1355
1400
  * Whether this app build answers `method`. An app installed before a read
@@ -1372,6 +1417,14 @@ export declare const health: {
1372
1417
  getHeartRateSamples(options?: HealthHeartRateQuery): Promise<HealthHeartRateSample[]>;
1373
1418
  /** Sleep between two instants, oldest first: time in bed and each asleep or awake stage. iOS only for now. */
1374
1419
  getSleepIntervals(options?: HealthSampleQuery): Promise<HealthSleepInterval[]>;
1420
+ /**
1421
+ * Record a meditation or breathing session as mindful minutes. Needs the
1422
+ * `mindful` grant with writing allowed. Saving the same `id` again keeps
1423
+ * the first sample. iOS only for now.
1424
+ */
1425
+ saveMindfulSession(session: HealthMindfulSession): Promise<HealthMindfulSessionResult>;
1426
+ /** Mindful sessions between two instants, oldest first, each marked `ownApp` when this app wrote it. iOS only for now. */
1427
+ getMindfulSessions(options?: HealthMindfulSessionQuery): Promise<HealthMindfulSample[]>;
1375
1428
  };
1376
1429
  export interface LiveActivityState {
1377
1430
  status?: string;
package/dist/cli.js CHANGED
@@ -3303,7 +3303,7 @@ function pinCraftNativeDependency(packagePath, craftVersion) {
3303
3303
  `);
3304
3304
  }
3305
3305
  // package.json
3306
- var version = "0.0.158";
3306
+ var version = "0.0.161";
3307
3307
 
3308
3308
  // bin/cli.ts
3309
3309
  var spawnedFrom = process6.env[CRAFT_CLI_SPAWN_MARKER];
package/dist/index.cjs CHANGED
@@ -310,7 +310,7 @@ function craftBinaryNotFoundMessage(triedPath) {
310
310
  `);
311
311
  }
312
312
  // package.json
313
- var version = "0.0.158";
313
+ var version = "0.0.161";
314
314
 
315
315
  // src/sdk-version.ts
316
316
  var SDK_VERSION = version;
package/dist/index.js CHANGED
@@ -58,7 +58,7 @@ function craftBinaryNotFoundMessage(triedPath) {
58
58
  `);
59
59
  }
60
60
  // package.json
61
- var version = "0.0.158";
61
+ var version = "0.0.161";
62
62
 
63
63
  // src/sdk-version.ts
64
64
  var SDK_VERSION = version;
@@ -3,6 +3,8 @@
3
3
  *
4
4
  * Generates native iOS apps from web content using WKWebView.
5
5
  */
6
+ /** This package's version, which the app names in its user agent (`Craft/<version>`). */
7
+ export declare function resolveCraftVersion(startDir?: string): string;
6
8
  export type CraftAppearance = 'light' | 'dark' | 'system';
7
9
  export interface CraftConfig {
8
10
  appName: string;
@@ -144,13 +144,22 @@ function createNativeMutationValidator() {
144
144
 
145
145
  // ../ios/src/index.ts
146
146
  var TEMPLATES_DIR = join2(dirname2(import.meta.dir), "templates");
147
- var CRAFT_VERSION = (() => {
148
- try {
149
- return JSON.parse(readFileSync2(join2(dirname2(import.meta.dir), "package.json"), "utf8")).version ?? "0.0.0";
150
- } catch {
151
- return "0.0.0";
147
+ function resolveCraftVersion(startDir = import.meta.dir) {
148
+ let directory = resolve(startDir);
149
+ while (true) {
150
+ try {
151
+ const packageJSON = JSON.parse(readFileSync2(join2(directory, "package.json"), "utf8"));
152
+ if (packageJSON.name === "@craft-native/ios" && typeof packageJSON.version === "string" && packageJSON.version.length > 0)
153
+ return packageJSON.version;
154
+ } catch {}
155
+ const parent = dirname2(directory);
156
+ if (parent === directory)
157
+ break;
158
+ directory = parent;
152
159
  }
153
- })();
160
+ return "0.0.0";
161
+ }
162
+ var CRAFT_VERSION = resolveCraftVersion();
154
163
  function normalizeNativePath(raw) {
155
164
  if (typeof raw !== "string")
156
165
  return null;
@@ -1277,6 +1286,7 @@ export {
1277
1286
  run,
1278
1287
  restoreShell,
1279
1288
  resolveRuntimeDir,
1289
+ resolveCraftVersion,
1280
1290
  resolveAppearance,
1281
1291
  renderWatchEntitlements,
1282
1292
  renderUsageDescriptions,
@@ -2548,6 +2548,22 @@ struct CraftWebView: UIViewRepresentable {
2548
2548
  } else {
2549
2549
  rejectCallback(callbackId, error: "HealthKit is disabled", code: "CAPABILITY_DISABLED")
2550
2550
  }
2551
+ case "saveHealthMindfulSession":
2552
+ if config.enableHealthKit {
2553
+ CraftHealth.shared.saveMindfulSession(body: body) { [weak self] in self?.answer(callbackId, $0) }
2554
+ } else {
2555
+ rejectCallback(callbackId, error: "HealthKit is disabled", code: "CAPABILITY_DISABLED")
2556
+ }
2557
+ case "getHealthMindfulSessions":
2558
+ if config.enableHealthKit {
2559
+ CraftHealth.shared.getMindfulSessions(
2560
+ startDate: body["startDate"] as? Double,
2561
+ endDate: body["endDate"] as? Double,
2562
+ limit: body["limit"] as? Int
2563
+ ) { [weak self] in self?.answer(callbackId, $0) }
2564
+ } else {
2565
+ rejectCallback(callbackId, error: "HealthKit is disabled", code: "CAPABILITY_DISABLED")
2566
+ }
2551
2567
 
2552
2568
  // MARK: - Live Activities
2553
2569
  case "startLiveActivity":
@@ -5056,6 +5072,14 @@ struct CraftWebView: UIViewRepresentable {
5056
5072
  getSleepIntervals: function(options) {
5057
5073
  options = options || {};
5058
5074
  return craft._invoke('getHealthSleepIntervals', {startDate: options.startDate, endDate: options.endDate});
5075
+ },
5076
+ saveMindfulSession: function(session) {
5077
+ session = session || {};
5078
+ return craft._invoke('saveHealthMindfulSession', {id: session.id, startDate: session.startDate, endDate: session.endDate});
5079
+ },
5080
+ getMindfulSessions: function(options) {
5081
+ options = options || {};
5082
+ return craft._invoke('getHealthMindfulSessions', {startDate: options.startDate, endDate: options.endDate, limit: options.limit});
5059
5083
  }
5060
5084
  };
5061
5085
  // The Music app's song and transport. Nothing is read until
@@ -43,6 +43,10 @@ final class CraftHealth {
43
43
  getHeartRateSamples(startDate: options(0)["startDate"] as? Double, endDate: options(0)["endDate"] as? Double, limit: options(0)["limit"] as? Int, completion: completion)
44
44
  case "getSleepIntervals":
45
45
  getSleepIntervals(startDate: options(0)["startDate"] as? Double, endDate: options(0)["endDate"] as? Double, completion: completion)
46
+ case "saveMindfulSession":
47
+ saveMindfulSession(body: options(0), completion: completion)
48
+ case "getMindfulSessions":
49
+ getMindfulSessions(startDate: options(0)["startDate"] as? Double, endDate: options(0)["endDate"] as? Double, limit: options(0)["limit"] as? Int, completion: completion)
46
50
  default:
47
51
  completion(.failure(CraftHealthError(message: "Unsupported Health method", code: "UNKNOWN_ACTION")))
48
52
  }
@@ -106,6 +110,20 @@ final class CraftHealth {
106
110
  if let sleepType = HKObjectType.categoryType(forIdentifier: .sleepAnalysis) {
107
111
  readTypes.insert(sleepType)
108
112
  }
113
+ case "mindful":
114
+ // Mindful minutes: a meditation or breathing session. Read
115
+ // to show them, and (unless read only) written by an app
116
+ // that times them.
117
+ if let mindfulType = HKObjectType.categoryType(forIdentifier: .mindfulSession) {
118
+ readTypes.insert(mindfulType)
119
+ shareTypes.insert(mindfulType)
120
+ }
121
+ case "dietaryEnergy", "dietaryProtein", "dietaryCarbohydrates", "dietaryFat":
122
+ // What a food log (MyFitnessPal, Lose It!, Cronometer...)
123
+ // writes to Apple Health: read only, never written back.
124
+ if let dietaryType = Self.healthQuantity(type)?.type {
125
+ readTypes.insert(dietaryType)
126
+ }
109
127
  default:
110
128
  break
111
129
  }
@@ -261,6 +279,11 @@ final class CraftHealth {
261
279
  case "restingHeartRate": return (HKQuantityType.quantityType(forIdentifier: .restingHeartRate), bpm, .discreteAverage)
262
280
  case "heartRateVariability": return (HKQuantityType.quantityType(forIdentifier: .heartRateVariabilitySDNN), .secondUnit(with: .milli), .discreteAverage)
263
281
  case "bodyMass": return (HKQuantityType.quantityType(forIdentifier: .bodyMass), .gramUnit(with: .kilo), .mostRecent)
282
+ // A day's food, summed over every meal any app logged: kcal and grams.
283
+ case "dietaryEnergy": return (HKQuantityType.quantityType(forIdentifier: .dietaryEnergyConsumed), .kilocalorie(), .cumulativeSum)
284
+ case "dietaryProtein": return (HKQuantityType.quantityType(forIdentifier: .dietaryProtein), .gram(), .cumulativeSum)
285
+ case "dietaryCarbohydrates": return (HKQuantityType.quantityType(forIdentifier: .dietaryCarbohydrates), .gram(), .cumulativeSum)
286
+ case "dietaryFat": return (HKQuantityType.quantityType(forIdentifier: .dietaryFatTotal), .gram(), .cumulativeSum)
264
287
  default: return nil
265
288
  }
266
289
  }
@@ -548,4 +571,104 @@ final class CraftHealth {
548
571
  healthStore.execute(query)
549
572
  }
550
573
 
574
+ /// Saves a mindful session (Mindful Minutes in the Health app): a
575
+ /// meditation or breathing session that ran from `startDate` to
576
+ /// `endDate`, epoch milliseconds. `id` is the app's own id for it, kept
577
+ /// as the sample's external UUID, so saving the same session twice (a
578
+ /// retry, a second device) keeps the first sample instead of adding
579
+ /// another: the answer then says `existing: true`.
580
+ func saveMindfulSession(body: [String: Any], completion: @escaping Completion) {
581
+ guard let healthStore = healthStore else {
582
+ completion(.failure(CraftHealthError(message: "HealthKit not available")))
583
+ return
584
+ }
585
+ guard let mindfulType = HKObjectType.categoryType(forIdentifier: .mindfulSession) else {
586
+ completion(.failure(CraftHealthError(message: "Mindful sessions are unavailable")))
587
+ return
588
+ }
589
+ guard let id = body["id"] as? String, !id.isEmpty, id.count <= 128,
590
+ let startValue = body["startDate"] as? Double,
591
+ let endValue = body["endDate"] as? Double,
592
+ endValue > startValue,
593
+ endValue - startValue <= 24 * 3600 * 1000 else {
594
+ completion(.failure(CraftHealthError(message: "A valid id, startDate and endDate (at most a day apart) are required", code: "INVALID_ARGUMENT")))
595
+ return
596
+ }
597
+
598
+ let start = Date(timeIntervalSince1970: startValue / 1000)
599
+ let end = Date(timeIntervalSince1970: endValue / 1000)
600
+ // Only this app's own samples can be the same session.
601
+ let predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [
602
+ HKQuery.predicateForObjects(withMetadataKey: HKMetadataKeyExternalUUID, allowedValues: [id]),
603
+ HKQuery.predicateForObjects(from: HKSource.default()),
604
+ ])
605
+ let lookup = HKSampleQuery(sampleType: mindfulType, predicate: predicate, limit: 1, sortDescriptors: nil) { _, samples, _ in
606
+ if let kept = samples?.first {
607
+ completion(.success(["id": kept.uuid.uuidString, "externalId": id, "existing": true]))
608
+ return
609
+ }
610
+ let sample = HKCategorySample(
611
+ type: mindfulType,
612
+ value: HKCategoryValue.notApplicable.rawValue,
613
+ start: start,
614
+ end: end,
615
+ metadata: [HKMetadataKeyExternalUUID: id]
616
+ )
617
+ healthStore.save(sample) { success, error in
618
+ if success {
619
+ completion(.success(["id": sample.uuid.uuidString, "externalId": id, "existing": false]))
620
+ } else {
621
+ completion(.failure(CraftHealthError(message: error?.localizedDescription ?? "Mindful session could not be saved")))
622
+ }
623
+ }
624
+ }
625
+ healthStore.execute(lookup)
626
+ }
627
+
628
+ /// Mindful sessions between two instants, oldest first, from every app
629
+ /// that writes them (the Mindfulness app, Calm, this one). Each says
630
+ /// whether this app wrote it (`ownApp`) and carries the external id it
631
+ /// was saved with, so a page can tell its own sessions from the rest.
632
+ func getMindfulSessions(startDate: Double?, endDate: Double?, limit: Int?, completion: @escaping Completion) {
633
+ guard let healthStore = healthStore else {
634
+ completion(.failure(CraftHealthError(message: "HealthKit not available")))
635
+ return
636
+ }
637
+ guard let mindfulType = HKObjectType.categoryType(forIdentifier: .mindfulSession) else {
638
+ completion(.failure(CraftHealthError(message: "Mindful sessions are unavailable")))
639
+ return
640
+ }
641
+
642
+ let end = endDate.map { Date(timeIntervalSince1970: $0 / 1000) } ?? Date()
643
+ let start = startDate.map { Date(timeIntervalSince1970: $0 / 1000) } ?? Calendar.current.date(byAdding: .day, value: -30, to: end)!
644
+ let predicate = HKQuery.predicateForSamples(withStart: start, end: end, options: [])
645
+ let oldestFirst = NSSortDescriptor(key: HKSampleSortIdentifierStartDate, ascending: true)
646
+ let cap = max(1, min(limit ?? 1000, 5000))
647
+ let ownBundle = Bundle.main.bundleIdentifier
648
+
649
+ let query = HKSampleQuery(sampleType: mindfulType, predicate: predicate, limit: cap, sortDescriptors: [oldestFirst]) { _, samples, error in
650
+ if let error = error, (error as? HKError)?.code != .errorNoData {
651
+ completion(.failure(CraftHealthError(message: error.localizedDescription)))
652
+ return
653
+ }
654
+ let rows: [[String: Any]] = (samples ?? []).map { sample in
655
+ let source = sample.sourceRevision.source
656
+ var item: [String: Any] = [
657
+ "id": sample.uuid.uuidString,
658
+ "startDate": sample.startDate.timeIntervalSince1970 * 1000,
659
+ "endDate": sample.endDate.timeIntervalSince1970 * 1000,
660
+ "durationSeconds": sample.endDate.timeIntervalSince(sample.startDate),
661
+ "sourceName": source.name,
662
+ "sourceBundleId": source.bundleIdentifier,
663
+ "ownApp": ownBundle != nil && source.bundleIdentifier == ownBundle,
664
+ ]
665
+ if let external = sample.metadata?[HKMetadataKeyExternalUUID] as? String { item["externalId"] = external }
666
+ if let device = sample.device?.name { item["deviceName"] = device }
667
+ return item
668
+ }
669
+ completion(.success(rows))
670
+ }
671
+ healthStore.execute(query)
672
+ }
673
+
551
674
  }
@@ -1457,6 +1457,19 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
1457
1457
  return popRecognizers(of: nav).contains { $0 === recognizer }
1458
1458
  }
1459
1459
 
1460
+ private func nativeScreenContainsHorizontalScroll(_ screen: CraftNativeScreenController) -> Bool {
1461
+ guard let root = screen.viewIfLoaded else { return false }
1462
+ func visit(_ view: UIView) -> Bool {
1463
+ if let scroll = view as? UIScrollView,
1464
+ scroll.isScrollEnabled,
1465
+ scroll.alwaysBounceHorizontal || scroll.contentSize.width > scroll.bounds.width + 1 {
1466
+ return true
1467
+ }
1468
+ return view.subviews.contains(where: visit)
1469
+ }
1470
+ return visit(root)
1471
+ }
1472
+
1460
1473
  /// The edge swipe goes ahead of the web view's own gestures, which
1461
1474
  /// otherwise hold every touch over the page until the page has answered it.
1462
1475
  func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
@@ -1470,6 +1483,12 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
1470
1483
  func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
1471
1484
  guard let nav = visibleStack, isPopRecognizer(gestureRecognizer),
1472
1485
  nav.viewControllers.count > 1, nav.transitionCoordinator == nil else { return false }
1486
+ if let native = nav.topViewController as? CraftNativeScreenController,
1487
+ gestureRecognizer !== nav.interactivePopGestureRecognizer,
1488
+ nativeScreenContainsHorizontalScroll(native) {
1489
+ let moved = (gestureRecognizer as? UIPanGestureRecognizer)?.translation(in: nav.view).x ?? 0
1490
+ if gestureRecognizer.location(in: nav.view).x - moved > 32 { return false }
1491
+ }
1473
1492
  guard let slot = nav.topViewController as? CraftHybridWebSlot else { return true }
1474
1493
  // Over the page, only from its left edge: a swipe across the page is
1475
1494
  // the page's (a carousel, a slider).