craft-native 0.0.55 → 0.0.74

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.
Files changed (146) hide show
  1. package/dist/android/src/index.d.ts +73 -0
  2. package/dist/android/src/index.js +177 -37
  3. package/dist/android/templates/AndroidManifest.xml.template +10 -36
  4. package/dist/android/templates/CraftBridge.kt.template +192 -60
  5. package/dist/android/templates/CraftHealthConnect.kt.template +200 -0
  6. package/dist/android/templates/CraftHealthConnectStub.kt.template +28 -0
  7. package/dist/android/templates/LocationRecordingService.kt.template +160 -0
  8. package/dist/android/templates/MainActivity.kt.template +140 -19
  9. package/dist/android/templates/build.gradle.kts.app.template +23 -1
  10. package/dist/android/templates/build.gradle.kts.project.template +3 -2
  11. package/dist/android/templates/proguard-rules.pro.template +9 -0
  12. package/dist/api/android-advanced.d.ts +5 -6
  13. package/dist/api/app.d.ts +0 -1
  14. package/dist/api/clipboard.d.ts +0 -1
  15. package/dist/api/crypto.d.ts +1 -2
  16. package/dist/api/db.d.ts +1 -2
  17. package/dist/api/dialog.d.ts +0 -1
  18. package/dist/api/fs.d.ts +1 -2
  19. package/dist/api/gestures.d.ts +66 -0
  20. package/dist/api/http.d.ts +1 -2
  21. package/dist/api/index.d.ts +37 -34
  22. package/dist/api/ios-advanced.d.ts +6 -7
  23. package/dist/api/macos-advanced.d.ts +2 -3
  24. package/dist/api/media.d.ts +0 -1
  25. package/dist/api/mobile.d.ts +109 -1
  26. package/dist/api/process.d.ts +0 -1
  27. package/dist/api/sidebar.d.ts +3 -20
  28. package/dist/api/spaces.d.ts +83 -0
  29. package/dist/api/tray.d.ts +2 -3
  30. package/dist/api/window.d.ts +1 -2
  31. package/dist/api/windows-advanced.d.ts +6 -7
  32. package/dist/assets/index.d.ts +0 -1
  33. package/dist/binary-resolver.d.ts +23 -1
  34. package/dist/bridge/android.d.ts +0 -1
  35. package/dist/bridge/core.d.ts +0 -38
  36. package/dist/bridge/ids.d.ts +0 -1
  37. package/dist/bridge/ios.d.ts +0 -1
  38. package/dist/bridge/webkit-pending.d.ts +0 -1
  39. package/dist/build/bundle-optimizer.d.ts +0 -1
  40. package/dist/cli.js +666 -90
  41. package/dist/components/index.d.ts +6 -7
  42. package/dist/components/native.d.ts +0 -1
  43. package/dist/components/sidebar.d.ts +2 -81
  44. package/dist/dev/devtools.d.ts +0 -1
  45. package/dist/dev/error-overlay.d.ts +0 -1
  46. package/dist/dev/hot-reload.d.ts +0 -1
  47. package/dist/index.cjs +113 -3
  48. package/dist/index.d.cts +16 -16
  49. package/dist/index.d.ts +16 -16
  50. package/dist/index.js +112 -4
  51. package/dist/ios/src/index.d.ts +172 -0
  52. package/dist/ios/src/index.js +440 -42
  53. package/dist/ios/templates/CraftActivityAttributes.swift +14 -0
  54. package/dist/ios/templates/CraftApp.swift +1001 -73
  55. package/dist/ios/templates/CraftLiveActivityWidget.swift.template +62 -0
  56. package/dist/ios/templates/CraftWatchApp.swift.template +155 -0
  57. package/dist/ios/templates/Info.plist.template +10 -9
  58. package/dist/ios/templates/WatchApp.Info.plist.template +26 -0
  59. package/dist/ios/templates/WidgetExtension.Info.plist +31 -0
  60. package/dist/ios/templates/project.yml.template +12 -1
  61. package/dist/logging/index.d.ts +0 -1
  62. package/dist/mobile.d.ts +8 -0
  63. package/dist/mobile.js +755 -0
  64. package/dist/optimizations/index.d.ts +3 -4
  65. package/dist/package.d.ts +11 -1
  66. package/dist/performance/runtime.d.ts +0 -1
  67. package/dist/performance/startup.d.ts +0 -1
  68. package/dist/plugins/index.d.ts +0 -1
  69. package/dist/security/index.d.ts +0 -1
  70. package/dist/sidebar-types.d.ts +102 -0
  71. package/dist/signing/index.d.ts +0 -1
  72. package/dist/styles/headwind.d.ts +0 -1
  73. package/dist/styles/index.d.ts +9 -10
  74. package/dist/styles/sidebar-templates.d.ts +0 -1
  75. package/dist/styles/sidebars.d.ts +0 -1
  76. package/dist/types.d.ts +131 -1
  77. package/dist/updater/index.d.ts +0 -1
  78. package/dist/utils/audio.d.ts +0 -1
  79. package/dist/utils/index.d.ts +4 -5
  80. package/dist/utils/native.d.ts +1 -2
  81. package/dist/utils/react.d.ts +0 -1
  82. package/dist/utils/storage.d.ts +0 -1
  83. package/dist/utils/svelte.d.ts +0 -1
  84. package/dist/utils/timer.d.ts +0 -1
  85. package/dist/utils/vue.d.ts +0 -1
  86. package/package.json +32 -4
  87. package/dist/android/src/index.js.map +0 -10
  88. package/dist/api/android-advanced.d.ts.map +0 -1
  89. package/dist/api/app.d.ts.map +0 -1
  90. package/dist/api/clipboard.d.ts.map +0 -1
  91. package/dist/api/crypto.d.ts.map +0 -1
  92. package/dist/api/db.d.ts.map +0 -1
  93. package/dist/api/dialog.d.ts.map +0 -1
  94. package/dist/api/fs.d.ts.map +0 -1
  95. package/dist/api/http.d.ts.map +0 -1
  96. package/dist/api/index.d.ts.map +0 -1
  97. package/dist/api/ios-advanced.d.ts.map +0 -1
  98. package/dist/api/macos-advanced.d.ts.map +0 -1
  99. package/dist/api/media.d.ts.map +0 -1
  100. package/dist/api/mobile.d.ts.map +0 -1
  101. package/dist/api/process.d.ts.map +0 -1
  102. package/dist/api/sidebar.d.ts.map +0 -1
  103. package/dist/api/tray.d.ts.map +0 -1
  104. package/dist/api/window.d.ts.map +0 -1
  105. package/dist/api/windows-advanced.d.ts.map +0 -1
  106. package/dist/assets/index.d.ts.map +0 -1
  107. package/dist/binary-resolver.d.ts.map +0 -1
  108. package/dist/bridge/android.d.ts.map +0 -1
  109. package/dist/bridge/core.d.ts.map +0 -1
  110. package/dist/bridge/ids.d.ts.map +0 -1
  111. package/dist/bridge/ios.d.ts.map +0 -1
  112. package/dist/bridge/webkit-pending.d.ts.map +0 -1
  113. package/dist/build/bundle-optimizer.d.ts.map +0 -1
  114. package/dist/components/index.d.ts.map +0 -1
  115. package/dist/components/native.d.ts.map +0 -1
  116. package/dist/components/sidebar.d.ts.map +0 -1
  117. package/dist/dev/devtools.d.ts.map +0 -1
  118. package/dist/dev/error-overlay.d.ts.map +0 -1
  119. package/dist/dev/hot-reload.d.ts.map +0 -1
  120. package/dist/index.d.ts.map +0 -1
  121. package/dist/index.js.map +0 -45
  122. package/dist/ios/src/index.js.map +0 -10
  123. package/dist/logging/index.d.ts.map +0 -1
  124. package/dist/optimizations/index.d.ts.map +0 -1
  125. package/dist/package.d.ts.map +0 -1
  126. package/dist/package.test.d.ts +0 -2
  127. package/dist/package.test.d.ts.map +0 -1
  128. package/dist/performance/runtime.d.ts.map +0 -1
  129. package/dist/performance/startup.d.ts.map +0 -1
  130. package/dist/plugins/index.d.ts.map +0 -1
  131. package/dist/security/index.d.ts.map +0 -1
  132. package/dist/signing/index.d.ts.map +0 -1
  133. package/dist/styles/headwind.d.ts.map +0 -1
  134. package/dist/styles/index.d.ts.map +0 -1
  135. package/dist/styles/sidebar-templates.d.ts.map +0 -1
  136. package/dist/styles/sidebars.d.ts.map +0 -1
  137. package/dist/types.d.ts.map +0 -1
  138. package/dist/updater/index.d.ts.map +0 -1
  139. package/dist/utils/audio.d.ts.map +0 -1
  140. package/dist/utils/index.d.ts.map +0 -1
  141. package/dist/utils/native.d.ts.map +0 -1
  142. package/dist/utils/react.d.ts.map +0 -1
  143. package/dist/utils/storage.d.ts.map +0 -1
  144. package/dist/utils/svelte.d.ts.map +0 -1
  145. package/dist/utils/timer.d.ts.map +0 -1
  146. package/dist/utils/vue.d.ts.map +0 -1
@@ -29,13 +29,46 @@ import Vision
29
29
  import WidgetKit
30
30
  import Intents
31
31
  import WatchConnectivity
32
+ import ActivityKit
33
+
34
+ extension Notification.Name {
35
+ static let craftPushToken = Notification.Name("craftPushToken")
36
+ static let craftPushRegistrationError = Notification.Name("craftPushRegistrationError")
37
+ static let craftNotificationResponse = Notification.Name("craftNotificationResponse")
38
+ }
39
+
40
+ final class CraftAppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCenterDelegate {
41
+ func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool {
42
+ UNUserNotificationCenter.current().delegate = self
43
+ return true
44
+ }
45
+
46
+ func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
47
+ let token = deviceToken.map { String(format: "%02x", $0) }.joined()
48
+ NotificationCenter.default.post(name: .craftPushToken, object: token)
49
+ }
50
+
51
+ func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error) {
52
+ NotificationCenter.default.post(name: .craftPushRegistrationError, object: error.localizedDescription)
53
+ }
54
+
55
+ func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) {
56
+ NotificationCenter.default.post(name: .craftNotificationResponse, object: response.notification.request.content.userInfo)
57
+ completionHandler()
58
+ }
59
+
60
+ func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) {
61
+ completionHandler([.banner, .badge, .sound])
62
+ }
63
+ }
32
64
 
33
65
  // MARK: - App Entry Point
34
66
  @main
35
67
  struct CraftApp: App {
68
+ @UIApplicationDelegateAdaptor(CraftAppDelegate.self) private var appDelegate
36
69
  @StateObject private var appState = AppState()
37
70
 
38
- var body: some Scene {
71
+ var body: some SwiftUI.Scene {
39
72
  WindowGroup {
40
73
  CraftWebView(config: appState.config)
41
74
  .ignoresSafeArea()
@@ -45,6 +78,11 @@ struct CraftApp: App {
45
78
  // Handle deep links and universal links
46
79
  DeepLinkManager.shared.handleURL(url)
47
80
  }
81
+ .onContinueUserActivity(NSUserActivityTypeBrowsingWeb) { activity in
82
+ if let url = activity.webpageURL {
83
+ DeepLinkManager.shared.handleURL(url)
84
+ }
85
+ }
48
86
  }
49
87
  }
50
88
  }
@@ -148,42 +186,139 @@ struct CraftConfig: Codable {
148
186
  var bundleId: String = "com.craft.app"
149
187
  var darkMode: Bool = true
150
188
  var backgroundColor: String = "#1a1a2e"
151
- var enableSpeechRecognition: Bool = true
152
- var enableHaptics: Bool = true
153
- var enableShare: Bool = true
154
- var enableCamera: Bool = true
155
- var enableBiometric: Bool = true
189
+ var enableSpeechRecognition: Bool = false
190
+ var enableHaptics: Bool = false
191
+ var enableShare: Bool = false
192
+ var enableCamera: Bool = false
193
+ var enableBiometric: Bool = false
156
194
  var enablePushNotifications: Bool = false
157
- var enableSecureStorage: Bool = true
158
- var enableGeolocation: Bool = true
159
- var enableClipboard: Bool = true
160
- var enableContacts: Bool = true
161
- var enableCalendar: Bool = true
162
- var enableLocalNotifications: Bool = true
163
- var enableInAppPurchase: Bool = true
164
- var enableKeepAwake: Bool = true
165
- var enableOrientationLock: Bool = true
166
- var enableDeepLinks: Bool = true
167
- var enableQRScanner: Bool = true
168
- var enableFilePicker: Bool = true
169
- var enableFileDownload: Bool = true
170
- var enableSocialAuth: Bool = true
171
- var enableAudioRecording: Bool = true
172
- var enableVideoRecording: Bool = true
173
- var enableMotionSensors: Bool = true
174
- var enableLocalDatabase: Bool = true
175
- var enableBluetooth: Bool = true
176
- var enableNFC: Bool = true
195
+ var enableSecureStorage: Bool = false
196
+ var enableGeolocation: Bool = false
197
+ var enableClipboard: Bool = false
198
+ var enableContacts: Bool = false
199
+ var enableCalendar: Bool = false
200
+ var enableLocalNotifications: Bool = false
201
+ var enableInAppPurchase: Bool = false
202
+ var enableKeepAwake: Bool = false
203
+ var enableOrientationLock: Bool = false
204
+ var enableDeepLinks: Bool = false
205
+ var enableQRScanner: Bool = false
206
+ var enableFilePicker: Bool = false
207
+ var enableFileDownload: Bool = false
208
+ var enableSocialAuth: Bool = false
209
+ var enableAudioRecording: Bool = false
210
+ var enableVideoRecording: Bool = false
211
+ var enableMotionSensors: Bool = false
212
+ var enableLocalDatabase: Bool = false
213
+ var enableBluetooth: Bool = false
214
+ var enableNFC: Bool = false
177
215
  var enableHealthKit: Bool = false
178
- var enableBackgroundTasks: Bool = true
179
- var enableScreenCapture: Bool = true
180
- var enablePDFViewer: Bool = true
181
- var enableAR: Bool = true
182
- var enableMLKit: Bool = true
216
+ var enableLiveActivities: Bool = false
217
+ var enableWatchApp: Bool = false
218
+ var enableBackgroundLocation: Bool = false
219
+ var enableBackgroundTasks: Bool = false
220
+ var enableScreenCapture: Bool = false
221
+ var enablePDFViewer: Bool = false
222
+ var enableAR: Bool = false
223
+ var enableMLKit: Bool = false
183
224
  var devServerURL: String? = nil
225
+ var trustedOrigins: [String] = []
184
226
  }
185
227
 
186
228
  // MARK: - WebView
229
+ final class BundledAssetSchemeHandler: NSObject, WKURLSchemeHandler {
230
+ private let rootURL: URL?
231
+
232
+ override init() {
233
+ rootURL = Self.findBundledRoot()
234
+ super.init()
235
+ }
236
+
237
+ private static func findBundledRoot() -> URL? {
238
+ let bundle = Bundle.main
239
+ let directCandidates = [
240
+ bundle.url(forResource: "index", withExtension: "html", subdirectory: "dist"),
241
+ bundle.url(forResource: "index", withExtension: "html")
242
+ ]
243
+ if let indexURL = directCandidates.compactMap({ $0 }).first {
244
+ return indexURL.deletingLastPathComponent()
245
+ }
246
+
247
+ // Folder references, synchronized folders, and different Xcode
248
+ // versions can materialize resources at different bundle depths.
249
+ // Search the app resources once so a valid bundle never becomes a
250
+ // silent blank screen merely because its generated layout changed.
251
+ guard let resources = bundle.resourceURL,
252
+ let files = FileManager.default.enumerator(
253
+ at: resources,
254
+ includingPropertiesForKeys: [.isRegularFileKey],
255
+ options: [.skipsHiddenFiles, .skipsPackageDescendants]
256
+ ) else { return nil }
257
+
258
+ for case let fileURL as URL in files where fileURL.lastPathComponent == "index.html" {
259
+ return fileURL.deletingLastPathComponent()
260
+ }
261
+ return nil
262
+ }
263
+
264
+ func webView(_ webView: WKWebView, start urlSchemeTask: WKURLSchemeTask) {
265
+ guard let requestURL = urlSchemeTask.request.url,
266
+ requestURL.scheme == "craft",
267
+ requestURL.host == "app" else {
268
+ fail(urlSchemeTask, code: .badURL)
269
+ return
270
+ }
271
+ guard let rootURL = rootURL else {
272
+ fail(urlSchemeTask, code: .fileDoesNotExist)
273
+ return
274
+ }
275
+
276
+ let path = requestURL.path.removingPercentEncoding?.trimmingCharacters(in: CharacterSet(charactersIn: "/")) ?? ""
277
+ guard !path.split(separator: "/").contains("..") else {
278
+ fail(urlSchemeTask, code: .noPermissionsToReadFile)
279
+ return
280
+ }
281
+
282
+ let candidates: [String]
283
+ if path.isEmpty {
284
+ candidates = ["index.html"]
285
+ } else if path.hasSuffix("/") {
286
+ candidates = ["\(path)index.html"]
287
+ } else if URL(fileURLWithPath: path).pathExtension.isEmpty {
288
+ candidates = ["\(path).html", "\(path)/index.html"]
289
+ } else {
290
+ candidates = [path]
291
+ }
292
+
293
+ let standardizedRoot = rootURL.standardizedFileURL
294
+ for candidate in candidates {
295
+ let fileURL = rootURL.appendingPathComponent(candidate).standardizedFileURL
296
+ guard fileURL.path.hasPrefix(standardizedRoot.path + "/") else { continue }
297
+ guard let data = try? Data(contentsOf: fileURL) else { continue }
298
+ let mimeType = UTType(filenameExtension: fileURL.pathExtension)?.preferredMIMEType ?? "application/octet-stream"
299
+ let encoding = mimeType.hasPrefix("text/") || mimeType.contains("javascript") || mimeType.contains("json") ? "utf-8" : nil
300
+ let response = URLResponse(
301
+ url: requestURL,
302
+ mimeType: mimeType,
303
+ expectedContentLength: data.count,
304
+ textEncodingName: encoding
305
+ )
306
+ urlSchemeTask.didReceive(response)
307
+ urlSchemeTask.didReceive(data)
308
+ urlSchemeTask.didFinish()
309
+ return
310
+ }
311
+
312
+ fail(urlSchemeTask, code: .fileDoesNotExist)
313
+ }
314
+
315
+ func webView(_ webView: WKWebView, stop urlSchemeTask: WKURLSchemeTask) {}
316
+
317
+ private func fail(_ task: WKURLSchemeTask, code: URLError.Code) {
318
+ task.didFailWithError(URLError(code))
319
+ }
320
+ }
321
+
187
322
  struct CraftWebView: UIViewRepresentable {
188
323
  let config: CraftConfig
189
324
 
@@ -192,6 +327,7 @@ struct CraftWebView: UIViewRepresentable {
192
327
  webConfig.defaultWebpagePreferences.allowsContentJavaScript = true
193
328
  webConfig.allowsInlineMediaPlayback = true
194
329
  webConfig.mediaTypesRequiringUserActionForPlayback = []
330
+ webConfig.setURLSchemeHandler(BundledAssetSchemeHandler(), forURLScheme: "craft")
195
331
 
196
332
  // Add native bridge
197
333
  let contentController = WKUserContentController()
@@ -216,10 +352,10 @@ struct CraftWebView: UIViewRepresentable {
216
352
  if let url = URL(string: devURL) {
217
353
  webView.load(URLRequest(url: url))
218
354
  }
219
- } else if let htmlPath = Bundle.main.path(forResource: "index", ofType: "html") {
220
- // Production mode - load bundled HTML
221
- let htmlURL = URL(fileURLWithPath: htmlPath)
222
- webView.loadFileURL(htmlURL, allowingReadAccessTo: htmlURL.deletingLastPathComponent())
355
+ } else if let bundledURL = URL(string: "craft://app/index.html") {
356
+ // A route-aware local origin keeps root-relative STX assets and
357
+ // clean links working without granting arbitrary file access.
358
+ webView.load(URLRequest(url: bundledURL))
223
359
  }
224
360
 
225
361
  return webView
@@ -232,7 +368,7 @@ struct CraftWebView: UIViewRepresentable {
232
368
  }
233
369
 
234
370
  // MARK: - Coordinator (Native Bridge)
235
- class Coordinator: NSObject, WKNavigationDelegate, WKScriptMessageHandler, UIImagePickerControllerDelegate, UINavigationControllerDelegate, CLLocationManagerDelegate, ARSCNViewDelegate, WCSessionDelegate {
371
+ class Coordinator: NSObject, WKNavigationDelegate, WKScriptMessageHandler, UIImagePickerControllerDelegate, UINavigationControllerDelegate, CLLocationManagerDelegate, ARSCNViewDelegate {
236
372
  let config: CraftConfig
237
373
  private var speechRecognizer: SFSpeechRecognizer?
238
374
  private var recognitionRequest: SFSpeechAudioBufferRecognitionRequest?
@@ -240,10 +376,18 @@ struct CraftWebView: UIViewRepresentable {
240
376
  private var audioEngine = AVAudioEngine()
241
377
  private weak var webView: WKWebView?
242
378
  private var pendingCallbackId: String?
379
+ private var pendingPushCallbackId: String?
380
+ private var loadedBundledFallback = false
243
381
 
244
382
  // Location
245
383
  private var locationManager: CLLocationManager?
246
- private var locationCallbackId: String?
384
+ private var singleLocationCallbackId: String?
385
+ private var locationPermissionCallbackId: String?
386
+ private var isWatchingLocation = false
387
+ private var isRecordingLocation = false
388
+ private var isLocationRecordingPaused = false
389
+ private var locationRecordingId: String?
390
+ private var locationRecordingStartedAt: TimeInterval?
247
391
 
248
392
  // Network monitoring
249
393
  private var networkMonitor: NWPathMonitor?
@@ -284,9 +428,6 @@ struct CraftWebView: UIViewRepresentable {
284
428
  // SQLite database
285
429
  private var db: OpaquePointer?
286
430
 
287
- // Pending callback for async operations
288
- private var pendingCallbackId: String?
289
-
290
431
  init(config: CraftConfig) {
291
432
  self.config = config
292
433
  super.init()
@@ -295,6 +436,10 @@ struct CraftWebView: UIViewRepresentable {
295
436
  }
296
437
  if config.enableGeolocation {
297
438
  locationManager = CLLocationManager()
439
+ locationManager?.desiredAccuracy = kCLLocationAccuracyBest
440
+ locationManager?.activityType = .fitness
441
+ locationManager?.pausesLocationUpdatesAutomatically = false
442
+ restoreLocationRecordingState()
298
443
  }
299
444
  if config.enableContacts {
300
445
  contactStore = CNContactStore()
@@ -308,12 +453,23 @@ struct CraftWebView: UIViewRepresentable {
308
453
  if config.enableHealthKit && HKHealthStore.isHealthDataAvailable() {
309
454
  healthStore = HKHealthStore()
310
455
  }
456
+ if config.enableWatchApp {
457
+ setupWatchConnectivity()
458
+ }
311
459
  if config.enableLocalDatabase {
312
460
  setupDatabase()
313
461
  }
462
+ NotificationCenter.default.addObserver(self, selector: #selector(receivePushToken(_:)), name: .craftPushToken, object: nil)
463
+ NotificationCenter.default.addObserver(self, selector: #selector(receivePushRegistrationError(_:)), name: .craftPushRegistrationError, object: nil)
464
+ NotificationCenter.default.addObserver(self, selector: #selector(receiveNotificationResponse(_:)), name: .craftNotificationResponse, object: nil)
314
465
  setupNetworkMonitoring()
315
466
  }
316
467
 
468
+ deinit {
469
+ NotificationCenter.default.removeObserver(self)
470
+ networkMonitor?.cancel()
471
+ }
472
+
317
473
  private func setupDatabase() {
318
474
  let documentsPath = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
319
475
  let dbPath = documentsPath.appendingPathComponent("craft.db").path
@@ -347,6 +503,11 @@ struct CraftWebView: UIViewRepresentable {
347
503
  }
348
504
 
349
505
  func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
506
+ let securityOrigin = message.frameInfo.securityOrigin
507
+ guard isTrustedOrigin(scheme: securityOrigin.protocol, host: securityOrigin.host, port: securityOrigin.port) else {
508
+ print("Blocked Craft bridge message from untrusted origin: \(securityOrigin.protocol)://\(securityOrigin.host)")
509
+ return
510
+ }
350
511
  guard let body = message.body as? [String: Any],
351
512
  let action = body["action"] as? String else { return }
352
513
 
@@ -363,8 +524,16 @@ struct CraftWebView: UIViewRepresentable {
363
524
  triggerHaptic(style: style)
364
525
  }
365
526
  case "share":
366
- if config.enableShare, let text = body["text"] as? String {
367
- shareText(text)
527
+ if config.enableShare {
528
+ if let options = body["options"] as? [String: Any] {
529
+ share(options: options, callbackId: callbackId)
530
+ } else if let text = body["text"] as? String {
531
+ share(options: ["text": text], callbackId: callbackId)
532
+ } else {
533
+ rejectCallback(callbackId, error: "Nothing to share", code: "INVALID_ARGUMENT")
534
+ }
535
+ } else {
536
+ rejectCallback(callbackId, error: "Sharing is disabled", code: "CAPABILITY_DISABLED")
368
537
  }
369
538
  case "openCamera":
370
539
  if config.enableCamera {
@@ -404,6 +573,24 @@ struct CraftWebView: UIViewRepresentable {
404
573
  let success = secureRemove(key: key)
405
574
  resolveCallback(callbackId, result: success)
406
575
  }
576
+ case "secureClear":
577
+ if config.enableSecureStorage {
578
+ resolveCallback(callbackId, result: secureClear())
579
+ } else {
580
+ rejectCallback(callbackId, error: "Secure storage is disabled", code: "CAPABILITY_DISABLED")
581
+ }
582
+ case "checkPermission":
583
+ checkPermission(body["permission"] as? String, callbackId: callbackId)
584
+ case "requestPermission":
585
+ requestPermission(body["permission"] as? String, callbackId: callbackId)
586
+ case "openSettings":
587
+ if let url = URL(string: UIApplication.openSettingsURLString) {
588
+ UIApplication.shared.open(url) { opened in
589
+ self.resolveCallback(callbackId, result: opened)
590
+ }
591
+ } else {
592
+ rejectCallback(callbackId, error: "Settings URL is unavailable")
593
+ }
407
594
  case "log":
408
595
  if let msg = body["message"] as? String {
409
596
  print("[Craft Web] \(msg)")
@@ -417,13 +604,34 @@ struct CraftWebView: UIViewRepresentable {
417
604
  case "getCurrentPosition":
418
605
  if config.enableGeolocation {
419
606
  getCurrentPosition(callbackId: callbackId)
607
+ } else {
608
+ rejectCallback(callbackId, error: "Geolocation is disabled", code: "CAPABILITY_DISABLED")
420
609
  }
421
610
  case "watchPosition":
422
611
  if config.enableGeolocation {
423
612
  watchPosition(callbackId: callbackId)
613
+ } else {
614
+ rejectCallback(callbackId, error: "Geolocation is disabled", code: "CAPABILITY_DISABLED")
424
615
  }
425
616
  case "clearWatch":
426
617
  stopWatchingPosition()
618
+ resolveCallback(callbackId, result: true)
619
+ case "startLocationRecording":
620
+ if config.enableGeolocation {
621
+ startLocationRecording(callbackId: callbackId)
622
+ } else {
623
+ rejectCallback(callbackId, error: "Geolocation is disabled", code: "CAPABILITY_DISABLED")
624
+ }
625
+ case "pauseLocationRecording":
626
+ pauseLocationRecording(callbackId: callbackId)
627
+ case "resumeLocationRecording":
628
+ resumeLocationRecording(callbackId: callbackId)
629
+ case "stopLocationRecording":
630
+ stopLocationRecording(callbackId: callbackId)
631
+ case "getLocationRecordingState":
632
+ getLocationRecordingState(callbackId: callbackId)
633
+ case "readLocationRecording":
634
+ readLocationRecording(callbackId: callbackId)
427
635
  // Clipboard
428
636
  case "clipboardWrite":
429
637
  if config.enableClipboard, let text = body["text"] as? String {
@@ -682,6 +890,24 @@ struct CraftWebView: UIViewRepresentable {
682
890
  let endDate = body["endDate"] as? Double
683
891
  getHealthData(type: dataType, startDate: startDate, endDate: endDate, callbackId: callbackId)
684
892
  }
893
+ case "saveHealthWorkout":
894
+ if config.enableHealthKit {
895
+ saveHealthWorkout(body: body, callbackId: callbackId)
896
+ } else {
897
+ rejectCallback(callbackId, error: "HealthKit is disabled")
898
+ }
899
+
900
+ // MARK: - Live Activities
901
+ case "startLiveActivity":
902
+ if config.enableLiveActivities {
903
+ startLiveActivity(body: body, callbackId: callbackId)
904
+ } else {
905
+ rejectCallback(callbackId, error: "Live Activities are disabled")
906
+ }
907
+ case "updateLiveActivity":
908
+ updateLiveActivity(body: body, callbackId: callbackId)
909
+ case "endLiveActivity":
910
+ endLiveActivity(callbackId: callbackId)
685
911
 
686
912
  // MARK: - Screen Capture
687
913
  case "takeScreenshot":
@@ -848,9 +1074,68 @@ struct CraftWebView: UIViewRepresentable {
848
1074
 
849
1075
  func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
850
1076
  self.webView = webView
1077
+ guard isTrustedURL(webView.url) else { return }
851
1078
  injectNativeBridge()
852
1079
  }
853
1080
 
1081
+ func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
1082
+ guard let url = navigationAction.request.url else {
1083
+ decisionHandler(.cancel)
1084
+ return
1085
+ }
1086
+ if isTrustedURL(url) {
1087
+ decisionHandler(.allow)
1088
+ return
1089
+ }
1090
+ if navigationAction.targetFrame?.isMainFrame != false {
1091
+ UIApplication.shared.open(url)
1092
+ }
1093
+ decisionHandler(.cancel)
1094
+ }
1095
+
1096
+ func webViewWebContentProcessDidTerminate(_ webView: WKWebView) {
1097
+ webView.reload()
1098
+ }
1099
+
1100
+ func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) {
1101
+ loadBundledFallback(in: webView)
1102
+ }
1103
+
1104
+ func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) {
1105
+ loadBundledFallback(in: webView)
1106
+ }
1107
+
1108
+ private func loadBundledFallback(in webView: WKWebView) {
1109
+ guard !loadedBundledFallback,
1110
+ config.devServerURL != nil,
1111
+ let bundledURL = URL(string: "craft://app/index.html") else { return }
1112
+ loadedBundledFallback = true
1113
+ webView.load(URLRequest(url: bundledURL))
1114
+ }
1115
+
1116
+ private func isTrustedURL(_ url: URL?) -> Bool {
1117
+ guard let url = url else { return false }
1118
+ if url.scheme == "craft" && url.host == "app" { return true }
1119
+ return isTrustedOrigin(scheme: url.scheme ?? "", host: url.host ?? "", port: url.port ?? 0)
1120
+ }
1121
+
1122
+ private func isTrustedOrigin(scheme: String, host: String, port: Int) -> Bool {
1123
+ if scheme == "file" { return true }
1124
+ guard scheme == "https" || (scheme == "http" && ["localhost", "127.0.0.1", "::1"].contains(host)) else { return false }
1125
+ let defaultPort = scheme == "https" ? 443 : 80
1126
+ let normalizedPort = port == 0 ? defaultPort : port
1127
+ let origin = "\(scheme)://\(host)\(normalizedPort == defaultPort ? "" : ":\(normalizedPort)")"
1128
+ var allowed = Set(config.trustedOrigins)
1129
+ if let devServerURL = config.devServerURL,
1130
+ let components = URLComponents(string: devServerURL),
1131
+ let configuredHost = components.host,
1132
+ let configuredScheme = components.scheme {
1133
+ let configuredPort = components.port ?? (configuredScheme == "https" ? 443 : 80)
1134
+ allowed.insert("\(configuredScheme)://\(configuredHost)\(configuredPort == (configuredScheme == "https" ? 443 : 80) ? "" : ":\(configuredPort)")")
1135
+ }
1136
+ return allowed.contains(origin)
1137
+ }
1138
+
854
1139
  private func injectNativeBridge() {
855
1140
  let laContext = LAContext()
856
1141
  var biometricAvailable = false
@@ -870,6 +1155,10 @@ struct CraftWebView: UIViewRepresentable {
870
1155
  pushNotifications: \(config.enablePushNotifications),
871
1156
  secureStorage: \(config.enableSecureStorage),
872
1157
  geolocation: \(config.enableGeolocation),
1158
+ backgroundLocation: \(config.enableBackgroundLocation),
1159
+ healthKit: \(config.enableHealthKit),
1160
+ liveActivities: \(config.enableLiveActivities),
1161
+ watchConnectivity: true,
873
1162
  clipboard: \(config.enableClipboard),
874
1163
  contacts: \(config.enableContacts),
875
1164
  calendar: \(config.enableCalendar),
@@ -1994,6 +2283,136 @@ struct CraftWebView: UIViewRepresentable {
1994
2283
  }
1995
2284
  };
1996
2285
 
2286
+ // Stable, versioned mobile contract consumed by craft-native/mobile.
2287
+ // Legacy flat methods remain available while every public SDK method
2288
+ // is routed through this nested contract.
2289
+ (function installCraftMobileContract(craft) {
2290
+ var legacyShare = craft.share.bind(craft);
2291
+ var legacyOpenCamera = craft.openCamera.bind(craft);
2292
+ var legacyPickImage = craft.pickImage.bind(craft);
2293
+ var legacyAuthenticate = craft.authenticate.bind(craft);
2294
+ var legacySecureStore = craft.secureStore;
2295
+ var legacyGeolocation = craft.geolocation;
2296
+ var legacyNotifications = craft.notifications;
2297
+
2298
+ craft.contractVersion = '1.0.0';
2299
+ craft._invoke = function(action, payload) {
2300
+ var self = craft;
2301
+ var id = 'cb_' + (++self._callbackId);
2302
+ var message = Object.assign({}, payload || {}, {action: action, callbackId: id});
2303
+ window.webkit.messageHandlers.craft.postMessage(message);
2304
+ return new Promise(function(resolve, reject) {
2305
+ var timeout = setTimeout(function() {
2306
+ delete self._callbacks[id];
2307
+ reject(new Error('Craft bridge timed out: ' + action));
2308
+ }, 30000);
2309
+ self._callbacks[id] = {
2310
+ resolve: function(value) { clearTimeout(timeout); resolve(value); },
2311
+ reject: function(error) { clearTimeout(timeout); reject(error); }
2312
+ };
2313
+ });
2314
+ };
2315
+
2316
+ craft.device = {
2317
+ getInfo: function() { return craft.getDeviceInfo(); },
2318
+ getCapabilities: function() { return Promise.resolve(Object.assign({}, craft.capabilities)); }
2319
+ };
2320
+ craft.haptics = {
2321
+ impact: function(style) { craft.haptic(style || 'medium'); return Promise.resolve(); },
2322
+ notification: function(type) {
2323
+ craft.haptic(type === 'error' ? 'heavy' : type === 'warning' ? 'medium' : 'light');
2324
+ return Promise.resolve();
2325
+ },
2326
+ selection: function() { craft.haptic('soft'); return Promise.resolve(); },
2327
+ vibrate: function(pattern) { craft.vibrate(pattern || []); return Promise.resolve(); }
2328
+ };
2329
+ craft.permissions = {
2330
+ check: function(permission) { return craft._invoke('checkPermission', {permission: permission}); },
2331
+ request: function(permission) { return craft._invoke('requestPermission', {permission: permission}); },
2332
+ openSettings: function() { return craft._invoke('openSettings'); }
2333
+ };
2334
+ craft.camera = {
2335
+ takePicture: function() { return legacyOpenCamera().then(normalizePhoto); },
2336
+ pickImage: function() { return legacyPickImage().then(normalizePhoto); },
2337
+ pickMultiple: function() { return legacyPickImage().then(function(photo) { return [normalizePhoto(photo)]; }); },
2338
+ isAvailable: function() { return Promise.resolve(!!craft.capabilities.camera); }
2339
+ };
2340
+ craft.biometrics = {
2341
+ isAvailable: function() { return Promise.resolve(!!craft.capabilities.biometric); },
2342
+ getBiometricType: function() { return Promise.resolve(craft.capabilities.biometric ? 'faceId' : null); },
2343
+ authenticate: function(reason) { return legacyAuthenticate(reason); }
2344
+ };
2345
+ craft.secureStorage = {
2346
+ set: function(key, value) { return legacySecureStore.set(key, value).then(function() {}); },
2347
+ get: function(key) { return legacySecureStore.get(key); },
2348
+ delete: function(key) { return legacySecureStore.remove(key).then(function() {}); },
2349
+ clear: function() { return craft._invoke('secureClear').then(function() {}); }
2350
+ };
2351
+
2352
+ var locationWatchCallbacks = new Map();
2353
+ var nextLocationWatchId = 0;
2354
+ window.addEventListener('craftLocationUpdate', function(event) {
2355
+ locationWatchCallbacks.forEach(function(callback) { callback(event.detail); });
2356
+ });
2357
+ craft.location = {
2358
+ getCurrentPosition: function(options) { return legacyGeolocation.getCurrentPosition(options || {}); },
2359
+ watchPosition: function(callback) {
2360
+ var id = ++nextLocationWatchId;
2361
+ locationWatchCallbacks.set(id, callback);
2362
+ if (locationWatchCallbacks.size === 1) void craft._invoke('watchPosition');
2363
+ return id;
2364
+ },
2365
+ clearWatch: function(id) {
2366
+ locationWatchCallbacks.delete(id);
2367
+ if (locationWatchCallbacks.size === 0) {
2368
+ window.webkit.messageHandlers.craft.postMessage({action: 'clearWatch'});
2369
+ }
2370
+ },
2371
+ startRecording: function() { return craft._invoke('startLocationRecording'); },
2372
+ pauseRecording: function() { return craft._invoke('pauseLocationRecording'); },
2373
+ resumeRecording: function() { return craft._invoke('resumeLocationRecording'); },
2374
+ stopRecording: function() { return craft._invoke('stopLocationRecording'); },
2375
+ getRecordingState: function() { return craft._invoke('getLocationRecordingState'); },
2376
+ readRecording: function() { return craft._invoke('readLocationRecording'); }
2377
+ };
2378
+ craft.health = {
2379
+ requestAuthorization: function(types) { return craft._invoke('requestHealthAuthorization', {types: types || []}); },
2380
+ getData: function(type, options) {
2381
+ options = options || {};
2382
+ return craft._invoke('getHealthData', {type: type, startDate: options.startDate, endDate: options.endDate});
2383
+ },
2384
+ saveWorkout: function(workout) { return craft._invoke('saveHealthWorkout', workout || {}); }
2385
+ };
2386
+ craft.liveActivity = {
2387
+ start: function(options) { return craft._invoke('startLiveActivity', options || {}); },
2388
+ update: function(options) { return craft._invoke('updateLiveActivity', options || {}); },
2389
+ end: function() { return craft._invoke('endLiveActivity'); }
2390
+ };
2391
+ var shareApi = function(text) { return legacyShare(text); };
2392
+ shareApi.share = function(options) { return craft._invoke('share', {options: options || {}}); };
2393
+ craft.share = shareApi;
2394
+ craft.lifecycle = {
2395
+ getState: function() { return document.visibilityState === 'visible' ? 'active' : 'background'; },
2396
+ onStateChange: function(callback) {
2397
+ var handler = function() { callback(document.visibilityState === 'visible' ? 'active' : 'background'); };
2398
+ document.addEventListener('visibilitychange', handler);
2399
+ return function() { document.removeEventListener('visibilitychange', handler); };
2400
+ }
2401
+ };
2402
+ craft.notifications = Object.assign({}, legacyNotifications, {
2403
+ show: function(options) {
2404
+ return legacyNotifications.schedule(Object.assign({}, options || {}, {scheduleAt: Date.now()}));
2405
+ },
2406
+ setBadge: function(count) { return craft.setBadge(count).then(function() {}); }
2407
+ });
2408
+
2409
+ function normalizePhoto(photo) {
2410
+ if (photo && typeof photo === 'object') return photo;
2411
+ var base64 = String(photo || '');
2412
+ return {base64: base64, uri: 'data:image/jpeg;base64,' + base64, width: 0, height: 0, mimeType: 'image/jpeg'};
2413
+ }
2414
+ })(window.craft);
2415
+
1997
2416
  // Dispatch ready event
1998
2417
  window.dispatchEvent(new CustomEvent('craftReady', {detail: window.craft}));
1999
2418
  console.log('Craft iOS bridge initialized');
@@ -2007,20 +2426,22 @@ struct CraftWebView: UIViewRepresentable {
2007
2426
  // MARK: - Callback Helpers
2008
2427
  private func resolveCallback(_ callbackId: String?, result: Any) {
2009
2428
  guard let id = callbackId else { return }
2010
- var resultStr: String
2011
- if let str = result as? String {
2012
- resultStr = "'\(str.replacingOccurrences(of: "'", with: "\\'"))'"
2013
- } else if let bool = result as? Bool {
2014
- resultStr = bool ? "true" : "false"
2015
- } else if result is NSNull {
2016
- resultStr = "null"
2017
- } else {
2018
- resultStr = "null"
2429
+ let resultStr: String
2430
+ do {
2431
+ let data = try JSONSerialization.data(withJSONObject: result, options: [.fragmentsAllowed])
2432
+ resultStr = String(data: data, encoding: .utf8) ?? "null"
2433
+ } catch {
2434
+ rejectCallback(callbackId, error: "Native result could not be serialized", code: "SERIALIZATION_ERROR")
2435
+ return
2019
2436
  }
2020
2437
  let script = "window.craft._resolveCallback('\(id)', \(resultStr));"
2021
2438
  DispatchQueue.main.async { self.webView?.evaluateJavaScript(script, completionHandler: nil) }
2022
2439
  }
2023
2440
 
2441
+ private func resolveCallbackJSON(_ callbackId: String?, json: [String: Any]) {
2442
+ resolveCallback(callbackId, result: json)
2443
+ }
2444
+
2024
2445
  private func rejectCallback(_ callbackId: String?, error: String, code: String = "CRAFT_ERROR") {
2025
2446
  guard let id = callbackId else { return }
2026
2447
  let escapedError = error.replacingOccurrences(of: "'", with: "\\'").replacingOccurrences(of: "\n", with: "\\n")
@@ -2136,10 +2557,34 @@ struct CraftWebView: UIViewRepresentable {
2136
2557
  }
2137
2558
 
2138
2559
  // MARK: - Share
2139
- private func shareText(_ text: String) {
2560
+ private func share(options: [String: Any], callbackId: String?) {
2140
2561
  guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
2141
- let rootVC = windowScene.windows.first?.rootViewController else { return }
2142
- let activityVC = UIActivityViewController(activityItems: [text], applicationActivities: nil)
2562
+ let rootVC = windowScene.windows.first?.rootViewController else {
2563
+ rejectCallback(callbackId, error: "Unable to present the share sheet")
2564
+ return
2565
+ }
2566
+ var items: [Any] = []
2567
+ if let title = options["title"] as? String, !title.isEmpty { items.append(title) }
2568
+ if let text = options["text"] as? String, !text.isEmpty { items.append(text) }
2569
+ if let urlString = options["url"] as? String, let url = URL(string: urlString) { items.append(url) }
2570
+ if let files = options["files"] as? [String] {
2571
+ for file in files {
2572
+ if let url = URL(string: file), url.isFileURL { items.append(url) }
2573
+ else if FileManager.default.fileExists(atPath: file) { items.append(URL(fileURLWithPath: file)) }
2574
+ }
2575
+ }
2576
+ guard !items.isEmpty else {
2577
+ rejectCallback(callbackId, error: "Nothing to share", code: "INVALID_ARGUMENT")
2578
+ return
2579
+ }
2580
+ let activityVC = UIActivityViewController(activityItems: items, applicationActivities: nil)
2581
+ activityVC.completionWithItemsHandler = { _, completed, _, error in
2582
+ if let error = error {
2583
+ self.rejectCallback(callbackId, error: error.localizedDescription, code: "SHARE_ERROR")
2584
+ } else {
2585
+ self.resolveCallback(callbackId, result: completed)
2586
+ }
2587
+ }
2143
2588
  rootVC.present(activityVC, animated: true)
2144
2589
  }
2145
2590
 
@@ -2177,8 +2622,14 @@ struct CraftWebView: UIViewRepresentable {
2177
2622
 
2178
2623
  if let image = info[.originalImage] as? UIImage,
2179
2624
  let imageData = image.jpegData(compressionQuality: 0.8) {
2180
- let base64 = "data:image/jpeg;base64," + imageData.base64EncodedString()
2181
- resolveCallback(pendingCallbackId, result: base64)
2625
+ let base64 = imageData.base64EncodedString()
2626
+ resolveCallback(pendingCallbackId, result: [
2627
+ "base64": base64,
2628
+ "uri": "data:image/jpeg;base64," + base64,
2629
+ "width": image.size.width,
2630
+ "height": image.size.height,
2631
+ "mimeType": "image/jpeg",
2632
+ ])
2182
2633
  } else {
2183
2634
  rejectCallback(pendingCallbackId, error: "Failed to process image")
2184
2635
  }
@@ -2213,19 +2664,37 @@ struct CraftWebView: UIViewRepresentable {
2213
2664
 
2214
2665
  // MARK: - Push Notifications
2215
2666
  private func registerPushNotifications(callbackId: String?) {
2667
+ pendingPushCallbackId = callbackId
2216
2668
  UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .badge, .sound]) { [weak self] granted, error in
2217
2669
  if granted {
2218
2670
  DispatchQueue.main.async {
2219
2671
  UIApplication.shared.registerForRemoteNotifications()
2220
- // In a real app, you'd get the device token from application delegate
2221
- self?.resolveCallback(callbackId, result: "push-registered")
2222
2672
  }
2223
2673
  } else {
2224
2674
  self?.rejectCallback(callbackId, error: error?.localizedDescription ?? "Permission denied")
2675
+ self?.pendingPushCallbackId = nil
2225
2676
  }
2226
2677
  }
2227
2678
  }
2228
2679
 
2680
+ @objc private func receivePushToken(_ notification: Notification) {
2681
+ guard let token = notification.object as? String else { return }
2682
+ resolveCallback(pendingPushCallbackId, result: token)
2683
+ pendingPushCallbackId = nil
2684
+ sendToWeb("craftPushToken", data: ["token": token])
2685
+ }
2686
+
2687
+ @objc private func receivePushRegistrationError(_ notification: Notification) {
2688
+ let message = notification.object as? String ?? "Push registration failed"
2689
+ rejectCallback(pendingPushCallbackId, error: message, code: "PUSH_REGISTRATION_ERROR")
2690
+ pendingPushCallbackId = nil
2691
+ }
2692
+
2693
+ @objc private func receiveNotificationResponse(_ notification: Notification) {
2694
+ let data = notification.object as? [String: Any] ?? [:]
2695
+ sendToWeb("craftNotificationResponse", data: data)
2696
+ }
2697
+
2229
2698
  // MARK: - Secure Storage (Keychain)
2230
2699
  private func secureStore(key: String, value: String) -> Bool {
2231
2700
  let data = value.data(using: .utf8)!
@@ -2267,23 +2736,313 @@ struct CraftWebView: UIViewRepresentable {
2267
2736
  return status == errSecSuccess || status == errSecItemNotFound
2268
2737
  }
2269
2738
 
2739
+ private func secureClear() -> Bool {
2740
+ let query: [String: Any] = [kSecClass as String: kSecClassGenericPassword]
2741
+ let status = SecItemDelete(query as CFDictionary)
2742
+ return status == errSecSuccess || status == errSecItemNotFound
2743
+ }
2744
+
2745
+ // MARK: - Permissions
2746
+ private func permissionStatus(_ granted: Bool?, denied: Bool = false, restricted: Bool = false) -> String {
2747
+ if restricted { return "restricted" }
2748
+ if denied { return "denied" }
2749
+ guard let granted = granted else { return "undetermined" }
2750
+ return granted ? "granted" : "undetermined"
2751
+ }
2752
+
2753
+ private func checkPermission(_ permission: String?, callbackId: String?) {
2754
+ guard let permission = permission else {
2755
+ rejectCallback(callbackId, error: "Missing permission", code: "INVALID_ARGUMENT")
2756
+ return
2757
+ }
2758
+ switch permission {
2759
+ case "location", "locationAlways":
2760
+ let status = CLLocationManager.authorizationStatus()
2761
+ let granted = status == .authorizedAlways || (permission == "location" && status == .authorizedWhenInUse)
2762
+ resolveCallback(callbackId, result: permissionStatus(granted, denied: status == .denied, restricted: status == .restricted))
2763
+ case "camera":
2764
+ let status = AVCaptureDevice.authorizationStatus(for: .video)
2765
+ resolveCallback(callbackId, result: permissionStatus(status == .authorized, denied: status == .denied, restricted: status == .restricted))
2766
+ case "microphone":
2767
+ let status = AVAudioSession.sharedInstance().recordPermission
2768
+ resolveCallback(callbackId, result: permissionStatus(status == .granted, denied: status == .denied))
2769
+ case "photos":
2770
+ let status = PHPhotoLibrary.authorizationStatus(for: .readWrite)
2771
+ resolveCallback(callbackId, result: permissionStatus(status == .authorized || status == .limited, denied: status == .denied, restricted: status == .restricted))
2772
+ case "contacts":
2773
+ let status = CNContactStore.authorizationStatus(for: .contacts)
2774
+ resolveCallback(callbackId, result: permissionStatus(status == .authorized, denied: status == .denied, restricted: status == .restricted))
2775
+ case "calendar", "reminders":
2776
+ let entity: EKEntityType = permission == "calendar" ? .event : .reminder
2777
+ let status = EKEventStore.authorizationStatus(for: entity)
2778
+ resolveCallback(callbackId, result: permissionStatus(status == .authorized || status.rawValue >= 4, denied: status == .denied, restricted: status == .restricted))
2779
+ case "motion":
2780
+ let status = CMMotionActivityManager.authorizationStatus()
2781
+ resolveCallback(callbackId, result: permissionStatus(status == .authorized, denied: status == .denied, restricted: status == .restricted))
2782
+ case "bluetooth":
2783
+ let status = CBManager.authorization
2784
+ resolveCallback(callbackId, result: permissionStatus(status == .allowedAlways, denied: status == .denied, restricted: status == .restricted))
2785
+ case "notifications":
2786
+ UNUserNotificationCenter.current().getNotificationSettings { settings in
2787
+ let status: String
2788
+ switch settings.authorizationStatus {
2789
+ case .authorized, .provisional, .ephemeral: status = "granted"
2790
+ case .denied: status = "denied"
2791
+ case .notDetermined: status = "undetermined"
2792
+ @unknown default: status = "undetermined"
2793
+ }
2794
+ self.resolveCallback(callbackId, result: status)
2795
+ }
2796
+ default:
2797
+ resolveCallback(callbackId, result: "undetermined")
2798
+ }
2799
+ }
2800
+
2801
+ private func requestPermission(_ permission: String?, callbackId: String?) {
2802
+ guard let permission = permission else {
2803
+ rejectCallback(callbackId, error: "Missing permission", code: "INVALID_ARGUMENT")
2804
+ return
2805
+ }
2806
+ switch permission {
2807
+ case "location", "locationAlways":
2808
+ locationPermissionCallbackId = callbackId
2809
+ if permission == "locationAlways" || config.enableBackgroundLocation {
2810
+ locationManager?.requestAlwaysAuthorization()
2811
+ } else {
2812
+ locationManager?.requestWhenInUseAuthorization()
2813
+ }
2814
+ case "camera":
2815
+ AVCaptureDevice.requestAccess(for: .video) { granted in
2816
+ self.resolveCallback(callbackId, result: granted ? "granted" : "denied")
2817
+ }
2818
+ case "microphone":
2819
+ AVAudioSession.sharedInstance().requestRecordPermission { granted in
2820
+ self.resolveCallback(callbackId, result: granted ? "granted" : "denied")
2821
+ }
2822
+ case "photos":
2823
+ PHPhotoLibrary.requestAuthorization(for: .readWrite) { status in
2824
+ self.resolveCallback(callbackId, result: status == .authorized || status == .limited ? "granted" : "denied")
2825
+ }
2826
+ case "contacts":
2827
+ (contactStore ?? CNContactStore()).requestAccess(for: .contacts) { granted, _ in
2828
+ self.resolveCallback(callbackId, result: granted ? "granted" : "denied")
2829
+ }
2830
+ case "calendar", "reminders":
2831
+ let store = eventStore ?? EKEventStore()
2832
+ let entity: EKEntityType = permission == "calendar" ? .event : .reminder
2833
+ if #available(iOS 17.0, *) {
2834
+ if permission == "calendar" {
2835
+ store.requestFullAccessToEvents { granted, _ in
2836
+ self.resolveCallback(callbackId, result: granted ? "granted" : "denied")
2837
+ }
2838
+ } else {
2839
+ store.requestFullAccessToReminders { granted, _ in
2840
+ self.resolveCallback(callbackId, result: granted ? "granted" : "denied")
2841
+ }
2842
+ }
2843
+ } else {
2844
+ store.requestAccess(to: entity) { granted, _ in
2845
+ self.resolveCallback(callbackId, result: granted ? "granted" : "denied")
2846
+ }
2847
+ }
2848
+ case "notifications":
2849
+ UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .badge, .sound]) { granted, _ in
2850
+ self.resolveCallback(callbackId, result: granted ? "granted" : "denied")
2851
+ }
2852
+ default:
2853
+ resolveCallback(callbackId, result: "undetermined")
2854
+ }
2855
+ }
2856
+
2270
2857
  // MARK: - Geolocation
2271
2858
  private func getCurrentPosition(callbackId: String?) {
2272
2859
  locationManager?.delegate = self
2273
- locationCallbackId = callbackId
2274
- locationManager?.requestWhenInUseAuthorization()
2860
+ singleLocationCallbackId = callbackId
2861
+ requestLocationAuthorization()
2275
2862
  locationManager?.requestLocation()
2276
2863
  }
2277
2864
 
2278
2865
  private func watchPosition(callbackId: String?) {
2279
2866
  locationManager?.delegate = self
2280
- locationCallbackId = callbackId
2281
- locationManager?.requestWhenInUseAuthorization()
2867
+ isWatchingLocation = true
2868
+ requestLocationAuthorization()
2869
+ configureBackgroundLocationIfNeeded()
2282
2870
  locationManager?.startUpdatingLocation()
2871
+ resolveCallback(callbackId, result: true)
2283
2872
  }
2284
2873
 
2285
2874
  private func stopWatchingPosition() {
2286
- locationManager?.stopUpdatingLocation()
2875
+ isWatchingLocation = false
2876
+ if !isRecordingLocation {
2877
+ locationManager?.stopUpdatingLocation()
2878
+ locationManager?.allowsBackgroundLocationUpdates = false
2879
+ }
2880
+ }
2881
+
2882
+ private var locationRecordingStateURL: URL {
2883
+ FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
2884
+ .appendingPathComponent("craft-location-recording-state.json")
2885
+ }
2886
+
2887
+ private var locationRecordingTrackURL: URL {
2888
+ FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
2889
+ .appendingPathComponent("craft-location-recording.jsonl")
2890
+ }
2891
+
2892
+ private func requestLocationAuthorization() {
2893
+ if config.enableBackgroundLocation {
2894
+ locationManager?.requestAlwaysAuthorization()
2895
+ } else {
2896
+ locationManager?.requestWhenInUseAuthorization()
2897
+ }
2898
+ }
2899
+
2900
+ private func configureBackgroundLocationIfNeeded() {
2901
+ locationManager?.allowsBackgroundLocationUpdates = config.enableBackgroundLocation
2902
+ locationManager?.showsBackgroundLocationIndicator = config.enableBackgroundLocation
2903
+ }
2904
+
2905
+ private func persistLocationRecordingState() {
2906
+ let state: [String: Any] = [
2907
+ "active": isRecordingLocation,
2908
+ "paused": isLocationRecordingPaused,
2909
+ "id": locationRecordingId ?? NSNull(),
2910
+ "startedAt": locationRecordingStartedAt ?? NSNull(),
2911
+ ]
2912
+ do {
2913
+ let directory = locationRecordingStateURL.deletingLastPathComponent()
2914
+ try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
2915
+ let data = try JSONSerialization.data(withJSONObject: state)
2916
+ try data.write(to: locationRecordingStateURL, options: .atomic)
2917
+ protectLocationFile(locationRecordingStateURL)
2918
+ } catch {
2919
+ print("Unable to persist location recording state: \(error)")
2920
+ }
2921
+ }
2922
+
2923
+ private func restoreLocationRecordingState() {
2924
+ guard let data = try? Data(contentsOf: locationRecordingStateURL),
2925
+ let state = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
2926
+ state["active"] as? Bool == true else { return }
2927
+ isRecordingLocation = true
2928
+ isLocationRecordingPaused = state["paused"] as? Bool ?? false
2929
+ locationRecordingId = state["id"] as? String
2930
+ locationRecordingStartedAt = state["startedAt"] as? TimeInterval
2931
+ if !isLocationRecordingPaused {
2932
+ locationManager?.delegate = self
2933
+ configureBackgroundLocationIfNeeded()
2934
+ locationManager?.startUpdatingLocation()
2935
+ }
2936
+ }
2937
+
2938
+ private func startLocationRecording(callbackId: String?) {
2939
+ let directory = locationRecordingTrackURL.deletingLastPathComponent()
2940
+ do {
2941
+ try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
2942
+ try Data().write(to: locationRecordingTrackURL, options: .atomic)
2943
+ protectLocationFile(locationRecordingTrackURL)
2944
+ } catch {
2945
+ rejectCallback(callbackId, error: "Unable to create the location recording", code: "RECORDING_STORAGE_ERROR")
2946
+ return
2947
+ }
2948
+ locationRecordingId = UUID().uuidString
2949
+ locationRecordingStartedAt = Date().timeIntervalSince1970 * 1000
2950
+ isRecordingLocation = true
2951
+ isLocationRecordingPaused = false
2952
+ persistLocationRecordingState()
2953
+ locationManager?.delegate = self
2954
+ requestLocationAuthorization()
2955
+ configureBackgroundLocationIfNeeded()
2956
+ locationManager?.startUpdatingLocation()
2957
+ resolveCallback(callbackId, result: locationRecordingSummary())
2958
+ }
2959
+
2960
+ private func pauseLocationRecording(callbackId: String?) {
2961
+ guard isRecordingLocation else {
2962
+ rejectCallback(callbackId, error: "No active location recording", code: "NO_ACTIVE_RECORDING")
2963
+ return
2964
+ }
2965
+ isLocationRecordingPaused = true
2966
+ persistLocationRecordingState()
2967
+ if !isWatchingLocation { locationManager?.stopUpdatingLocation() }
2968
+ resolveCallback(callbackId, result: locationRecordingSummary())
2969
+ }
2970
+
2971
+ private func resumeLocationRecording(callbackId: String?) {
2972
+ guard isRecordingLocation else {
2973
+ rejectCallback(callbackId, error: "No active location recording", code: "NO_ACTIVE_RECORDING")
2974
+ return
2975
+ }
2976
+ isLocationRecordingPaused = false
2977
+ persistLocationRecordingState()
2978
+ requestLocationAuthorization()
2979
+ configureBackgroundLocationIfNeeded()
2980
+ locationManager?.startUpdatingLocation()
2981
+ resolveCallback(callbackId, result: locationRecordingSummary())
2982
+ }
2983
+
2984
+ private func stopLocationRecording(callbackId: String?) {
2985
+ isRecordingLocation = false
2986
+ isLocationRecordingPaused = false
2987
+ persistLocationRecordingState()
2988
+ if !isWatchingLocation {
2989
+ locationManager?.stopUpdatingLocation()
2990
+ locationManager?.allowsBackgroundLocationUpdates = false
2991
+ }
2992
+ let locations = loadRecordedLocations()
2993
+ var summary = locationRecordingSummary()
2994
+ summary["locations"] = locations
2995
+ resolveCallback(callbackId, result: summary)
2996
+ }
2997
+
2998
+ private func getLocationRecordingState(callbackId: String?) {
2999
+ var summary = locationRecordingSummary()
3000
+ summary["sampleCount"] = loadRecordedLocations().count
3001
+ resolveCallback(callbackId, result: summary)
3002
+ }
3003
+
3004
+ private func readLocationRecording(callbackId: String?) {
3005
+ resolveCallback(callbackId, result: loadRecordedLocations())
3006
+ }
3007
+
3008
+ private func locationRecordingSummary() -> [String: Any] {
3009
+ [
3010
+ "id": locationRecordingId ?? NSNull(),
3011
+ "active": isRecordingLocation,
3012
+ "paused": isLocationRecordingPaused,
3013
+ "startedAt": locationRecordingStartedAt ?? NSNull(),
3014
+ ]
3015
+ }
3016
+
3017
+ private func appendRecordedLocation(_ data: [String: Any]) {
3018
+ guard isRecordingLocation && !isLocationRecordingPaused,
3019
+ let json = try? JSONSerialization.data(withJSONObject: data) else { return }
3020
+ var line = json
3021
+ line.append(0x0A)
3022
+ do {
3023
+ let handle = try FileHandle(forWritingTo: locationRecordingTrackURL)
3024
+ try handle.seekToEnd()
3025
+ try handle.write(contentsOf: line)
3026
+ try handle.close()
3027
+ } catch {
3028
+ print("Unable to append location sample: \(error)")
3029
+ }
3030
+ }
3031
+
3032
+ private func protectLocationFile(_ url: URL) {
3033
+ try? FileManager.default.setAttributes([.protectionKey: FileProtectionType.completeUntilFirstUserAuthentication], ofItemAtPath: url.path)
3034
+ var values = URLResourceValues()
3035
+ values.isExcludedFromBackup = true
3036
+ var protectedURL = url
3037
+ try? protectedURL.setResourceValues(values)
3038
+ }
3039
+
3040
+ private func loadRecordedLocations() -> [[String: Any]] {
3041
+ guard let text = try? String(contentsOf: locationRecordingTrackURL, encoding: .utf8) else { return [] }
3042
+ return text.split(separator: "\n").compactMap { line in
3043
+ guard let data = line.data(using: .utf8) else { return nil }
3044
+ return try? JSONSerialization.jsonObject(with: data) as? [String: Any]
3045
+ }
2287
3046
  }
2288
3047
 
2289
3048
  func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
@@ -2299,20 +3058,30 @@ struct CraftWebView: UIViewRepresentable {
2299
3058
  "timestamp": location.timestamp.timeIntervalSince1970 * 1000
2300
3059
  ]
2301
3060
 
2302
- if let callbackId = locationCallbackId {
3061
+ if let callbackId = singleLocationCallbackId {
2303
3062
  resolveCallback(callbackId, result: data)
2304
- // For single request, clear callback
2305
- if !manager.allowsBackgroundLocationUpdates {
2306
- locationCallbackId = nil
2307
- }
3063
+ singleLocationCallbackId = nil
2308
3064
  }
2309
3065
 
2310
- sendToWeb("craftLocationUpdate", data: data)
3066
+ appendRecordedLocation(data)
3067
+ if isWatchingLocation || isRecordingLocation {
3068
+ sendToWeb("craftLocationUpdate", data: data)
3069
+ }
2311
3070
  }
2312
3071
 
2313
3072
  func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) {
2314
- rejectCallback(locationCallbackId, error: error.localizedDescription)
2315
- locationCallbackId = nil
3073
+ rejectCallback(singleLocationCallbackId, error: error.localizedDescription)
3074
+ singleLocationCallbackId = nil
3075
+ sendToWeb("craftLocationError", data: ["message": error.localizedDescription])
3076
+ }
3077
+
3078
+ func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
3079
+ guard let callbackId = locationPermissionCallbackId else { return }
3080
+ let status = manager.authorizationStatus
3081
+ if status == .notDetermined { return }
3082
+ let granted = status == .authorizedAlways || status == .authorizedWhenInUse
3083
+ resolveCallback(callbackId, result: permissionStatus(granted, denied: status == .denied, restricted: status == .restricted))
3084
+ locationPermissionCallbackId = nil
2316
3085
  }
2317
3086
 
2318
3087
  // MARK: - Memory Usage (for Profiling)
@@ -2342,6 +3111,11 @@ struct CraftWebView: UIViewRepresentable {
2342
3111
  private func getDeviceInfo(callbackId: String?) {
2343
3112
  let device = UIDevice.current
2344
3113
  let screen = UIScreen.main
3114
+ #if targetEnvironment(simulator)
3115
+ let isSimulator = true
3116
+ #else
3117
+ let isSimulator = false
3118
+ #endif
2345
3119
  let info: [String: Any] = [
2346
3120
  "platform": "ios",
2347
3121
  "model": device.model,
@@ -2349,7 +3123,7 @@ struct CraftWebView: UIViewRepresentable {
2349
3123
  "systemName": device.systemName,
2350
3124
  "systemVersion": device.systemVersion,
2351
3125
  "identifierForVendor": device.identifierForVendor?.uuidString ?? "",
2352
- "isSimulator": TARGET_OS_SIMULATOR != 0,
3126
+ "isSimulator": isSimulator,
2353
3127
  "screenWidth": screen.bounds.width,
2354
3128
  "screenHeight": screen.bounds.height,
2355
3129
  "screenScale": screen.scale,
@@ -3127,6 +3901,10 @@ struct CraftWebView: UIViewRepresentable {
3127
3901
  }
3128
3902
 
3129
3903
  var readTypes: Set<HKObjectType> = []
3904
+ var shareTypes: Set<HKSampleType> = [HKObjectType.workoutType()]
3905
+ if let routeType = HKSeriesType.workoutRoute() as? HKSampleType {
3906
+ shareTypes.insert(routeType)
3907
+ }
3130
3908
 
3131
3909
  for type in types {
3132
3910
  switch type {
@@ -3141,17 +3919,21 @@ struct CraftWebView: UIViewRepresentable {
3141
3919
  case "activeEnergy":
3142
3920
  if let energyType = HKQuantityType.quantityType(forIdentifier: .activeEnergyBurned) {
3143
3921
  readTypes.insert(energyType)
3922
+ shareTypes.insert(energyType)
3144
3923
  }
3145
3924
  case "distance":
3146
3925
  if let distanceType = HKQuantityType.quantityType(forIdentifier: .distanceWalkingRunning) {
3147
3926
  readTypes.insert(distanceType)
3927
+ shareTypes.insert(distanceType)
3148
3928
  }
3929
+ case "workouts":
3930
+ readTypes.insert(HKObjectType.workoutType())
3149
3931
  default:
3150
3932
  break
3151
3933
  }
3152
3934
  }
3153
3935
 
3154
- healthStore.requestAuthorization(toShare: nil, read: readTypes) { [weak self] success, error in
3936
+ healthStore.requestAuthorization(toShare: shareTypes, read: readTypes) { [weak self] success, error in
3155
3937
  if success {
3156
3938
  self?.resolveCallback(callbackId, result: true)
3157
3939
  } else {
@@ -3210,6 +3992,151 @@ struct CraftWebView: UIViewRepresentable {
3210
3992
  healthStore.execute(query)
3211
3993
  }
3212
3994
 
3995
+ private func saveHealthWorkout(body: [String: Any], callbackId: String?) {
3996
+ guard let healthStore = healthStore else {
3997
+ rejectCallback(callbackId, error: "HealthKit not available")
3998
+ return
3999
+ }
4000
+ guard let activityId = body["activityId"] as? String,
4001
+ let type = body["type"] as? String,
4002
+ let startValue = body["startDate"] as? Double,
4003
+ let endValue = body["endDate"] as? Double,
4004
+ endValue > startValue else {
4005
+ rejectCallback(callbackId, error: "A valid activityId, type, startDate, and endDate are required")
4006
+ return
4007
+ }
4008
+
4009
+ let activityType: HKWorkoutActivityType
4010
+ switch type {
4011
+ case "running": activityType = .running
4012
+ case "walking": activityType = .walking
4013
+ case "hiking": activityType = .hiking
4014
+ case "cycling": activityType = .cycling
4015
+ default:
4016
+ rejectCallback(callbackId, error: "Unsupported workout type")
4017
+ return
4018
+ }
4019
+
4020
+ let distance = (body["distanceMeters"] as? Double).flatMap { value in
4021
+ value > 0 ? HKQuantity(unit: .meter(), doubleValue: value) : nil
4022
+ }
4023
+ let energy = (body["activeEnergyCalories"] as? Double).flatMap { value in
4024
+ value > 0 ? HKQuantity(unit: .kilocalorie(), doubleValue: value) : nil
4025
+ }
4026
+ let start = Date(timeIntervalSince1970: startValue / 1000)
4027
+ let end = Date(timeIntervalSince1970: endValue / 1000)
4028
+ let workout = HKWorkout(
4029
+ activityType: activityType,
4030
+ start: start,
4031
+ end: end,
4032
+ workoutEvents: nil,
4033
+ totalEnergyBurned: energy,
4034
+ totalDistance: distance,
4035
+ metadata: [HKMetadataKeyExternalUUID: activityId, HKMetadataKeyIndoorWorkout: false]
4036
+ )
4037
+
4038
+ healthStore.save(workout) { [weak self] success, error in
4039
+ guard let self = self else { return }
4040
+ guard success else {
4041
+ self.rejectCallback(callbackId, error: error?.localizedDescription ?? "Workout could not be saved")
4042
+ return
4043
+ }
4044
+
4045
+ let locations = (body["locations"] as? [[String: Any]] ?? []).compactMap { item -> CLLocation? in
4046
+ guard let latitude = item["latitude"] as? Double,
4047
+ let longitude = item["longitude"] as? Double,
4048
+ let timestamp = item["timestamp"] as? Double else { return nil }
4049
+ return CLLocation(
4050
+ coordinate: CLLocationCoordinate2D(latitude: latitude, longitude: longitude),
4051
+ altitude: item["altitude"] as? Double ?? 0,
4052
+ horizontalAccuracy: item["accuracy"] as? Double ?? 10,
4053
+ verticalAccuracy: -1,
4054
+ timestamp: Date(timeIntervalSince1970: timestamp / 1000)
4055
+ )
4056
+ }.filter { $0.timestamp >= start && $0.timestamp <= end }
4057
+
4058
+ guard !locations.isEmpty else {
4059
+ self.resolveCallback(callbackId, result: ["id": workout.uuid.uuidString])
4060
+ return
4061
+ }
4062
+
4063
+ let routeBuilder = HKWorkoutRouteBuilder(healthStore: healthStore, device: .local())
4064
+ routeBuilder.insertRouteData(locations) { inserted, routeError in
4065
+ guard inserted else {
4066
+ self.rejectCallback(callbackId, error: routeError?.localizedDescription ?? "Workout route could not be saved")
4067
+ return
4068
+ }
4069
+ routeBuilder.finishRoute(with: workout, metadata: [HKMetadataKeyExternalUUID: activityId]) { route, finishError in
4070
+ if let finishError = finishError {
4071
+ self.rejectCallback(callbackId, error: finishError.localizedDescription)
4072
+ } else {
4073
+ self.resolveCallback(callbackId, result: [
4074
+ "id": workout.uuid.uuidString,
4075
+ "routeId": route?.uuid.uuidString ?? ""
4076
+ ])
4077
+ }
4078
+ }
4079
+ }
4080
+ }
4081
+ }
4082
+
4083
+ // MARK: - Live Activities
4084
+ private func startLiveActivity(body: [String: Any], callbackId: String?) {
4085
+ guard #available(iOS 16.2, *), ActivityAuthorizationInfo().areActivitiesEnabled else {
4086
+ rejectCallback(callbackId, error: "Live Activities are unavailable")
4087
+ return
4088
+ }
4089
+ let attributes = CraftActivityAttributes(
4090
+ activityId: body["activityId"] as? String ?? UUID().uuidString,
4091
+ title: body["title"] as? String ?? config.appName
4092
+ )
4093
+ let state = CraftActivityAttributes.ContentState(
4094
+ status: body["status"] as? String ?? "Recording",
4095
+ distanceMeters: body["distanceMeters"] as? Double ?? 0,
4096
+ durationSeconds: body["durationSeconds"] as? Double ?? 0,
4097
+ progress: min(max(body["progress"] as? Double ?? 0, 0), 1)
4098
+ )
4099
+ do {
4100
+ let activity = try Activity.request(
4101
+ attributes: attributes,
4102
+ content: ActivityContent(state: state, staleDate: nil),
4103
+ pushType: nil
4104
+ )
4105
+ resolveCallback(callbackId, result: ["id": activity.id])
4106
+ } catch {
4107
+ rejectCallback(callbackId, error: error.localizedDescription)
4108
+ }
4109
+ }
4110
+
4111
+ private func updateLiveActivity(body: [String: Any], callbackId: String?) {
4112
+ guard #available(iOS 16.2, *), let activity = Activity<CraftActivityAttributes>.activities.first else {
4113
+ rejectCallback(callbackId, error: "No Live Activity is running")
4114
+ return
4115
+ }
4116
+ let current = activity.content.state
4117
+ let state = CraftActivityAttributes.ContentState(
4118
+ status: body["status"] as? String ?? current.status,
4119
+ distanceMeters: body["distanceMeters"] as? Double ?? current.distanceMeters,
4120
+ durationSeconds: body["durationSeconds"] as? Double ?? current.durationSeconds,
4121
+ progress: min(max(body["progress"] as? Double ?? current.progress, 0), 1)
4122
+ )
4123
+ Task {
4124
+ await activity.update(ActivityContent(state: state, staleDate: nil))
4125
+ resolveCallback(callbackId, result: ["updated": true])
4126
+ }
4127
+ }
4128
+
4129
+ private func endLiveActivity(callbackId: String?) {
4130
+ guard #available(iOS 16.2, *), let activity = Activity<CraftActivityAttributes>.activities.first else {
4131
+ resolveCallback(callbackId, result: ["ended": false])
4132
+ return
4133
+ }
4134
+ Task {
4135
+ await activity.end(nil, dismissalPolicy: .default)
4136
+ resolveCallback(callbackId, result: ["ended": true])
4137
+ }
4138
+ }
4139
+
3213
4140
  // MARK: - Screen Capture
3214
4141
  private func takeScreenshot(callbackId: String?) {
3215
4142
  DispatchQueue.main.async {
@@ -4236,6 +5163,7 @@ extension CraftWebView.Coordinator: WCSessionDelegate {
4236
5163
  func session(_ session: WCSession, didReceiveUserInfo userInfo: [String : Any]) {
4237
5164
  DispatchQueue.main.async { [weak self] in
4238
5165
  self?.sendToWeb("craftWatchUserInfo", data: userInfo)
5166
+ self?.sendToWeb("craftWatchMessage", data: userInfo)
4239
5167
  }
4240
5168
  }
4241
5169
  }