craft-native 0.0.187 → 0.0.189
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/android/templates/CraftNative.kt.template +34 -0
- package/dist/android/templates/CraftNativeCapabilities.kt.template +92 -9
- package/dist/android/templates/CraftNativeShell.kt.template +14 -2
- package/dist/android/templates/MainActivityNative.kt.template +312 -24
- package/dist/browser/dialogs.d.ts +20 -0
- package/dist/browser/storage.d.ts +31 -0
- package/dist/browser.d.ts +22 -0
- package/dist/browser.js +705 -0
- package/dist/cli.js +1 -1
- package/dist/index.cjs +1 -1
- package/dist/index.js +1 -1
- package/dist/ios/templates/CraftHybrid.swift +36 -4
- package/dist/ios/templates/CraftNativeActions.swift +16 -4
- package/dist/ios/templates/CraftNativeFiles.swift +131 -25
- package/dist/ios/templates/CraftNativeScreen.swift +454 -3
- package/dist/macos/templates/native_layout.zig +299 -6
- package/package.json +9 -1
package/dist/cli.js
CHANGED
|
@@ -3424,7 +3424,7 @@ function pinCraftNativeDependency(packagePath, craftVersion) {
|
|
|
3424
3424
|
`);
|
|
3425
3425
|
}
|
|
3426
3426
|
// package.json
|
|
3427
|
-
var version = "0.0.
|
|
3427
|
+
var version = "0.0.189";
|
|
3428
3428
|
|
|
3429
3429
|
// bin/cli.ts
|
|
3430
3430
|
var spawnedFrom = process6.env[CRAFT_CLI_SPAWN_MARKER];
|
package/dist/index.cjs
CHANGED
package/dist/index.js
CHANGED
|
@@ -992,7 +992,10 @@ final class CraftHybridWebSlot: UIViewController {
|
|
|
992
992
|
|
|
993
993
|
override func viewWillAppear(_ animated: Bool) {
|
|
994
994
|
super.viewWillAppear(animated)
|
|
995
|
-
|
|
995
|
+
if let controller = parent?.parent as? CraftHybridController {
|
|
996
|
+
controller.prepareWebTransition(to: self)
|
|
997
|
+
controller.adoptWeb(into: self)
|
|
998
|
+
}
|
|
996
999
|
}
|
|
997
1000
|
}
|
|
998
1001
|
|
|
@@ -1011,11 +1014,18 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
|
|
|
1011
1014
|
private var shownStacks: [ObjectIdentifier: [UIViewController]] = [:]
|
|
1012
1015
|
/// A tab whose page has not caught up yet; shown when it has, or soon.
|
|
1013
1016
|
private var pendingTab: (id: String, deadline: DispatchWorkItem)?
|
|
1017
|
+
/// The live WebView moves to the destination before UIKit animates a web
|
|
1018
|
+
/// push. Keep the outgoing page visible until that transition completes.
|
|
1019
|
+
private var webTransitionSnapshots: [UIView] = []
|
|
1014
1020
|
private var openingWeb = false
|
|
1015
1021
|
private var firstFrameLogged = false
|
|
1016
1022
|
private var tabBarHeight: CGFloat = 0
|
|
1017
1023
|
private var tabSwipeRecognizers: [UIGestureRecognizer] = []
|
|
1018
1024
|
private var activeTabSwipeTarget: String?
|
|
1025
|
+
/// The edge the incoming tab started from for the current gesture. Keep
|
|
1026
|
+
/// this separate from the live frame: deriving it after the drag begins
|
|
1027
|
+
/// makes the target jump when the translation crosses the midpoint.
|
|
1028
|
+
private var tabSwipeFrom: CGFloat = 0
|
|
1019
1029
|
|
|
1020
1030
|
init(config: CraftConfig) {
|
|
1021
1031
|
self.config = config
|
|
@@ -1134,7 +1144,8 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
|
|
|
1134
1144
|
}
|
|
1135
1145
|
target.view.isHidden = false
|
|
1136
1146
|
let width = view.bounds.width
|
|
1137
|
-
|
|
1147
|
+
tabSwipeFrom = -direction * width
|
|
1148
|
+
target.view.frame = view.bounds.offsetBy(dx: tabSwipeFrom, dy: 0)
|
|
1138
1149
|
view.bringSubviewToFront(target.view)
|
|
1139
1150
|
CraftChrome.shared.beginTabSwipe(to: id)
|
|
1140
1151
|
return true
|
|
@@ -1144,9 +1155,8 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
|
|
|
1144
1155
|
guard let targetId = activeTabSwipeTarget, let current = visibleStack,
|
|
1145
1156
|
let target = stacks[targetId] else { return }
|
|
1146
1157
|
let width = max(1, view.bounds.width)
|
|
1147
|
-
let start = target.view.frame.minX >= width * 0.5 ? width : -width
|
|
1148
1158
|
let offset = min(width, max(-width, translation))
|
|
1149
|
-
target.view.frame.origin.x =
|
|
1159
|
+
target.view.frame.origin.x = tabSwipeFrom + offset
|
|
1150
1160
|
current.view.frame.origin.x = offset
|
|
1151
1161
|
}
|
|
1152
1162
|
|
|
@@ -1244,6 +1254,26 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
|
|
|
1244
1254
|
])
|
|
1245
1255
|
}
|
|
1246
1256
|
|
|
1257
|
+
/// UIKit calls the destination's viewWillAppear before it starts a push.
|
|
1258
|
+
/// Since the hybrid shell has one WebView shared by all web entries, moving
|
|
1259
|
+
/// it at that point would leave the outgoing slot blank for the whole
|
|
1260
|
+
/// transition. A snapshot gives that slot the page it was showing while
|
|
1261
|
+
/// the live WebView is adopted by the destination.
|
|
1262
|
+
func prepareWebTransition(to slot: CraftHybridWebSlot) {
|
|
1263
|
+
guard let nav = slot.navigationController,
|
|
1264
|
+
nav === visibleStack,
|
|
1265
|
+
let from = nav.transitionCoordinator?.viewController(forKey: .from),
|
|
1266
|
+
let source = from as? CraftHybridWebSlot,
|
|
1267
|
+
source !== slot,
|
|
1268
|
+
let webHost,
|
|
1269
|
+
webHost.view.superview === source.view,
|
|
1270
|
+
let snapshot = webHost.view.snapshotView(afterScreenUpdates: false) else { return }
|
|
1271
|
+
snapshot.frame = source.view.bounds
|
|
1272
|
+
snapshot.autoresizingMask = [.flexibleWidth, .flexibleHeight]
|
|
1273
|
+
source.view.addSubview(snapshot)
|
|
1274
|
+
webTransitionSnapshots.append(snapshot)
|
|
1275
|
+
}
|
|
1276
|
+
|
|
1247
1277
|
private var visibleStack: UINavigationController? { stacks[currentTab] }
|
|
1248
1278
|
|
|
1249
1279
|
var webIsVisible: Bool {
|
|
@@ -1593,6 +1623,8 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
|
|
|
1593
1623
|
if let depth = removed.compactMap({ ($0 as? CraftHybridWebSlot)?.depth }).min() {
|
|
1594
1624
|
hybrid.pageBack(to: depth - 1)
|
|
1595
1625
|
}
|
|
1626
|
+
for snapshot in webTransitionSnapshots { snapshot.removeFromSuperview() }
|
|
1627
|
+
webTransitionSnapshots.removeAll()
|
|
1596
1628
|
// Back at a tab's native root while the page, with no history left
|
|
1597
1629
|
// to go back through (it reloaded on the screen just popped), still
|
|
1598
1630
|
// shows that screen: it moves to the root's path. Left there, the
|
|
@@ -31,7 +31,7 @@ enum CraftNativeActions {
|
|
|
31
31
|
private static let fetchSession: URLSession = {
|
|
32
32
|
let configuration = URLSessionConfiguration.default
|
|
33
33
|
configuration.timeoutIntervalForRequest = craftNativeFetchTimeoutSeconds
|
|
34
|
-
configuration.timeoutIntervalForResource =
|
|
34
|
+
configuration.timeoutIntervalForResource = 600
|
|
35
35
|
return URLSession(configuration: configuration)
|
|
36
36
|
}()
|
|
37
37
|
private static var initialDeepLinkClaimed = false
|
|
@@ -219,6 +219,12 @@ enum CraftNativeActions {
|
|
|
219
219
|
CraftNativeFiles.pickImage(options, cameraEnabled: config.enableCamera) { complete(requestToken, $0, completion) }
|
|
220
220
|
}
|
|
221
221
|
return
|
|
222
|
+
case ("Files", "pickVideo"):
|
|
223
|
+
let options = args.first as? [String: Any] ?? [:]
|
|
224
|
+
DispatchQueue.main.async {
|
|
225
|
+
CraftNativeFiles.pickVideo(options) { complete(requestToken, $0, completion) }
|
|
226
|
+
}
|
|
227
|
+
return
|
|
222
228
|
case ("Files", "download"):
|
|
223
229
|
download(requestToken: requestToken, args: args, completion: completion)
|
|
224
230
|
return
|
|
@@ -594,6 +600,7 @@ enum CraftNativeActions {
|
|
|
594
600
|
request.setValue(value, forHTTPHeaderField: name)
|
|
595
601
|
}
|
|
596
602
|
}
|
|
603
|
+
var uploadFile: URL?
|
|
597
604
|
if let form = options["form"] as? [Any] {
|
|
598
605
|
// A screen's FormData: text fields, and files craft.files made.
|
|
599
606
|
guard method != "GET", method != "HEAD" else {
|
|
@@ -604,8 +611,9 @@ enum CraftNativeActions {
|
|
|
604
611
|
case .failure(let error):
|
|
605
612
|
complete(requestToken, .failure(error), completion)
|
|
606
613
|
return
|
|
607
|
-
case .success(let (
|
|
608
|
-
|
|
614
|
+
case .success(let (file, boundary)):
|
|
615
|
+
uploadFile = file
|
|
616
|
+
request.timeoutInterval = 600
|
|
609
617
|
request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
|
|
610
618
|
}
|
|
611
619
|
} else if let body = options["body"] as? String {
|
|
@@ -619,7 +627,8 @@ enum CraftNativeActions {
|
|
|
619
627
|
return
|
|
620
628
|
}
|
|
621
629
|
|
|
622
|
-
let
|
|
630
|
+
let responseHandler: (Data?, URLResponse?, Error?) -> Void = { data, response, error in
|
|
631
|
+
if let uploadFile { CraftNativeFiles.remove(uploadFile.path) }
|
|
623
632
|
cancellationLock.lock()
|
|
624
633
|
fetchTasks.removeValue(forKey: requestToken)
|
|
625
634
|
cancellationLock.unlock()
|
|
@@ -656,11 +665,14 @@ enum CraftNativeActions {
|
|
|
656
665
|
"body": String(decoding: body, as: UTF8.self),
|
|
657
666
|
]), completion)
|
|
658
667
|
}
|
|
668
|
+
let task: URLSessionTask = uploadFile.map { fetchSession.uploadTask(with: request, fromFile: $0, completionHandler: responseHandler) }
|
|
669
|
+
?? fetchSession.dataTask(with: request, completionHandler: responseHandler)
|
|
659
670
|
cancellationLock.lock()
|
|
660
671
|
let alreadyCancelled = cancelledRequests.contains(requestToken)
|
|
661
672
|
if !alreadyCancelled { fetchTasks[requestToken] = task }
|
|
662
673
|
cancellationLock.unlock()
|
|
663
674
|
if !alreadyCancelled { task.resume() }
|
|
675
|
+
else if let uploadFile { CraftNativeFiles.remove(uploadFile.path) }
|
|
664
676
|
}
|
|
665
677
|
|
|
666
678
|
/// `craft.files.download`: a GET straight to a file made here (for the
|
|
@@ -274,41 +274,147 @@ enum CraftNativeFiles {
|
|
|
274
274
|
}
|
|
275
275
|
}
|
|
276
276
|
|
|
277
|
+
// MARK: Picking a video
|
|
278
|
+
|
|
279
|
+
/// System pickers grant access to just the selected video. Copy it while
|
|
280
|
+
/// the provider's URL is valid; no movie bytes cross JavaScriptCore.
|
|
281
|
+
static func pickVideo(_ options: [String: Any], done: @escaping (Result<Any, CraftNativeActionError>) -> Void) {
|
|
282
|
+
guard activePicker == nil else {
|
|
283
|
+
done(.failure(CraftNativeActionError(code: "BUSY", message: "A picker is already open")))
|
|
284
|
+
return
|
|
285
|
+
}
|
|
286
|
+
let limit = min(512 * 1024 * 1024, max(1, (options["maxBytes"] as? NSNumber)?.intValue ?? 512 * 1024 * 1024))
|
|
287
|
+
let finish: (Result<Any, CraftNativeActionError>) -> Void = { result in
|
|
288
|
+
DispatchQueue.main.async { activePicker = nil; done(result) }
|
|
289
|
+
}
|
|
290
|
+
if options["source"] as? String == "files" {
|
|
291
|
+
let delegate = VideoDocumentDelegate(limit: limit, done: finish)
|
|
292
|
+
var types: [UTType] = [.movie]
|
|
293
|
+
if let webm = UTType(filenameExtension: "webm") { types.append(webm) }
|
|
294
|
+
let picker = UIDocumentPickerViewController(forOpeningContentTypes: types, asCopy: true)
|
|
295
|
+
picker.allowsMultipleSelection = false
|
|
296
|
+
picker.delegate = delegate
|
|
297
|
+
activePicker = delegate
|
|
298
|
+
if !CraftPresenter.present(picker, from: nil) {
|
|
299
|
+
activePicker = nil
|
|
300
|
+
done(.failure(CraftNativeActionError(code: "PICKER_ERROR", message: "Unable to present the video picker")))
|
|
301
|
+
}
|
|
302
|
+
} else {
|
|
303
|
+
var configuration = PHPickerConfiguration()
|
|
304
|
+
configuration.filter = .videos
|
|
305
|
+
configuration.selectionLimit = 1
|
|
306
|
+
configuration.preferredAssetRepresentationMode = .current
|
|
307
|
+
let delegate = VideoLibraryDelegate(limit: limit, done: finish)
|
|
308
|
+
let picker = PHPickerViewController(configuration: configuration)
|
|
309
|
+
picker.delegate = delegate
|
|
310
|
+
activePicker = delegate
|
|
311
|
+
if !CraftPresenter.present(picker, from: nil) {
|
|
312
|
+
activePicker = nil
|
|
313
|
+
done(.failure(CraftNativeActionError(code: "PICKER_ERROR", message: "Unable to present the video picker")))
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
static func copyVideo(_ source: URL, limit: Int) -> Result<Any, CraftNativeActionError> {
|
|
319
|
+
let ext = source.pathExtension.lowercased()
|
|
320
|
+
let types = ["mp4": "video/mp4", "m4v": "video/x-m4v", "mov": "video/quicktime", "webm": "video/webm"]
|
|
321
|
+
guard let type = types[ext] else {
|
|
322
|
+
return .failure(CraftNativeActionError(code: "INVALID_VIDEO", message: "Choose an MP4, MOV or WebM video."))
|
|
323
|
+
}
|
|
324
|
+
do {
|
|
325
|
+
let size = ((try FileManager.default.attributesOfItem(atPath: source.path))[.size] as? NSNumber)?.intValue ?? 0
|
|
326
|
+
guard size > 0, size <= limit else {
|
|
327
|
+
return .failure(CraftNativeActionError(code: "FILE_TOO_LARGE", message: "Choose a nonempty video under \(limit / 1024 / 1024)MB."))
|
|
328
|
+
}
|
|
329
|
+
let destination = try newFile(named: source.lastPathComponent)
|
|
330
|
+
do { try FileManager.default.copyItem(at: source, to: destination) }
|
|
331
|
+
catch { try? FileManager.default.removeItem(at: destination.deletingLastPathComponent()); throw error }
|
|
332
|
+
return .success(describe(destination, type: type))
|
|
333
|
+
} catch {
|
|
334
|
+
return .failure(CraftNativeActionError(code: "READ_ERROR", message: error.localizedDescription))
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
private final class VideoLibraryDelegate: NSObject, PHPickerViewControllerDelegate {
|
|
339
|
+
let limit: Int
|
|
340
|
+
let done: (Result<Any, CraftNativeActionError>) -> Void
|
|
341
|
+
init(limit: Int, done: @escaping (Result<Any, CraftNativeActionError>) -> Void) { self.limit = limit; self.done = done }
|
|
342
|
+
func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) {
|
|
343
|
+
picker.dismiss(animated: true)
|
|
344
|
+
guard let provider = results.first?.itemProvider else { done(.success(NSNull())); return }
|
|
345
|
+
let (limit, done) = (self.limit, self.done)
|
|
346
|
+
provider.loadFileRepresentation(forTypeIdentifier: UTType.movie.identifier) { url, error in
|
|
347
|
+
guard let url else {
|
|
348
|
+
done(.failure(CraftNativeActionError(code: "READ_ERROR", message: error?.localizedDescription ?? "The video could not be read")))
|
|
349
|
+
return
|
|
350
|
+
}
|
|
351
|
+
done(CraftNativeFiles.copyVideo(url, limit: limit))
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
private final class VideoDocumentDelegate: NSObject, UIDocumentPickerDelegate {
|
|
357
|
+
let limit: Int
|
|
358
|
+
let done: (Result<Any, CraftNativeActionError>) -> Void
|
|
359
|
+
init(limit: Int, done: @escaping (Result<Any, CraftNativeActionError>) -> Void) { self.limit = limit; self.done = done }
|
|
360
|
+
func documentPickerWasCancelled(_ controller: UIDocumentPickerViewController) { done(.success(NSNull())) }
|
|
361
|
+
func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) {
|
|
362
|
+
guard let url = urls.first else { done(.success(NSNull())); return }
|
|
363
|
+
let (limit, done) = (self.limit, self.done)
|
|
364
|
+
DispatchQueue.global(qos: .userInitiated).async {
|
|
365
|
+
let granted = url.startAccessingSecurityScopedResource()
|
|
366
|
+
defer { if granted { url.stopAccessingSecurityScopedResource() } }
|
|
367
|
+
done(CraftNativeFiles.copyVideo(url, limit: limit))
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
277
372
|
// MARK: Sending files
|
|
278
373
|
|
|
279
374
|
/// A multipart/form-data body from a screen's `FormData`: parts are
|
|
280
375
|
/// `{ name, value }` or `{ name, path, filename?, type? }`, a file made
|
|
281
376
|
/// here. Answers the body and its boundary.
|
|
282
|
-
static func multipart(_ parts: [Any]) -> Result<(
|
|
377
|
+
static func multipart(_ parts: [Any]) -> Result<(URL, String), CraftNativeActionError> {
|
|
283
378
|
let boundary = "craft-\(UUID().uuidString)"
|
|
284
|
-
var
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
379
|
+
var temporary: URL?
|
|
380
|
+
do {
|
|
381
|
+
let destination = try newFile(named: "upload.multipart")
|
|
382
|
+
temporary = destination
|
|
383
|
+
FileManager.default.createFile(atPath: destination.path, contents: nil)
|
|
384
|
+
let output = try FileHandle(forWritingTo: destination)
|
|
385
|
+
defer { try? output.close() }
|
|
386
|
+
func append(_ text: String) throws { try output.write(contentsOf: Data(text.utf8)) }
|
|
387
|
+
for item in parts {
|
|
388
|
+
guard let part = item as? [String: Any], let name = part["name"] as? String else {
|
|
389
|
+
throw CraftNativeActionError(code: "INVALID_ARGUMENT", message: "A form part needs a name")
|
|
294
390
|
}
|
|
295
|
-
|
|
296
|
-
|
|
391
|
+
try append("--\(boundary)\r\n")
|
|
392
|
+
if let path = part["path"] as? String {
|
|
393
|
+
guard let url = owned(path) else {
|
|
394
|
+
throw CraftNativeActionError(code: "INVALID_ARGUMENT", message: "A form file must be one craft.files made, and still there")
|
|
395
|
+
}
|
|
396
|
+
let filename = (part["filename"] as? String).map(safeName) ?? url.lastPathComponent
|
|
397
|
+
let type = (part["type"] as? String).flatMap { $0.isEmpty ? nil : $0 } ?? "application/octet-stream"
|
|
398
|
+
try append("Content-Disposition: form-data; name=\"\(quoted(name))\"; filename=\"\(quoted(filename))\"\r\n")
|
|
399
|
+
try append("Content-Type: \(type.replacingOccurrences(of: "\r", with: "").replacingOccurrences(of: "\n", with: ""))\r\n\r\n")
|
|
400
|
+
let input = try FileHandle(forReadingFrom: url)
|
|
401
|
+
defer { try? input.close() }
|
|
402
|
+
while let chunk = try input.read(upToCount: 64 * 1024), !chunk.isEmpty {
|
|
403
|
+
try output.write(contentsOf: chunk)
|
|
404
|
+
}
|
|
405
|
+
try append("\r\n")
|
|
406
|
+
} else {
|
|
407
|
+
try append("Content-Disposition: form-data; name=\"\(quoted(name))\"\r\n\r\n")
|
|
408
|
+
try append(part["value"] as? String ?? "")
|
|
409
|
+
try append("\r\n")
|
|
297
410
|
}
|
|
298
|
-
let filename = (part["filename"] as? String).map(safeName) ?? url.lastPathComponent
|
|
299
|
-
let type = (part["type"] as? String).flatMap { $0.isEmpty ? nil : $0 } ?? "application/octet-stream"
|
|
300
|
-
append("Content-Disposition: form-data; name=\"\(quoted(name))\"; filename=\"\(quoted(filename))\"\r\n")
|
|
301
|
-
append("Content-Type: \(type.replacingOccurrences(of: "\r", with: "").replacingOccurrences(of: "\n", with: ""))\r\n\r\n")
|
|
302
|
-
body.append(data)
|
|
303
|
-
append("\r\n")
|
|
304
|
-
} else {
|
|
305
|
-
append("Content-Disposition: form-data; name=\"\(quoted(name))\"\r\n\r\n")
|
|
306
|
-
append(part["value"] as? String ?? "")
|
|
307
|
-
append("\r\n")
|
|
308
411
|
}
|
|
412
|
+
try append("--\(boundary)--\r\n")
|
|
413
|
+
return .success((destination, boundary))
|
|
414
|
+
} catch {
|
|
415
|
+
if let temporary { remove(temporary.path) }
|
|
416
|
+
return .failure((error as? CraftNativeActionError) ?? CraftNativeActionError(code: "WRITE_ERROR", message: error.localizedDescription))
|
|
309
417
|
}
|
|
310
|
-
append("--\(boundary)--\r\n")
|
|
311
|
-
return .success((body, boundary))
|
|
312
418
|
}
|
|
313
419
|
|
|
314
420
|
/// A name inside a quoted header value, escaped as browsers send it.
|