craft-native 0.0.152 → 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/cli.js +1 -1
- package/dist/index.cjs +1 -1
- package/dist/index.js +1 -1
- package/dist/ios/src/index.js +1 -0
- package/dist/ios/templates/CraftNativeActions.swift +90 -2
- package/dist/ios/templates/CraftNativeFiles.swift +343 -0
- package/dist/ios/templates/CraftNativeScreen.swift +11 -5
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -3303,7 +3303,7 @@ function pinCraftNativeDependency(packagePath, craftVersion) {
|
|
|
3303
3303
|
`);
|
|
3304
3304
|
}
|
|
3305
3305
|
// package.json
|
|
3306
|
-
var version = "0.0.
|
|
3306
|
+
var version = "0.0.153";
|
|
3307
3307
|
|
|
3308
3308
|
// bin/cli.ts
|
|
3309
3309
|
var spawnedFrom = process6.env[CRAFT_CLI_SPAWN_MARKER];
|
package/dist/index.cjs
CHANGED
package/dist/index.js
CHANGED
package/dist/ios/src/index.js
CHANGED
|
@@ -794,6 +794,7 @@ async function init(options) {
|
|
|
794
794
|
cpSync(join(TEMPLATES_DIR, "CraftNativeMutation.swift"), join(output, "Sources", "CraftNativeMutation.swift"));
|
|
795
795
|
cpSync(join(TEMPLATES_DIR, "CraftNativeUI.swift"), join(output, "Sources", "CraftNativeUI.swift"));
|
|
796
796
|
cpSync(join(TEMPLATES_DIR, "CraftNativeServices.swift"), join(output, "Sources", "CraftNativeServices.swift"));
|
|
797
|
+
cpSync(join(TEMPLATES_DIR, "CraftNativeFiles.swift"), join(output, "Sources", "CraftNativeFiles.swift"));
|
|
797
798
|
cpSync(join(TEMPLATES_DIR, "CraftWebAudio.swift"), join(output, "Sources", "CraftWebAudio.swift"));
|
|
798
799
|
cpSync(join(TEMPLATES_DIR, "CraftHybrid.swift"), join(output, "Sources", "CraftHybrid.swift"));
|
|
799
800
|
const infoPlistTemplate = readFileSync(join(TEMPLATES_DIR, "Info.plist.template"), "utf-8");
|
|
@@ -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
|
|
@@ -64,6 +64,12 @@ enum CraftNativeActions {
|
|
|
64
64
|
// A heart-rate strap is the app's own service (CraftHeartRate.swift),
|
|
65
65
|
// so it needs no web view.
|
|
66
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") }
|
|
67
73
|
return values
|
|
68
74
|
}
|
|
69
75
|
|
|
@@ -155,6 +161,18 @@ enum CraftNativeActions {
|
|
|
155
161
|
case ("Network", "fetch"):
|
|
156
162
|
fetch(requestToken: requestToken, args: args, completion: completion)
|
|
157
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 ?? ""))
|
|
158
176
|
case ("Lifecycle", "getState"):
|
|
159
177
|
sync = .success(currentAppState())
|
|
160
178
|
case ("DeepLinks", "getInitialURL"):
|
|
@@ -434,7 +452,21 @@ enum CraftNativeActions {
|
|
|
434
452
|
request.setValue(value, forHTTPHeaderField: name)
|
|
435
453
|
}
|
|
436
454
|
}
|
|
437
|
-
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 {
|
|
438
470
|
guard method != "GET", method != "HEAD" else {
|
|
439
471
|
complete(requestToken, failure("INVALID_ARGUMENT", "A GET or HEAD request cannot have a body"), completion)
|
|
440
472
|
return
|
|
@@ -483,6 +515,62 @@ enum CraftNativeActions {
|
|
|
483
515
|
if !alreadyCancelled { task.resume() }
|
|
484
516
|
}
|
|
485
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
|
+
|
|
486
574
|
private static func claimInitialDeepLink() -> URL? {
|
|
487
575
|
cancellationLock.lock()
|
|
488
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
|