craft-native 0.0.123 → 0.0.125

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.
@@ -6,6 +6,11 @@ import UIKit
6
6
  import UserNotifications
7
7
 
8
8
  let craftNativeCapabilityProtocolVersion = 1
9
+ /// URLSession's own request timeout, matching the bridge's 30-second deadline.
10
+ let craftNativeFetchTimeoutSeconds: TimeInterval = 30
11
+ /// A response body larger than this rejects with RESPONSE_TOO_LARGE rather
12
+ /// than being copied into JavaScriptCore as one string.
13
+ let craftNativeFetchMaxResponseBytes = 8 * 1024 * 1024
9
14
 
10
15
  struct CraftNativeActionError: Error {
11
16
  let code: String
@@ -21,12 +26,19 @@ enum CraftNativeActions {
21
26
  private static let cancellationLock = NSLock()
22
27
  private static var cancelledRequests = Set<String>()
23
28
  private static var biometricContexts = [String: LAContext]()
29
+ private static var fetchTasks = [String: URLSessionDataTask]()
30
+ private static let fetchSession: URLSession = {
31
+ let configuration = URLSessionConfiguration.default
32
+ configuration.timeoutIntervalForRequest = craftNativeFetchTimeoutSeconds
33
+ configuration.timeoutIntervalForResource = craftNativeFetchTimeoutSeconds
34
+ return URLSession(configuration: configuration)
35
+ }()
24
36
  private static var initialDeepLinkClaimed = false
25
37
  private static var database: OpaquePointer?
26
38
  private static let sqliteTransient = unsafeBitCast(-1, to: sqlite3_destructor_type.self)
27
39
 
28
40
  static func capabilities(config: CraftConfig) -> [String] {
29
- var values = ["device", "clipboard", "haptics", "storage", "lifecycle"]
41
+ var values = ["device", "clipboard", "haptics", "storage", "lifecycle", "fetch"]
30
42
  if config.enableBiometric { values.append("biometric") }
31
43
  if config.enableSecureStorage { values.append("secureStorage") }
32
44
  if config.enableLocalDatabase { values.append("database") }
@@ -44,6 +56,10 @@ enum CraftNativeActions {
44
56
  guard config.enableHaptics else { return failure("CAPABILITY_DISABLED", "Haptics is disabled") }
45
57
  triggerHaptic(style: body["style"] as? String ?? "medium")
46
58
  return .success(true)
59
+ case "hapticPrepare":
60
+ guard config.enableHaptics else { return failure("CAPABILITY_DISABLED", "Haptics is disabled") }
61
+ CraftHaptics.prepare(body["kind"] as? String)
62
+ return .success(true)
47
63
  case "clipboardWrite":
48
64
  guard config.enableClipboard else { return failure("CAPABILITY_DISABLED", "Clipboard is disabled") }
49
65
  guard let text = body["text"] as? String else {
@@ -82,6 +98,12 @@ enum CraftNativeActions {
82
98
  sync = perform(action: "getDeviceInfo", body: [:], config: config)
83
99
  case ("Haptics", "impact"):
84
100
  sync = perform(action: "haptic", body: ["style": args.first ?? NSNull()], config: config)
101
+ case ("Haptics", "notification"):
102
+ sync = perform(action: "haptic", body: ["style": (args.first as? String) ?? "success"], config: config)
103
+ case ("Haptics", "selection"):
104
+ sync = perform(action: "haptic", body: ["style": "selection"], config: config)
105
+ case ("Haptics", "prepare"):
106
+ sync = perform(action: "hapticPrepare", body: ["kind": args.first ?? NSNull()], config: config)
85
107
  case ("Clipboard", "write"):
86
108
  sync = perform(action: "clipboardWrite", body: ["text": args.first ?? NSNull()], config: config)
87
109
  case ("Clipboard", "read"):
@@ -105,6 +127,9 @@ enum CraftNativeActions {
105
127
  }
106
128
  authenticate(requestToken: requestToken, args: args, completion: completion)
107
129
  return
130
+ case ("Network", "fetch"):
131
+ fetch(requestToken: requestToken, args: args, completion: completion)
132
+ return
108
133
  case ("Lifecycle", "getState"):
109
134
  sync = .success(currentAppState())
110
135
  case ("DeepLinks", "getInitialURL"):
@@ -137,8 +162,91 @@ enum CraftNativeActions {
137
162
  cancellationLock.lock()
138
163
  cancelledRequests.insert(requestToken)
139
164
  let context = biometricContexts.removeValue(forKey: requestToken)
165
+ let task = fetchTasks.removeValue(forKey: requestToken)
140
166
  cancellationLock.unlock()
141
167
  context?.invalidate()
168
+ task?.cancel()
169
+ }
170
+
171
+ /// `fetch` for native screens: one URLSession data task per request, keyed
172
+ /// by the request token so the bridge deadline, `API_CANCEL` and route
173
+ /// teardown all cancel the transfer. Bodies are UTF-8 text both ways.
174
+ private static func fetch(
175
+ requestToken: String,
176
+ args: [Any],
177
+ completion: @escaping (Result<Any, CraftNativeActionError>) -> Void
178
+ ) {
179
+ guard let options = args.first as? [String: Any],
180
+ let address = options["url"] as? String,
181
+ let url = URL(string: address),
182
+ let scheme = url.scheme?.lowercased(), scheme == "https" || scheme == "http",
183
+ url.host?.isEmpty == false else {
184
+ complete(requestToken, failure("INVALID_ARGUMENT", "fetch needs an absolute http or https URL"), completion)
185
+ return
186
+ }
187
+ let method = ((options["method"] as? String) ?? "GET").uppercased()
188
+ guard !method.isEmpty, method.allSatisfy({ $0.isLetter && $0.isASCII }) else {
189
+ complete(requestToken, failure("INVALID_ARGUMENT", "fetch method must be an HTTP token"), completion)
190
+ return
191
+ }
192
+ var request = URLRequest(url: url, timeoutInterval: craftNativeFetchTimeoutSeconds)
193
+ request.httpMethod = method
194
+ if let headers = options["headers"] as? [String: Any] {
195
+ for (name, value) in headers {
196
+ guard let value = value as? String else {
197
+ complete(requestToken, failure("INVALID_ARGUMENT", "fetch header values must be strings"), completion)
198
+ return
199
+ }
200
+ request.setValue(value, forHTTPHeaderField: name)
201
+ }
202
+ }
203
+ if let body = options["body"] as? String {
204
+ guard method != "GET", method != "HEAD" else {
205
+ complete(requestToken, failure("INVALID_ARGUMENT", "A GET or HEAD request cannot have a body"), completion)
206
+ return
207
+ }
208
+ request.httpBody = Data(body.utf8)
209
+ } else if let body = options["body"], !(body is NSNull) {
210
+ complete(requestToken, failure("INVALID_ARGUMENT", "fetch body must be a string"), completion)
211
+ return
212
+ }
213
+
214
+ let task = fetchSession.dataTask(with: request) { data, response, error in
215
+ cancellationLock.lock()
216
+ fetchTasks.removeValue(forKey: requestToken)
217
+ cancellationLock.unlock()
218
+ if let error = error {
219
+ let code = (error as? URLError)?.code == .timedOut ? "TIMEOUT" : "NETWORK_ERROR"
220
+ complete(requestToken, failure(code, error.localizedDescription), completion)
221
+ return
222
+ }
223
+ guard let http = response as? HTTPURLResponse else {
224
+ complete(requestToken, failure("NETWORK_ERROR", "fetch received a response that is not HTTP"), completion)
225
+ return
226
+ }
227
+ let body = data ?? Data()
228
+ guard body.count <= craftNativeFetchMaxResponseBytes else {
229
+ complete(requestToken, failure("RESPONSE_TOO_LARGE", "fetch responses are limited to \(craftNativeFetchMaxResponseBytes) bytes"), completion)
230
+ return
231
+ }
232
+ var headers: [String: String] = [:]
233
+ for (name, value) in http.allHeaderFields {
234
+ headers[String(describing: name).lowercased()] = String(describing: value)
235
+ }
236
+ complete(requestToken, .success([
237
+ "url": http.url?.absoluteString ?? address,
238
+ "status": http.statusCode,
239
+ "statusText": HTTPURLResponse.localizedString(forStatusCode: http.statusCode),
240
+ "headers": headers,
241
+ "redirected": http.url.map { $0 != url } ?? false,
242
+ "body": String(decoding: body, as: UTF8.self),
243
+ ]), completion)
244
+ }
245
+ cancellationLock.lock()
246
+ let alreadyCancelled = cancelledRequests.contains(requestToken)
247
+ if !alreadyCancelled { fetchTasks[requestToken] = task }
248
+ cancellationLock.unlock()
249
+ if !alreadyCancelled { task.resume() }
142
250
  }
143
251
 
144
252
  private static func claimInitialDeepLink() -> URL? {
@@ -460,15 +568,7 @@ enum CraftNativeActions {
460
568
  }
461
569
 
462
570
  static func triggerHaptic(style: String) {
463
- switch style {
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()
471
- }
571
+ CraftHaptics.play(style)
472
572
  }
473
573
 
474
574
  static func currentAppState() -> String {
@@ -532,3 +632,203 @@ enum CraftNativeActions {
532
632
  ]
533
633
  }
534
634
  }
635
+
636
+ // MARK: - Synchronous reads for a screen's first frame
637
+
638
+ extension CraftNativeActions {
639
+ /// The JSON text `craft.storage` keeps under `key`, or nil. The same store
640
+ /// as the asynchronous Storage module, read without a bridge round trip.
641
+ static func storageJSON(forKey key: String) -> String? {
642
+ guard let key = validKey(key), let data = UserDefaults.standard.data(forKey: storagePrefix + key) else { return nil }
643
+ return String(decoding: data, as: UTF8.self)
644
+ }
645
+
646
+ /// Stores JSON text under `key` (nil removes it). False for an invalid key
647
+ /// or text that is not JSON, which the asynchronous `get` could not read.
648
+ @discardableResult
649
+ static func setStorageJSON(_ json: String?, forKey key: String) -> Bool {
650
+ guard let key = validKey(key) else { return false }
651
+ guard let json else {
652
+ UserDefaults.standard.removeObject(forKey: storagePrefix + key)
653
+ return true
654
+ }
655
+ let data = Data(json.utf8)
656
+ guard (try? JSONSerialization.jsonObject(with: data, options: [.fragmentsAllowed])) != nil else { return false }
657
+ UserDefaults.standard.set(data, forKey: storagePrefix + key)
658
+ return true
659
+ }
660
+
661
+ /// The Keychain item the web page's `craft.secureStorage` wrote under
662
+ /// `key`: a generic password whose account is the key, as CraftApp's
663
+ /// `secureStore` writes it. Nil when there is none.
664
+ static func webSecureValue(forKey key: String) -> String? {
665
+ guard let key = validKey(key) else { return nil }
666
+ let query: [String: Any] = [
667
+ kSecClass as String: kSecClassGenericPassword,
668
+ kSecAttrAccount as String: key,
669
+ kSecReturnData as String: true,
670
+ kSecMatchLimit as String: kSecMatchLimitOne,
671
+ ]
672
+ var result: AnyObject?
673
+ guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess, let data = result as? Data else { return nil }
674
+ return String(data: data, encoding: .utf8)
675
+ }
676
+ }
677
+
678
+ /// Last-known data for a screen's first frame: one JSON file per name in
679
+ /// `Application Support/craft-snapshots/<name>.json`. A web page writes them
680
+ /// (`snapshots.set` in the mobile API) and a native screen reads them
681
+ /// synchronously before its first render, or writes its own.
682
+ enum CraftSnapshots {
683
+ private static let queue = DispatchQueue(label: "dev.craft.snapshots", qos: .utility)
684
+ private static let lock = NSLock()
685
+ /// Writes not on disk yet, so a read right after a write sees it.
686
+ private static var pending: [String: String?] = [:]
687
+
688
+ static var directory: URL? {
689
+ FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first?
690
+ .appendingPathComponent("craft-snapshots", isDirectory: true)
691
+ }
692
+
693
+ /// Letters, digits, `-`, `_` and `.`, not starting with a dot: a file
694
+ /// name, never a path.
695
+ static func isValidName(_ name: String) -> Bool {
696
+ !name.isEmpty && name.count <= 128 && !name.hasPrefix(".")
697
+ && name.unicodeScalars.allSatisfy { $0.isASCII && (CharacterSet.alphanumerics.contains($0) || "-_.".unicodeScalars.contains($0)) }
698
+ }
699
+
700
+ static func url(for name: String) -> URL? {
701
+ guard isValidName(name) else { return nil }
702
+ return directory?.appendingPathComponent("\(name).json", isDirectory: false)
703
+ }
704
+
705
+ /// The snapshot's JSON text, or nil.
706
+ static func read(_ name: String) -> String? {
707
+ guard let url = url(for: name) else { return nil }
708
+ lock.lock()
709
+ let waiting = pending[name]
710
+ lock.unlock()
711
+ if let waiting { return waiting }
712
+ guard let data = try? Data(contentsOf: url) else { return nil }
713
+ return String(decoding: data, as: UTF8.self)
714
+ }
715
+
716
+ /// Replaces the snapshot with JSON text (nil deletes it) off the main
717
+ /// thread, atomically. `completion` runs on the main queue.
718
+ static func write(_ name: String, json: String?, completion: ((Bool) -> Void)? = nil) {
719
+ guard let url = url(for: name), let directory else {
720
+ DispatchQueue.main.async { completion?(false) }
721
+ return
722
+ }
723
+ if let json, (try? JSONSerialization.jsonObject(with: Data(json.utf8), options: [.fragmentsAllowed])) == nil {
724
+ DispatchQueue.main.async { completion?(false) }
725
+ return
726
+ }
727
+ lock.lock()
728
+ pending[name] = .some(json)
729
+ lock.unlock()
730
+ queue.async {
731
+ var ok = true
732
+ do {
733
+ if let json {
734
+ try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
735
+ try Data(json.utf8).write(to: url, options: [.atomic])
736
+ } else if FileManager.default.fileExists(atPath: url.path) {
737
+ try FileManager.default.removeItem(at: url)
738
+ }
739
+ } catch {
740
+ ok = false
741
+ }
742
+ lock.lock()
743
+ if let current = pending[name], current == json { pending.removeValue(forKey: name) }
744
+ lock.unlock()
745
+ DispatchQueue.main.async { completion?(ok) }
746
+ }
747
+ }
748
+ }
749
+
750
+ /// The Taptic Engine's generators, held for the life of the process.
751
+ ///
752
+ /// Each haptic used to make a fresh generator at the moment of the tap. A new
753
+ /// generator has to wake the engine before it can play, which is the tens of
754
+ /// milliseconds between a finger landing and the feel that makes web haptics
755
+ /// read as late. These are made once, prepared again after every use, and
756
+ /// `prepare` lets a page warm one before a gesture it knows is coming (the
757
+ /// start of a drag, a long press about to complete).
758
+ ///
759
+ /// The three kinds are the three UIKit has, and they are not interchangeable:
760
+ /// a notification is the success, warning or error pattern, a selection is the
761
+ /// detent a picker plays, and an impact is a single tap at one of five weights.
762
+ /// The page used to get impacts for all of them.
763
+ ///
764
+ /// Main thread only, like every UIFeedbackGenerator. Every caller is.
765
+ enum CraftHaptics {
766
+ private static var impacts: [UIImpactFeedbackGenerator.FeedbackStyle: UIImpactFeedbackGenerator] = [:]
767
+ private static var notifier: UINotificationFeedbackGenerator?
768
+ private static var selector: UISelectionFeedbackGenerator?
769
+
770
+ /// An impact weight by name, or nil for a name that is not one.
771
+ static func impactStyle(_ name: String) -> UIImpactFeedbackGenerator.FeedbackStyle? {
772
+ switch name {
773
+ case "light": return .light
774
+ case "medium": return .medium
775
+ case "heavy": return .heavy
776
+ case "soft": return .soft
777
+ case "rigid": return .rigid
778
+ default: return nil
779
+ }
780
+ }
781
+
782
+ /// Play one haptic: an impact weight, a notification type, or `selection`.
783
+ /// Anything else plays a medium impact, as an unknown style always has.
784
+ static func play(_ style: String) {
785
+ switch style {
786
+ case "success": notification().notificationOccurred(.success); notification().prepare()
787
+ case "warning": notification().notificationOccurred(.warning); notification().prepare()
788
+ case "error": notification().notificationOccurred(.error); notification().prepare()
789
+ case "selection": selection().selectionChanged(); selection().prepare()
790
+ default:
791
+ let generator = impact(impactStyle(style) ?? .medium)
792
+ generator.impactOccurred()
793
+ generator.prepare()
794
+ }
795
+ }
796
+
797
+ /// Warm a generator so the next haptic plays without the engine's wake-up
798
+ /// delay. `impact`, `notification` or `selection` for one kind, an impact
799
+ /// weight or notification type for that one, nothing for all of them.
800
+ /// UIKit keeps a prepared engine awake for a few seconds only.
801
+ static func prepare(_ kind: String?) {
802
+ switch kind {
803
+ case "impact": impact(.medium).prepare()
804
+ case "notification", "success", "warning", "error": notification().prepare()
805
+ case "selection": selection().prepare()
806
+ case let name? where impactStyle(name) != nil: impact(impactStyle(name)!).prepare()
807
+ default:
808
+ impact(.medium).prepare()
809
+ notification().prepare()
810
+ selection().prepare()
811
+ }
812
+ }
813
+
814
+ private static func impact(_ style: UIImpactFeedbackGenerator.FeedbackStyle) -> UIImpactFeedbackGenerator {
815
+ if let generator = impacts[style] { return generator }
816
+ let generator = UIImpactFeedbackGenerator(style: style)
817
+ impacts[style] = generator
818
+ return generator
819
+ }
820
+
821
+ private static func notification() -> UINotificationFeedbackGenerator {
822
+ if let notifier { return notifier }
823
+ let generator = UINotificationFeedbackGenerator()
824
+ notifier = generator
825
+ return generator
826
+ }
827
+
828
+ private static func selection() -> UISelectionFeedbackGenerator {
829
+ if let selector { return selector }
830
+ let generator = UISelectionFeedbackGenerator()
831
+ selector = generator
832
+ return generator
833
+ }
834
+ }