craft-native 0.0.173 → 0.0.174

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.
@@ -94,6 +94,9 @@ dependencies {
94
94
  // Google Play Location Services (for geolocation)
95
95
  implementation("com.google.android.gms:play-services-location:21.1.0")
96
96
 
97
+ // Native STX Map overlays and trail routes
98
+ implementation("com.google.android.gms:play-services-maps:18.2.0")
99
+
97
100
  // Google Play In-App Review
98
101
  implementation("com.google.android.play:review:2.0.1")
99
102
  implementation("com.google.android.play:review-ktx:2.0.1")
package/dist/cli.js CHANGED
@@ -3303,7 +3303,7 @@ function pinCraftNativeDependency(packagePath, craftVersion) {
3303
3303
  `);
3304
3304
  }
3305
3305
  // package.json
3306
- var version = "0.0.173";
3306
+ var version = "0.0.174";
3307
3307
 
3308
3308
  // bin/cli.ts
3309
3309
  var spawnedFrom = process6.env[CRAFT_CLI_SPAWN_MARKER];
package/dist/index.cjs CHANGED
@@ -310,7 +310,7 @@ function craftBinaryNotFoundMessage(triedPath) {
310
310
  `);
311
311
  }
312
312
  // package.json
313
- var version = "0.0.173";
313
+ var version = "0.0.174";
314
314
 
315
315
  // src/sdk-version.ts
316
316
  var SDK_VERSION = version;
package/dist/index.js CHANGED
@@ -58,7 +58,7 @@ function craftBinaryNotFoundMessage(triedPath) {
58
58
  `);
59
59
  }
60
60
  // package.json
61
- var version = "0.0.173";
61
+ var version = "0.0.174";
62
62
 
63
63
  // src/sdk-version.ts
64
64
  var SDK_VERSION = version;
@@ -47,6 +47,8 @@ export interface CraftConfig {
47
47
  enableFilePicker?: boolean;
48
48
  enableFileDownload?: boolean;
49
49
  enableSocialAuth?: boolean;
50
+ /** Native passkeys; requires a matching `webcredentials:` associated domain. */
51
+ enablePasskeys?: boolean;
50
52
  enableAudioRecording?: boolean;
51
53
  enableVideoRecording?: boolean;
52
54
  enableMotionSensors?: boolean;
@@ -380,6 +380,7 @@ var DEFAULT_CONFIG = {
380
380
  enableFilePicker: false,
381
381
  enableFileDownload: false,
382
382
  enableSocialAuth: false,
383
+ enablePasskeys: false,
383
384
  enableAudioRecording: false,
384
385
  enableVideoRecording: false,
385
386
  enableMotionSensors: false,
@@ -1239,6 +1239,9 @@ struct CraftConfig: Codable {
1239
1239
  var enableFilePicker: Bool = false
1240
1240
  var enableFileDownload: Bool = false
1241
1241
  var enableSocialAuth: Bool = false
1242
+ /// Native passkeys through AuthenticationServices. Requires a matching
1243
+ /// `webcredentials:` associated-domain entry.
1244
+ var enablePasskeys: Bool = false
1242
1245
  var enableAudioRecording: Bool = false
1243
1246
  var enableVideoRecording: Bool = false
1244
1247
  var enableMotionSensors: Bool = false
@@ -1264,6 +1267,8 @@ struct CraftConfig: Codable {
1264
1267
  var enableScheduledWorkouts: Bool = false
1265
1268
  var devServerURL: String? = nil
1266
1269
  var trustedOrigins: [String] = []
1270
+ /// Domains allowed to back native passkey relying-party identifiers.
1271
+ var associatedDomains: [String] = []
1267
1272
  /// The longest the launch splash stays up when the page neither says it is
1268
1273
  /// ready nor paints.
1269
1274
  var splashMaxSeconds: Double = 3
@@ -1,4 +1,5 @@
1
1
  import Foundation
2
+ import AuthenticationServices
2
3
  import LocalAuthentication
3
4
  import SQLite3
4
5
  import Security
@@ -50,6 +51,7 @@ enum CraftNativeActions {
50
51
  // The in-app browser and the system's web sign-in sheet (CraftBrowser).
51
52
  values.append("browser")
52
53
  if config.enableSocialAuth { values.append("appleSignIn") }
54
+ if config.enablePasskeys && !CraftNativePasskeys.domains(in: config).isEmpty { values.append("passkeys") }
53
55
  if config.enablePushNotifications { values.append("push") }
54
56
  if config.enableShare { values.append("share") }
55
57
  // The screen's brightness and the status bar's style need nothing
@@ -247,6 +249,23 @@ enum CraftNativeActions {
247
249
  }
248
250
  }
249
251
  return
252
+ case ("Passkeys", "isAvailable"):
253
+ sync = .success(CraftNativePasskeys.isAvailable(config: config))
254
+ case ("Passkeys", "create"), ("Passkeys", "get"):
255
+ guard config.enablePasskeys else {
256
+ complete(requestToken, failure("CAPABILITY_DISABLED", "Passkeys are disabled"), completion)
257
+ return
258
+ }
259
+ DispatchQueue.main.async {
260
+ CraftNativePasskeys.shared.perform(
261
+ requestToken: requestToken,
262
+ method: method,
263
+ options: args.first as? [String: Any] ?? [:],
264
+ config: config,
265
+ completion: completion
266
+ )
267
+ }
268
+ return
250
269
  case ("Dialog", _):
251
270
  // The system's own alert, confirmation and action sheet, the same
252
271
  // ones a page gets (CraftNativeUI.swift), over the native screen.
@@ -490,6 +509,7 @@ enum CraftNativeActions {
490
509
  cancellationLock.unlock()
491
510
  context?.invalidate()
492
511
  task?.cancel()
512
+ CraftNativePasskeys.shared.cancel(requestToken: requestToken)
493
513
  }
494
514
 
495
515
  /// `fetch` for native screens: one URLSession data task per request, keyed
@@ -1123,6 +1143,160 @@ enum CraftNativeActions {
1123
1143
  }
1124
1144
  }
1125
1145
 
1146
+ /// The native WebAuthn half for screens without a browser context. The
1147
+ /// server still owns challenges and verifies the returned client data; this
1148
+ /// class only translates WebAuthn options into Apple's passkey sheet and
1149
+ /// returns the authenticator response as base64url strings.
1150
+ final class CraftNativePasskeys: NSObject, ASAuthorizationControllerDelegate, ASAuthorizationControllerPresentationContextProviding {
1151
+ static let shared = CraftNativePasskeys()
1152
+
1153
+ private struct Pending {
1154
+ let requestToken: String
1155
+ let completion: (Result<Any, CraftNativeActionError>) -> Void
1156
+ }
1157
+
1158
+ private var pending: [ObjectIdentifier: Pending] = [:]
1159
+ private var controllers: [String: ASAuthorizationController] = [:]
1160
+
1161
+ static func domains(in config: CraftConfig) -> Set<String> {
1162
+ Set(config.associatedDomains.compactMap { entry in
1163
+ let value = entry.trimmingCharacters(in: .whitespacesAndNewlines)
1164
+ guard value.lowercased().hasPrefix("webcredentials:") else { return nil }
1165
+ let domain = String(value.dropFirst("webcredentials:".count)).split(separator: "?", maxSplits: 1).first.map(String.init) ?? ""
1166
+ let normalized = domain.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
1167
+ return normalized.isEmpty ? nil : normalized
1168
+ })
1169
+ }
1170
+
1171
+ static func isAvailable(config: CraftConfig) -> Bool {
1172
+ if #available(iOS 15.0, *) {
1173
+ return config.enablePasskeys && !domains(in: config).isEmpty
1174
+ }
1175
+ return false
1176
+ }
1177
+
1178
+ func perform(
1179
+ requestToken: String,
1180
+ method: String,
1181
+ options: [String: Any],
1182
+ config: CraftConfig,
1183
+ completion: @escaping (Result<Any, CraftNativeActionError>) -> Void
1184
+ ) {
1185
+ guard #available(iOS 15.0, *) else {
1186
+ completion(.failure(CraftNativeActionError(code: "UNAVAILABLE", message: "Passkeys require iOS 15 or later")))
1187
+ return
1188
+ }
1189
+ guard Self.isAvailable(config: config) else {
1190
+ completion(.failure(CraftNativeActionError(code: "UNAVAILABLE", message: "Passkeys need a matching webcredentials associated domain")))
1191
+ return
1192
+ }
1193
+ guard let relyingParty = relyingPartyID(options: options, config: config),
1194
+ let challenge = data(options["challenge"]) else {
1195
+ completion(.failure(CraftNativeActionError(code: "INVALID_ARGUMENT", message: "Passkeys need a base64url challenge and an associated relying-party id")))
1196
+ return
1197
+ }
1198
+
1199
+ let provider = ASAuthorizationPlatformPublicKeyCredentialProvider(relyingPartyIdentifier: relyingParty)
1200
+ let request: ASAuthorizationRequest
1201
+ if method == "create" {
1202
+ guard let user = options["user"] as? [String: Any],
1203
+ let name = user["name"] as? String, !name.isEmpty,
1204
+ let userID = data(user["id"]) else {
1205
+ completion(.failure(CraftNativeActionError(code: "INVALID_ARGUMENT", message: "Passkeys.create needs user.name and a base64url user.id")))
1206
+ return
1207
+ }
1208
+ request = provider.createCredentialRegistrationRequest(challenge: challenge, name: name, userID: userID)
1209
+ } else {
1210
+ let assertion = provider.createCredentialAssertionRequest(challenge: challenge)
1211
+ if let allowCredentials = options["allowCredentials"] as? [[String: Any]] {
1212
+ assertion.allowedCredentials = allowCredentials.compactMap { descriptor in
1213
+ guard let credentialID = data(descriptor["id"]) else { return nil }
1214
+ return ASAuthorizationPlatformPublicKeyCredentialDescriptor(credentialID: credentialID)
1215
+ }
1216
+ }
1217
+ request = assertion
1218
+ }
1219
+
1220
+ let controller = ASAuthorizationController(authorizationRequests: [request])
1221
+ controller.delegate = self
1222
+ controller.presentationContextProvider = self
1223
+ let key = ObjectIdentifier(controller)
1224
+ pending[key] = Pending(requestToken: requestToken, completion: completion)
1225
+ controllers[requestToken] = controller
1226
+ controller.performRequests()
1227
+ }
1228
+
1229
+ func cancel(requestToken: String) {
1230
+ controllers.removeValue(forKey: requestToken)
1231
+ pending = pending.filter { $0.value.requestToken != requestToken }
1232
+ }
1233
+
1234
+ func authorizationController(controller: ASAuthorizationController, didCompleteWithAuthorization authorization: ASAuthorization) {
1235
+ let key = ObjectIdentifier(controller)
1236
+ guard let request = pending.removeValue(forKey: key) else { return }
1237
+ controllers.removeValue(forKey: request.requestToken)
1238
+ if let registration = authorization.credential as? ASAuthorizationPlatformPublicKeyCredentialRegistration {
1239
+ request.completion(.success([
1240
+ "id": Self.base64url(registration.credentialID),
1241
+ "rawId": Self.base64url(registration.credentialID),
1242
+ "type": "public-key",
1243
+ "response": [
1244
+ "clientDataJSON": Self.base64url(registration.rawClientDataJSON),
1245
+ "attestationObject": Self.base64url(registration.rawAttestationObject),
1246
+ ],
1247
+ ]))
1248
+ return
1249
+ }
1250
+ if let assertion = authorization.credential as? ASAuthorizationPlatformPublicKeyCredentialAssertion {
1251
+ var response: [String: Any] = [
1252
+ "clientDataJSON": Self.base64url(assertion.rawClientDataJSON),
1253
+ "authenticatorData": Self.base64url(assertion.rawAuthenticatorData),
1254
+ "signature": Self.base64url(assertion.signature),
1255
+ ]
1256
+ if let userID = assertion.userID { response["userHandle"] = Self.base64url(userID) }
1257
+ request.completion(.success([
1258
+ "id": Self.base64url(assertion.credentialID),
1259
+ "rawId": Self.base64url(assertion.credentialID),
1260
+ "type": "public-key",
1261
+ "response": response,
1262
+ ]))
1263
+ return
1264
+ }
1265
+ request.completion(.failure(CraftNativeActionError(code: "PASSKEY_FAILED", message: "Apple returned an unsupported passkey credential")))
1266
+ }
1267
+
1268
+ func authorizationController(controller: ASAuthorizationController, didCompleteWithError error: Error) {
1269
+ let key = ObjectIdentifier(controller)
1270
+ guard let request = pending.removeValue(forKey: key) else { return }
1271
+ controllers.removeValue(forKey: request.requestToken)
1272
+ let cancelled = (error as? ASAuthorizationError)?.code == .canceled
1273
+ request.completion(.failure(CraftNativeActionError(code: cancelled ? "CANCELLED" : "PASSKEY_FAILED", message: error.localizedDescription)))
1274
+ }
1275
+
1276
+ func presentationAnchor(for controller: ASAuthorizationController) -> ASPresentationAnchor {
1277
+ CraftPresenter.keyWindow() ?? ASPresentationAnchor()
1278
+ }
1279
+
1280
+ private func relyingPartyID(options: [String: Any], config: CraftConfig) -> String? {
1281
+ let value = (options["rpId"] as? String) ?? (options["rp"] as? [String: Any])?["id"] as? String
1282
+ let domains = Self.domains(in: config)
1283
+ let candidate = value?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() ?? (domains.count == 1 ? domains.first : nil)
1284
+ guard let candidate, domains.contains(candidate), candidate.range(of: #"^[a-z0-9.-]+$"#, options: .regularExpression) != nil else { return nil }
1285
+ return candidate
1286
+ }
1287
+
1288
+ private func data(_ value: Any?) -> Data? {
1289
+ guard let string = value as? String, !string.isEmpty else { return nil }
1290
+ var encoded = string.replacingOccurrences(of: "-", with: "+").replacingOccurrences(of: "_", with: "/")
1291
+ encoded += String(repeating: "=", count: (4 - encoded.count % 4) % 4)
1292
+ return Data(base64Encoded: encoded)
1293
+ }
1294
+
1295
+ private static func base64url(_ data: Data) -> String {
1296
+ data.base64EncodedString().replacingOccurrences(of: "+", with: "-").replacingOccurrences(of: "/", with: "_").replacingOccurrences(of: "=", with: "")
1297
+ }
1298
+ }
1299
+
1126
1300
  // MARK: - Synchronous reads for a screen's first frame
1127
1301
 
1128
1302
  extension CraftNativeActions {
@@ -277,7 +277,7 @@ final class CraftNativeFlatList: UICollectionView, UICollectionViewDelegateFlowL
277
277
  }
278
278
 
279
279
  func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
280
- true
280
+ craftScrollGesture(gestureRecognizer, runsWith: otherGestureRecognizer)
281
281
  }
282
282
 
283
283
  var visibleItemIdentities: [String] {