@craft-native/ios 0.0.272 → 0.0.278

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
@@ -594,6 +594,28 @@ stopListening()
594
594
  // sends a made-up watch reading every 5 s instead: someone settling into slow
595
595
  // breathing, from about 72 toward 60 bpm.
596
596
 
597
+ // Live workout sessions (iOS 26+, requires enableHealthKit: true). The OS
598
+ // keeps the session alive while the app is backgrounded; samples arrive as
599
+ // typed craftHealthWorkout events. Ask for the workout/heart-rate grants before
600
+ // starting a session.
601
+ await health.requestAuthorization(['workouts', 'heartRate', 'activeEnergy', 'distance'], { write: true })
602
+ const stopWorkout = health.onWorkout(event => {
603
+ if (event.event === 'metrics') showMetrics(event.heartRateBpm, event.activeEnergyCalories, event.distanceMeters)
604
+ if (event.event === 'state') showWorkoutState(event.state)
605
+ if (event.event === 'error') showWorkoutError(event.message)
606
+ })
607
+ const live = await health.startWorkout({ type: 'running', location: 'outdoor' })
608
+ await health.pauseWorkout()
609
+ await health.resumeWorkout()
610
+ const saved = await health.endWorkout() // { id, workoutId, endDate, durationSeconds }
611
+ stopWorkout()
612
+
613
+ `startWorkout`, `pauseWorkout`, `resumeWorkout`, and `endWorkout` reject with
614
+ `Live workouts are unavailable` when the build does not include HealthKit or
615
+ when the OS is older than iOS 26. `onWorkout` receives lifecycle events plus
616
+ live `heartRateBpm`, `activeEnergyCalories`, and `distanceMeters` metrics; the
617
+ existing post-hoc `saveWorkout` API remains separate.
618
+
597
619
  // Screen Capture
598
620
  const screenshotBase64 = await window.craft.takeScreenshot();
599
621
 
@@ -841,6 +863,14 @@ if (await window.craft.scheduledWorkouts.isSupported()) {
841
863
  // FormData through fetch, or hand its path to the share sheet.
842
864
  const photo = await craft.files.pickImage({ maxPixels: 2048, quality: 0.85 });
843
865
  // { path, name: 'IMG_1234.jpg', type: 'image/jpeg', size, width, height }
866
+ // General documents use the same owned-file path and can be selected more
867
+ // than one at a time. Filters are MIME types, UTIs, or filename extensions.
868
+ const attachments = await craft.files.pickFile({
869
+ contentTypes: ['application/pdf', 'csv', 'gpx', 'fit'],
870
+ maxSelections: 4,
871
+ maxBytes: 20 * 1024 * 1024,
872
+ });
873
+ // [{ path, name, type, size }], or [] when the picker is dismissed.
844
874
  const form = new FormData();
845
875
  form.append('photo', photo); // multipart, built natively
846
876
  await fetch('https://example.com/upload', { method: 'POST', body: form });
@@ -1004,6 +1034,26 @@ await window.craft.watch.updateContext({
1004
1034
  status: 'active'
1005
1035
  });
1006
1036
 
1037
+ // A planned workout is delivered through application context, so the latest
1038
+ // plan replaces an older one while the watch is offline and is replayed when
1039
+ // the companion launches. The watch persists its current plan and metrics in
1040
+ // its own UserDefaults store, then publishes a `watch-state` context back.
1041
+ await window.craft.watch.updateContext({
1042
+ type: 'planned-session',
1043
+ plan: {
1044
+ id: 'morning-run',
1045
+ activity: 'running',
1046
+ startDate: Date.now() + 60 * 60 * 1000,
1047
+ targetDistanceMeters: 5000,
1048
+ },
1049
+ });
1050
+
1051
+ // Watch controls include a protocol version and requestId. Use requestId for
1052
+ // idempotency when a queued command arrives after a reconnect. The watch's
1053
+ // `watch-state` context includes a monotonically increasing stateVersion; use
1054
+ // the highest version when a queued command and a reconnect snapshot cross.
1055
+ await window.craft.watch.send({ type: 'recording-control', action: 'start' });
1056
+
1007
1057
  // Listen for messages from watch
1008
1058
  window.craft.watch.onMessage((message) => {
1009
1059
  console.log('Watch message:', message);
package/dist/cli.js CHANGED
@@ -589,12 +589,15 @@ function renderWatchEntitlements(config) {
589
589
  <array>
590
590
  ${plistArray(config.appGroups)}
591
591
  </array>
592
+ ` : "";
593
+ const healthKit = config.enableHealthKit ? ` <key>com.apple.developer.healthkit</key>
594
+ <true/>
592
595
  ` : "";
593
596
  return `<?xml version="1.0" encoding="UTF-8"?>
594
597
  <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
595
598
  <plist version="1.0">
596
599
  <dict>
597
- ${appGroups}</dict>
600
+ ${appGroups}${healthKit}</dict>
598
601
  </plist>
599
602
  `;
600
603
  }
@@ -995,7 +998,7 @@ function writeShell(output, config) {
995
998
  if (!enabled)
996
999
  continue;
997
1000
  mkdirSync(join3(output, folder), { recursive: true });
998
- const source = readFileSync(join3(TEMPLATES_DIR, template), "utf8").replace(/\{\{APP_NAME\}\}/g, name);
1001
+ const source = readFileSync(join3(TEMPLATES_DIR, template), "utf8").replace(/\{\{APP_NAME\}\}/g, name).replace(/\{\{HEALTH_KIT_ENABLED\}\}/g, config.enableHealthKit ? "true" : "false");
999
1002
  writeFileSync(join3(output, folder, filename), source);
1000
1003
  }
1001
1004
  }
@@ -1128,7 +1131,8 @@ ${nativeDependencies.join(`
1128
1131
  const watchInfo = readFileSync(join3(TEMPLATES_DIR, "WatchApp.Info.plist.template"), "utf8").replace(/\{\{APP_NAME\}\}/g, xmlEscape(displayName)).replace(/\{\{BUNDLE_ID\}\}/g, finalBundleId);
1129
1132
  writeFileSync(join3(output, "WatchApp", "Info.plist"), watchInfo);
1130
1133
  writeFileSync(join3(output, "WatchApp", "Watch.entitlements"), renderWatchEntitlements(config));
1131
- const watchExtensionInfo = readFileSync(join3(TEMPLATES_DIR, "WatchExtension.Info.plist.template"), "utf8").replace(/\{\{APP_NAME\}\}/g, xmlEscape(displayName)).replace(/\{\{BUNDLE_ID\}\}/g, finalBundleId);
1134
+ const watchExtensionInfo = readFileSync(join3(TEMPLATES_DIR, "WatchExtension.Info.plist.template"), "utf8").replace(/\{\{APP_NAME\}\}/g, xmlEscape(displayName)).replace(/\{\{BUNDLE_ID\}\}/g, finalBundleId).replace(/\{\{HEALTH_USAGE_DESCRIPTIONS\}\}/g, config.enableHealthKit ? `${plistString("NSHealthShareUsageDescription", `${displayName} reads health data you choose to share.`)}
1135
+ ${plistString("NSHealthUpdateUsageDescription", `${displayName} writes health data only with your permission.`)}` : "");
1132
1136
  writeFileSync(join3(output, "WatchExtension", "Info.plist"), watchExtensionInfo);
1133
1137
  writeFileSync(join3(output, "WatchExtension", "Watch.entitlements"), renderWatchEntitlements(config));
1134
1138
  }
package/dist/index.js CHANGED
@@ -776,12 +776,15 @@ function renderWatchEntitlements(config) {
776
776
  <array>
777
777
  ${plistArray(config.appGroups)}
778
778
  </array>
779
+ ` : "";
780
+ const healthKit = config.enableHealthKit ? ` <key>com.apple.developer.healthkit</key>
781
+ <true/>
779
782
  ` : "";
780
783
  return `<?xml version="1.0" encoding="UTF-8"?>
781
784
  <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
782
785
  <plist version="1.0">
783
786
  <dict>
784
- ${appGroups}</dict>
787
+ ${appGroups}${healthKit}</dict>
785
788
  </plist>
786
789
  `;
787
790
  }
@@ -1195,7 +1198,7 @@ function writeShell(output, config) {
1195
1198
  if (!enabled)
1196
1199
  continue;
1197
1200
  mkdirSync2(join3(output, folder), { recursive: true });
1198
- const source = readFileSync2(join3(TEMPLATES_DIR, template), "utf8").replace(/\{\{APP_NAME\}\}/g, name);
1201
+ const source = readFileSync2(join3(TEMPLATES_DIR, template), "utf8").replace(/\{\{APP_NAME\}\}/g, name).replace(/\{\{HEALTH_KIT_ENABLED\}\}/g, config.enableHealthKit ? "true" : "false");
1199
1202
  writeFileSync2(join3(output, folder, filename), source);
1200
1203
  }
1201
1204
  }
@@ -1328,7 +1331,8 @@ ${nativeDependencies.join(`
1328
1331
  const watchInfo = readFileSync2(join3(TEMPLATES_DIR, "WatchApp.Info.plist.template"), "utf8").replace(/\{\{APP_NAME\}\}/g, xmlEscape(displayName)).replace(/\{\{BUNDLE_ID\}\}/g, finalBundleId);
1329
1332
  writeFileSync2(join3(output, "WatchApp", "Info.plist"), watchInfo);
1330
1333
  writeFileSync2(join3(output, "WatchApp", "Watch.entitlements"), renderWatchEntitlements(config));
1331
- const watchExtensionInfo = readFileSync2(join3(TEMPLATES_DIR, "WatchExtension.Info.plist.template"), "utf8").replace(/\{\{APP_NAME\}\}/g, xmlEscape(displayName)).replace(/\{\{BUNDLE_ID\}\}/g, finalBundleId);
1334
+ const watchExtensionInfo = readFileSync2(join3(TEMPLATES_DIR, "WatchExtension.Info.plist.template"), "utf8").replace(/\{\{APP_NAME\}\}/g, xmlEscape(displayName)).replace(/\{\{BUNDLE_ID\}\}/g, finalBundleId).replace(/\{\{HEALTH_USAGE_DESCRIPTIONS\}\}/g, config.enableHealthKit ? `${plistString("NSHealthShareUsageDescription", `${displayName} reads health data you choose to share.`)}
1335
+ ${plistString("NSHealthUpdateUsageDescription", `${displayName} writes health data only with your permission.`)}` : "");
1332
1336
  writeFileSync2(join3(output, "WatchExtension", "Info.plist"), watchExtensionInfo);
1333
1337
  writeFileSync2(join3(output, "WatchExtension", "Watch.entitlements"), renderWatchEntitlements(config));
1334
1338
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@craft-native/ios",
3
- "version": "0.0.272",
3
+ "version": "0.0.278",
4
4
  "homepage": "https://github.com/craft-native/craft/tree/main/packages/ios#readme",
5
5
  "repository": {
6
6
  "type": "git",
@@ -357,6 +357,7 @@ final class CraftHybrid: NSObject {
357
357
  isConfigured = true
358
358
  self.config = config
359
359
  CraftNativeScriptThread.isBackground = config.nativeScriptThread == "background"
360
+ if CraftNativeScriptThread.isBackground { CraftNativeBundle.prewarm() }
360
361
  hasWeb = config.renderer != "native"
361
362
  siteURL = config.devServerURL.flatMap(URL.init(string:)).flatMap { url in
362
363
  ["http", "https"].contains((url.scheme ?? "").lowercased()) ? url : nil
@@ -2295,13 +2296,15 @@ final class CraftFramePacing: NSObject {
2295
2296
  }
2296
2297
  }
2297
2298
 
2298
- /// One main-thread block, timed: a screen mount, a bundle evaluation.
2299
+ /// One block, timed: a screen mount, a bundle evaluation. Labelled by the
2300
+ /// thread it ran on, since a screen's script now runs off the main thread
2301
+ /// and a bundle's time there costs no frames.
2299
2302
  @discardableResult
2300
2303
  func measure<T>(_ name: String, _ work: () -> T) -> T {
2301
2304
  guard enabled else { return work() }
2302
2305
  let started = CACurrentMediaTime()
2303
2306
  let result = work()
2304
- write(String(format: "%@ main=%.1fms", name, (CACurrentMediaTime() - started) * 1000))
2307
+ write(String(format: "%@ %@=%.1fms", name, Thread.isMainThread ? "main" : "script", (CACurrentMediaTime() - started) * 1000))
2305
2308
  return result
2306
2309
  }
2307
2310
 
@@ -253,6 +253,12 @@ enum CraftNativeActions {
253
253
  CraftNativeFiles.pickVideo(options) { complete(requestToken, $0, completion) }
254
254
  }
255
255
  return
256
+ case ("Files", "pickFile"):
257
+ let options = args.first as? [String: Any] ?? [:]
258
+ DispatchQueue.main.async {
259
+ CraftNativeFiles.pickFile(options) { complete(requestToken, $0, completion) }
260
+ }
261
+ return
256
262
  case ("Files", "download"):
257
263
  download(requestToken: requestToken, args: args, completion: completion)
258
264
  return
@@ -328,6 +328,142 @@ enum CraftNativeFiles {
328
328
  }
329
329
  }
330
330
 
331
+ // MARK: Picking a general file
332
+
333
+ /// Pick one or more documents and copy them into app-owned storage while
334
+ /// the security-scoped URLs are valid. `contentTypes` accepts MIME types,
335
+ /// UTIs, or filename extensions; an empty list means any document.
336
+ static func pickFile(_ options: [String: Any], done: @escaping (Result<Any, CraftNativeActionError>) -> Void) {
337
+ guard activePicker == nil else {
338
+ done(.failure(CraftNativeActionError(code: "BUSY", message: "A picker is already open")))
339
+ return
340
+ }
341
+ let limit = craftNativeNumber(options["maxBytes"])
342
+ .map { $0.doubleValue }
343
+ .flatMap { value in
344
+ guard value.isFinite else { return nil }
345
+ return Int(max(1, min(Double(512 * 1024 * 1024), value)))
346
+ } ?? 512 * 1024 * 1024
347
+ let selectionLimit = craftNativeNumber(options["maxSelections"])
348
+ .map { $0.intValue }
349
+ .map { max(1, min(20, $0)) } ?? 1
350
+ let types: [UTType]
351
+ do {
352
+ types = try documentTypes(options["contentTypes"])
353
+ } catch let error as CraftNativeActionError {
354
+ done(.failure(error))
355
+ return
356
+ } catch {
357
+ done(.failure(CraftNativeActionError(code: "INVALID_ARGUMENT", message: "files.pickFile contentTypes are invalid")))
358
+ return
359
+ }
360
+ let finish: (Result<Any, CraftNativeActionError>) -> Void = { result in
361
+ DispatchQueue.main.async { activePicker = nil; done(result) }
362
+ }
363
+ let delegate = FileDocumentDelegate(limit: limit, done: finish)
364
+ let picker = UIDocumentPickerViewController(forOpeningContentTypes: types, asCopy: true)
365
+ picker.allowsMultipleSelection = selectionLimit > 1
366
+ picker.delegate = delegate
367
+ activePicker = delegate
368
+ if !CraftPresenter.present(picker, from: nil) {
369
+ activePicker = nil
370
+ done(.failure(CraftNativeActionError(code: "PICKER_ERROR", message: "Unable to present the file picker")))
371
+ }
372
+ }
373
+
374
+ /// Resolve an app-supplied content filter without silently broadening it.
375
+ /// A MIME type, UTI, or extension is enough for `UTType`; applications can
376
+ /// therefore ask for `application/pdf`, `com.adobe.pdf`, or `pdf` alike.
377
+ private static func documentTypes(_ value: Any?) throws -> [UTType] {
378
+ guard let value else { return [.item] }
379
+ guard let entries = value as? [Any] else {
380
+ throw CraftNativeActionError(code: "INVALID_ARGUMENT", message: "files.pickFile contentTypes must be an array")
381
+ }
382
+ guard !entries.isEmpty else { return [.item] }
383
+ var types: [UTType] = []
384
+ for entry in entries {
385
+ guard let raw = entry as? String else {
386
+ throw CraftNativeActionError(code: "INVALID_ARGUMENT", message: "files.pickFile contentTypes must be strings")
387
+ }
388
+ let name = raw.trimmingCharacters(in: .whitespacesAndNewlines)
389
+ guard !name.isEmpty,
390
+ let type = UTType(mimeType: name) ?? UTType(name) ?? UTType(filenameExtension: name.trimmingCharacters(in: CharacterSet(charactersIn: "."))) else {
391
+ throw CraftNativeActionError(code: "INVALID_ARGUMENT", message: "files.pickFile contentType is not a MIME type, UTI, or extension: \(raw)")
392
+ }
393
+ types.append(type)
394
+ }
395
+ return types
396
+ }
397
+
398
+ /// Copy an arbitrary selected document into Craft's private file area and
399
+ /// return the same descriptor used by downloads, writes, and uploads.
400
+ static func copyFile(_ source: URL, limit: Int) -> Result<Any, CraftNativeActionError> {
401
+ do {
402
+ let values = try source.resourceValues(forKeys: [.fileSizeKey, .contentTypeKey])
403
+ let size: Int
404
+ if let fileSize = values.fileSize {
405
+ size = fileSize
406
+ } else {
407
+ size = ((try? FileManager.default.attributesOfItem(atPath: source.path))?[.size] as? NSNumber)?.intValue ?? 0
408
+ }
409
+ guard size > 0, size <= limit else {
410
+ return .failure(CraftNativeActionError(code: "FILE_TOO_LARGE", message: "Choose a nonempty file under \(limit / 1024 / 1024)MB."))
411
+ }
412
+ let destination = try newFile(named: source.lastPathComponent)
413
+ do { try FileManager.default.copyItem(at: source, to: destination) }
414
+ catch { try? FileManager.default.removeItem(at: destination.deletingLastPathComponent()); throw error }
415
+ let type = mimeType(for: source, contentType: values.contentType)
416
+ return .success(describe(destination, type: type))
417
+ } catch {
418
+ return .failure(CraftNativeActionError(code: "READ_ERROR", message: error.localizedDescription))
419
+ }
420
+ }
421
+
422
+ private static func mimeType(for source: URL, contentType: UTType?) -> String {
423
+ let generic = "application/octet-stream"
424
+ if let preferred = contentType?.preferredMIMEType, preferred != generic { return preferred }
425
+ if let preferred = UTType(filenameExtension: source.pathExtension)?.preferredMIMEType, preferred != generic {
426
+ return preferred
427
+ }
428
+ switch source.pathExtension.lowercased() {
429
+ case "gpx": return "application/gpx+xml"
430
+ case "tcx": return "application/vnd.garmin.tcx+xml"
431
+ case "fit": return "application/vnd.ant.fit"
432
+ case "csv": return "text/csv"
433
+ case "json": return "application/json"
434
+ case "txt": return "text/plain"
435
+ default: return generic
436
+ }
437
+ }
438
+
439
+ private final class FileDocumentDelegate: NSObject, UIDocumentPickerDelegate {
440
+ let limit: Int
441
+ let done: (Result<Any, CraftNativeActionError>) -> Void
442
+ init(limit: Int, done: @escaping (Result<Any, CraftNativeActionError>) -> Void) { self.limit = limit; self.done = done }
443
+ func documentPickerWasCancelled(_ controller: UIDocumentPickerViewController) { done(.success([])) }
444
+ func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) {
445
+ guard !urls.isEmpty else { done(.success([])); return }
446
+ let (limit, done) = (self.limit, self.done)
447
+ DispatchQueue.global(qos: .userInitiated).async {
448
+ var results: [[String: Any]] = []
449
+ for url in urls {
450
+ let granted = url.startAccessingSecurityScopedResource()
451
+ defer { if granted { url.stopAccessingSecurityScopedResource() } }
452
+ switch CraftNativeFiles.copyFile(url, limit: limit) {
453
+ case .success(let value):
454
+ guard let info = value as? [String: Any] else { done(.failure(CraftNativeActionError(code: "READ_ERROR", message: "The selected file could not be described"))); return }
455
+ results.append(info)
456
+ case .failure(let error):
457
+ results.compactMap { $0["path"] as? String }.forEach { CraftNativeFiles.remove($0) }
458
+ done(.failure(error))
459
+ return
460
+ }
461
+ }
462
+ done(.success(results))
463
+ }
464
+ }
465
+ }
466
+
331
467
  static func copyVideo(_ source: URL, limit: Int) -> Result<Any, CraftNativeActionError> {
332
468
  let ext = source.pathExtension.lowercased()
333
469
  let types = ["mp4": "video/mp4", "m4v": "video/x-m4v", "mov": "video/quicktime", "webm": "video/webm"]
@@ -115,6 +115,9 @@ final class CraftNativeMutationDocument {
115
115
  // A child crossing parents needs the committed tree to be
116
116
  // reconciled so the host can move its existing native view.
117
117
  requiresFullRender = true
118
+ case "replaceChildren":
119
+ let parentId = try replaceChildren(operation, nodes: &candidateNodes)
120
+ appendUnique(parentId, to: &affectedNodeIds)
118
121
  case "removeNode":
119
122
  if let id = operation["id"] as? String, let parent = candidateNodes[id]?.parent {
120
123
  appendUnique(parent, to: &affectedNodeIds)
@@ -287,6 +290,54 @@ final class CraftNativeMutationDocument {
287
290
  return previousParentId
288
291
  }
289
292
 
293
+ /// Replaces a container's ordered child snapshot in one atomic mutation.
294
+ ///
295
+ /// The list protocol uses this operation when a keyed `:for` expansion
296
+ /// produces a new row set. Existing nodes may stay attached to the same
297
+ /// parent; removed children become detached and can be reused or removed
298
+ /// by later operations in the same batch.
299
+ private func replaceChildren(_ operation: [String: Any], nodes: inout [String: Node]) throws -> String {
300
+ let parentId = try requiredId(operation, field: "parentId", name: "replaceChildren")
301
+ guard var parent = nodes[parentId] else {
302
+ throw CraftNativeMutationFailure("UNKNOWN_PARENT", "node \(parentId) does not exist")
303
+ }
304
+ guard acceptsChildren(parent.type) else {
305
+ throw CraftNativeMutationFailure("INVALID_PARENT", "node \(parentId) cannot contain child nodes")
306
+ }
307
+ guard let values = operation["children"] as? [Any] else {
308
+ throw CraftNativeMutationFailure("INVALID_CHILDREN", "replaceChildren.children must be an array")
309
+ }
310
+ var childIds: [String] = []
311
+ for value in values {
312
+ guard let childId = value as? String, !childId.isEmpty else {
313
+ throw CraftNativeMutationFailure("INVALID_CHILDREN", "replaceChildren.children must contain non-empty ids")
314
+ }
315
+ guard !childIds.contains(childId) else {
316
+ throw CraftNativeMutationFailure("DUPLICATE_CHILD", "child \(childId) appears more than once")
317
+ }
318
+ guard let child = nodes[childId] else {
319
+ throw CraftNativeMutationFailure("UNKNOWN_NODE", "node \(childId) does not exist")
320
+ }
321
+ guard childId != parentId, !descendants(of: childId, nodes: nodes).contains(parentId) else {
322
+ throw CraftNativeMutationFailure("CYCLE", "attaching node \(childId) would create a cycle")
323
+ }
324
+ guard child.parent == nil || child.parent == parentId else {
325
+ throw CraftNativeMutationFailure("NODE_ATTACHED", "node \(childId) already has a parent")
326
+ }
327
+ childIds.append(childId)
328
+ }
329
+ let removedChildIds = parent.children.filter { !childIds.contains($0) }
330
+ for oldChildId in removedChildIds {
331
+ for descendant in descendants(of: oldChildId, nodes: nodes).union([oldChildId]) {
332
+ nodes.removeValue(forKey: descendant)
333
+ }
334
+ }
335
+ parent.children = childIds
336
+ nodes[parentId] = parent
337
+ for childId in childIds { nodes[childId]?.parent = parentId }
338
+ return parentId
339
+ }
340
+
290
341
  private func removeNode(
291
342
  _ operation: [String: Any],
292
343
  nodes: inout [String: Node],
@@ -1345,6 +1345,64 @@ private final class CraftNativeVideoView: UIView, AVPlayerViewControllerDelegate
1345
1345
  deinit { stop() }
1346
1346
  }
1347
1347
 
1348
+ /// A map pin that answers being activated as it answers a tap.
1349
+ final class CraftMarkerAnnotationView: MKAnnotationView {
1350
+ var onActivate: (() -> Void)?
1351
+
1352
+ override func accessibilityActivate() -> Bool {
1353
+ guard let onActivate else { return super.accessibilityActivate() }
1354
+ onActivate()
1355
+ return true
1356
+ }
1357
+
1358
+ override func prepareForReuse() {
1359
+ super.prepareForReuse()
1360
+ onActivate = nil
1361
+ }
1362
+ }
1363
+
1364
+ /// A tile overlay whose tiles are kept on disk.
1365
+ ///
1366
+ /// MKTileOverlay asked the network for every tile on every pan back over the
1367
+ /// same ground, and drew nothing where a tile had not come yet. These go
1368
+ /// through one session with a cache of its own (Caches/craft-map-tiles), so
1369
+ /// ground seen before draws at once and offline, and a phone on a trail
1370
+ /// does not fetch the same hillside twice.
1371
+ final class CraftCachedTileOverlay: MKTileOverlay {
1372
+ static let session: URLSession = {
1373
+ let configuration = URLSessionConfiguration.default
1374
+ let directory = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first?.appendingPathComponent("craft-map-tiles", isDirectory: true)
1375
+ configuration.urlCache = URLCache(memoryCapacity: 48 << 20, diskCapacity: 512 << 20, directory: directory)
1376
+ // Topography and imagery change on the scale of months: a tile held
1377
+ // is used without asking again.
1378
+ configuration.requestCachePolicy = .returnCacheDataElseLoad
1379
+ configuration.httpMaximumConnectionsPerHost = 8
1380
+ return URLSession(configuration: configuration)
1381
+ }()
1382
+
1383
+ override func loadTile(at path: MKTileOverlayPath, result: @escaping (Data?, Error?) -> Void) {
1384
+ let request = URLRequest(url: url(forTilePath: path), cachePolicy: .returnCacheDataElseLoad, timeoutInterval: 20)
1385
+ Self.session.dataTask(with: request) { data, response, error in
1386
+ if let status = (response as? HTTPURLResponse)?.statusCode, !(200..<300).contains(status) {
1387
+ result(nil, error ?? URLError(.badServerResponse))
1388
+ return
1389
+ }
1390
+ result(data, error)
1391
+ }.resume()
1392
+ }
1393
+ }
1394
+
1395
+ /// The basemaps a `Map` names with `basemap`, drawn the best way each
1396
+ /// platform can: Apple's vector map (with real terrain from iOS 16) for
1397
+ /// standard, imagery and hybrid, and topography with contours over a muted
1398
+ /// Apple map for outdoors.
1399
+ enum CraftBasemap: String {
1400
+ case standard, outdoors, satellite, hybrid
1401
+
1402
+ static let outdoorTiles = "https://server.arcgisonline.com/ArcGIS/rest/services/World_Topo_Map/MapServer/tile/{z}/{y}/{x}"
1403
+ static let outdoorCredit = "Topography: Esri, USGS, NOAA, OpenStreetMap contributors"
1404
+ }
1405
+
1348
1406
  private final class CraftNativeMapView: MKMapView, MKMapViewDelegate {
1349
1407
  private var annotationsByID: [String: MKPointAnnotation] = [:]
1350
1408
  private var overlaysByID: [String: MKPolyline] = [:]
@@ -1371,6 +1429,8 @@ private final class CraftNativeMapView: MKMapView, MKMapViewDelegate {
1371
1429
  private var markerPinColors: [String: UIColor] = [:]
1372
1430
  private var tileOverlay: MKTileOverlay?
1373
1431
  private var tileTemplate: String?
1432
+ private var tileReplacesMap = true
1433
+ private var appliedBasemap: CraftBasemap?
1374
1434
  private var tileAttributionBottom: NSLayoutConstraint?
1375
1435
  private lazy var tileAttribution: UILabel = {
1376
1436
  let label = UILabel()
@@ -1395,6 +1455,10 @@ private final class CraftNativeMapView: MKMapView, MKMapViewDelegate {
1395
1455
  isRotateEnabled = false
1396
1456
  let tap = UITapGestureRecognizer(target: self, action: #selector(pressedMap(_:)))
1397
1457
  tap.cancelsTouchesInView = false
1458
+ // Alongside MapKit's own recognizers, not instead of them: on its own
1459
+ // this tap won the touch, MapKit never selected the pin under it, and
1460
+ // a pin's onPress (Explore's trail card) never fired on a phone.
1461
+ tap.delegate = CraftMapTapDelegate.shared
1398
1462
  addGestureRecognizer(tap)
1399
1463
  }
1400
1464
 
@@ -1429,8 +1493,13 @@ private final class CraftNativeMapView: MKMapView, MKMapViewDelegate {
1429
1493
  isZoomEnabled = props["zoomEnabled"] as? Bool ?? true
1430
1494
  isRotateEnabled = props["rotateEnabled"] as? Bool ?? false
1431
1495
  isPitchEnabled = props["pitchEnabled"] as? Bool ?? true
1432
- mapType = Self.mapType(props["mapType"] as? String)
1433
- configureTiles(props["tileOverlay"] as? [String: Any])
1496
+ if let basemap = (props["basemap"] as? String).flatMap({ CraftBasemap(rawValue: $0.lowercased()) }) {
1497
+ applyBasemap(basemap)
1498
+ } else {
1499
+ appliedBasemap = nil
1500
+ mapType = Self.mapType(props["mapType"] as? String)
1501
+ configureTiles(props["tileOverlay"] as? [String: Any])
1502
+ }
1434
1503
  showsCompass = props["showsCompass"] as? Bool ?? false
1435
1504
  showsScale = props["showsScale"] as? Bool ?? false
1436
1505
  authoredPitch = Self.numeric(props["cameraPitch"])
@@ -1562,7 +1631,9 @@ private final class CraftNativeMapView: MKMapView, MKMapViewDelegate {
1562
1631
  overlayCoordinatesByID[id] = value.coordinates
1563
1632
  overlayColors[ObjectIdentifier(overlay)] = value.color
1564
1633
  overlayWidths[ObjectIdentifier(overlay)] = value.width
1565
- addOverlay(overlay)
1634
+ // Above labels, as a basemap's topography is: at a lower level
1635
+ // the outdoors layer covered every route drawn on it.
1636
+ addOverlay(overlay, level: .aboveLabels)
1566
1637
  }
1567
1638
  let stalePolygons = polygonsByID.keys.filter { nextPolygons[$0] == nil }
1568
1639
  for id in stalePolygons {
@@ -1595,7 +1666,7 @@ private final class CraftNativeMapView: MKMapView, MKMapViewDelegate {
1595
1666
  polygonFills[ObjectIdentifier(polygon)] = value.fill
1596
1667
  polygonStrokes[ObjectIdentifier(polygon)] = value.stroke
1597
1668
  polygonWidths[ObjectIdentifier(polygon)] = value.width
1598
- addOverlay(polygon)
1669
+ addOverlay(polygon, level: .aboveLabels)
1599
1670
  }
1600
1671
  // Keep authored casing/foreground order when an earlier line changes
1601
1672
  // or retained children move. Exchanging native overlays preserves the
@@ -1684,20 +1755,48 @@ private final class CraftNativeMapView: MKMapView, MKMapViewDelegate {
1684
1755
  tileAttribution.isHidden = true
1685
1756
  }
1686
1757
 
1687
- private func configureTiles(_ options: [String: Any]?) {
1758
+ /// A named basemap. Applied once per change: setting a map configuration
1759
+ /// again redraws the whole map.
1760
+ private func applyBasemap(_ basemap: CraftBasemap) {
1761
+ if appliedBasemap != basemap {
1762
+ appliedBasemap = basemap
1763
+ if #available(iOS 16.0, *) {
1764
+ switch basemap {
1765
+ case .satellite: preferredConfiguration = MKImageryMapConfiguration(elevationStyle: .realistic)
1766
+ case .hybrid: preferredConfiguration = MKHybridMapConfiguration(elevationStyle: .realistic)
1767
+ // Flat under the topography, so the overlay's contours sit
1768
+ // on the ground they describe.
1769
+ case .outdoors: preferredConfiguration = MKStandardMapConfiguration(elevationStyle: .flat, emphasisStyle: .muted)
1770
+ case .standard: preferredConfiguration = MKStandardMapConfiguration(elevationStyle: .realistic)
1771
+ }
1772
+ }
1773
+ else {
1774
+ mapType = basemap == .satellite ? .satellite : basemap == .hybrid ? .hybrid : basemap == .outdoors ? .mutedStandard : .standard
1775
+ }
1776
+ }
1777
+ configureTiles(basemap == .outdoors ? ["urlTemplate": CraftBasemap.outdoorTiles, "attribution": CraftBasemap.outdoorCredit] : nil, replacesMap: false)
1778
+ }
1779
+
1780
+ /// `replacesMap`: an authored `tileOverlay` replaces Apple's map, as it
1781
+ /// always has. A basemap's topography draws over it instead, above its
1782
+ /// labels, so ground whose tiles have not come yet shows the map rather
1783
+ /// than an empty grid.
1784
+ private func configureTiles(_ options: [String: Any]?, replacesMap: Bool = true) {
1688
1785
  let template = options?["urlTemplate"] as? String
1689
1786
  let credit = options?["attribution"] as? String ?? ""
1690
1787
  let valid = template.map { value in value.hasPrefix("https://") && ["{z}", "{x}", "{y}"].allSatisfy { value.contains($0) } } ?? false
1691
1788
  let next = valid && !credit.isEmpty ? template : nil
1692
- if tileTemplate != next {
1789
+ if tileTemplate != next || tileReplacesMap != replacesMap {
1693
1790
  if let overlay = tileOverlay { removeOverlay(overlay) }
1694
1791
  tileOverlay = nil
1695
1792
  tileTemplate = next
1793
+ tileReplacesMap = replacesMap
1696
1794
  if let next {
1697
- let overlay = MKTileOverlay(urlTemplate: next)
1698
- overlay.canReplaceMapContent = true
1795
+ let overlay = CraftCachedTileOverlay(urlTemplate: next)
1796
+ overlay.canReplaceMapContent = replacesMap
1797
+ overlay.maximumZ = 19
1699
1798
  tileOverlay = overlay
1700
- insertOverlay(overlay, at: 0, level: .aboveRoads)
1799
+ insertOverlay(overlay, at: 0, level: replacesMap ? .aboveRoads : .aboveLabels)
1701
1800
  }
1702
1801
  }
1703
1802
  tileAttribution.text = next == nil ? nil : credit
@@ -1756,11 +1855,27 @@ private final class CraftNativeMapView: MKMapView, MKMapViewDelegate {
1756
1855
  view.tintColor = color
1757
1856
  view.image = Self.markerImage(color: color, direction: true)
1758
1857
  view.transform = CGAffineTransform(rotationAngle: CGFloat((bearing - camera.heading) * .pi / 180))
1858
+ // Direction marks thin themselves: MapKit hides the ones that
1859
+ // would overlap at this zoom and brings them back closer in, so
1860
+ // a trail reads as a line with chevrons along it, not a column of
1861
+ // badges over the route.
1862
+ view.displayPriority = .defaultLow
1863
+ view.collisionMode = .circle
1864
+ // Decoration along the route: not a stop for VoiceOver.
1865
+ view.isAccessibilityElement = false
1759
1866
  } else {
1760
1867
  let color = (markerPinColors[id] ?? .systemRed).resolvedColor(with: traitCollection)
1761
1868
  view.tintColor = color
1762
1869
  view.image = Self.markerImage(color: color, direction: false)
1763
1870
  view.transform = .identity
1871
+ view.displayPriority = .required
1872
+ view.collisionMode = .rectangle
1873
+ // Above the user's own location: at a trailhead - where the map
1874
+ // is most often opened - the dot sat on the trail's pin and took
1875
+ // every tap meant for it.
1876
+ if #available(iOS 14.0, *) { view.zPriority = .max }
1877
+ view.isAccessibilityElement = true
1878
+ view.accessibilityTraits = .button
1764
1879
  }
1765
1880
  }
1766
1881
 
@@ -1771,9 +1886,10 @@ private final class CraftNativeMapView: MKMapView, MKMapViewDelegate {
1771
1886
  }()
1772
1887
 
1773
1888
  private static func markerImage(color: UIColor, direction: Bool) -> UIImage {
1774
- let key = "\(direction ? "direction" : "pin"):\(color.description)" as NSString
1889
+ let key = "\(direction ? "chevron" : "pin"):\(color.description)" as NSString
1775
1890
  if let cached = markerImages.object(forKey: key) { return cached }
1776
- let size = direction ? CGSize(width: 20, height: 24) : CGSize(width: 28, height: 28)
1891
+ if direction { return directionImage(color: color, key: key) }
1892
+ let size = CGSize(width: 28, height: 28)
1777
1893
  // Explicit native drawing preserves the light backing independently
1778
1894
  // of MapKit's tint and SF Symbols' multicolor rendering preferences.
1779
1895
  let image = UIGraphicsImageRenderer(size: size).image { _ in
@@ -1787,7 +1903,32 @@ private final class CraftNativeMapView: MKMapView, MKMapViewDelegate {
1787
1903
  let symbol = UIImage(systemName: direction ? "arrow.up" : "mappin",
1788
1904
  withConfiguration: UIImage.SymbolConfiguration(pointSize: 16, weight: .semibold))?
1789
1905
  .withTintColor(color, renderingMode: .alwaysOriginal)
1790
- symbol?.draw(in: direction ? CGRect(x: 5, y: 6, width: 10, height: 12) : CGRect(x: 7, y: 5, width: 14, height: 18))
1906
+ symbol?.draw(in: CGRect(x: 7, y: 5, width: 14, height: 18))
1907
+ }.withRenderingMode(.alwaysOriginal)
1908
+ markerImages.setObject(image, forKey: key)
1909
+ return image
1910
+ }
1911
+
1912
+ /// A route's direction: a 16-point chevron in the route's colour with a
1913
+ /// white edge, so it sits on the line it describes and stays legible
1914
+ /// over topography and imagery alike.
1915
+ private static func directionImage(color: UIColor, key: NSString) -> UIImage {
1916
+ let size = CGSize(width: 16, height: 16)
1917
+ let image = UIGraphicsImageRenderer(size: size).image { context in
1918
+ let chevron = UIBezierPath()
1919
+ chevron.move(to: CGPoint(x: 8, y: 2.5))
1920
+ chevron.addLine(to: CGPoint(x: 13.5, y: 12))
1921
+ chevron.addLine(to: CGPoint(x: 8, y: 9.25))
1922
+ chevron.addLine(to: CGPoint(x: 2.5, y: 12))
1923
+ chevron.close()
1924
+ chevron.lineJoinStyle = .round
1925
+ context.cgContext.setShadow(offset: .zero, blur: 1.5, color: UIColor.black.withAlphaComponent(0.25).cgColor)
1926
+ UIColor.white.setStroke()
1927
+ chevron.lineWidth = 3
1928
+ chevron.stroke()
1929
+ context.cgContext.setShadow(offset: .zero, blur: 0, color: nil)
1930
+ color.setFill()
1931
+ chevron.fill()
1791
1932
  }.withRenderingMode(.alwaysOriginal)
1792
1933
  markerImages.setObject(image, forKey: key)
1793
1934
  return image
@@ -1844,9 +1985,12 @@ private final class CraftNativeMapView: MKMapView, MKMapViewDelegate {
1844
1985
 
1845
1986
  func mapView(_ mapView: MKMapView, viewFor annotation: MKAnnotation) -> MKAnnotationView? {
1846
1987
  guard let id = annotationsByID.first(where: { $0.value === annotation })?.key else { return nil }
1847
- let view = dequeueReusableAnnotationView(withIdentifier: "craft-native-marker")
1848
- ?? MKAnnotationView(annotation: annotation, reuseIdentifier: "craft-native-marker")
1988
+ let view = (dequeueReusableAnnotationView(withIdentifier: "craft-native-marker") as? CraftMarkerAnnotationView)
1989
+ ?? CraftMarkerAnnotationView(annotation: annotation, reuseIdentifier: "craft-native-marker")
1849
1990
  view.annotation = annotation
1991
+ // Activated (VoiceOver, Switch Control, a UI test) as a tap would:
1992
+ // the plain annotation view did nothing when activated.
1993
+ view.onActivate = { [weak self] in self?.onMarkerTap?(id) }
1850
1994
  configureMarkerView(view, id: id)
1851
1995
  view.accessibilityIdentifier = id
1852
1996
  return view
@@ -3998,6 +4142,16 @@ private final class CraftNativeModalView: UIView, UIGestureRecognizerDelegate {
3998
4142
  }
3999
4143
  }
4000
4144
 
4145
+ /// The delegate of a map's own tap (`Map`'s onPress): it watches beside
4146
+ /// MapKit's recognizers, so a tap on a pin still selects it.
4147
+ final class CraftMapTapDelegate: NSObject, UIGestureRecognizerDelegate {
4148
+ static let shared = CraftMapTapDelegate()
4149
+
4150
+ func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
4151
+ true
4152
+ }
4153
+ }
4154
+
4001
4155
  /// The delegate of a Pressable's press-in/out feedback. That recognizer is
4002
4156
  /// a long press of no duration, so it begins the moment a finger lands; on its
4003
4157
  /// own it would claim the touch, and the tap that is the Pressable's onPress
@@ -5095,6 +5249,17 @@ enum CraftNativeBundle {
5095
5249
  /// is not parsed again. Each screen keeps a global object of its own.
5096
5250
  static let virtualMachine = JSVirtualMachine()!
5097
5251
 
5252
+ /// JavaScriptCore's first machine and context, made on the script thread
5253
+ /// at launch instead of on the main thread by the first screen: that one-
5254
+ /// time setup was a quarter of a second of the first tab's mount, all of
5255
+ /// it in front of the first frame. Nothing is evaluated, so the main
5256
+ /// thread never waits on the machine for it.
5257
+ static func prewarm() {
5258
+ CraftNativeScriptThread.queue.async {
5259
+ _ = JSContext(virtualMachine: virtualMachine)
5260
+ }
5261
+ }
5262
+
5098
5263
  private static let sources: NSCache<NSString, NSString> = {
5099
5264
  let cache = NSCache<NSString, NSString>()
5100
5265
  // Room for the one-file bundle, or a dozen route files.
@@ -8649,8 +8814,12 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
8649
8814
  /// Views that take their own touches whatever handlers the screen gives
8650
8815
  /// them: native controls, a scroll view, a modal, and a video's controls (with
8651
8816
  /// interaction off, its play, scrubber and full screen never answered).
8817
+ /// A map and a wheel picker too: neither is a UIControl, so a map with no
8818
+ /// long-press handler - every map - was made non-interactive, and could
8819
+ /// not be panned, zoomed, or have a pin tapped; a picker could not be spun.
8652
8820
  private func ownsTouches(_ view: UIView) -> Bool {
8653
8821
  view is UIControl || view is UIScrollView || view is CraftNativeModalView || view is CraftNativeVideoView
8822
+ || view is MKMapView || view is UIPickerView
8654
8823
  }
8655
8824
 
8656
8825
  private func updatePressHandler(_ handler: String?, for view: UIView) {
@@ -1,4 +1,5 @@
1
1
  import SwiftUI
2
+ import HealthKit
2
3
  import WatchConnectivity
3
4
 
4
5
  @main
@@ -15,14 +16,28 @@ struct {{APP_NAME}}WatchApp: App {
15
16
 
16
17
  @MainActor
17
18
  final class CraftWatchSession: NSObject, ObservableObject {
19
+ private static let protocolVersion = 1
20
+ private static let persistedStateKey = "craft.watch.state.v1"
21
+ private static let healthKitEnabled = {{HEALTH_KIT_ENABLED}}
22
+
18
23
  @Published private(set) var isPhoneReachable = false
19
24
  @Published private(set) var isRecording = false
20
25
  @Published private(set) var isPaused = false
21
26
  @Published private(set) var distanceMeters = 0.0
22
27
  @Published private(set) var durationSeconds = 0.0
23
28
  @Published private(set) var status = "Ready"
29
+ @Published private(set) var plannedSession: [String: Any]?
30
+ @Published private(set) var heartRateBpm: Double?
31
+ @Published private(set) var heartRateZone: Int?
32
+ @Published private(set) var lastWorkout: [String: Any]?
24
33
 
25
34
  private let session: WCSession?
35
+ private let healthStore = HKHealthStore()
36
+ private var stateVersion = 0
37
+ private var workoutSession: HKWorkoutSession?
38
+ private var workoutBuilder: HKLiveWorkoutBuilder?
39
+ private var workoutId: String?
40
+ private var workoutStartedAt: Date?
26
41
 
27
42
  override init() {
28
43
  if WCSession.isSupported() {
@@ -32,49 +47,320 @@ final class CraftWatchSession: NSObject, ObservableObject {
32
47
  }
33
48
  super.init()
34
49
  session?.delegate = self
50
+ restoreState()
35
51
  session?.activate()
36
52
  }
37
53
 
38
54
  func toggleRecording() {
39
- let action: String
40
55
  if !isRecording {
41
- action = "start"
56
+ if Self.healthKitEnabled {
57
+ startHealthWorkout { [weak self] started in
58
+ if started { self?.send(action: "start") }
59
+ }
60
+ } else {
61
+ send(action: "start")
62
+ }
42
63
  } else if isPaused {
43
- action = "resume"
64
+ resumeHealthWorkout()
65
+ send(action: "resume")
44
66
  } else {
45
- action = "pause"
67
+ pauseHealthWorkout()
68
+ send(action: "pause")
46
69
  }
47
- send(action: action)
48
70
  }
49
71
 
50
72
  func finishRecording() {
51
- send(action: "finish")
73
+ if Self.healthKitEnabled, workoutSession != nil {
74
+ endHealthWorkout { [weak self] _ in self?.send(action: "finish") }
75
+ } else {
76
+ send(action: "finish")
77
+ }
78
+ }
79
+
80
+ /// The phone sends a planned session through application context. Context
81
+ /// is the right transport here: it replaces stale plans while the watch is
82
+ /// offline and is replayed when the app launches again.
83
+ func clearPlannedSession() {
84
+ plannedSession = nil
85
+ persistState()
86
+ publishState()
52
87
  }
53
88
 
54
89
  private func send(action: String) {
55
90
  guard let session, session.activationState == .activated else {
56
91
  status = "Open WildLoop on iPhone"
92
+ persistState()
57
93
  return
58
94
  }
59
- let message: [String: Any] = ["type": "recording-control", "action": action]
95
+ let message: [String: Any] = [
96
+ "protocolVersion": Self.protocolVersion,
97
+ "requestId": UUID().uuidString,
98
+ "type": "recording-control",
99
+ "action": action,
100
+ ]
60
101
  if session.isReachable {
61
102
  session.sendMessage(message, replyHandler: { [weak self] reply in
62
103
  Task { @MainActor in self?.apply(reply) }
63
104
  }, errorHandler: { [weak self] _ in
64
- Task { @MainActor in self?.status = "Could not reach iPhone" }
105
+ Task { @MainActor in
106
+ self?.status = "Could not reach iPhone"
107
+ self?.persistState()
108
+ self?.publishState()
109
+ }
65
110
  })
66
111
  } else {
67
112
  session.transferUserInfo(message)
68
113
  status = "Command queued"
114
+ persistState()
115
+ publishState()
69
116
  }
70
117
  }
71
118
 
72
119
  private func apply(_ payload: [String: Any]) {
120
+ if let plan = payload["plan"] as? [String: Any], payload["type"] as? String == "planned-session" {
121
+ plannedSession = plan
122
+ status = "Plan ready"
123
+ } else if let plan = payload["plannedSession"] as? [String: Any] {
124
+ plannedSession = plan
125
+ }
73
126
  if let recording = payload["recording"] as? Bool { isRecording = recording }
74
127
  if let paused = payload["paused"] as? Bool { isPaused = paused }
75
128
  if let distance = payload["distanceMeters"] as? Double { distanceMeters = max(distance, 0) }
76
129
  if let duration = payload["durationSeconds"] as? Double { durationSeconds = max(duration, 0) }
77
130
  if let value = payload["status"] as? String { status = value }
131
+ if let heartRate = payload["heartRateBpm"] as? Double, heartRate.isFinite { heartRateBpm = max(heartRate, 0) }
132
+ if let zone = payload["heartRateZone"] as? Int { heartRateZone = min(max(zone, 1), 5) }
133
+ if let workout = payload["workout"] as? [String: Any] { lastWorkout = workout }
134
+ persistState()
135
+ publishState()
136
+ }
137
+
138
+ private func restoreState() {
139
+ guard let state = UserDefaults.standard.dictionary(forKey: Self.persistedStateKey) else { return }
140
+ if let recording = state["recording"] as? Bool { isRecording = recording }
141
+ if let paused = state["paused"] as? Bool { isPaused = paused }
142
+ if let distance = state["distanceMeters"] as? Double { distanceMeters = max(distance, 0) }
143
+ if let duration = state["durationSeconds"] as? Double { durationSeconds = max(duration, 0) }
144
+ if let value = state["status"] as? String { status = value }
145
+ if let version = state["stateVersion"] as? Int { stateVersion = max(version, stateVersion) }
146
+ plannedSession = state["plannedSession"] as? [String: Any]
147
+ if let heartRate = state["heartRateBpm"] as? Double { heartRateBpm = max(heartRate, 0) }
148
+ if let zone = state["heartRateZone"] as? Int { heartRateZone = min(max(zone, 1), 5) }
149
+ lastWorkout = state["lastWorkout"] as? [String: Any]
150
+ }
151
+
152
+ private func persistState() {
153
+ stateVersion += 1
154
+ var state: [String: Any] = [
155
+ "protocolVersion": Self.protocolVersion,
156
+ "stateVersion": stateVersion,
157
+ "recording": isRecording,
158
+ "paused": isPaused,
159
+ "distanceMeters": distanceMeters,
160
+ "durationSeconds": durationSeconds,
161
+ "status": status,
162
+ ]
163
+ if let plannedSession { state["plannedSession"] = plannedSession }
164
+ if let heartRateBpm { state["heartRateBpm"] = heartRateBpm }
165
+ if let heartRateZone { state["heartRateZone"] = heartRateZone }
166
+ if let lastWorkout { state["lastWorkout"] = lastWorkout }
167
+ UserDefaults.standard.set(state, forKey: Self.persistedStateKey)
168
+ }
169
+
170
+ private func publishState() {
171
+ guard let session, session.activationState == .activated else { return }
172
+ var state: [String: Any] = [
173
+ "protocolVersion": Self.protocolVersion,
174
+ "stateVersion": stateVersion,
175
+ "type": "watch-state",
176
+ "recording": isRecording,
177
+ "paused": isPaused,
178
+ "distanceMeters": distanceMeters,
179
+ "durationSeconds": durationSeconds,
180
+ "status": status,
181
+ ]
182
+ if let plannedSession { state["plannedSession"] = plannedSession }
183
+ if let heartRateBpm { state["heartRateBpm"] = heartRateBpm }
184
+ if let heartRateZone { state["heartRateZone"] = heartRateZone }
185
+ if let lastWorkout { state["workout"] = lastWorkout }
186
+ try? session.updateApplicationContext(state)
187
+ }
188
+
189
+ private func startHealthWorkout(completion: @escaping (Bool) -> Void) {
190
+ guard HKHealthStore.isHealthDataAvailable() else {
191
+ status = "HealthKit unavailable"
192
+ persistState()
193
+ completion(false)
194
+ return
195
+ }
196
+ let shareTypes: Set<HKSampleType> = [HKObjectType.workoutType()]
197
+ var readTypes: Set<HKObjectType> = [HKObjectType.workoutType()]
198
+ if let heartRate = HKQuantityType.quantityType(forIdentifier: .heartRate) { readTypes.insert(heartRate) }
199
+ if let distance = HKQuantityType.quantityType(forIdentifier: .distanceWalkingRunning) { readTypes.insert(distance) }
200
+ if let energy = HKQuantityType.quantityType(forIdentifier: .activeEnergyBurned) { readTypes.insert(energy) }
201
+ healthStore.requestAuthorization(toShare: shareTypes, read: readTypes) { [weak self] success, error in
202
+ DispatchQueue.main.async {
203
+ guard let self else { return completion(false) }
204
+ guard success else {
205
+ self.status = error?.localizedDescription ?? "HealthKit authorization denied"
206
+ self.persistState()
207
+ return completion(false)
208
+ }
209
+ self.beginHealthWorkout(completion: completion)
210
+ }
211
+ }
212
+ }
213
+
214
+ private func beginHealthWorkout(completion: @escaping (Bool) -> Void) {
215
+ guard workoutSession == nil else { completion(true); return }
216
+ let configuration = HKWorkoutConfiguration()
217
+ configuration.activityType = .running
218
+ configuration.locationType = .outdoor
219
+ do {
220
+ let session = try HKWorkoutSession(healthStore: healthStore, configuration: configuration)
221
+ let builder = session.associatedWorkoutBuilder()
222
+ session.delegate = self
223
+ builder.delegate = self
224
+ builder.shouldCollectWorkoutEvents = true
225
+ builder.dataSource = HKLiveWorkoutDataSource(healthStore: healthStore, workoutConfiguration: configuration)
226
+ workoutSession = session
227
+ workoutBuilder = builder
228
+ workoutId = UUID().uuidString.lowercased()
229
+ let start = Date()
230
+ workoutStartedAt = start
231
+ session.startActivity(with: start)
232
+ builder.beginCollection(withStart: start) { [weak self] success, error in
233
+ DispatchQueue.main.async {
234
+ guard let self else { return completion(false) }
235
+ guard success else {
236
+ self.clearHealthWorkout()
237
+ self.status = error?.localizedDescription ?? "Workout could not start"
238
+ self.persistState()
239
+ return completion(false)
240
+ }
241
+ self.isRecording = true
242
+ self.isPaused = false
243
+ self.status = "Recording"
244
+ self.persistState()
245
+ self.publishState()
246
+ completion(true)
247
+ }
248
+ }
249
+ } catch {
250
+ status = error.localizedDescription
251
+ persistState()
252
+ completion(false)
253
+ }
254
+ }
255
+
256
+ private func pauseHealthWorkout() {
257
+ guard let workoutSession else { return }
258
+ workoutSession.pause()
259
+ isPaused = true
260
+ status = "Paused"
261
+ persistState()
262
+ publishState()
263
+ }
264
+
265
+ private func resumeHealthWorkout() {
266
+ guard let workoutSession else { return }
267
+ workoutSession.resume()
268
+ isPaused = false
269
+ status = "Recording"
270
+ persistState()
271
+ publishState()
272
+ }
273
+
274
+ private func endHealthWorkout(completion: @escaping ([String: Any]?) -> Void) {
275
+ guard let workoutSession, let workoutBuilder, let id = workoutId else {
276
+ completion(nil)
277
+ return
278
+ }
279
+ let end = Date()
280
+ workoutSession.end()
281
+ workoutBuilder.endCollection(withEnd: end) { [weak self] success, error in
282
+ guard let self else { return }
283
+ guard success else {
284
+ DispatchQueue.main.async {
285
+ self.status = error?.localizedDescription ?? "Workout could not finish"
286
+ self.persistState()
287
+ completion(nil)
288
+ }
289
+ return
290
+ }
291
+ workoutBuilder.finishWorkout { [weak self] workout, finishError in
292
+ DispatchQueue.main.async {
293
+ guard let self else { return completion(nil) }
294
+ guard let workout else {
295
+ self.status = finishError?.localizedDescription ?? "Workout could not be saved"
296
+ self.persistState()
297
+ return completion(nil)
298
+ }
299
+ let result: [String: Any] = [
300
+ "id": id,
301
+ "workoutId": workout.uuid.uuidString,
302
+ "startDate": (self.workoutStartedAt ?? workout.startDate).timeIntervalSince1970 * 1000,
303
+ "endDate": end.timeIntervalSince1970 * 1000,
304
+ "durationSeconds": workout.duration,
305
+ "distanceMeters": self.distanceMeters,
306
+ ]
307
+ self.lastWorkout = result
308
+ self.isRecording = false
309
+ self.isPaused = false
310
+ self.status = "Saved to Apple Health"
311
+ self.clearHealthWorkout()
312
+ self.persistState()
313
+ self.publishState()
314
+ completion(result)
315
+ }
316
+ }
317
+ }
318
+ }
319
+
320
+ private func clearHealthWorkout() {
321
+ workoutSession = nil
322
+ workoutBuilder = nil
323
+ workoutId = nil
324
+ workoutStartedAt = nil
325
+ }
326
+
327
+ private func updateHealthMetrics(_ builder: HKLiveWorkoutBuilder) {
328
+ if let heartRate = HKQuantityType.quantityType(forIdentifier: .heartRate),
329
+ let value = builder.statistics(for: heartRate)?.mostRecentQuantity()?.doubleValue(for: HKUnit.count().unitDivided(by: .minute())),
330
+ value.isFinite {
331
+ heartRateBpm = max(value, 0)
332
+ let maximum = (plannedSession?["maxHeartRateBpm"] as? Double) ?? 190
333
+ heartRateZone = min(max(Int(ceil(value / maximum * 5)), 1), 5)
334
+ }
335
+ if let distance = HKQuantityType.quantityType(forIdentifier: .distanceWalkingRunning),
336
+ let value = builder.statistics(for: distance)?.sumQuantity()?.doubleValue(for: .meter()),
337
+ value.isFinite {
338
+ distanceMeters = max(value, 0)
339
+ }
340
+ if let start = workoutStartedAt { durationSeconds = max(Date().timeIntervalSince(start), 0) }
341
+ persistState()
342
+ publishState()
343
+ }
344
+ }
345
+
346
+ extension CraftWatchSession: HKWorkoutSessionDelegate, HKLiveWorkoutBuilderDelegate {
347
+ nonisolated func workoutSession(_ workoutSession: HKWorkoutSession, didChangeTo toState: HKWorkoutSessionState, from fromState: HKWorkoutSessionState, date: Date) {}
348
+
349
+ nonisolated func workoutSession(_ workoutSession: HKWorkoutSession, didFailWithError error: Error) {
350
+ Task { @MainActor in
351
+ status = error.localizedDescription
352
+ isRecording = false
353
+ isPaused = false
354
+ clearHealthWorkout()
355
+ persistState()
356
+ publishState()
357
+ }
358
+ }
359
+
360
+ nonisolated func workoutBuilderDidCollectEvent(_ workoutBuilder: HKLiveWorkoutBuilder) {}
361
+
362
+ nonisolated func workoutBuilder(_ workoutBuilder: HKLiveWorkoutBuilder, didCollectDataOf types: Set<HKSampleType>) {
363
+ Task { @MainActor in updateHealthMetrics(workoutBuilder) }
78
364
  }
79
365
  }
80
366
 
@@ -84,11 +370,15 @@ extension CraftWatchSession: WCSessionDelegate {
84
370
  isPhoneReachable = session.isReachable
85
371
  status = error == nil ? "Ready" : "Connection unavailable"
86
372
  apply(session.receivedApplicationContext)
373
+ publishState()
87
374
  }
88
375
  }
89
376
 
90
377
  nonisolated func sessionReachabilityDidChange(_ session: WCSession) {
91
- Task { @MainActor in isPhoneReachable = session.isReachable }
378
+ Task { @MainActor in
379
+ isPhoneReachable = session.isReachable
380
+ if session.isReachable { publishState() }
381
+ }
92
382
  }
93
383
 
94
384
  nonisolated func session(_ session: WCSession, didReceiveApplicationContext applicationContext: [String: Any]) {
@@ -129,6 +419,10 @@ struct CraftWatchRecordingView: View {
129
419
  metric(distance, label: "Distance")
130
420
  metric(duration, label: "Time")
131
421
  }
422
+ if let heartRate = session.heartRateBpm {
423
+ metric(String(format: "%.0f bpm", heartRate), label: "Heart rate")
424
+ if let zone = session.heartRateZone { Text("Zone \(zone) of 5").font(.caption2).foregroundStyle(.secondary) }
425
+ }
132
426
  Button(session.isRecording && !session.isPaused ? "Pause" : session.isPaused ? "Resume" : "Start") {
133
427
  session.toggleRecording()
134
428
  }
@@ -30,5 +30,6 @@
30
30
  <key>NSExtensionPointIdentifier</key>
31
31
  <string>com.apple.watchkit</string>
32
32
  </dict>
33
+ {{HEALTH_USAGE_DESCRIPTIONS}}
33
34
  </dict>
34
35
  </plist>