craft-native 0.0.219 → 0.0.221

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 CHANGED
@@ -3424,7 +3424,7 @@ function pinCraftNativeDependency(packagePath, craftVersion) {
3424
3424
  `);
3425
3425
  }
3426
3426
  // package.json
3427
- var version = "0.0.219";
3427
+ var version = "0.0.221";
3428
3428
 
3429
3429
  // bin/cli.ts
3430
3430
  var spawnedFrom = process6.env[CRAFT_CLI_SPAWN_MARKER];
package/dist/index.cjs CHANGED
@@ -309,7 +309,7 @@ function craftBinaryNotFoundMessage(triedPath) {
309
309
  `);
310
310
  }
311
311
  // package.json
312
- var version = "0.0.219";
312
+ var version = "0.0.221";
313
313
 
314
314
  // src/sdk-version.ts
315
315
  var SDK_VERSION = version;
package/dist/index.js CHANGED
@@ -57,7 +57,7 @@ function craftBinaryNotFoundMessage(triedPath) {
57
57
  `);
58
58
  }
59
59
  // package.json
60
- var version = "0.0.219";
60
+ var version = "0.0.221";
61
61
 
62
62
  // src/sdk-version.ts
63
63
  var SDK_VERSION = version;
@@ -137,7 +137,12 @@ function serializeSimulatorSteps(steps) {
137
137
  arg = step.state;
138
138
  else if ("direction" in step)
139
139
  arg = step.direction;
140
- else if ("x" in step) {
140
+ else if ("fromX" in step) {
141
+ const coordinates = [step.fromX, step.fromY, step.toX, step.toY];
142
+ if (coordinates.some((value) => number(value) > 1))
143
+ throw new Error("drag coordinates must be between 0 and 1");
144
+ arg = coordinates.join(" ");
145
+ } else if ("x" in step) {
141
146
  if (number(step.x) > 1 || number(step.y) > 1)
142
147
  throw new Error("tapxy coordinates must be between 0 and 1");
143
148
  arg = `${step.x} ${step.y}`;
@@ -27,6 +27,14 @@ export type SimulatorStep = {
27
27
  command: 'swipe';
28
28
  direction: 'up' | 'down' | 'left' | 'right';
29
29
  }
30
+ /** A slow drag in app-relative coordinates, held before release to avoid a flick. */
31
+ | {
32
+ command: 'drag';
33
+ fromX: number;
34
+ fromY: number;
35
+ toX: number;
36
+ toY: number;
37
+ }
30
38
  /** The system back-swipe: a drag in from the left edge (or the right). */
31
39
  | {
32
40
  command: 'edge';
@@ -691,12 +691,18 @@ final class CraftChrome: ObservableObject {
691
691
  }
692
692
  /// A destination beside the tab pill, outside the tab stacks and swipe order.
693
693
  func updateTabAccessory(_ item: [String: Any]?) {
694
- guard let item, let path = item["path"] as? String,
695
- path.hasPrefix("/"), !path.hasPrefix("//"),
696
- let title = item["title"] as? String else { tabAccessory = nil; return }
697
- tabAccessory = Tab(id: path, title: title, symbol: item["symbol"] as? String ?? "message",
698
- badge: (item["badge"] as? String).flatMap { $0.isEmpty ? nil : String($0.prefix(8)) },
699
- accessibilityLabel: item["accessibilityLabel"] as? String)
694
+ let next: Tab?
695
+ if let item, let path = item["path"] as? String,
696
+ path.hasPrefix("/"), !path.hasPrefix("//"),
697
+ let title = item["title"] as? String {
698
+ next = Tab(id: path, title: title, symbol: item["symbol"] as? String ?? "message",
699
+ badge: (item["badge"] as? String).flatMap { $0.isEmpty ? nil : String($0.prefix(8)) },
700
+ accessibilityLabel: item["accessibilityLabel"] as? String)
701
+ } else {
702
+ next = nil
703
+ }
704
+ guard tabAccessory != next else { return }
705
+ tabAccessory = next
700
706
  }
701
707
 
702
708
  func setHiddenByScreen(_ hidden: Bool) {
@@ -936,6 +942,7 @@ struct CraftTabBarView: View {
936
942
  .foregroundStyle(Color.primary)
937
943
  .frame(width: 62, height: 62)
938
944
  }
945
+ .contentShape(Rectangle())
939
946
  .overlay(alignment: .topTrailing) {
940
947
  if let badge = accessory.badge {
941
948
  Text(badge)
@@ -1098,23 +1105,58 @@ struct CraftSplashView: View {
1098
1105
  }
1099
1106
 
1100
1107
  // MARK: - Deep Link Manager
1108
+ /// Launch intent settles when UIKit delivers its URL or completes activation,
1109
+ /// independently of when a page or native screen registers its listeners.
1110
+ final class CraftInitialLinkState {
1111
+ private(set) var url: URL?
1112
+ private(set) var settled = false
1113
+ private var readers: [(URL?) -> Void] = []
1114
+
1115
+ func receive(_ url: URL) {
1116
+ guard !settled else { return }
1117
+ self.url = url
1118
+ settle()
1119
+ }
1120
+
1121
+ func settle() {
1122
+ guard !settled else { return }
1123
+ settled = true
1124
+ let waiting = readers
1125
+ readers.removeAll()
1126
+ for reader in waiting { reader(url) }
1127
+ }
1128
+
1129
+ func resolve(_ completion: @escaping (URL?) -> Void) {
1130
+ if settled { completion(url) }
1131
+ else { readers.append(completion) }
1132
+ }
1133
+ }
1134
+
1101
1135
  class DeepLinkManager {
1102
1136
  static let shared = DeepLinkManager()
1103
1137
 
1104
- private var initialURL: URL?
1138
+ private let launch = CraftInitialLinkState()
1139
+ private var initialURL: URL? { launch.url }
1140
+ private var activationObservers: [NSObjectProtocol] = []
1105
1141
  // Every link that arrived while no page could receive it, in order. This
1106
1142
  // was a single slot, so a second link before the bridge was ready replaced
1107
1143
  // the first; on a cold start, that could be the link that launched the app.
1108
1144
  private var pendingURLs: [URL] = []
1109
1145
  private weak var webView: WKWebView?
1110
1146
  private var isReady = false
1111
- // Whether a page has ever become ready. A link that arrives before that is
1112
- // the one the app was opened with; a link that arrives later is not, and
1113
- // is no answer to getInitialURL.
1147
+ // Replay bookkeeping only. A native screen can become ready before UIKit
1148
+ // delivers the launch URL; listener readiness cannot classify that URL.
1114
1149
  private var hasBeenReady = false
1115
1150
  private var nativeListeners: [UUID: (URL, Bool) -> Void] = [:]
1116
1151
 
1117
- private init() {}
1152
+ private init() {
1153
+ for name in [UIApplication.didBecomeActiveNotification, UIApplication.didEnterBackgroundNotification] {
1154
+ activationObservers.append(NotificationCenter.default.addObserver(forName: name, object: nil, queue: .main) { [weak self] _ in
1155
+ self?.launch.settle()
1156
+ })
1157
+ }
1158
+ if UIApplication.shared.applicationState == .active { launch.settle() }
1159
+ }
1118
1160
 
1119
1161
  func setWebView(_ webView: WKWebView) {
1120
1162
  self.webView = webView
@@ -1139,9 +1181,7 @@ class DeepLinkManager {
1139
1181
  }
1140
1182
 
1141
1183
  func handleURL(_ url: URL) {
1142
- if initialURL == nil && !hasBeenReady {
1143
- initialURL = url
1144
- }
1184
+ launch.receive(url)
1145
1185
 
1146
1186
  // A hybrid app opens a native path's screen itself, and tells the
1147
1187
  // native screens that listen; every other link is the page's. An
@@ -1172,6 +1212,17 @@ class DeepLinkManager {
1172
1212
  return initialURL
1173
1213
  }
1174
1214
 
1215
+ /// A foreground scene can draw before SwiftUI delivers its launch activity.
1216
+ /// A headless background launch has no scene or foreground URL to await.
1217
+ func resolveInitialURL(_ completion: @escaping (URL?) -> Void) {
1218
+ let application = UIApplication.shared
1219
+ if application.applicationState == .active ||
1220
+ (application.applicationState == .background && application.connectedScenes.isEmpty) {
1221
+ launch.settle()
1222
+ }
1223
+ launch.resolve(completion)
1224
+ }
1225
+
1175
1226
  @discardableResult
1176
1227
  func addNativeListener(_ listener: @escaping (URL, Bool) -> Void) -> UUID {
1177
1228
  let token = UUID()
@@ -1243,6 +1243,7 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
1243
1243
  target.view.isHidden = true
1244
1244
  target.view.frame = self.view.bounds
1245
1245
  current.view.frame = self.view.bounds
1246
+ (current.topViewController as? CraftNativeScreenController)?.screenVisibilityChanged(true)
1246
1247
  if target.topViewController is CraftHybridWebSlot,
1247
1248
  current.topViewController is CraftNativeScreenController {
1248
1249
  self.parkWeb()
@@ -1272,6 +1273,7 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
1272
1273
  recognizer.isEnabled = true
1273
1274
  }
1274
1275
  CraftChrome.shared.cancelTabSwipe(restoringSelection: restoringSelection)
1276
+ (visibleStack?.topViewController as? CraftNativeScreenController)?.screenVisibilityChanged(true)
1275
1277
  if visibleStack?.topViewController is CraftNativeScreenController { parkWeb() }
1276
1278
  }
1277
1279
 
@@ -258,7 +258,10 @@ enum CraftNativeActions {
258
258
  sync = .success(currentAppState())
259
259
  case ("DeepLinks", "getInitialURL"):
260
260
  if config.enableDeepLinks {
261
- sync = .success(claimInitialDeepLink().map(deepLinkData) ?? NSNull())
261
+ DeepLinkManager.shared.resolveInitialURL { url in
262
+ complete(requestToken, .success(claimInitialDeepLink(url).map(deepLinkData) ?? NSNull()), completion)
263
+ }
264
+ return
262
265
  } else {
263
266
  sync = failure("CAPABILITY_DISABLED", "Deep links are disabled")
264
267
  }
@@ -809,12 +812,12 @@ enum CraftNativeActions {
809
812
  else { task.resume() }
810
813
  }
811
814
 
812
- private static func claimInitialDeepLink() -> URL? {
815
+ private static func claimInitialDeepLink(_ url: URL?) -> URL? {
813
816
  cancellationLock.lock()
814
817
  defer { cancellationLock.unlock() }
815
818
  guard !initialDeepLinkClaimed else { return nil }
816
819
  initialDeepLinkClaimed = true
817
- return DeepLinkManager.shared.getInitialURL()
820
+ return url
818
821
  }
819
822
 
820
823
  private static func complete(
@@ -4128,8 +4128,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
4128
4128
  applyNavigationBarAppearance()
4129
4129
  // Each screen says whether it wants the tab bar, so one that hid it
4130
4130
  // never leaves it hidden for the next.
4131
- CraftChrome.shared.setHiddenByScreen(tabBarHidden)
4132
- CraftChrome.shared.updateTabAccessory(tabAccessory)
4131
+ restoreShellChrome()
4133
4132
  refreshTraitDefaults()
4134
4133
  }
4135
4134
 
@@ -4176,13 +4175,24 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
4176
4175
  }
4177
4176
  }
4178
4177
 
4178
+ private func restoreShellChrome() {
4179
+ guard !isAppScript else { return }
4180
+ CraftChrome.shared.setHiddenByScreen(tabBarHidden)
4181
+ CraftChrome.shared.updateTabAccessory(tabAccessory)
4182
+ }
4183
+
4179
4184
  /// The screen came on screen or went off it: a screen pushed over it was
4180
4185
  /// popped, or the hybrid shell switched to or away from its tab (which
4181
4186
  /// hides a stack rather than moving it, so UIKit says nothing). The first
4182
4187
  /// appearance is the screen's mount, not news; every later one is
4183
4188
  /// `SCREEN_FOCUS { focused: true }`, and going away is `focused: false`.
4184
4189
  func screenVisibilityChanged(_ visible: Bool) {
4185
- guard !runtimeDisposed, visible != reportedVisible else { return }
4190
+ guard !runtimeDisposed else { return }
4191
+ // Hidden tab stacks receive no UIKit appearance callback. A cancelled
4192
+ // preview also restores a screen that never lost focus, so restore its
4193
+ // chrome even when no new SCREEN_FOCUS event is needed.
4194
+ if visible { restoreShellChrome() }
4195
+ guard visible != reportedVisible else { return }
4186
4196
  reportedVisible = visible
4187
4197
  if visible, !hasAppeared {
4188
4198
  hasAppeared = true
@@ -37,6 +37,7 @@ import XCTest
37
37
  /// tab <name> tap a tab bar button
38
38
  /// scrollto <text> swipes up (at most 8 times) until text is hittable
39
39
  /// swipe up|down|left|right
40
+ /// drag x1 y1 x2 y2 slow app-relative drag, held 0.3s before release
40
41
  /// audit <name> accessibility audit (element descriptions, Dynamic Type, contrast); logs each issue
41
42
  /// unlabelled logs every hittable button without a label (FAIL if any)
42
43
  /// dismiss [label] a system prompt's button (default Not Now: "Save Password?")
@@ -367,6 +368,18 @@ final class DriverTests: XCTestCase {
367
368
  case "left": app.swipeLeft()
368
369
  default: app.swipeRight()
369
370
  }
371
+ case "drag":
372
+ let parts = arg.split(separator: " ")
373
+ let coordinates = parts.compactMap { Double($0) }
374
+ guard parts.count == 4, coordinates.count == 4,
375
+ coordinates.allSatisfy({ $0.isFinite && (0...1).contains($0) }) else {
376
+ check(false, "drag needs four coordinates between 0 and 1")
377
+ return
378
+ }
379
+ let start = app.coordinate(withNormalizedOffset: CGVector(dx: CGFloat(coordinates[0]), dy: CGFloat(coordinates[1])))
380
+ let end = app.coordinate(withNormalizedOffset: CGVector(dx: CGFloat(coordinates[2]), dy: CGFloat(coordinates[3])))
381
+ start.press(forDuration: 0.05, thenDragTo: end, withVelocity: .slow, thenHoldForDuration: 0.3)
382
+ note("drag \(arg): ok")
370
383
  case "edge":
371
384
  // The system back-swipe: a drag in from the left edge (or the
372
385
  // right one with `edge right`), which a centred swipe never is.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "craft-native",
3
- "version": "0.0.219",
3
+ "version": "0.0.221",
4
4
  "type": "module",
5
5
  "description": "Build desktop apps with web languages - TypeScript SDK for Craft",
6
6
  "author": "Chris Breuer <chris@stacksjs.org>",