craft-native 0.0.118 → 0.0.119

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.
@@ -259,6 +259,7 @@ class DeepLinkManager {
259
259
  // the one the app was opened with; a link that arrives later is not, and
260
260
  // is no answer to getInitialURL.
261
261
  private var hasBeenReady = false
262
+ private var nativeListeners: [UUID: (URL, Bool) -> Void] = [:]
262
263
 
263
264
  private init() {}
264
265
 
@@ -289,7 +290,9 @@ class DeepLinkManager {
289
290
  initialURL = url
290
291
  }
291
292
 
292
- if isReady && webView != nil {
293
+ if !nativeListeners.isEmpty {
294
+ dispatchNative(url, initial: false)
295
+ } else if isReady && webView != nil {
293
296
  dispatchDeepLink(url, initial: false)
294
297
  } else {
295
298
  pendingURLs.append(url)
@@ -300,6 +303,28 @@ class DeepLinkManager {
300
303
  return initialURL
301
304
  }
302
305
 
306
+ @discardableResult
307
+ func addNativeListener(_ listener: @escaping (URL, Bool) -> Void) -> UUID {
308
+ let token = UUID()
309
+ nativeListeners[token] = listener
310
+ let firstNativeScreen = !hasBeenReady
311
+ hasBeenReady = true
312
+ let urls = pendingURLs
313
+ pendingURLs.removeAll()
314
+ for url in urls {
315
+ listener(url, firstNativeScreen && url == initialURL)
316
+ }
317
+ return token
318
+ }
319
+
320
+ func removeNativeListener(_ token: UUID) {
321
+ nativeListeners.removeValue(forKey: token)
322
+ }
323
+
324
+ private func dispatchNative(_ url: URL, initial: Bool) {
325
+ for listener in nativeListeners.values { listener(url, initial) }
326
+ }
327
+
303
328
  private func dispatchDeepLink(_ url: URL, initial: Bool) {
304
329
  guard let webView = webView else { return }
305
330
 
@@ -392,6 +417,8 @@ struct CraftConfig: Codable {
392
417
  var backgroundColorDark: String? = nil
393
418
  /// Edge-swipe back and forward through the page's history.
394
419
  var swipeNavigation: Bool? = nil
420
+ /// Refuse to load anything but the Info.plist's WKAppBoundDomains.
421
+ var limitNavigationsToAppBoundDomains: Bool? = nil
395
422
  var enableSpeechRecognition: Bool = false
396
423
  var enableHaptics: Bool = false
397
424
  var enableShare: Bool = false
@@ -596,6 +623,13 @@ struct CraftWebView: UIViewRepresentable {
596
623
  webConfig.allowsInlineMediaPlayback = true
597
624
  webConfig.mediaTypesRequiringUserActionForPlayback = []
598
625
  webConfig.setURLSchemeHandler(BundledAssetSchemeHandler(), forURLScheme: "craft")
626
+ // The app's own domains (WKAppBoundDomains in Info.plist) get what an
627
+ // iOS web view reserves for them, service workers among them, so a
628
+ // site that works offline in Safari works offline here. This only
629
+ // narrows loading further when the app asks for it.
630
+ if config.limitNavigationsToAppBoundDomains == true {
631
+ webConfig.limitsNavigationsToAppBoundDomains = true
632
+ }
599
633
 
600
634
  // Add native bridge
601
635
  let contentController = WKUserContentController()
@@ -3460,6 +3494,10 @@ struct CraftWebView: UIViewRepresentable {
3460
3494
  craft.secureStorage = {
3461
3495
  set: function(key, value) { return legacySecureStore.set(key, value).then(function() {}); },
3462
3496
  get: function(key) { return legacySecureStore.get(key); },
3497
+ // `remove` is the legacy name retained by the public Craft
3498
+ // type declaration; keep it as an alias of `delete` so
3499
+ // native STX callers can migrate without a feature check.
3500
+ remove: function(key) { return legacySecureStore.remove(key).then(function() {}); },
3463
3501
  delete: function(key) { return legacySecureStore.remove(key).then(function() {}); },
3464
3502
  clear: function() { return craft._invoke('secureClear').then(function() {}); }
3465
3503
  };
@@ -1,63 +1,498 @@
1
+ import Foundation
2
+ import LocalAuthentication
3
+ import SQLite3
4
+ import Security
1
5
  import UIKit
6
+ import UserNotifications
7
+
8
+ let craftNativeCapabilityProtocolVersion = 1
2
9
 
3
- /// The result of a Craft action, independent of whether the caller is a
4
- /// WebKit page or the JavaScriptCore native-screen runtime.
5
10
  struct CraftNativeActionError: Error {
6
11
  let code: String
7
12
  let message: String
8
13
  }
9
14
 
15
+ /// Process-wide capability services used by every retained native route.
16
+ /// Database work is serialized so transactions cannot be interleaved by two
17
+ /// JavaScript contexts, while completions return to JavaScriptCore on main.
10
18
  enum CraftNativeActions {
11
- /// Nil means this small shared router does not own the action. Both hosts
12
- /// can then continue through their existing unsupported-action path.
19
+ private static let storagePrefix = "craft.native.storage."
20
+ private static let databaseQueue = DispatchQueue(label: "dev.craft.native.database")
21
+ private static let cancellationLock = NSLock()
22
+ private static var cancelledRequests = Set<String>()
23
+ private static var biometricContexts = [String: LAContext]()
24
+ private static var initialDeepLinkClaimed = false
25
+ private static var database: OpaquePointer?
26
+ private static let sqliteTransient = unsafeBitCast(-1, to: sqlite3_destructor_type.self)
27
+
28
+ static func capabilities(config: CraftConfig) -> [String] {
29
+ var values = ["device", "clipboard", "haptics", "storage", "lifecycle"]
30
+ if config.enableBiometric { values.append("biometric") }
31
+ if config.enableSecureStorage { values.append("secureStorage") }
32
+ if config.enableLocalDatabase { values.append("database") }
33
+ if config.enableDeepLinks { values.append("deepLinks") }
34
+ if config.enableLocalNotifications { values.append("notifications") }
35
+ return values
36
+ }
37
+
38
+ /// Nil means this small shared router does not own the action.
13
39
  static func perform(action: String, body: [String: Any], config: CraftConfig) -> Result<Any, CraftNativeActionError>? {
14
40
  switch action {
15
41
  case "getDeviceInfo":
16
42
  return .success(deviceInfo())
17
43
  case "haptic":
18
- guard config.enableHaptics else {
19
- return .failure(CraftNativeActionError(code: "CAPABILITY_DISABLED", message: "Haptics is disabled"))
20
- }
44
+ guard config.enableHaptics else { return failure("CAPABILITY_DISABLED", "Haptics is disabled") }
21
45
  triggerHaptic(style: body["style"] as? String ?? "medium")
22
46
  return .success(true)
23
47
  case "clipboardWrite":
24
- guard config.enableClipboard else {
25
- return .failure(CraftNativeActionError(code: "CAPABILITY_DISABLED", message: "Clipboard is disabled"))
26
- }
48
+ guard config.enableClipboard else { return failure("CAPABILITY_DISABLED", "Clipboard is disabled") }
27
49
  guard let text = body["text"] as? String else {
28
- return .failure(CraftNativeActionError(code: "INVALID_ARGUMENT", message: "clipboardWrite was called without the values it needs"))
50
+ return failure("INVALID_ARGUMENT", "clipboardWrite was called without the values it needs")
29
51
  }
30
52
  UIPasteboard.general.string = text
31
53
  return .success(true)
32
54
  case "clipboardRead":
33
- guard config.enableClipboard else {
34
- return .failure(CraftNativeActionError(code: "CAPABILITY_DISABLED", message: "Clipboard is disabled"))
35
- }
55
+ guard config.enableClipboard else { return failure("CAPABILITY_DISABLED", "Clipboard is disabled") }
36
56
  return .success(UIPasteboard.general.string ?? "")
37
57
  default:
38
58
  return nil
39
59
  }
40
60
  }
41
61
 
62
+ static func perform(
63
+ requestToken: String,
64
+ version: Int,
65
+ module: String,
66
+ method: String,
67
+ args: [Any],
68
+ config: CraftConfig,
69
+ completion: @escaping (Result<Any, CraftNativeActionError>) -> Void
70
+ ) {
71
+ cancellationLock.lock()
72
+ cancelledRequests.remove(requestToken)
73
+ cancellationLock.unlock()
74
+ guard version == craftNativeCapabilityProtocolVersion else {
75
+ complete(requestToken, failure("UNSUPPORTED_VERSION", "Unsupported native capability protocol version"), completion)
76
+ return
77
+ }
78
+
79
+ let sync: Result<Any, CraftNativeActionError>?
80
+ switch (module, method) {
81
+ case ("Device", "getInfo"):
82
+ sync = perform(action: "getDeviceInfo", body: [:], config: config)
83
+ case ("Haptics", "impact"):
84
+ sync = perform(action: "haptic", body: ["style": args.first ?? NSNull()], config: config)
85
+ case ("Clipboard", "write"):
86
+ sync = perform(action: "clipboardWrite", body: ["text": args.first ?? NSNull()], config: config)
87
+ case ("Clipboard", "read"):
88
+ sync = perform(action: "clipboardRead", body: [:], config: config)
89
+ case ("Storage", _):
90
+ sync = storage(method: method, args: args)
91
+ case ("SecureStorage", _):
92
+ guard config.enableSecureStorage else {
93
+ complete(requestToken, failure("CAPABILITY_DISABLED", "Secure storage is disabled"), completion)
94
+ return
95
+ }
96
+ sync = secureStorage(method: method, args: args)
97
+ case ("Biometrics", "isAvailable"):
98
+ sync = config.enableBiometric ? .success(biometricContext().canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: nil)) : failure("CAPABILITY_DISABLED", "Biometric authentication is disabled")
99
+ case ("Biometrics", "getBiometricType"):
100
+ sync = config.enableBiometric ? .success(biometricType()) : failure("CAPABILITY_DISABLED", "Biometric authentication is disabled")
101
+ case ("Biometrics", "authenticate"):
102
+ guard config.enableBiometric else {
103
+ complete(requestToken, failure("CAPABILITY_DISABLED", "Biometric authentication is disabled"), completion)
104
+ return
105
+ }
106
+ authenticate(requestToken: requestToken, args: args, completion: completion)
107
+ return
108
+ case ("Lifecycle", "getState"):
109
+ sync = .success(currentAppState())
110
+ case ("DeepLinks", "getInitialURL"):
111
+ if config.enableDeepLinks {
112
+ sync = .success(claimInitialDeepLink().map(deepLinkData) ?? NSNull())
113
+ } else {
114
+ sync = failure("CAPABILITY_DISABLED", "Deep links are disabled")
115
+ }
116
+ case ("Database", _):
117
+ guard config.enableLocalDatabase else {
118
+ complete(requestToken, failure("CAPABILITY_DISABLED", "Local database is disabled"), completion)
119
+ return
120
+ }
121
+ databaseQueue.async { complete(requestToken, database(method: method, args: args), completion) }
122
+ return
123
+ case ("Notifications", _):
124
+ guard config.enableLocalNotifications else {
125
+ complete(requestToken, failure("CAPABILITY_DISABLED", "Local notifications are disabled"), completion)
126
+ return
127
+ }
128
+ notifications(requestToken: requestToken, method: method, args: args, completion: completion)
129
+ return
130
+ default:
131
+ sync = failure("UNKNOWN_ACTION", "Unsupported native API")
132
+ }
133
+ complete(requestToken, sync ?? failure("UNKNOWN_ACTION", "Unsupported native API"), completion)
134
+ }
135
+
136
+ static func cancel(requestToken: String) {
137
+ cancellationLock.lock()
138
+ cancelledRequests.insert(requestToken)
139
+ let context = biometricContexts.removeValue(forKey: requestToken)
140
+ cancellationLock.unlock()
141
+ context?.invalidate()
142
+ }
143
+
144
+ private static func claimInitialDeepLink() -> URL? {
145
+ cancellationLock.lock()
146
+ defer { cancellationLock.unlock() }
147
+ guard !initialDeepLinkClaimed else { return nil }
148
+ initialDeepLinkClaimed = true
149
+ return DeepLinkManager.shared.getInitialURL()
150
+ }
151
+
152
+ private static func complete(
153
+ _ requestToken: String,
154
+ _ result: Result<Any, CraftNativeActionError>,
155
+ _ completion: @escaping (Result<Any, CraftNativeActionError>) -> Void
156
+ ) {
157
+ DispatchQueue.main.async {
158
+ cancellationLock.lock()
159
+ let cancelled = cancelledRequests.remove(requestToken) != nil
160
+ cancellationLock.unlock()
161
+ if !cancelled { completion(result) }
162
+ }
163
+ }
164
+
165
+ private static func storage(method: String, args: [Any]) -> Result<Any, CraftNativeActionError> {
166
+ if method == "clear" {
167
+ for key in UserDefaults.standard.dictionaryRepresentation().keys where key.hasPrefix(storagePrefix) {
168
+ UserDefaults.standard.removeObject(forKey: key)
169
+ }
170
+ return .success(true)
171
+ }
172
+ if method == "keys" {
173
+ let keys = UserDefaults.standard.dictionaryRepresentation().keys
174
+ .filter { $0.hasPrefix(storagePrefix) }
175
+ .map { String($0.dropFirst(storagePrefix.count)) }
176
+ .sorted()
177
+ return .success(keys)
178
+ }
179
+ guard let key = validKey(args.first) else { return failure("INVALID_ARGUMENT", "Storage needs a non-empty key") }
180
+ let defaultsKey = storagePrefix + key
181
+ switch method {
182
+ case "get":
183
+ guard let data = UserDefaults.standard.data(forKey: defaultsKey) else { return .success(NSNull()) }
184
+ do {
185
+ return .success(try JSONSerialization.jsonObject(with: data, options: [.fragmentsAllowed]))
186
+ } catch {
187
+ return failure("STORAGE_CORRUPT", "Stored value is not valid JSON")
188
+ }
189
+ case "set":
190
+ guard args.count == 2 else { return failure("INVALID_ARGUMENT", "Storage.set needs a key and value") }
191
+ do {
192
+ let data = try JSONSerialization.data(withJSONObject: args[1], options: [.fragmentsAllowed])
193
+ UserDefaults.standard.set(data, forKey: defaultsKey)
194
+ return .success(true)
195
+ } catch {
196
+ return failure("INVALID_ARGUMENT", "Storage values must be JSON serializable")
197
+ }
198
+ case "remove":
199
+ UserDefaults.standard.removeObject(forKey: defaultsKey)
200
+ return .success(true)
201
+ default:
202
+ return failure("UNKNOWN_ACTION", "Unsupported Storage method")
203
+ }
204
+ }
205
+
206
+ private static func secureStorage(method: String, args: [Any]) -> Result<Any, CraftNativeActionError> {
207
+ let service = "dev.craft.native.secure"
208
+ if method == "clear" {
209
+ let query: [String: Any] = [kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: service]
210
+ let status = SecItemDelete(query as CFDictionary)
211
+ guard status == errSecSuccess || status == errSecItemNotFound else {
212
+ return failure("SECURE_STORAGE_ERROR", "Secure storage could not be cleared")
213
+ }
214
+ return .success(true)
215
+ }
216
+ guard let key = validKey(args.first) else { return failure("INVALID_ARGUMENT", "Secure storage needs a non-empty key") }
217
+ let base: [String: Any] = [
218
+ kSecClass as String: kSecClassGenericPassword,
219
+ kSecAttrService as String: service,
220
+ kSecAttrAccount as String: key,
221
+ ]
222
+ switch method {
223
+ case "set":
224
+ guard args.count == 2, let value = args[1] as? String else {
225
+ return failure("INVALID_ARGUMENT", "SecureStorage.set needs a key and string value")
226
+ }
227
+ var query = base
228
+ query[kSecValueData as String] = Data(value.utf8)
229
+ query[kSecAttrAccessible as String] = kSecAttrAccessibleWhenUnlockedThisDeviceOnly
230
+ let removed = SecItemDelete(base as CFDictionary)
231
+ guard removed == errSecSuccess || removed == errSecItemNotFound else {
232
+ return failure("SECURE_STORAGE_ERROR", "Secure storage could not be replaced (\(removed))")
233
+ }
234
+ let status = SecItemAdd(query as CFDictionary, nil)
235
+ return status == errSecSuccess ? .success(true) : failure("SECURE_STORAGE_ERROR", "Secure storage could not be written (\(status))")
236
+ case "get":
237
+ var query = base
238
+ query[kSecReturnData as String] = true
239
+ query[kSecMatchLimit as String] = kSecMatchLimitOne
240
+ var result: AnyObject?
241
+ let status = SecItemCopyMatching(query as CFDictionary, &result)
242
+ if status == errSecItemNotFound { return .success(NSNull()) }
243
+ guard status == errSecSuccess, let data = result as? Data else {
244
+ return failure("SECURE_STORAGE_ERROR", "Secure storage could not be read")
245
+ }
246
+ return .success(String(decoding: data, as: UTF8.self))
247
+ case "remove":
248
+ let status = SecItemDelete(base as CFDictionary)
249
+ return status == errSecSuccess || status == errSecItemNotFound ? .success(true) : failure("SECURE_STORAGE_ERROR", "Secure storage could not be removed")
250
+ default:
251
+ return failure("UNKNOWN_ACTION", "Unsupported SecureStorage method")
252
+ }
253
+ }
254
+
255
+ private static func biometricContext() -> LAContext {
256
+ let context = LAContext()
257
+ context.localizedCancelTitle = "Cancel"
258
+ return context
259
+ }
260
+
261
+ private static func biometricType() -> String {
262
+ let context = biometricContext()
263
+ _ = context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: nil)
264
+ switch context.biometryType {
265
+ case .faceID: return "faceId"
266
+ case .touchID: return "touchId"
267
+ default: return "none"
268
+ }
269
+ }
270
+
271
+ private static func authenticate(
272
+ requestToken: String,
273
+ args: [Any],
274
+ completion: @escaping (Result<Any, CraftNativeActionError>) -> Void
275
+ ) {
276
+ let reason = (args.first as? String).flatMap { $0.isEmpty ? nil : $0 } ?? "Authenticate to continue"
277
+ let context = biometricContext()
278
+ cancellationLock.lock()
279
+ biometricContexts[requestToken] = context
280
+ cancellationLock.unlock()
281
+ context.evaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, localizedReason: reason) { result, error in
282
+ cancellationLock.lock()
283
+ biometricContexts.removeValue(forKey: requestToken)
284
+ cancellationLock.unlock()
285
+ if result { complete(requestToken, .success(true), completion) }
286
+ else { complete(requestToken, failure("AUTHENTICATION_FAILED", error?.localizedDescription ?? "Biometric authentication failed"), completion) }
287
+ }
288
+ }
289
+
290
+ private static func database(method: String, args: [Any]) -> Result<Any, CraftNativeActionError> {
291
+ guard openDatabase() else { return failure("DATABASE_ERROR", "Could not open the local database") }
292
+ switch method {
293
+ case "execute":
294
+ guard let sql = args.first as? String, !sql.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
295
+ return failure("INVALID_ARGUMENT", "Database.execute needs a SQL statement")
296
+ }
297
+ return execute(sql: sql, params: args.count > 1 ? args[1] as? [Any] ?? [] : [])
298
+ case "query":
299
+ guard let sql = args.first as? String, !sql.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
300
+ return failure("INVALID_ARGUMENT", "Database.query needs a SQL statement")
301
+ }
302
+ return query(sql: sql, params: args.count > 1 ? args[1] as? [Any] ?? [] : [])
303
+ case "beginTransaction": return transaction(sql: "BEGIN TRANSACTION")
304
+ case "commit": return transaction(sql: "COMMIT")
305
+ case "rollback": return transaction(sql: "ROLLBACK")
306
+ default: return failure("UNKNOWN_ACTION", "Unsupported Database method")
307
+ }
308
+ }
309
+
310
+ private static func transaction(sql: String) -> Result<Any, CraftNativeActionError> {
311
+ switch execute(sql: sql, params: []) {
312
+ case .success: return .success(true)
313
+ case .failure(let error): return .failure(error)
314
+ }
315
+ }
316
+
317
+ private static func openDatabase() -> Bool {
318
+ if database != nil { return true }
319
+ guard let directory = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first else { return false }
320
+ do { try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) } catch { return false }
321
+ let path = directory.appendingPathComponent("craft-native.sqlite").path
322
+ if sqlite3_open(path, &database) != SQLITE_OK {
323
+ if database != nil { sqlite3_close(database) }
324
+ database = nil
325
+ return false
326
+ }
327
+ sqlite3_busy_timeout(database, 5_000)
328
+ return true
329
+ }
330
+
331
+ private static func bind(_ params: [Any], to statement: OpaquePointer?) -> Bool {
332
+ for (offset, value) in params.enumerated() {
333
+ let index = Int32(offset + 1)
334
+ let code: Int32
335
+ switch value {
336
+ case is NSNull: code = sqlite3_bind_null(statement, index)
337
+ case let string as String: code = sqlite3_bind_text(statement, index, string, -1, sqliteTransient)
338
+ case let number as NSNumber:
339
+ if CFGetTypeID(number) == CFBooleanGetTypeID() {
340
+ code = sqlite3_bind_int64(statement, index, number.boolValue ? 1 : 0)
341
+ } else if number.doubleValue.rounded() == number.doubleValue {
342
+ code = sqlite3_bind_int64(statement, index, number.int64Value)
343
+ } else {
344
+ code = sqlite3_bind_double(statement, index, number.doubleValue)
345
+ }
346
+ default: return false
347
+ }
348
+ if code != SQLITE_OK { return false }
349
+ }
350
+ return true
351
+ }
352
+
353
+ private static func execute(sql: String, params: [Any]) -> Result<Any, CraftNativeActionError> {
354
+ var statement: OpaquePointer?
355
+ guard sqlite3_prepare_v2(database, sql, -1, &statement, nil) == SQLITE_OK else { return databaseFailure() }
356
+ defer { sqlite3_finalize(statement) }
357
+ guard bind(params, to: statement) else { return failure("INVALID_ARGUMENT", "Database parameters must be strings, numbers, booleans, or null") }
358
+ guard sqlite3_step(statement) == SQLITE_DONE else { return databaseFailure() }
359
+ return .success(["rowsAffected": Int(sqlite3_changes(database)), "lastInsertId": Int(sqlite3_last_insert_rowid(database))])
360
+ }
361
+
362
+ private static func query(sql: String, params: [Any]) -> Result<Any, CraftNativeActionError> {
363
+ var statement: OpaquePointer?
364
+ guard sqlite3_prepare_v2(database, sql, -1, &statement, nil) == SQLITE_OK else { return databaseFailure() }
365
+ defer { sqlite3_finalize(statement) }
366
+ guard bind(params, to: statement) else { return failure("INVALID_ARGUMENT", "Database parameters must be strings, numbers, booleans, or null") }
367
+ var rows: [[String: Any]] = []
368
+ while true {
369
+ let step = sqlite3_step(statement)
370
+ if step == SQLITE_DONE { return .success(rows) }
371
+ guard step == SQLITE_ROW else { return databaseFailure() }
372
+ var row: [String: Any] = [:]
373
+ for column in 0..<sqlite3_column_count(statement) {
374
+ let name = String(cString: sqlite3_column_name(statement, column))
375
+ switch sqlite3_column_type(statement, column) {
376
+ case SQLITE_INTEGER: row[name] = Int(sqlite3_column_int64(statement, column))
377
+ case SQLITE_FLOAT: row[name] = sqlite3_column_double(statement, column)
378
+ case SQLITE_TEXT: row[name] = sqlite3_column_text(statement, column).map { String(cString: $0) } ?? ""
379
+ case SQLITE_BLOB:
380
+ let length = Int(sqlite3_column_bytes(statement, column))
381
+ row[name] = sqlite3_column_blob(statement, column).map { Data(bytes: $0, count: length).base64EncodedString() } ?? ""
382
+ default: row[name] = NSNull()
383
+ }
384
+ }
385
+ rows.append(row)
386
+ }
387
+ }
388
+
389
+ private static func databaseFailure() -> Result<Any, CraftNativeActionError> {
390
+ let message = database.map { String(cString: sqlite3_errmsg($0)) } ?? "Database unavailable"
391
+ return failure("DATABASE_ERROR", message)
392
+ }
393
+
394
+ private static func notifications(
395
+ requestToken: String,
396
+ method: String,
397
+ args: [Any],
398
+ completion: @escaping (Result<Any, CraftNativeActionError>) -> Void
399
+ ) {
400
+ let center = UNUserNotificationCenter.current()
401
+ switch method {
402
+ case "cancel":
403
+ guard let id = validKey(args.first) else {
404
+ complete(requestToken, failure("INVALID_ARGUMENT", "Notifications.cancel needs an id"), completion)
405
+ return
406
+ }
407
+ center.removePendingNotificationRequests(withIdentifiers: [id])
408
+ complete(requestToken, .success(true), completion)
409
+ case "cancelAll":
410
+ center.removeAllPendingNotificationRequests()
411
+ complete(requestToken, .success(true), completion)
412
+ case "pending":
413
+ center.getPendingNotificationRequests { requests in
414
+ complete(requestToken, .success(requests.map { request in
415
+ ["id": request.identifier, "title": request.content.title, "body": request.content.body]
416
+ }), completion)
417
+ }
418
+ case "schedule":
419
+ guard let value = args.first as? [String: Any], let title = value["title"] as? String, !title.isEmpty else {
420
+ complete(requestToken, failure("INVALID_ARGUMENT", "Notifications.schedule needs a title"), completion)
421
+ return
422
+ }
423
+ center.requestAuthorization(options: [.alert, .sound, .badge]) { granted, authorizationError in
424
+ guard authorizationError == nil, granted else {
425
+ complete(requestToken, failure("PERMISSION_DENIED", authorizationError?.localizedDescription ?? "Notification permission denied"), completion)
426
+ return
427
+ }
428
+ let content = UNMutableNotificationContent()
429
+ content.title = title
430
+ content.body = value["body"] as? String ?? ""
431
+ content.sound = .default
432
+ if let data = value["data"] as? [String: Any] { content.userInfo = data }
433
+ if let badge = value["badge"] as? NSNumber { content.badge = badge }
434
+ let id = (value["id"] as? String).flatMap { validKey($0) } ?? UUID().uuidString
435
+ let delay = (value["delay"] as? NSNumber)?.doubleValue
436
+ let trigger = delay.map { UNTimeIntervalNotificationTrigger(timeInterval: max($0 / 1_000, 1), repeats: false) }
437
+ center.add(UNNotificationRequest(identifier: id, content: content, trigger: trigger)) { addError in
438
+ if let addError = addError {
439
+ complete(requestToken, failure("NOTIFICATION_ERROR", addError.localizedDescription), completion)
440
+ } else {
441
+ complete(requestToken, .success(id), completion)
442
+ }
443
+ }
444
+ }
445
+ default:
446
+ complete(requestToken, failure("UNKNOWN_ACTION", "Unsupported Notifications method"), completion)
447
+ }
448
+ }
449
+
450
+ static func deepLinkData(_ url: URL) -> [String: Any] {
451
+ let components = URLComponents(url: url, resolvingAgainstBaseURL: false)
452
+ return [
453
+ "url": url.absoluteString,
454
+ "scheme": url.scheme ?? "",
455
+ "host": url.host ?? "",
456
+ "path": url.path,
457
+ "query": url.query ?? "",
458
+ "queryParams": Dictionary(uniqueKeysWithValues: (components?.queryItems ?? []).map { ($0.name, $0.value ?? "") }),
459
+ ]
460
+ }
461
+
42
462
  static func triggerHaptic(style: String) {
43
463
  switch style {
44
- case "light":
45
- UIImpactFeedbackGenerator(style: .light).impactOccurred()
46
- case "heavy":
47
- UIImpactFeedbackGenerator(style: .heavy).impactOccurred()
48
- case "success":
49
- UINotificationFeedbackGenerator().notificationOccurred(.success)
50
- case "warning":
51
- UINotificationFeedbackGenerator().notificationOccurred(.warning)
52
- case "error":
53
- UINotificationFeedbackGenerator().notificationOccurred(.error)
54
- case "selection":
55
- UISelectionFeedbackGenerator().selectionChanged()
56
- default:
57
- UIImpactFeedbackGenerator(style: .medium).impactOccurred()
464
+ case "light": UIImpactFeedbackGenerator(style: .light).impactOccurred()
465
+ case "heavy": UIImpactFeedbackGenerator(style: .heavy).impactOccurred()
466
+ case "success": UINotificationFeedbackGenerator().notificationOccurred(.success)
467
+ case "warning": UINotificationFeedbackGenerator().notificationOccurred(.warning)
468
+ case "error": UINotificationFeedbackGenerator().notificationOccurred(.error)
469
+ case "selection": UISelectionFeedbackGenerator().selectionChanged()
470
+ default: UIImpactFeedbackGenerator(style: .medium).impactOccurred()
58
471
  }
59
472
  }
60
473
 
474
+ static func currentAppState() -> String {
475
+ switch UIApplication.shared.applicationState {
476
+ case .active: return "active"
477
+ case .inactive: return "inactive"
478
+ case .background: return "background"
479
+ @unknown default: return "inactive"
480
+ }
481
+ }
482
+
483
+ private static func validKey(_ value: Any?) -> String? {
484
+ guard let key = value as? String, !key.isEmpty, key.count <= 512, !key.contains("\0") else { return nil }
485
+ return key
486
+ }
487
+
488
+ private static func error(_ code: String, _ message: String) -> CraftNativeActionError {
489
+ CraftNativeActionError(code: code, message: message)
490
+ }
491
+
492
+ private static func failure(_ code: String, _ message: String) -> Result<Any, CraftNativeActionError> {
493
+ .failure(error(code, message))
494
+ }
495
+
61
496
  private static func deviceInfo() -> [String: Any] {
62
497
  let device = UIDevice.current
63
498
  let screen = UIScreen.main
@@ -75,19 +510,25 @@ enum CraftNativeActions {
75
510
  }
76
511
  return [
77
512
  "platform": "ios",
513
+ "osVersion": device.systemVersion,
78
514
  "model": device.model,
515
+ "manufacturer": "Apple",
516
+ "deviceId": device.identifierForVendor?.uuidString ?? "",
517
+ "isTablet": device.userInterfaceIdiom == .pad,
518
+ "screen": ["width": screen.bounds.width, "height": screen.bounds.height, "scale": screen.scale],
519
+ "battery": ["level": device.batteryLevel, "isCharging": device.batteryState == .charging || device.batteryState == .full],
520
+ "locale": Locale.current.identifier,
521
+ "timezone": TimeZone.current.identifier,
522
+ "isSimulator": isSimulator,
79
523
  "name": device.name,
80
524
  "systemName": device.systemName,
81
525
  "systemVersion": device.systemVersion,
82
526
  "identifierForVendor": device.identifierForVendor?.uuidString ?? "",
83
- "isSimulator": isSimulator,
84
527
  "screenWidth": screen.bounds.width,
85
528
  "screenHeight": screen.bounds.height,
86
529
  "screenScale": screen.scale,
87
530
  "batteryLevel": device.batteryLevel,
88
531
  "batteryState": batteryState,
89
- "locale": Locale.current.identifier,
90
- "timezone": TimeZone.current.identifier
91
532
  ]
92
533
  }
93
534
  }