craft-native 0.0.162 → 0.0.164
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/MainActivityNative.kt.template +133 -114
- package/dist/api/mobile.d.ts +147 -2
- package/dist/cli.js +1 -1
- package/dist/index.cjs +1 -1
- package/dist/index.js +1 -1
- package/dist/ios/src/index.d.ts +18 -0
- package/dist/ios/src/index.js +12 -1
- package/dist/ios/templates/CraftApp.swift +68 -0
- package/dist/ios/templates/CraftHealth.swift +48 -2
- package/dist/ios/templates/CraftHybrid.swift +49 -0
- package/dist/ios/templates/CraftNativeActions.swift +125 -1
- package/dist/ios/templates/CraftNativeFiles.swift +107 -0
- package/dist/ios/templates/CraftNativeScreen.swift +38 -3
- package/dist/ios/templates/CraftScheduledWorkouts.swift +524 -0
- package/dist/ios/templates/project.yml.template +1 -0
- package/dist/mobile.js +75 -0
- package/package.json +1 -1
|
@@ -40,7 +40,7 @@ enum CraftNativeActions {
|
|
|
40
40
|
static func capabilities(config: CraftConfig) -> [String] {
|
|
41
41
|
// `speech`, `audio` (background sound) and `eventStream` (a fetch read
|
|
42
42
|
// as it arrives) need nothing the app has to declare.
|
|
43
|
-
var values = ["device", "clipboard", "haptics", "storage", "lifecycle", "fetch", "dialog", "speech", "audio", "eventStream", "linking"]
|
|
43
|
+
var values = ["device", "clipboard", "haptics", "storage", "lifecycle", "fetch", "dialog", "speech", "audio", "eventStream", "linking", "badge"]
|
|
44
44
|
if config.enableBiometric { values.append("biometric") }
|
|
45
45
|
if config.enableSecureStorage { values.append("secureStorage") }
|
|
46
46
|
if config.enableLocalDatabase { values.append("database") }
|
|
@@ -68,11 +68,17 @@ enum CraftNativeActions {
|
|
|
68
68
|
// A heart-rate strap is the app's own service (CraftHeartRate.swift),
|
|
69
69
|
// so it needs no web view.
|
|
70
70
|
if config.enableHeartRate { values.append("heartRate") }
|
|
71
|
+
// Workouts on the paired Apple Watch (CraftScheduledWorkouts.swift),
|
|
72
|
+
// also the app's own service.
|
|
73
|
+
if config.enableScheduledWorkouts { values.append("scheduledWorkouts") }
|
|
71
74
|
// Photos picked from the library, files downloaded for the share
|
|
72
75
|
// sheet and sent in a form (CraftNativeFiles.swift): the photo picker
|
|
73
76
|
// runs out of process and asks no permission. Taking a photo needs
|
|
74
77
|
// the camera, which the app declares.
|
|
75
78
|
values.append("files")
|
|
79
|
+
// Video and audio kept on the phone for offline (CraftNativeMedia),
|
|
80
|
+
// which the native Video element plays in place of the address.
|
|
81
|
+
values.append("media")
|
|
76
82
|
if config.enableCamera { values.append("camera") }
|
|
77
83
|
return values
|
|
78
84
|
}
|
|
@@ -187,6 +193,19 @@ enum CraftNativeActions {
|
|
|
187
193
|
return
|
|
188
194
|
case ("Files", "remove"):
|
|
189
195
|
sync = .success(CraftNativeFiles.remove(args.first as? String ?? ""))
|
|
196
|
+
case ("Media", "keep"):
|
|
197
|
+
keepMedia(requestToken: requestToken, args: args, completion: completion)
|
|
198
|
+
return
|
|
199
|
+
case ("Media", "path"):
|
|
200
|
+
sync = .success(CraftNativeMedia.local(args.first as? String ?? "")?.path ?? NSNull())
|
|
201
|
+
case ("Media", "prune"):
|
|
202
|
+
let keep = (args.first as? [Any] ?? []).compactMap { $0 as? String }
|
|
203
|
+
DispatchQueue.global(qos: .utility).async {
|
|
204
|
+
complete(requestToken, .success(CraftNativeMedia.prune(keep: keep)), completion)
|
|
205
|
+
}
|
|
206
|
+
return
|
|
207
|
+
case ("Media", "usage"):
|
|
208
|
+
sync = .success(CraftNativeMedia.usage())
|
|
190
209
|
case ("Lifecycle", "getState"):
|
|
191
210
|
sync = .success(currentAppState())
|
|
192
211
|
case ("DeepLinks", "getInitialURL"):
|
|
@@ -338,6 +357,17 @@ enum CraftNativeActions {
|
|
|
338
357
|
complete(requestToken, result.mapError { CraftNativeActionError(code: $0.code, message: $0.message) }, completion)
|
|
339
358
|
}
|
|
340
359
|
return
|
|
360
|
+
case ("ScheduledWorkouts", _):
|
|
361
|
+
// Workouts on the paired Apple Watch, the same service as a
|
|
362
|
+
// page's `craft.scheduledWorkouts` (WorkoutKit's scheduler).
|
|
363
|
+
guard config.enableScheduledWorkouts else {
|
|
364
|
+
complete(requestToken, failure("CAPABILITY_DISABLED", "Scheduled workouts are disabled"), completion)
|
|
365
|
+
return
|
|
366
|
+
}
|
|
367
|
+
CraftScheduledWorkouts.shared.perform(method: method, args: args) { result in
|
|
368
|
+
complete(requestToken, result.mapError { CraftNativeActionError(code: $0.code, message: $0.message) }, completion)
|
|
369
|
+
}
|
|
370
|
+
return
|
|
341
371
|
case ("Linking", "openURL"), ("Linking", "canOpenURL"):
|
|
342
372
|
// Another app or the system: `webcal:` subscriptions, `mailto:`,
|
|
343
373
|
// `tel:`, the App Store, this app's page in Settings
|
|
@@ -426,6 +456,19 @@ enum CraftNativeActions {
|
|
|
426
456
|
complete(requestToken, .success(true), completion)
|
|
427
457
|
}
|
|
428
458
|
return
|
|
459
|
+
case ("Badge", "set"):
|
|
460
|
+
guard let value = args.first as? NSNumber,
|
|
461
|
+
value.doubleValue.isFinite,
|
|
462
|
+
value.doubleValue >= 0,
|
|
463
|
+
value.doubleValue.rounded() == value.doubleValue else {
|
|
464
|
+
complete(requestToken, failure("INVALID_ARGUMENT", "Badge.set needs a non-negative whole number"), completion)
|
|
465
|
+
return
|
|
466
|
+
}
|
|
467
|
+
setBadgeCount(value.intValue) { complete(requestToken, $0, completion) }
|
|
468
|
+
return
|
|
469
|
+
case ("Badge", "clear"):
|
|
470
|
+
setBadgeCount(0) { complete(requestToken, $0, completion) }
|
|
471
|
+
return
|
|
429
472
|
case ("Notifications", _):
|
|
430
473
|
guard config.enableLocalNotifications else {
|
|
431
474
|
complete(requestToken, failure("CAPABILITY_DISABLED", "Local notifications are disabled"), completion)
|
|
@@ -600,6 +643,63 @@ enum CraftNativeActions {
|
|
|
600
643
|
if !alreadyCancelled { task.resume() }
|
|
601
644
|
}
|
|
602
645
|
|
|
646
|
+
/// `craft.media.keep`: the file at an address kept on the phone for
|
|
647
|
+
/// offline, fetched once (a copy already there answers at once, and two
|
|
648
|
+
/// screens asking at the same time share one download). A refused answer
|
|
649
|
+
/// (not 2xx) keeps nothing and says its status. Cancelled like fetch.
|
|
650
|
+
private static func keepMedia(
|
|
651
|
+
requestToken: String,
|
|
652
|
+
args: [Any],
|
|
653
|
+
completion: @escaping (Result<Any, CraftNativeActionError>) -> Void
|
|
654
|
+
) {
|
|
655
|
+
guard let address = args.first as? String,
|
|
656
|
+
let url = URL(string: address),
|
|
657
|
+
let scheme = url.scheme?.lowercased(), scheme == "https" || scheme == "http",
|
|
658
|
+
url.host?.isEmpty == false else {
|
|
659
|
+
complete(requestToken, failure("INVALID_ARGUMENT", "media.keep needs an absolute http or https URL"), completion)
|
|
660
|
+
return
|
|
661
|
+
}
|
|
662
|
+
if let kept = CraftNativeMedia.local(address) {
|
|
663
|
+
complete(requestToken, .success(CraftNativeMedia.describe(address, kept)), completion)
|
|
664
|
+
return
|
|
665
|
+
}
|
|
666
|
+
let options = args.count > 1 ? args[1] as? [String: Any] ?? [:] : [:]
|
|
667
|
+
var request = URLRequest(url: url, timeoutInterval: craftNativeFetchTimeoutSeconds)
|
|
668
|
+
request.httpMethod = "GET"
|
|
669
|
+
if let headers = options["headers"] as? [String: Any] {
|
|
670
|
+
for (name, value) in headers {
|
|
671
|
+
guard let value = value as? String else {
|
|
672
|
+
complete(requestToken, failure("INVALID_ARGUMENT", "media.keep header values must be strings"), completion)
|
|
673
|
+
return
|
|
674
|
+
}
|
|
675
|
+
request.setValue(value, forHTTPHeaderField: name)
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
if CraftNativeMedia.join(address, { complete(requestToken, $0, completion) }) { return }
|
|
679
|
+
let task = fetchSession.downloadTask(with: request) { location, response, error in
|
|
680
|
+
cancellationLock.lock()
|
|
681
|
+
fetchTasks.removeValue(forKey: requestToken)
|
|
682
|
+
cancellationLock.unlock()
|
|
683
|
+
if let error = error {
|
|
684
|
+
let code = (error as? URLError)?.code == .timedOut ? "TIMEOUT" : "NETWORK_ERROR"
|
|
685
|
+
CraftNativeMedia.finish(address, failure(code, error.localizedDescription))
|
|
686
|
+
return
|
|
687
|
+
}
|
|
688
|
+
guard let http = response as? HTTPURLResponse, let location else {
|
|
689
|
+
CraftNativeMedia.finish(address, failure("NETWORK_ERROR", "media.keep received a response that is not HTTP"))
|
|
690
|
+
return
|
|
691
|
+
}
|
|
692
|
+
// The system deletes `location` once this returns: move it now.
|
|
693
|
+
CraftNativeMedia.finish(address, CraftNativeMedia.store(address, at: location, response: http))
|
|
694
|
+
}
|
|
695
|
+
cancellationLock.lock()
|
|
696
|
+
let alreadyCancelled = cancelledRequests.contains(requestToken)
|
|
697
|
+
if !alreadyCancelled { fetchTasks[requestToken] = task }
|
|
698
|
+
cancellationLock.unlock()
|
|
699
|
+
if alreadyCancelled { CraftNativeMedia.finish(address, failure("CANCELLED", "media.keep was cancelled")) }
|
|
700
|
+
else { task.resume() }
|
|
701
|
+
}
|
|
702
|
+
|
|
603
703
|
private static func claimInitialDeepLink() -> URL? {
|
|
604
704
|
cancellationLock.lock()
|
|
605
705
|
defer { cancellationLock.unlock() }
|
|
@@ -906,6 +1006,30 @@ enum CraftNativeActions {
|
|
|
906
1006
|
}
|
|
907
1007
|
}
|
|
908
1008
|
|
|
1009
|
+
/// Set the app icon badge without requesting notification authorization.
|
|
1010
|
+
/// Native screens use this same permission-free path as the page bridge.
|
|
1011
|
+
private static func setBadgeCount(
|
|
1012
|
+
_ count: Int,
|
|
1013
|
+
completion: @escaping (Result<Any, CraftNativeActionError>) -> Void
|
|
1014
|
+
) {
|
|
1015
|
+
DispatchQueue.main.async {
|
|
1016
|
+
if #available(iOS 16.0, *) {
|
|
1017
|
+
UNUserNotificationCenter.current().setBadgeCount(count) { error in
|
|
1018
|
+
DispatchQueue.main.async {
|
|
1019
|
+
if let error {
|
|
1020
|
+
completion(.failure(CraftNativeActionError(code: "NATIVE_CALL_FAILED", message: error.localizedDescription)))
|
|
1021
|
+
} else {
|
|
1022
|
+
completion(.success(true))
|
|
1023
|
+
}
|
|
1024
|
+
}
|
|
1025
|
+
}
|
|
1026
|
+
} else {
|
|
1027
|
+
UIApplication.shared.applicationIconBadgeNumber = count
|
|
1028
|
+
completion(.success(true))
|
|
1029
|
+
}
|
|
1030
|
+
}
|
|
1031
|
+
}
|
|
1032
|
+
|
|
909
1033
|
static func deepLinkData(_ url: URL) -> [String: Any] {
|
|
910
1034
|
let components = URLComponents(url: url, resolvingAgainstBaseURL: false)
|
|
911
1035
|
return [
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import CryptoKit
|
|
1
2
|
import Foundation
|
|
2
3
|
import ImageIO
|
|
3
4
|
import PhotosUI
|
|
@@ -375,3 +376,109 @@ enum CraftNativeFiles {
|
|
|
375
376
|
return ["ok": false, "status": response.statusCode, "body": text]
|
|
376
377
|
}
|
|
377
378
|
}
|
|
379
|
+
|
|
380
|
+
// `craft.media` on a native screen: video and audio kept on the phone for
|
|
381
|
+
// when there is no signal (a gym's basement, a plane). Not tmp/craft-files,
|
|
382
|
+
// which iOS and the sweep above both empty: Application Support/craft-media,
|
|
383
|
+
// left out of iCloud backups because every file can be fetched again. A file
|
|
384
|
+
// is named by its address's SHA-256, so the native `Video` element finds the
|
|
385
|
+
// copy of the URL it was given and plays it instead, with no lookup in
|
|
386
|
+
// JavaScript. The screen decides what to keep (`keep`) and what to drop
|
|
387
|
+
// (`prune`); nothing is evicted behind its back.
|
|
388
|
+
enum CraftNativeMedia {
|
|
389
|
+
static var root: URL {
|
|
390
|
+
let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first
|
|
391
|
+
?? FileManager.default.temporaryDirectory
|
|
392
|
+
return base.appendingPathComponent("craft-media", isDirectory: true)
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
private static let lock = NSLock()
|
|
396
|
+
/// Addresses being fetched now, so a second `keep` waits for the first.
|
|
397
|
+
private static var inFlight: [String: [(Result<Any, CraftNativeActionError>) -> Void]] = [:]
|
|
398
|
+
|
|
399
|
+
/// Where `address`'s copy lives (whether or not it is there yet). The
|
|
400
|
+
/// extension is the address's, because AVPlayer reads the type from it.
|
|
401
|
+
static func file(for address: String) -> URL {
|
|
402
|
+
let digest = SHA256.hash(data: Data(address.utf8)).map { String(format: "%02x", $0) }.joined()
|
|
403
|
+
let ext = URL(string: address)?.pathExtension.lowercased() ?? ""
|
|
404
|
+
let safe = ext.range(of: "^[a-z0-9]{1,5}$", options: .regularExpression) != nil ? ext : "bin"
|
|
405
|
+
return root.appendingPathComponent("\(digest).\(safe)")
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/// The copy of `address` on this phone, or nil.
|
|
409
|
+
static func local(_ address: String) -> URL? {
|
|
410
|
+
guard !address.isEmpty else { return nil }
|
|
411
|
+
let url = file(for: address)
|
|
412
|
+
return FileManager.default.fileExists(atPath: url.path) ? url : nil
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
static func describe(_ address: String, _ url: URL) -> [String: Any] {
|
|
416
|
+
let size = (try? url.resourceValues(forKeys: [.fileSizeKey]).fileSize) ?? 0
|
|
417
|
+
return ["ok": true, "url": address, "path": url.path, "size": size]
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/// Joins a fetch of `address` already under way; true when one was.
|
|
421
|
+
static func join(_ address: String, _ done: @escaping (Result<Any, CraftNativeActionError>) -> Void) -> Bool {
|
|
422
|
+
lock.lock()
|
|
423
|
+
defer { lock.unlock() }
|
|
424
|
+
if inFlight[address] != nil {
|
|
425
|
+
inFlight[address]!.append(done)
|
|
426
|
+
return true
|
|
427
|
+
}
|
|
428
|
+
inFlight[address] = [done]
|
|
429
|
+
return false
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
static func finish(_ address: String, _ result: Result<Any, CraftNativeActionError>) {
|
|
433
|
+
lock.lock()
|
|
434
|
+
let waiting = inFlight.removeValue(forKey: address) ?? []
|
|
435
|
+
lock.unlock()
|
|
436
|
+
for done in waiting { done(result) }
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/// A finished download moved into place (atomically, so a half-written
|
|
440
|
+
/// file is never played), or a refusal with the server's status.
|
|
441
|
+
static func store(_ address: String, at location: URL, response: HTTPURLResponse) -> Result<Any, CraftNativeActionError> {
|
|
442
|
+
guard (200..<300).contains(response.statusCode) else {
|
|
443
|
+
return .success(["ok": false, "url": address, "status": response.statusCode])
|
|
444
|
+
}
|
|
445
|
+
let fm = FileManager.default
|
|
446
|
+
do {
|
|
447
|
+
var folder = root
|
|
448
|
+
if !fm.fileExists(atPath: folder.path) {
|
|
449
|
+
try fm.createDirectory(at: folder, withIntermediateDirectories: true)
|
|
450
|
+
var values = URLResourceValues()
|
|
451
|
+
values.isExcludedFromBackup = true
|
|
452
|
+
try? folder.setResourceValues(values)
|
|
453
|
+
}
|
|
454
|
+
let target = file(for: address)
|
|
455
|
+
let staging = root.appendingPathComponent(UUID().uuidString + ".part")
|
|
456
|
+
try fm.moveItem(at: location, to: staging)
|
|
457
|
+
_ = try? fm.removeItem(at: target)
|
|
458
|
+
try fm.moveItem(at: staging, to: target)
|
|
459
|
+
return .success(describe(address, target))
|
|
460
|
+
}
|
|
461
|
+
catch {
|
|
462
|
+
return .failure(CraftNativeActionError(code: "IO_ERROR", message: "media.keep could not store the file: \(error.localizedDescription)"))
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
/// Removes every copy whose address is not in `keep`; answers how many.
|
|
467
|
+
static func prune(keep: [String]) -> Int {
|
|
468
|
+
let wanted = Set(keep.map { file(for: $0).lastPathComponent })
|
|
469
|
+
let files = (try? FileManager.default.contentsOfDirectory(at: root, includingPropertiesForKeys: nil)) ?? []
|
|
470
|
+
var removed = 0
|
|
471
|
+
for url in files where !wanted.contains(url.lastPathComponent) {
|
|
472
|
+
if (try? FileManager.default.removeItem(at: url)) != nil { removed += 1 }
|
|
473
|
+
}
|
|
474
|
+
return removed
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
/// What the kept copies come to, in bytes, and how many there are.
|
|
478
|
+
static func usage() -> [String: Any] {
|
|
479
|
+
let files = (try? FileManager.default.contentsOfDirectory(at: root, includingPropertiesForKeys: [.fileSizeKey])) ?? []
|
|
480
|
+
let kept = files.filter { $0.pathExtension != "part" }
|
|
481
|
+
let bytes = kept.reduce(0) { $0 + ((try? $1.resourceValues(forKeys: [.fileSizeKey]).fileSize) ?? 0) }
|
|
482
|
+
return ["files": kept.count, "bytes": bytes]
|
|
483
|
+
}
|
|
484
|
+
}
|
|
@@ -341,8 +341,19 @@ private final class CraftNativeVideoView: UIView {
|
|
|
341
341
|
detachPlayer()
|
|
342
342
|
sourceURI = uri
|
|
343
343
|
loops = false
|
|
344
|
-
|
|
344
|
+
// The copy `craft.media.keep` put on the phone plays in place of
|
|
345
|
+
// the address, so a kept video plays with no signal.
|
|
346
|
+
let kept = CraftNativeMedia.local(uri)
|
|
347
|
+
guard let url = kept ?? URL(string: uri), !uri.isEmpty, kept != nil || url.scheme != nil else {
|
|
345
348
|
playerController.player = nil
|
|
349
|
+
// A path with no scheme ("/videos/a.mp4") has nowhere to load
|
|
350
|
+
// from on a native screen. Say so rather than stay black.
|
|
351
|
+
if !uri.isEmpty {
|
|
352
|
+
DispatchQueue.main.async { [weak self] in
|
|
353
|
+
guard let self, self.sourceURI == uri else { return }
|
|
354
|
+
self.fire("onError", ["message": "The video address is not a full URL: \(uri)"])
|
|
355
|
+
}
|
|
356
|
+
}
|
|
346
357
|
return
|
|
347
358
|
}
|
|
348
359
|
let next = AVPlayer(url: url)
|
|
@@ -1110,6 +1121,10 @@ private class CraftNativeFlowView: UIStackView {
|
|
|
1110
1121
|
}
|
|
1111
1122
|
if start < items.count { lines.append(start..<items.count) }
|
|
1112
1123
|
}
|
|
1124
|
+
// Reconciliation reverses the child order for the requested main
|
|
1125
|
+
// axis. A wrapped flex container must keep its lines in cross-axis
|
|
1126
|
+
// order, so restore the groups without changing each line's order.
|
|
1127
|
+
if reverse && wrap && lines.count > 1 { lines.reverse() }
|
|
1113
1128
|
|
|
1114
1129
|
var lineMains: [CGFloat] = []
|
|
1115
1130
|
var lineCrosses: [CGFloat] = []
|
|
@@ -1178,6 +1193,7 @@ private class CraftNativeFlowView: UIStackView {
|
|
|
1178
1193
|
case "flex-end": 2
|
|
1179
1194
|
case "space-between": 3
|
|
1180
1195
|
case "space-around": 4
|
|
1196
|
+
case "space-evenly": 6
|
|
1181
1197
|
case "stretch": 5
|
|
1182
1198
|
default: 0
|
|
1183
1199
|
}
|
|
@@ -1374,6 +1390,9 @@ private class CraftNativeFlowView: UIStackView {
|
|
|
1374
1390
|
case "space-around":
|
|
1375
1391
|
rowLeading = gridFree / CGFloat(rowHeights.count * 2)
|
|
1376
1392
|
rowBetween += gridFree / CGFloat(rowHeights.count)
|
|
1393
|
+
case "space-evenly":
|
|
1394
|
+
rowLeading = gridFree / CGFloat(rowHeights.count + 1)
|
|
1395
|
+
rowBetween += gridFree / CGFloat(rowHeights.count + 1)
|
|
1377
1396
|
case "stretch": rowExtra = gridFree / CGFloat(max(1, rowHeights.count))
|
|
1378
1397
|
default: break
|
|
1379
1398
|
}
|
|
@@ -1918,7 +1937,9 @@ private final class CraftNativeSvgView: UIView {
|
|
|
1918
1937
|
if let fill = style["fill"] as? String ?? props["fill"] as? String { next.fill = fill }
|
|
1919
1938
|
if let stroke = style["stroke"] as? String ?? props["stroke"] as? String { next.stroke = stroke }
|
|
1920
1939
|
if let width = (style["strokeWidth"] as? NSNumber ?? props["strokeWidth"] as? NSNumber) { next.strokeWidth = CGFloat(truncating: width) }
|
|
1921
|
-
|
|
1940
|
+
// Computed style wins over the component prop, matching Android
|
|
1941
|
+
// and the browser's cascade when both provide opacity.
|
|
1942
|
+
if let opacity = style["opacity"] as? NSNumber ?? props["opacity"] as? NSNumber { next.opacity *= CGFloat(truncating: opacity) }
|
|
1922
1943
|
if let cap = props["strokeLinecap"] as? String { next.linecap = cap }
|
|
1923
1944
|
if let join = props["strokeLinejoin"] as? String { next.linejoin = join }
|
|
1924
1945
|
if let dashes = props["strokeDasharray"] as? [Any] { next.dashes = dashes.compactMap { ($0 as? NSNumber).map { CGFloat(truncating: $0) } } }
|
|
@@ -2705,7 +2726,8 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
2705
2726
|
/// - `craft.secureStorage.getSync(key)`: the Keychain item the web page's
|
|
2706
2727
|
/// `craft.secureStorage.set` wrote.
|
|
2707
2728
|
/// - `craft.navigation.setOptions({ title, largeTitle, hidden, backTitle,
|
|
2708
|
-
/// rightButtons })`; a right button's
|
|
2729
|
+
/// tabBarHidden, contentSwipeBack, tabSwipeEnabled, rightButtons })`; a right button's
|
|
2730
|
+
/// tap is the event `navButton`.
|
|
2709
2731
|
///
|
|
2710
2732
|
/// A runtime that assigns `craft.storage` (or the others) afterwards keeps
|
|
2711
2733
|
/// these: each namespace is a property whose setter adds them back to the
|
|
@@ -2949,6 +2971,13 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
2949
2971
|
private var navigationBarHidden: Bool?
|
|
2950
2972
|
/// The app's tab bar away while this screen shows (`tabBarHidden`).
|
|
2951
2973
|
private var tabBarHidden = false
|
|
2974
|
+
/// Whether iOS 26's content-wide back-swipe may begin on this screen.
|
|
2975
|
+
/// Setting `contentSwipeBack: false` leaves the 32-point edge gesture.
|
|
2976
|
+
private(set) var contentSwipeBack = true
|
|
2977
|
+
/// Whether the shell's horizontal tab switch gesture may begin here.
|
|
2978
|
+
/// This is separate so a screen can keep navigation back while owning its
|
|
2979
|
+
/// own horizontal pager or chart gesture.
|
|
2980
|
+
private(set) var tabSwipeEnabled = true
|
|
2952
2981
|
private var prefersLargeTitle: Bool?
|
|
2953
2982
|
|
|
2954
2983
|
/// Whether this screen asked for a bar of its own through `setOptions`: a
|
|
@@ -2984,6 +3013,12 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
2984
3013
|
CraftChrome.shared.setHiddenByScreen(hidden)
|
|
2985
3014
|
}
|
|
2986
3015
|
}
|
|
3016
|
+
if let contentSwipeBack = options["contentSwipeBack"] as? Bool {
|
|
3017
|
+
self.contentSwipeBack = contentSwipeBack
|
|
3018
|
+
}
|
|
3019
|
+
if let tabSwipeEnabled = options["tabSwipeEnabled"] as? Bool {
|
|
3020
|
+
self.tabSwipeEnabled = tabSwipeEnabled
|
|
3021
|
+
}
|
|
2987
3022
|
if let hidden = options["hidden"] as? Bool {
|
|
2988
3023
|
navigationBarHidden = hidden
|
|
2989
3024
|
if navigationController?.topViewController === self {
|