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.
- package/dist/api/mobile.d.ts +113 -0
- package/dist/cli.js +1 -1
- package/dist/index.cjs +2 -1
- package/dist/index.js +2 -1
- package/dist/ios/src/index.d.ts +9 -0
- package/dist/ios/src/index.js +6 -1
- package/dist/ios/templates/CraftApp.swift +56 -1
- package/dist/ios/templates/CraftHeartRate.swift +699 -0
- package/dist/ios/templates/CraftNativeActions.swift +106 -2
- package/dist/ios/templates/CraftNativeFiles.swift +343 -0
- package/dist/ios/templates/CraftNativeScreen.swift +11 -5
- package/dist/mobile.js +163 -0
- package/package.json +1 -1
|
@@ -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:
|
|
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
|
|
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
|
-
|
|
3064
|
-
|
|
3065
|
-
|
|
3066
|
-
|
|
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
|