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.
- package/dist/android/src/index.d.ts +73 -0
- package/dist/android/src/index.js +177 -37
- package/dist/android/templates/AndroidManifest.xml.template +10 -36
- package/dist/android/templates/CraftBridge.kt.template +192 -60
- package/dist/android/templates/CraftHealthConnect.kt.template +200 -0
- package/dist/android/templates/CraftHealthConnectStub.kt.template +28 -0
- package/dist/android/templates/LocationRecordingService.kt.template +160 -0
- package/dist/android/templates/MainActivity.kt.template +140 -19
- package/dist/android/templates/build.gradle.kts.app.template +23 -1
- package/dist/android/templates/build.gradle.kts.project.template +3 -2
- package/dist/android/templates/proguard-rules.pro.template +9 -0
- package/dist/api/android-advanced.d.ts +5 -6
- package/dist/api/app.d.ts +0 -1
- package/dist/api/clipboard.d.ts +0 -1
- package/dist/api/crypto.d.ts +1 -2
- package/dist/api/db.d.ts +1 -2
- package/dist/api/dialog.d.ts +0 -1
- package/dist/api/fs.d.ts +1 -2
- package/dist/api/gestures.d.ts +66 -0
- package/dist/api/http.d.ts +1 -2
- package/dist/api/index.d.ts +37 -34
- package/dist/api/ios-advanced.d.ts +6 -7
- package/dist/api/macos-advanced.d.ts +2 -3
- package/dist/api/media.d.ts +0 -1
- package/dist/api/mobile.d.ts +109 -1
- package/dist/api/process.d.ts +0 -1
- package/dist/api/sidebar.d.ts +3 -20
- package/dist/api/spaces.d.ts +83 -0
- package/dist/api/tray.d.ts +2 -3
- package/dist/api/window.d.ts +1 -2
- package/dist/api/windows-advanced.d.ts +6 -7
- package/dist/assets/index.d.ts +0 -1
- package/dist/binary-resolver.d.ts +23 -1
- package/dist/bridge/android.d.ts +0 -1
- package/dist/bridge/core.d.ts +0 -38
- package/dist/bridge/ids.d.ts +0 -1
- package/dist/bridge/ios.d.ts +0 -1
- package/dist/bridge/webkit-pending.d.ts +0 -1
- package/dist/build/bundle-optimizer.d.ts +0 -1
- package/dist/cli.js +666 -90
- package/dist/components/index.d.ts +6 -7
- package/dist/components/native.d.ts +0 -1
- package/dist/components/sidebar.d.ts +2 -81
- package/dist/dev/devtools.d.ts +0 -1
- package/dist/dev/error-overlay.d.ts +0 -1
- package/dist/dev/hot-reload.d.ts +0 -1
- package/dist/index.cjs +113 -3
- package/dist/index.d.cts +16 -16
- package/dist/index.d.ts +16 -16
- package/dist/index.js +112 -4
- package/dist/ios/src/index.d.ts +172 -0
- package/dist/ios/src/index.js +440 -42
- package/dist/ios/templates/CraftActivityAttributes.swift +14 -0
- package/dist/ios/templates/CraftApp.swift +1001 -73
- package/dist/ios/templates/CraftLiveActivityWidget.swift.template +62 -0
- package/dist/ios/templates/CraftWatchApp.swift.template +155 -0
- package/dist/ios/templates/Info.plist.template +10 -9
- package/dist/ios/templates/WatchApp.Info.plist.template +26 -0
- package/dist/ios/templates/WidgetExtension.Info.plist +31 -0
- package/dist/ios/templates/project.yml.template +12 -1
- package/dist/logging/index.d.ts +0 -1
- package/dist/mobile.d.ts +8 -0
- package/dist/mobile.js +755 -0
- package/dist/optimizations/index.d.ts +3 -4
- package/dist/package.d.ts +11 -1
- package/dist/performance/runtime.d.ts +0 -1
- package/dist/performance/startup.d.ts +0 -1
- package/dist/plugins/index.d.ts +0 -1
- package/dist/security/index.d.ts +0 -1
- package/dist/sidebar-types.d.ts +102 -0
- package/dist/signing/index.d.ts +0 -1
- package/dist/styles/headwind.d.ts +0 -1
- package/dist/styles/index.d.ts +9 -10
- package/dist/styles/sidebar-templates.d.ts +0 -1
- package/dist/styles/sidebars.d.ts +0 -1
- package/dist/types.d.ts +131 -1
- package/dist/updater/index.d.ts +0 -1
- package/dist/utils/audio.d.ts +0 -1
- package/dist/utils/index.d.ts +4 -5
- package/dist/utils/native.d.ts +1 -2
- package/dist/utils/react.d.ts +0 -1
- package/dist/utils/storage.d.ts +0 -1
- package/dist/utils/svelte.d.ts +0 -1
- package/dist/utils/timer.d.ts +0 -1
- package/dist/utils/vue.d.ts +0 -1
- package/package.json +32 -4
- package/dist/android/src/index.js.map +0 -10
- package/dist/api/android-advanced.d.ts.map +0 -1
- package/dist/api/app.d.ts.map +0 -1
- package/dist/api/clipboard.d.ts.map +0 -1
- package/dist/api/crypto.d.ts.map +0 -1
- package/dist/api/db.d.ts.map +0 -1
- package/dist/api/dialog.d.ts.map +0 -1
- package/dist/api/fs.d.ts.map +0 -1
- package/dist/api/http.d.ts.map +0 -1
- package/dist/api/index.d.ts.map +0 -1
- package/dist/api/ios-advanced.d.ts.map +0 -1
- package/dist/api/macos-advanced.d.ts.map +0 -1
- package/dist/api/media.d.ts.map +0 -1
- package/dist/api/mobile.d.ts.map +0 -1
- package/dist/api/process.d.ts.map +0 -1
- package/dist/api/sidebar.d.ts.map +0 -1
- package/dist/api/tray.d.ts.map +0 -1
- package/dist/api/window.d.ts.map +0 -1
- package/dist/api/windows-advanced.d.ts.map +0 -1
- package/dist/assets/index.d.ts.map +0 -1
- package/dist/binary-resolver.d.ts.map +0 -1
- package/dist/bridge/android.d.ts.map +0 -1
- package/dist/bridge/core.d.ts.map +0 -1
- package/dist/bridge/ids.d.ts.map +0 -1
- package/dist/bridge/ios.d.ts.map +0 -1
- package/dist/bridge/webkit-pending.d.ts.map +0 -1
- package/dist/build/bundle-optimizer.d.ts.map +0 -1
- package/dist/components/index.d.ts.map +0 -1
- package/dist/components/native.d.ts.map +0 -1
- package/dist/components/sidebar.d.ts.map +0 -1
- package/dist/dev/devtools.d.ts.map +0 -1
- package/dist/dev/error-overlay.d.ts.map +0 -1
- package/dist/dev/hot-reload.d.ts.map +0 -1
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js.map +0 -45
- package/dist/ios/src/index.js.map +0 -10
- package/dist/logging/index.d.ts.map +0 -1
- package/dist/optimizations/index.d.ts.map +0 -1
- package/dist/package.d.ts.map +0 -1
- package/dist/package.test.d.ts +0 -2
- package/dist/package.test.d.ts.map +0 -1
- package/dist/performance/runtime.d.ts.map +0 -1
- package/dist/performance/startup.d.ts.map +0 -1
- package/dist/plugins/index.d.ts.map +0 -1
- package/dist/security/index.d.ts.map +0 -1
- package/dist/signing/index.d.ts.map +0 -1
- package/dist/styles/headwind.d.ts.map +0 -1
- package/dist/styles/index.d.ts.map +0 -1
- package/dist/styles/sidebar-templates.d.ts.map +0 -1
- package/dist/styles/sidebars.d.ts.map +0 -1
- package/dist/types.d.ts.map +0 -1
- package/dist/updater/index.d.ts.map +0 -1
- package/dist/utils/audio.d.ts.map +0 -1
- package/dist/utils/index.d.ts.map +0 -1
- package/dist/utils/native.d.ts.map +0 -1
- package/dist/utils/react.d.ts.map +0 -1
- package/dist/utils/storage.d.ts.map +0 -1
- package/dist/utils/svelte.d.ts.map +0 -1
- package/dist/utils/timer.d.ts.map +0 -1
- 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 =
|
|
152
|
-
var enableHaptics: Bool =
|
|
153
|
-
var enableShare: Bool =
|
|
154
|
-
var enableCamera: Bool =
|
|
155
|
-
var enableBiometric: Bool =
|
|
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 =
|
|
158
|
-
var enableGeolocation: Bool =
|
|
159
|
-
var enableClipboard: Bool =
|
|
160
|
-
var enableContacts: Bool =
|
|
161
|
-
var enableCalendar: Bool =
|
|
162
|
-
var enableLocalNotifications: Bool =
|
|
163
|
-
var enableInAppPurchase: Bool =
|
|
164
|
-
var enableKeepAwake: Bool =
|
|
165
|
-
var enableOrientationLock: Bool =
|
|
166
|
-
var enableDeepLinks: Bool =
|
|
167
|
-
var enableQRScanner: Bool =
|
|
168
|
-
var enableFilePicker: Bool =
|
|
169
|
-
var enableFileDownload: Bool =
|
|
170
|
-
var enableSocialAuth: Bool =
|
|
171
|
-
var enableAudioRecording: Bool =
|
|
172
|
-
var enableVideoRecording: Bool =
|
|
173
|
-
var enableMotionSensors: Bool =
|
|
174
|
-
var enableLocalDatabase: Bool =
|
|
175
|
-
var enableBluetooth: Bool =
|
|
176
|
-
var enableNFC: Bool =
|
|
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
|
|
179
|
-
var
|
|
180
|
-
var
|
|
181
|
-
var
|
|
182
|
-
var
|
|
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
|
|
220
|
-
//
|
|
221
|
-
|
|
222
|
-
webView.
|
|
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
|
|
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
|
|
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
|
|
367
|
-
|
|
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
|
-
|
|
2011
|
-
|
|
2012
|
-
|
|
2013
|
-
|
|
2014
|
-
|
|
2015
|
-
|
|
2016
|
-
|
|
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
|
|
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 {
|
|
2142
|
-
|
|
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 =
|
|
2181
|
-
resolveCallback(pendingCallbackId, result:
|
|
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
|
-
|
|
2274
|
-
|
|
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
|
-
|
|
2281
|
-
|
|
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
|
-
|
|
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 =
|
|
3061
|
+
if let callbackId = singleLocationCallbackId {
|
|
2303
3062
|
resolveCallback(callbackId, result: data)
|
|
2304
|
-
|
|
2305
|
-
if !manager.allowsBackgroundLocationUpdates {
|
|
2306
|
-
locationCallbackId = nil
|
|
2307
|
-
}
|
|
3063
|
+
singleLocationCallbackId = nil
|
|
2308
3064
|
}
|
|
2309
3065
|
|
|
2310
|
-
|
|
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(
|
|
2315
|
-
|
|
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":
|
|
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:
|
|
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
|
}
|