craft-native 0.0.151 → 0.0.153

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.
@@ -26,7 +26,7 @@ enum CraftNativeActions {
26
26
  private static let cancellationLock = NSLock()
27
27
  private static var cancelledRequests = Set<String>()
28
28
  private static var biometricContexts = [String: LAContext]()
29
- private static var fetchTasks = [String: URLSessionDataTask]()
29
+ private static var fetchTasks = [String: URLSessionTask]()
30
30
  private static let fetchSession: URLSession = {
31
31
  let configuration = URLSessionConfiguration.default
32
32
  configuration.timeoutIntervalForRequest = craftNativeFetchTimeoutSeconds
@@ -61,6 +61,15 @@ enum CraftNativeActions {
61
61
  if config.enableGeolocation && config.renderer != "native" { values.append("geolocation") }
62
62
  // The Music app's song and transport are the coordinator's too.
63
63
  if config.enableAppleMusic && config.renderer != "native" { values.append("music") }
64
+ // A heart-rate strap is the app's own service (CraftHeartRate.swift),
65
+ // so it needs no web view.
66
+ if config.enableHeartRate { values.append("heartRate") }
67
+ // Photos picked from the library, files downloaded for the share
68
+ // sheet and sent in a form (CraftNativeFiles.swift): the photo picker
69
+ // runs out of process and asks no permission. Taking a photo needs
70
+ // the camera, which the app declares.
71
+ values.append("files")
72
+ if config.enableCamera { values.append("camera") }
64
73
  return values
65
74
  }
66
75
 
@@ -152,6 +161,18 @@ enum CraftNativeActions {
152
161
  case ("Network", "fetch"):
153
162
  fetch(requestToken: requestToken, args: args, completion: completion)
154
163
  return
164
+ case ("Files", "pickImage"):
165
+ // A person picks, so the bridge never times it out.
166
+ let options = args.first as? [String: Any] ?? [:]
167
+ DispatchQueue.main.async {
168
+ CraftNativeFiles.pickImage(options, cameraEnabled: config.enableCamera) { complete(requestToken, $0, completion) }
169
+ }
170
+ return
171
+ case ("Files", "download"):
172
+ download(requestToken: requestToken, args: args, completion: completion)
173
+ return
174
+ case ("Files", "remove"):
175
+ sync = .success(CraftNativeFiles.remove(args.first as? String ?? ""))
155
176
  case ("Lifecycle", "getState"):
156
177
  sync = .success(currentAppState())
157
178
  case ("DeepLinks", "getInitialURL"):
@@ -290,6 +311,19 @@ enum CraftNativeActions {
290
311
  }
291
312
  CraftWebServices.perform(action: action, body: args.first as? [String: Any] ?? [:]) { complete(requestToken, $0, completion) }
292
313
  return
314
+ case ("HeartRate", _):
315
+ // A Bluetooth heart-rate strap, the same service as a page's
316
+ // `craft.heartRate`: one connection and one recording, whichever
317
+ // side started them. Its events reach every screen as
318
+ // `craftHeartRate`.
319
+ guard config.enableHeartRate else {
320
+ complete(requestToken, failure("CAPABILITY_DISABLED", "Heart rate is disabled"), completion)
321
+ return
322
+ }
323
+ CraftHeartRate.shared.perform(method: method, args: args) { result in
324
+ complete(requestToken, result.mapError { CraftNativeActionError(code: $0.code, message: $0.message) }, completion)
325
+ }
326
+ return
293
327
  case ("Linking", "openURL"), ("Linking", "canOpenURL"):
294
328
  // Another app or the system: `webcal:` subscriptions, `mailto:`,
295
329
  // `tel:`, the App Store, this app's page in Settings
@@ -418,7 +452,21 @@ enum CraftNativeActions {
418
452
  request.setValue(value, forHTTPHeaderField: name)
419
453
  }
420
454
  }
421
- if let body = options["body"] as? String {
455
+ if let form = options["form"] as? [Any] {
456
+ // A screen's FormData: text fields, and files craft.files made.
457
+ guard method != "GET", method != "HEAD" else {
458
+ complete(requestToken, failure("INVALID_ARGUMENT", "A GET or HEAD request cannot have a body"), completion)
459
+ return
460
+ }
461
+ switch CraftNativeFiles.multipart(form) {
462
+ case .failure(let error):
463
+ complete(requestToken, .failure(error), completion)
464
+ return
465
+ case .success(let (data, boundary)):
466
+ request.httpBody = data
467
+ request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
468
+ }
469
+ } else if let body = options["body"] as? String {
422
470
  guard method != "GET", method != "HEAD" else {
423
471
  complete(requestToken, failure("INVALID_ARGUMENT", "A GET or HEAD request cannot have a body"), completion)
424
472
  return
@@ -467,6 +515,62 @@ enum CraftNativeActions {
467
515
  if !alreadyCancelled { task.resume() }
468
516
  }
469
517
 
518
+ /// `craft.files.download`: a GET straight to a file made here (for the
519
+ /// share sheet), with the screen's headers, so a large file never passes
520
+ /// through JavaScriptCore. A refused answer (not 2xx) keeps no file and
521
+ /// hands back its status and the start of its body. Cancelled like fetch.
522
+ private static func download(
523
+ requestToken: String,
524
+ args: [Any],
525
+ completion: @escaping (Result<Any, CraftNativeActionError>) -> Void
526
+ ) {
527
+ guard let address = args.first as? String,
528
+ let url = URL(string: address),
529
+ let scheme = url.scheme?.lowercased(), scheme == "https" || scheme == "http",
530
+ url.host?.isEmpty == false else {
531
+ complete(requestToken, failure("INVALID_ARGUMENT", "files.download needs an absolute http or https URL"), completion)
532
+ return
533
+ }
534
+ let options = args.count > 1 ? args[1] as? [String: Any] ?? [:] : [:]
535
+ var request = URLRequest(url: url, timeoutInterval: craftNativeFetchTimeoutSeconds)
536
+ request.httpMethod = "GET"
537
+ if let headers = options["headers"] as? [String: Any] {
538
+ for (name, value) in headers {
539
+ guard let value = value as? String else {
540
+ complete(requestToken, failure("INVALID_ARGUMENT", "files.download header values must be strings"), completion)
541
+ return
542
+ }
543
+ request.setValue(value, forHTTPHeaderField: name)
544
+ }
545
+ }
546
+ let name = options["name"] as? String
547
+ let task = fetchSession.downloadTask(with: request) { location, response, error in
548
+ cancellationLock.lock()
549
+ fetchTasks.removeValue(forKey: requestToken)
550
+ cancellationLock.unlock()
551
+ if let error = error {
552
+ let code = (error as? URLError)?.code == .timedOut ? "TIMEOUT" : "NETWORK_ERROR"
553
+ complete(requestToken, failure(code, error.localizedDescription), completion)
554
+ return
555
+ }
556
+ guard let http = response as? HTTPURLResponse else {
557
+ complete(requestToken, failure("NETWORK_ERROR", "files.download received a response that is not HTTP"), completion)
558
+ return
559
+ }
560
+ // The system deletes `location` once this returns: keep or read it now.
561
+ guard (200..<300).contains(http.statusCode), let location else {
562
+ complete(requestToken, .success(CraftNativeFiles.refusedDownload(at: location, response: http)), completion)
563
+ return
564
+ }
565
+ complete(requestToken, CraftNativeFiles.keepDownload(at: location, response: http, name: name), completion)
566
+ }
567
+ cancellationLock.lock()
568
+ let alreadyCancelled = cancelledRequests.contains(requestToken)
569
+ if !alreadyCancelled { fetchTasks[requestToken] = task }
570
+ cancellationLock.unlock()
571
+ if !alreadyCancelled { task.resume() }
572
+ }
573
+
470
574
  private static func claimInitialDeepLink() -> URL? {
471
575
  cancellationLock.lock()
472
576
  defer { cancellationLock.unlock() }
@@ -0,0 +1,343 @@
1
+ import Foundation
2
+ import ImageIO
3
+ import PhotosUI
4
+ import UIKit
5
+ import UniformTypeIdentifiers
6
+
7
+ // `craft.files` on a native screen: a photo picked from the library (or taken
8
+ // with the camera), a file downloaded for the share sheet, and those files
9
+ // sent as a multipart form through the screen's `fetch`.
10
+ //
11
+ // A file is a path in the app's temporary directory (tmp/craft-files/<id>/
12
+ // <name>), described to JavaScript as `{ path, name, type, size }`. Its bytes
13
+ // never cross the bridge: a picked photo goes from the picker to disk to the
14
+ // upload without becoming a base64 string in JavaScriptCore, so a photo costs
15
+ // what its file costs rather than a third more, twice over, in the screen's
16
+ // memory. `fetch` and the share sheet take the path; only files made here are
17
+ // read back for a form, so a screen cannot post an arbitrary file of the app's.
18
+
19
+ enum CraftNativeFiles {
20
+ /// The longest side of a picked photo unless the screen asks otherwise:
21
+ /// sharp on any phone screen and in a share card, a few hundred kilobytes
22
+ /// as JPEG, where the original can be 48 megapixels of HEIC.
23
+ static let defaultMaxPixels = 2048
24
+ static let largestMaxPixels = 8192
25
+ static let defaultQuality = 0.85
26
+ /// The part of a refused download's answer handed back, for its error.
27
+ static let errorBodyLimit = 64 * 1024
28
+
29
+ static var root: URL {
30
+ FileManager.default.temporaryDirectory.appendingPathComponent("craft-files", isDirectory: true)
31
+ }
32
+
33
+ private static let lock = NSLock()
34
+ private static var swept = false
35
+ /// The picker on screen, kept alive until it answers. One at a time.
36
+ private static var activePicker: NSObject?
37
+
38
+ // MARK: Files of our own
39
+
40
+ /// A new file called `name`, in a folder of its own so two files can share
41
+ /// a name (the share sheet shows it).
42
+ static func newFile(named name: String) throws -> URL {
43
+ sweepOnce()
44
+ let folder = root.appendingPathComponent(UUID().uuidString, isDirectory: true)
45
+ try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
46
+ return folder.appendingPathComponent(safeName(name))
47
+ }
48
+
49
+ /// The file at `path` when it is one made here and still there, else nil.
50
+ static func owned(_ path: String) -> URL? {
51
+ let url = URL(fileURLWithPath: path).standardizedFileURL.resolvingSymlinksInPath()
52
+ let base = root.standardizedFileURL.resolvingSymlinksInPath().path + "/"
53
+ var isDirectory: ObjCBool = false
54
+ guard url.path.hasPrefix(base),
55
+ FileManager.default.fileExists(atPath: url.path, isDirectory: &isDirectory),
56
+ !isDirectory.boolValue else { return nil }
57
+ return url
58
+ }
59
+
60
+ /// A file name with no path in it.
61
+ static func safeName(_ name: String) -> String {
62
+ let parts = name.components(separatedBy: CharacterSet(charactersIn: "/\\:\u{0}\r\n"))
63
+ var cleaned = parts.joined(separator: "-").trimmingCharacters(in: .whitespacesAndNewlines)
64
+ while cleaned.hasPrefix(".") { cleaned.removeFirst() }
65
+ return cleaned.isEmpty ? "file" : String(cleaned.prefix(120))
66
+ }
67
+
68
+ static func describe(_ url: URL, type: String, extra: [String: Any] = [:]) -> [String: Any] {
69
+ let size = ((try? FileManager.default.attributesOfItem(atPath: url.path))?[.size] as? NSNumber)?.intValue ?? 0
70
+ var info: [String: Any] = ["path": url.path, "name": url.lastPathComponent, "type": type, "size": size]
71
+ for (key, value) in extra { info[key] = value }
72
+ return info
73
+ }
74
+
75
+ /// Forget a file made here. True when there was one.
76
+ @discardableResult
77
+ static func remove(_ path: String) -> Bool {
78
+ guard let url = owned(path) else { return false }
79
+ try? FileManager.default.removeItem(at: url.deletingLastPathComponent())
80
+ return true
81
+ }
82
+
83
+ /// Files from earlier launches that nobody removed: the system clears tmp
84
+ /// eventually, this clears what is a day old the first time a file is made.
85
+ private static func sweepOnce() {
86
+ lock.lock()
87
+ let first = !swept
88
+ swept = true
89
+ lock.unlock()
90
+ guard first else { return }
91
+ let fm = FileManager.default
92
+ let dayAgo = Date().addingTimeInterval(-86_400)
93
+ let folders = (try? fm.contentsOfDirectory(at: root, includingPropertiesForKeys: [.creationDateKey])) ?? []
94
+ for folder in folders {
95
+ let created = (try? folder.resourceValues(forKeys: [.creationDateKey]))?.creationDate ?? .distantPast
96
+ if created < dayAgo { try? fm.removeItem(at: folder) }
97
+ }
98
+ }
99
+
100
+ // MARK: Picking a photo
101
+
102
+ /// A photo from the library (PHPickerViewController: no permission asked,
103
+ /// the person sees only the picker) or, with `source: 'camera'` and the
104
+ /// camera enabled, a new one (UIImagePickerController). Saved as a JPEG no
105
+ /// larger than `maxPixels` on its longest side, upright, with no metadata
106
+ /// (no location) left in it. Answers the file, or null when the person
107
+ /// closed the picker. Main thread.
108
+ static func pickImage(_ options: [String: Any], cameraEnabled: Bool, done: @escaping (Result<Any, CraftNativeActionError>) -> Void) {
109
+ let maxPixels = min(largestMaxPixels, max(64, (options["maxPixels"] as? NSNumber)?.intValue ?? defaultMaxPixels))
110
+ let quality = min(1, max(0.1, (options["quality"] as? NSNumber)?.doubleValue ?? defaultQuality))
111
+ let source = options["source"] as? String ?? "library"
112
+ guard activePicker == nil else {
113
+ done(.failure(CraftNativeActionError(code: "BUSY", message: "A picker is already open")))
114
+ return
115
+ }
116
+ let finish: (Result<Any, CraftNativeActionError>) -> Void = { result in
117
+ DispatchQueue.main.async {
118
+ activePicker = nil
119
+ done(result)
120
+ }
121
+ }
122
+
123
+ if source == "camera" {
124
+ guard cameraEnabled else {
125
+ done(.failure(CraftNativeActionError(code: "CAPABILITY_DISABLED", message: "The camera is disabled")))
126
+ return
127
+ }
128
+ guard UIImagePickerController.isSourceTypeAvailable(.camera) else {
129
+ done(.failure(CraftNativeActionError(code: "UNAVAILABLE", message: "This device has no camera")))
130
+ return
131
+ }
132
+ let delegate = CameraDelegate(maxPixels: maxPixels, quality: quality, done: finish)
133
+ let picker = UIImagePickerController()
134
+ picker.sourceType = .camera
135
+ picker.delegate = delegate
136
+ activePicker = delegate
137
+ if !CraftPresenter.present(picker, from: nil) {
138
+ activePicker = nil
139
+ done(.failure(CraftNativeActionError(code: "PICKER_ERROR", message: "Unable to present the camera")))
140
+ }
141
+ return
142
+ }
143
+ guard source == "library" else {
144
+ done(.failure(CraftNativeActionError(code: "INVALID_ARGUMENT", message: "files.pickImage source is 'library' or 'camera'")))
145
+ return
146
+ }
147
+
148
+ var configuration = PHPickerConfiguration()
149
+ configuration.filter = .images
150
+ configuration.selectionLimit = 1
151
+ // The file as it is (HEIC stays HEIC): it is decoded once, below, at the size wanted.
152
+ configuration.preferredAssetRepresentationMode = .current
153
+ let delegate = LibraryDelegate(maxPixels: maxPixels, quality: quality, done: finish)
154
+ let picker = PHPickerViewController(configuration: configuration)
155
+ picker.delegate = delegate
156
+ activePicker = delegate
157
+ if !CraftPresenter.present(picker, from: nil) {
158
+ activePicker = nil
159
+ done(.failure(CraftNativeActionError(code: "PICKER_ERROR", message: "Unable to present the photo picker")))
160
+ }
161
+ }
162
+
163
+ /// Decode `source` at no more than `maxPixels` on its longest side, turned
164
+ /// upright, and write it as a JPEG. ImageIO's thumbnail reads only what
165
+ /// that size needs, so a huge original never sits in memory whole.
166
+ static func writeJPEG(from source: CGImageSource, maxPixels: Int, quality: Double, name: String) -> Result<Any, CraftNativeActionError> {
167
+ let thumbnail: [CFString: Any] = [
168
+ kCGImageSourceCreateThumbnailFromImageAlways: true,
169
+ kCGImageSourceCreateThumbnailWithTransform: true,
170
+ kCGImageSourceShouldCacheImmediately: true,
171
+ kCGImageSourceThumbnailMaxPixelSize: maxPixels,
172
+ ]
173
+ guard let image = CGImageSourceCreateThumbnailAtIndex(source, 0, thumbnail as CFDictionary) else {
174
+ return .failure(CraftNativeActionError(code: "DECODE_ERROR", message: "The photo could not be read"))
175
+ }
176
+ return writeJPEG(image, quality: quality, name: name)
177
+ }
178
+
179
+ static func writeJPEG(_ image: CGImage, quality: Double, name: String) -> Result<Any, CraftNativeActionError> {
180
+ do {
181
+ let url = try newFile(named: name)
182
+ guard let destination = CGImageDestinationCreateWithURL(url as CFURL, UTType.jpeg.identifier as CFString, 1, nil) else {
183
+ return .failure(CraftNativeActionError(code: "WRITE_ERROR", message: "The photo could not be saved"))
184
+ }
185
+ CGImageDestinationAddImage(destination, image, [kCGImageDestinationLossyCompressionQuality: quality] as CFDictionary)
186
+ guard CGImageDestinationFinalize(destination) else {
187
+ return .failure(CraftNativeActionError(code: "WRITE_ERROR", message: "The photo could not be saved"))
188
+ }
189
+ return .success(describe(url, type: "image/jpeg", extra: ["width": image.width, "height": image.height]))
190
+ } catch {
191
+ return .failure(CraftNativeActionError(code: "WRITE_ERROR", message: error.localizedDescription))
192
+ }
193
+ }
194
+
195
+ /// `IMG_1234.jpg` for the picker's `IMG_1234`, `photo.jpg` without one.
196
+ static func jpegName(_ suggested: String?) -> String {
197
+ var base = safeName(suggested ?? "photo")
198
+ if let dot = base.lastIndex(of: "."), dot > base.startIndex { base = String(base[..<dot]) }
199
+ return "\(base).jpg"
200
+ }
201
+
202
+ private final class LibraryDelegate: NSObject, PHPickerViewControllerDelegate {
203
+ let maxPixels: Int
204
+ let quality: Double
205
+ let done: (Result<Any, CraftNativeActionError>) -> Void
206
+
207
+ init(maxPixels: Int, quality: Double, done: @escaping (Result<Any, CraftNativeActionError>) -> Void) {
208
+ self.maxPixels = maxPixels
209
+ self.quality = quality
210
+ self.done = done
211
+ }
212
+
213
+ func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) {
214
+ picker.dismiss(animated: true)
215
+ guard let provider = results.first?.itemProvider else {
216
+ done(.success(NSNull()))
217
+ return
218
+ }
219
+ guard provider.hasItemConformingToTypeIdentifier(UTType.image.identifier) else {
220
+ done(.failure(CraftNativeActionError(code: "DECODE_ERROR", message: "That is not a photo")))
221
+ return
222
+ }
223
+ let name = CraftNativeFiles.jpegName(provider.suggestedName)
224
+ let (maxPixels, quality, done) = (self.maxPixels, self.quality, self.done)
225
+ // The file is only there for the length of this callback.
226
+ provider.loadFileRepresentation(forTypeIdentifier: UTType.image.identifier) { url, error in
227
+ guard let url, let source = CGImageSourceCreateWithURL(url as CFURL, nil) else {
228
+ done(.failure(CraftNativeActionError(code: "DECODE_ERROR", message: error?.localizedDescription ?? "The photo could not be read")))
229
+ return
230
+ }
231
+ done(CraftNativeFiles.writeJPEG(from: source, maxPixels: maxPixels, quality: quality, name: name))
232
+ }
233
+ }
234
+ }
235
+
236
+ private final class CameraDelegate: NSObject, UIImagePickerControllerDelegate, UINavigationControllerDelegate {
237
+ let maxPixels: Int
238
+ let quality: Double
239
+ let done: (Result<Any, CraftNativeActionError>) -> Void
240
+
241
+ init(maxPixels: Int, quality: Double, done: @escaping (Result<Any, CraftNativeActionError>) -> Void) {
242
+ self.maxPixels = maxPixels
243
+ self.quality = quality
244
+ self.done = done
245
+ }
246
+
247
+ func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any]) {
248
+ picker.dismiss(animated: true)
249
+ guard let image = info[.originalImage] as? UIImage else {
250
+ done(.failure(CraftNativeActionError(code: "DECODE_ERROR", message: "The photo could not be read")))
251
+ return
252
+ }
253
+ // Drawn at the size wanted, which also turns it upright.
254
+ let longest = max(image.size.width * image.scale, image.size.height * image.scale)
255
+ let factor = min(1, CGFloat(maxPixels) / max(1, longest))
256
+ let size = CGSize(width: (image.size.width * image.scale * factor).rounded(), height: (image.size.height * image.scale * factor).rounded())
257
+ let format = UIGraphicsImageRendererFormat()
258
+ format.scale = 1
259
+ format.opaque = true
260
+ let drawn = UIGraphicsImageRenderer(size: size, format: format).image { _ in image.draw(in: CGRect(origin: .zero, size: size)) }
261
+ guard let cgImage = drawn.cgImage else {
262
+ done(.failure(CraftNativeActionError(code: "DECODE_ERROR", message: "The photo could not be read")))
263
+ return
264
+ }
265
+ let formatter = DateFormatter()
266
+ formatter.dateFormat = "yyyyMMdd_HHmmss"
267
+ done(CraftNativeFiles.writeJPEG(cgImage, quality: quality, name: "IMG_\(formatter.string(from: Date())).jpg"))
268
+ }
269
+
270
+ func imagePickerControllerDidCancel(_ picker: UIImagePickerController) {
271
+ picker.dismiss(animated: true)
272
+ done(.success(NSNull()))
273
+ }
274
+ }
275
+
276
+ // MARK: Sending files
277
+
278
+ /// A multipart/form-data body from a screen's `FormData`: parts are
279
+ /// `{ name, value }` or `{ name, path, filename?, type? }`, a file made
280
+ /// here. Answers the body and its boundary.
281
+ static func multipart(_ parts: [Any]) -> Result<(Data, String), CraftNativeActionError> {
282
+ let boundary = "craft-\(UUID().uuidString)"
283
+ var body = Data()
284
+ func append(_ text: String) { body.append(Data(text.utf8)) }
285
+ for item in parts {
286
+ guard let part = item as? [String: Any], let name = part["name"] as? String else {
287
+ return .failure(CraftNativeActionError(code: "INVALID_ARGUMENT", message: "A form part needs a name"))
288
+ }
289
+ append("--\(boundary)\r\n")
290
+ if let path = part["path"] as? String {
291
+ guard let url = owned(path) else {
292
+ return .failure(CraftNativeActionError(code: "INVALID_ARGUMENT", message: "A form file must be one craft.files made, and still there"))
293
+ }
294
+ guard let data = try? Data(contentsOf: url, options: .mappedIfSafe) else {
295
+ return .failure(CraftNativeActionError(code: "INVALID_ARGUMENT", message: "A form file could not be read"))
296
+ }
297
+ let filename = (part["filename"] as? String).map(safeName) ?? url.lastPathComponent
298
+ let type = (part["type"] as? String).flatMap { $0.isEmpty ? nil : $0 } ?? "application/octet-stream"
299
+ append("Content-Disposition: form-data; name=\"\(quoted(name))\"; filename=\"\(quoted(filename))\"\r\n")
300
+ append("Content-Type: \(type.replacingOccurrences(of: "\r", with: "").replacingOccurrences(of: "\n", with: ""))\r\n\r\n")
301
+ body.append(data)
302
+ append("\r\n")
303
+ } else {
304
+ append("Content-Disposition: form-data; name=\"\(quoted(name))\"\r\n\r\n")
305
+ append(part["value"] as? String ?? "")
306
+ append("\r\n")
307
+ }
308
+ }
309
+ append("--\(boundary)--\r\n")
310
+ return .success((body, boundary))
311
+ }
312
+
313
+ /// A name inside a quoted header value, escaped as browsers send it.
314
+ static func quoted(_ value: String) -> String {
315
+ value.replacingOccurrences(of: "\"", with: "%22").replacingOccurrences(of: "\r", with: "%0D").replacingOccurrences(of: "\n", with: "%0A")
316
+ }
317
+
318
+ /// Keep a downloaded body as a file made here: the name the screen gave,
319
+ /// else the server's (`Content-Disposition`), else the address's.
320
+ static func keepDownload(at location: URL, response: HTTPURLResponse, name: String?) -> Result<Any, CraftNativeActionError> {
321
+ let chosen = name.flatMap { $0.isEmpty ? nil : $0 } ?? response.suggestedFilename ?? "download"
322
+ do {
323
+ let url = try newFile(named: chosen)
324
+ try FileManager.default.moveItem(at: location, to: url)
325
+ let type = (response.mimeType ?? "application/octet-stream")
326
+ return .success(describe(url, type: type, extra: ["ok": true, "status": response.statusCode]))
327
+ } catch {
328
+ return .failure(CraftNativeActionError(code: "WRITE_ERROR", message: error.localizedDescription))
329
+ }
330
+ }
331
+
332
+ /// A refused download: its status and the start of its answer, so the
333
+ /// screen can say what the server said. No file is kept.
334
+ static func refusedDownload(at location: URL?, response: HTTPURLResponse) -> [String: Any] {
335
+ var text = ""
336
+ if let location, let handle = try? FileHandle(forReadingFrom: location) {
337
+ let data = handle.readData(ofLength: errorBodyLimit)
338
+ try? handle.close()
339
+ text = String(decoding: data, as: UTF8.self)
340
+ }
341
+ return ["ok": false, "status": response.statusCode, "body": text]
342
+ }
343
+ }
@@ -3060,11 +3060,17 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
3060
3060
  "message": "Native API request timed out",
3061
3061
  ], correlationId: id)
3062
3062
  }
3063
- pendingCapabilityDeadlines[requestToken] = deadline
3064
- DispatchQueue.main.asyncAfter(
3065
- deadline: .now() + .milliseconds(craftNativeCapabilityTimeoutMilliseconds),
3066
- execute: deadline
3067
- )
3063
+ // A request a person answers (a share sheet, a dialog, the photo
3064
+ // picker, Face ID) says so (`untimed`, stx's callPersonFacingAPI)
3065
+ // and waits for them: a deadline would answer TIMEOUT while the
3066
+ // sheet is still up and they are still choosing.
3067
+ if payload["untimed"] as? Bool != true {
3068
+ pendingCapabilityDeadlines[requestToken] = deadline
3069
+ DispatchQueue.main.asyncAfter(
3070
+ deadline: .now() + .milliseconds(craftNativeCapabilityTimeoutMilliseconds),
3071
+ execute: deadline
3072
+ )
3073
+ }
3068
3074
  let module = payload["module"] as? String ?? ""
3069
3075
  let method = payload["method"] as? String ?? ""
3070
3076
  let reply: (Result<Any, CraftNativeActionError>) -> Void = { [weak self] answer in