craft-native 0.0.115 → 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.
- package/dist/android/src/index.js +5 -1
- package/dist/android/templates/AndroidManifest.xml.template +9 -0
- package/dist/android/templates/CraftBridge.kt.template +288 -1
- package/dist/android/templates/CraftNativeCapabilities.kt.template +455 -0
- package/dist/android/templates/CraftNativeFlatList.kt.template +27 -5
- package/dist/android/templates/CraftNativeNotificationReceiver.kt.template +43 -0
- package/dist/android/templates/MainActivityNative.kt.template +229 -47
- package/dist/api/mobile.d.ts +53 -1
- package/dist/cli.js +1 -1
- package/dist/index.cjs +27 -1
- package/dist/index.js +27 -1
- package/dist/ios/src/index.d.ts +18 -0
- package/dist/ios/src/index.js +29 -1
- package/dist/ios/templates/CraftApp.swift +260 -1
- package/dist/ios/templates/CraftNativeActions.swift +472 -31
- package/dist/ios/templates/CraftNativeScreen.swift +93 -22
- package/dist/ios/templates/Info.plist.template +1 -0
- package/dist/mobile.js +83 -0
- package/package.json +1 -1
|
@@ -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
|
-
|
|
12
|
-
|
|
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
|
|
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
|
-
|
|
46
|
-
case "
|
|
47
|
-
|
|
48
|
-
case "
|
|
49
|
-
|
|
50
|
-
|
|
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
|
}
|