@craft-native/ios 0.0.244 → 0.0.245

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/index.d.ts CHANGED
@@ -253,6 +253,14 @@ export interface CraftConfig {
253
253
  * `craft.secureStorage.getSync('auth.token')`. Turns on secure storage.
254
254
  */
255
255
  shareStorage?: Record<string, string>;
256
+ /**
257
+ * The secure-storage key the signed-in account's token lives under
258
+ * (default `auth.token`). `craft.session` follows it: a change of it, made
259
+ * by any native screen or by the page through a `shareStorage` key, is
260
+ * `SESSION_CHANGED { key, token, previous }` on every native screen and a
261
+ * `craftsession` event on the page.
262
+ */
263
+ sessionTokenKey?: string;
256
264
  /**
257
265
  * Crash and error reporting to bughq (CraftCrashReporter.swift): crashes
258
266
  * and hangs (signal handlers, NSException, MetricKit), uncaught JavaScript
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@craft-native/ios",
3
- "version": "0.0.244",
3
+ "version": "0.0.245",
4
4
  "homepage": "https://github.com/craft-native/craft/tree/main/packages/ios#readme",
5
5
  "repository": {
6
6
  "type": "git",
@@ -1619,6 +1619,10 @@ struct CraftConfig: Codable {
1619
1619
  /// Hybrid: web-storage keys the page's own code writes, mirrored into the
1620
1620
  /// Keychain under another name for native screens (`{"auth_token": "auth.token"}`).
1621
1621
  var shareStorage: [String: String]? = nil
1622
+ /// The secure-storage key the signed-in account's token lives under
1623
+ /// (`auth.token` unless set): `craft.session` follows it, and a change of
1624
+ /// it is `SESSION_CHANGED` on every screen (CraftSession).
1625
+ var sessionTokenKey: String? = nil
1622
1626
  /// The bundle route of the app script: a screen-less script that runs for
1623
1627
  /// the app's whole life and hears the app's events (CraftHybrid.swift).
1624
1628
  var nativeApp: String? = nil
@@ -488,12 +488,16 @@ final class CraftHybrid: NSObject {
488
488
  func pageScript() -> String {
489
489
  let routeList = (config.nativeScreens ?? [:]).map { ["pattern": $0.key, "screen": $0.value] }
490
490
  let share = Array((config.shareStorage ?? [:]).keys).sorted()
491
+ // The page's key for the sign-in token, when it shares one.
492
+ let tokenKey = CraftSession.tokenKey(config)
493
+ let session = (config.shareStorage ?? [:]).filter { $0.value == tokenKey }.map(\.key).sorted().first
491
494
  func json(_ value: Any) -> String {
492
495
  (try? JSONSerialization.data(withJSONObject: value)).flatMap { String(data: $0, encoding: .utf8) } ?? "[]"
493
496
  }
494
497
  return Self.pageScriptTemplate
495
498
  .replacingOccurrences(of: "__CRAFT_HYBRID_ROUTES__", with: json(routeList))
496
499
  .replacingOccurrences(of: "__CRAFT_HYBRID_SHARE__", with: json(share))
500
+ .replacingOccurrences(of: "__CRAFT_HYBRID_SESSION__", with: session.map { json([$0]) } ?? "[]")
497
501
  }
498
502
 
499
503
  // CRAFT_HYBRID_PAGE_SCRIPT_START
@@ -691,13 +695,46 @@ final class CraftHybrid: NSObject {
691
695
 
692
696
  // Web storage the config shares with native screens (`shareStorage`).
693
697
  var shared = {};
694
- function share(key) {
698
+ // The page's key for the sign-in token (`sessionTokenKey`), if shared.
699
+ var SESSION = (__CRAFT_HYBRID_SESSION__)[0] || null;
700
+ var sessionListeners = [];
701
+ function stored(key) {
695
702
  var value = null;
696
703
  try { value = window.sessionStorage.getItem(key); } catch (e) {}
697
704
  try { if (value === null) value = window.localStorage.getItem(key); } catch (e) {}
705
+ return value;
706
+ }
707
+ function sessionToken(value) { return typeof value === 'string' && value !== '' ? value : null; }
708
+ function share(key) {
709
+ var value = stored(key);
698
710
  if (shared[key] === value) return;
711
+ var known = Object.prototype.hasOwnProperty.call(shared, key);
712
+ var before = shared[key];
699
713
  shared[key] = value;
700
714
  post({ type: 'shareStorage', key: key, value: value });
715
+ // A sign-in, sign-out or another account, made here or on a native
716
+ // screen: `craftsession { key, token, previous }` and craft.session.
717
+ if (key === SESSION && known && sessionToken(before) !== sessionToken(value)) {
718
+ var change = { key: key, token: sessionToken(value), previous: sessionToken(before) };
719
+ sessionListeners.slice().forEach(function (callback) {
720
+ try { callback(change.token, change.previous); } catch (e) { console.error(e); }
721
+ });
722
+ try { window.dispatchEvent(new CustomEvent('craftsession', { detail: change })); } catch (e) {}
723
+ }
724
+ }
725
+ if (SESSION && window.craft && !window.craft.session) {
726
+ window.craft.session = {
727
+ key: SESSION,
728
+ token: function () { return sessionToken(stored(SESSION)); },
729
+ onChange: function (callback) {
730
+ if (typeof callback !== 'function') throw new TypeError('craft.session.onChange needs a function');
731
+ sessionListeners.push(callback);
732
+ return function () {
733
+ var index = sessionListeners.indexOf(callback);
734
+ if (index >= 0) sessionListeners.splice(index, 1);
735
+ };
736
+ }
737
+ };
701
738
  }
702
739
  if (SHARE.length && window.Storage) {
703
740
  var proto = window.Storage.prototype;
@@ -808,7 +845,9 @@ final class CraftHybrid: NSObject {
808
845
  guard let nativeKey = config.shareStorage?[key] else { return }
809
846
  // The page's own write, or its echo of one a native screen made.
810
847
  pendingShared.removeValue(forKey: key)
811
- CraftHybridKeychain.set(nativeKey, value: value)
848
+ // The page's sign-in or sign-out moves the account for every
849
+ // screen; an echo writes what is already there and moves nothing.
850
+ CraftSession.observe(key: nativeKey, config: config) { CraftHybridKeychain.set(nativeKey, value: value) }
812
851
  case .navigated(let path, let direction, let depth, let byHost):
813
852
  pagePath = path
814
853
  pageDepth = depth
@@ -205,11 +205,21 @@ enum CraftNativeActions {
205
205
  complete(requestToken, failure("CAPABILITY_DISABLED", "Secure storage is disabled"), completion)
206
206
  return
207
207
  }
208
- sync = secureStorage(method: method, args: args)
209
- // A key the web page shares (`shareStorage`): the page gets it too,
210
- // so a sign-in on a native screen signs the page in.
211
- if case .success = sync, ["set", "remove", "delete"].contains(method), let key = args.first as? String {
212
- CraftHybrid.shared.nativeWroteShared(nativeKey: key, value: method == "set" ? args.count > 1 ? args[1] as? String : nil : nil)
208
+ let write = { () -> Result<Any, CraftNativeActionError> in
209
+ let result = secureStorage(method: method, args: args)
210
+ // A key the web page shares (`shareStorage`): the page gets it too,
211
+ // so a sign-in on a native screen signs the page in.
212
+ if case .success = result, ["set", "remove", "delete"].contains(method), let key = args.first as? String {
213
+ CraftHybrid.shared.nativeWroteShared(nativeKey: key, value: method == "set" ? args.count > 1 ? args[1] as? String : nil : nil)
214
+ }
215
+ return result
216
+ }
217
+ // A write that moves the sign-in token is the account's change:
218
+ // every screen hears it (CraftSession). Reads skip the check.
219
+ if ["set", "remove", "delete", "clear"].contains(method) {
220
+ sync = CraftSession.observe(key: method == "clear" ? nil : args.first as? String, config: config, write)
221
+ } else {
222
+ sync = write()
213
223
  }
214
224
  case ("Biometrics", "isAvailable"):
215
225
  sync = config.enableBiometric ? .success(biometricContext().canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: nil)) : failure("CAPABILITY_DISABLED", "Biometric authentication is disabled")
@@ -1423,6 +1433,54 @@ extension CraftNativeActions {
1423
1433
  }
1424
1434
  }
1425
1435
 
1436
+ /// The signed-in account: the token kept in secure storage under
1437
+ /// `sessionTokenKey` (default `auth.token`). Whoever moves it, a native
1438
+ /// screen's `craft.secureStorage`, the page's own sign-in through a
1439
+ /// `shareStorage` key, or a sign-out's clear, posts `changed` once with
1440
+ /// `key`, `token` and `previous` (NSNull when signed out), after the Keychain
1441
+ /// holds the new value. Each native screen hands it to its script as
1442
+ /// `SESSION_CHANGED` and to `craft.session.onChange` (CraftNativeScreen).
1443
+ enum CraftSession {
1444
+ static let changed = Notification.Name("craftSessionChanged")
1445
+ static let defaultKey = "auth.token"
1446
+ /// One write and its before and after at a time, so two quick writes
1447
+ /// from different queues are two changes, never one reported twice.
1448
+ private static let lock = NSLock()
1449
+
1450
+ static func tokenKey(_ config: CraftConfig) -> String {
1451
+ guard let key = config.sessionTokenKey, !key.isEmpty else { return defaultKey }
1452
+ return key
1453
+ }
1454
+
1455
+ /// The token now, as `craft.secureStorage.getSync` reads it; nil when
1456
+ /// signed out (an empty token is no account either).
1457
+ static func token(_ config: CraftConfig) -> String? {
1458
+ guard config.enableSecureStorage,
1459
+ let value = CraftNativeActions.webSecureValue(forKey: tokenKey(config)), !value.isEmpty else { return nil }
1460
+ return value
1461
+ }
1462
+
1463
+ /// Run `write`, and post `changed` when it moved the token. `key` is the
1464
+ /// key written; nil for a clear, which can remove every key.
1465
+ static func observe<T>(key: String?, config: CraftConfig, _ write: () -> T) -> T {
1466
+ let name = tokenKey(config)
1467
+ guard key == nil || key == name else { return write() }
1468
+ lock.lock()
1469
+ let before = token(config)
1470
+ let result = write()
1471
+ let after = token(config)
1472
+ lock.unlock()
1473
+ if before != after { post(key: name, token: after, previous: before) }
1474
+ return result
1475
+ }
1476
+
1477
+ static func post(key: String, token: String?, previous: String?) {
1478
+ let info: [String: Any] = ["key": key, "token": token ?? NSNull(), "previous": previous ?? NSNull()]
1479
+ let send = { NotificationCenter.default.post(name: changed, object: nil, userInfo: info) }
1480
+ if Thread.isMainThread { send() } else { DispatchQueue.main.async(execute: send) }
1481
+ }
1482
+ }
1483
+
1426
1484
  /// Last-known data for a screen's first frame: one JSON file per name in
1427
1485
  /// `Application Support/craft-snapshots/<name>.json`. A web page writes them
1428
1486
  /// (`snapshots.set` in the mobile API) and a native screen reads them
@@ -4855,6 +4855,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
4855
4855
  let appURL = Self.jsonString(config.devServerURL ?? "")
4856
4856
  let appVersion = Self.jsonString(info?["CFBundleShortVersionString"] as? String ?? "")
4857
4857
  let appBuild = Self.jsonString(info?["CFBundleVersion"] as? String ?? "")
4858
+ let sessionTokenKey = Self.jsonString(CraftSession.tokenKey(config))
4858
4859
  jsContext.evaluateScript("""
4859
4860
  // JavaScriptCore has no DOM window, but browser-safe SDK bridges
4860
4861
  // address the host through `window.craft`. Keep native screens
@@ -4873,6 +4874,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
4873
4874
  appURL: \(appURL),
4874
4875
  appVersion: \(appVersion),
4875
4876
  appBuild: \(appBuild),
4877
+ sessionTokenKey: \(sessionTokenKey),
4876
4878
  postMessage: function(message) { craftNativePostMessage(message); },
4877
4879
  onMessage: function(callback) { globalThis.__stxNativeCallback = callback; }
4878
4880
  };
@@ -5274,6 +5276,33 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
5274
5276
  return value === undefined ? null : value;
5275
5277
  }
5276
5278
  });
5279
+ // The signed-in account (CraftSession): the token under
5280
+ // `sessionTokenKey`, and each change of it, which the host
5281
+ // reports through `__craftSessionChanged` after the Keychain
5282
+ // holds it, beside the `SESSION_CHANGED` bridge message.
5283
+ var sessionListeners = [];
5284
+ var sessionKey = globalThis.__stxNativeBridge && __stxNativeBridge.sessionTokenKey || 'auth.token';
5285
+ function sessionToken() {
5286
+ var value = craftNativeSecureGet(String(sessionKey));
5287
+ return typeof value === 'string' && value !== '' ? value : null;
5288
+ }
5289
+ globalThis.__craftSessionChanged = function(token, previous) {
5290
+ sessionListeners.slice().forEach(function(callback) {
5291
+ try { callback(token, previous); } catch (error) { console.error('[session] ' + (error && error.stack || error)); }
5292
+ });
5293
+ };
5294
+ keep('session', {
5295
+ key: sessionKey,
5296
+ token: sessionToken,
5297
+ onChange: function(callback) {
5298
+ if (typeof callback !== 'function') throw new TypeError('craft.session.onChange needs a function');
5299
+ sessionListeners.push(callback);
5300
+ return function() {
5301
+ var index = sessionListeners.indexOf(callback);
5302
+ if (index >= 0) sessionListeners.splice(index, 1);
5303
+ };
5304
+ }
5305
+ });
5277
5306
  keep('navigation', {
5278
5307
  setOptions: function(options) { craftNativeSetOptions(JSON.stringify(options || {})); }
5279
5308
  });
@@ -5739,6 +5768,22 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
5739
5768
  guard let name = notification.userInfo?["name"] as? String else { return }
5740
5769
  self?.send(type: "SNAPSHOT_CHANGED", payload: ["name": name])
5741
5770
  })
5771
+ if config.enableSecureStorage {
5772
+ // A sign-in, sign-out or another account, made on any screen or
5773
+ // the page (CraftSession): `SESSION_CHANGED { key, token,
5774
+ // previous }` for the runtime, then craft.session's listeners.
5775
+ lifecycleObservers.append(NotificationCenter.default.addObserver(
5776
+ forName: CraftSession.changed,
5777
+ object: nil,
5778
+ queue: .main
5779
+ ) { [weak self] notification in
5780
+ guard let self, let info = notification.userInfo, let key = info["key"] as? String else { return }
5781
+ let token = info["token"] as? String
5782
+ let previous = info["previous"] as? String
5783
+ self.send(type: "SESSION_CHANGED", payload: ["key": key, "token": token ?? NSNull(), "previous": previous ?? NSNull()])
5784
+ self.jsContext.objectForKeyedSubscript("__craftSessionChanged")?.call(withArguments: [token ?? NSNull(), previous ?? NSNull()])
5785
+ })
5786
+ }
5742
5787
  if config.enablePushNotifications {
5743
5788
  // A new device token (iOS hands one out after a reinstall or a
5744
5789
  // restore): every screen's `craft.push.onToken` hears it.