@craft-native/ios 0.0.155 → 0.0.156

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.
@@ -367,7 +367,12 @@ struct CraftNativeScreen: UIViewControllerRepresentable {
367
367
  let config: CraftConfig
368
368
 
369
369
  func makeUIViewController(context: Context) -> UINavigationController {
370
- UINavigationController(rootViewController: CraftNativeScreenController(config: config))
370
+ let screen = CraftNativeScreenController(config: config)
371
+ // The shell hears the first frame (the app script starts after it)
372
+ // and `craft.navigation.open`, which has no paths to open here but
373
+ // web addresses, in the in-app browser.
374
+ screen.hybridEvents = CraftHybrid.shared
375
+ return UINavigationController(rootViewController: screen)
371
376
  }
372
377
 
373
378
  func updateUIViewController(_ controller: UINavigationController, context: Context) {}
@@ -2086,11 +2091,17 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
2086
2091
  weak var hybridEvents: CraftHybridScreenEvents?
2087
2092
  private var hybridRendered = false
2088
2093
  private var runtimeDisposed = false
2094
+ /// The app script (`nativeApp`, CraftHybrid.swift): a bundle route with
2095
+ /// no screen, loaded once and never shown, that runs for the app's life.
2096
+ /// It hears every `APP_STATE` and the app's events (`CRAFT_EVENT`), no
2097
+ /// `DEEP_LINK`; it can open paths but not push, pop or style a bar.
2098
+ let isAppScript: Bool
2089
2099
 
2090
- init(config: CraftConfig, routeName: String? = nil, routeParams: [String: Any] = [:]) {
2100
+ init(config: CraftConfig, routeName: String? = nil, routeParams: [String: Any] = [:], isAppScript: Bool = false) {
2091
2101
  self.config = config
2092
2102
  self.routeName = routeName
2093
2103
  self.routeParams = routeParams
2104
+ self.isAppScript = isAppScript
2094
2105
  super.init(nibName: nil, bundle: nil)
2095
2106
  }
2096
2107
 
@@ -2127,6 +2138,13 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
2127
2138
  setupJavaScript()
2128
2139
  loadBundle()
2129
2140
  observeNativeEvents()
2141
+ if isAppScript {
2142
+ // Its bundle has run, so its listeners are in place: the app's
2143
+ // events that waited for it arrive now, in order.
2144
+ CraftEventManager.shared.appScriptReady { [weak self] event, detail in
2145
+ self?.send(type: "CRAFT_EVENT", payload: ["event": event, "detail": detail])
2146
+ }
2147
+ }
2130
2148
  }
2131
2149
 
2132
2150
  override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
@@ -2280,6 +2298,13 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
2280
2298
  let capabilities = CraftNativeActions.capabilities(config: config)
2281
2299
  let capabilityData = try? JSONSerialization.data(withJSONObject: capabilities)
2282
2300
  let capabilityJSON = capabilityData.flatMap { String(data: $0, encoding: .utf8) } ?? "[]"
2301
+ // The app's site and build, as JSON strings: the site an all-native
2302
+ // app's paths and links belong to (`devServerURL`), and the version a
2303
+ // request or a crash report names.
2304
+ let info = Bundle.main.infoDictionary
2305
+ let appURL = Self.jsonString(config.devServerURL ?? "")
2306
+ let appVersion = Self.jsonString(info?["CFBundleShortVersionString"] as? String ?? "")
2307
+ let appBuild = Self.jsonString(info?["CFBundleVersion"] as? String ?? "")
2283
2308
  jsContext.evaluateScript("""
2284
2309
  globalThis.__stxNativeCallback = null;
2285
2310
  globalThis.__stxNativeBridge = {
@@ -2290,6 +2315,9 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
2290
2315
  capabilities: \(capabilityJSON),
2291
2316
  initialAppState: "\(CraftNativeActions.currentAppState())",
2292
2317
  colorScheme: "\(colorScheme)",
2318
+ appURL: \(appURL),
2319
+ appVersion: \(appVersion),
2320
+ appBuild: \(appBuild),
2293
2321
  postMessage: function(message) { craftNativePostMessage(message); },
2294
2322
  onMessage: function(callback) { globalThis.__stxNativeCallback = callback; }
2295
2323
  };
@@ -2350,6 +2378,13 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
2350
2378
  """)
2351
2379
  }
2352
2380
 
2381
+ /// `value` as a JavaScript string literal (JSON), quotes and all.
2382
+ static func jsonString(_ value: String) -> String {
2383
+ guard let data = try? JSONSerialization.data(withJSONObject: [value]),
2384
+ let array = String(data: data, encoding: .utf8), array.count >= 2 else { return "\"\"" }
2385
+ return String(array.dropFirst().dropLast())
2386
+ }
2387
+
2353
2388
  /// `"dark"` or `"light"` for the bundle's `dark:` classes (contract A4):
2354
2389
  /// the window's appearance once there is one, else the app's setting.
2355
2390
  private var colorScheme: String {
@@ -2379,6 +2414,9 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
2379
2414
  /// asynchronous `craft.storage` uses. `getSync` answers what `get` would.
2380
2415
  /// - `craft.snapshots.get(name)` (sync) and `set(name, value)` (a
2381
2416
  /// promise): `Application Support/craft-snapshots/<name>.json`.
2417
+ /// `clear(keep?)` (a promise) deletes every snapshot but the names in
2418
+ /// `keep`, as a sign-out does; each removal is a `SNAPSHOT_CHANGED`
2419
+ /// for every screen.
2382
2420
  /// - `craft.secureStorage.getSync(key)`: the Keychain item the web page's
2383
2421
  /// `craft.secureStorage.set` wrote.
2384
2422
  /// - `craft.navigation.setOptions({ title, largeTitle, hidden, backTitle,
@@ -2405,6 +2443,12 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
2405
2443
  done.call(withArguments: [ok])
2406
2444
  }
2407
2445
  }
2446
+ let snapshotClear: @convention(block) (JSValue, JSValue) -> Void = { keep, done in
2447
+ let names = (keep.isUndefined || keep.isNull ? [] : (keep.toArray() ?? [])).compactMap { $0 as? String }
2448
+ CraftSnapshots.clear(keeping: Set(names)) { ok in
2449
+ done.call(withArguments: [ok])
2450
+ }
2451
+ }
2408
2452
  let config = self.config
2409
2453
  let secureGet: @convention(block) (String) -> String? = { key in
2410
2454
  config.enableSecureStorage ? CraftNativeActions.webSecureValue(forKey: key) : nil
@@ -2419,6 +2463,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
2419
2463
  jsContext.setObject(storageSet, forKeyedSubscript: "craftNativeStorageSet" as NSString)
2420
2464
  jsContext.setObject(snapshotGet, forKeyedSubscript: "craftNativeSnapshotGet" as NSString)
2421
2465
  jsContext.setObject(snapshotSet, forKeyedSubscript: "craftNativeSnapshotSet" as NSString)
2466
+ jsContext.setObject(snapshotClear, forKeyedSubscript: "craftNativeSnapshotClear" as NSString)
2422
2467
  jsContext.setObject(secureGet, forKeyedSubscript: "craftNativeSecureGet" as NSString)
2423
2468
  jsContext.setObject(setOptions, forKeyedSubscript: "craftNativeSetOptions" as NSString)
2424
2469
  // `AudioContext` and the rest of Web Audio, rendered natively.
@@ -2568,6 +2613,16 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
2568
2613
  else reject(new Error('craft.snapshots.set could not write ' + name));
2569
2614
  });
2570
2615
  });
2616
+ },
2617
+ // Every snapshot but the names in `keep` (a sign-out).
2618
+ clear: function(keep) {
2619
+ var names = Array.isArray(keep) ? keep.map(String) : [];
2620
+ return new Promise(function(resolve, reject) {
2621
+ craftNativeSnapshotClear(names, function(ok) {
2622
+ if (ok) resolve();
2623
+ else reject(new Error('craft.snapshots.clear could not remove every snapshot'));
2624
+ });
2625
+ });
2571
2626
  }
2572
2627
  });
2573
2628
  keep('secureStorage', {
@@ -2623,6 +2678,8 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
2623
2678
  /// `craft.navigation.setOptions`: this screen's bar. Every key is
2624
2679
  /// optional and only the keys given change.
2625
2680
  private func applyNavigationOptions(_ options: [String: Any]) {
2681
+ // The app script has no screen, so no bar and no tab bar to change.
2682
+ guard !isAppScript else { return }
2626
2683
  if options.keys.contains("title") {
2627
2684
  navigationItem.title = options["title"] as? String
2628
2685
  titleFromOptions = true
@@ -2826,20 +2883,23 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
2826
2883
  observe(UIApplication.didBecomeActiveNotification, state: "active")
2827
2884
  observe(UIApplication.willResignActiveNotification, state: "inactive")
2828
2885
  observe(UIApplication.didEnterBackgroundNotification, state: "background")
2829
- lifecycleObservers.append(NotificationCenter.default.addObserver(
2830
- forName: UIResponder.keyboardWillChangeFrameNotification,
2831
- object: nil,
2832
- queue: .main
2833
- ) { [weak self] notification in
2834
- self?.applyKeyboardSafeArea(notification)
2835
- })
2836
- lifecycleObservers.append(NotificationCenter.default.addObserver(
2837
- forName: UIResponder.keyboardWillHideNotification,
2838
- object: nil,
2839
- queue: .main
2840
- ) { [weak self] notification in
2841
- self?.applyKeyboardSafeArea(notification, hidden: true)
2842
- })
2886
+ // The app script has no screen for the keyboard to cover.
2887
+ if !isAppScript {
2888
+ lifecycleObservers.append(NotificationCenter.default.addObserver(
2889
+ forName: UIResponder.keyboardWillChangeFrameNotification,
2890
+ object: nil,
2891
+ queue: .main
2892
+ ) { [weak self] notification in
2893
+ self?.applyKeyboardSafeArea(notification)
2894
+ })
2895
+ lifecycleObservers.append(NotificationCenter.default.addObserver(
2896
+ forName: UIResponder.keyboardWillHideNotification,
2897
+ object: nil,
2898
+ queue: .main
2899
+ ) { [weak self] notification in
2900
+ self?.applyKeyboardSafeArea(notification, hidden: true)
2901
+ })
2902
+ }
2843
2903
  lifecycleObservers.append(NotificationCenter.default.addObserver(
2844
2904
  forName: CraftSnapshots.changed,
2845
2905
  object: nil,
@@ -2873,6 +2933,8 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
2873
2933
  sounds.emit = { [weak self] payload in self?.send(type: "SOUND_EVENT", payload: payload) }
2874
2934
  streams.emit = { [weak self] payload in self?.send(type: "NETWORK_STREAM", payload: payload) }
2875
2935
  sendAppState(CraftNativeActions.currentAppState())
2936
+ // Links are the screens' to show; the app script hears none.
2937
+ guard !isAppScript else { return }
2876
2938
  deepLinkListener = DeepLinkManager.shared.addNativeListener { [weak self] url, initial in
2877
2939
  guard let self = self, self.navigationController?.topViewController === self else { return }
2878
2940
  var payload = CraftNativeActions.deepLinkData(url)
@@ -2952,7 +3014,9 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
2952
3014
  }
2953
3015
 
2954
3016
  private func sendAppState(_ state: String) {
2955
- guard navigationController?.topViewController === self else { return }
3017
+ // A screen hears it while it is on top of its stack; the app script,
3018
+ // which is on no stack, always.
3019
+ guard isAppScript || navigationController?.topViewController === self else { return }
2956
3020
  send(type: "APP_STATE", payload: ["state": state])
2957
3021
  }
2958
3022
 
@@ -2972,6 +3036,9 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
2972
3036
  /// is up, and are logged.
2973
3037
  private func hybridFailed(_ message: String) {
2974
3038
  guard !hybridRendered else { return }
3039
+ // The app script draws nothing for anything to stand in for: logged
3040
+ // (by the caller) and left to run on.
3041
+ guard !isAppScript else { return }
2975
3042
  hybridEvents?.nativeScreen(self, didFail: message)
2976
3043
  }
2977
3044
 
@@ -3014,7 +3081,8 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
3014
3081
  ])
3015
3082
  }
3016
3083
  case "NAVIGATE", "NAVIGATE_REPLACE":
3017
- guard navigationController?.topViewController === self,
3084
+ // A screen's own stack; the app script has none (it opens paths).
3085
+ guard !isAppScript, navigationController?.topViewController === self,
3018
3086
  let screen = payload["screen"] as? String, !screen.isEmpty,
3019
3087
  let navigation = navigationController else { return }
3020
3088
  let params = payload["params"] as? [String: Any] ?? [:]
@@ -3034,13 +3102,13 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
3034
3102
  guard let path = payload["path"] as? String, !path.isEmpty else { return }
3035
3103
  hybridEvents?.nativeScreen(self, open: path)
3036
3104
  case "NAVIGATE_BACK":
3037
- if navigationController?.topViewController === self {
3105
+ if !isAppScript, navigationController?.topViewController === self {
3038
3106
  navigationController?.popViewController(animated: true)
3039
3107
  }
3040
3108
  case "NAVIGATE_POP_TO_ROOT":
3041
3109
  // Back to the first screen of this screen's stack in one move: a
3042
3110
  // finished task (a saved workout) closes the way it was reached.
3043
- if navigationController?.topViewController === self {
3111
+ if !isAppScript, navigationController?.topViewController === self {
3044
3112
  navigationController?.popToRootViewController(animated: true)
3045
3113
  }
3046
3114
  case "API_REQUEST":
@@ -3181,7 +3249,8 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
3181
3249
  renderCommitted(document)
3182
3250
  if !hybridRendered {
3183
3251
  hybridRendered = true
3184
- hybridEvents?.nativeScreenDidRender(self)
3252
+ // The app script's drawing is never on screen: not a first frame.
3253
+ if !isAppScript { hybridEvents?.nativeScreenDidRender(self) }
3185
3254
  }
3186
3255
  }
3187
3256