@tinkerlandai/longplay-react-native 0.10.11 → 0.10.14

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.
@@ -9,6 +9,68 @@ public struct IndianaFeatureResult: Codable, Sendable {
9
9
  public let experimentResult: IndianaJSON?
10
10
  }
11
11
 
12
+ /// Readiness and pending calls of one evaluator host, confined to the main actor. Stopping fails every waiter with its
13
+ /// cause: `identityChanged` only for an identity change or close, otherwise the host's actual failure.
14
+ @MainActor final class IndianaFeatureCalls {
15
+ private let timeout: UInt64
16
+ private var readinessSignalled = false
17
+ private var waiting: [UUID: CheckedContinuation<Void, any Error>] = [:]
18
+ private var pending: [UUID: CheckedContinuation<IndianaJSON, any Error>] = [:]
19
+ private(set) var failure: (any Error)?
20
+ init(timeout nanoseconds: UInt64 = 45_000_000_000) { timeout = nanoseconds }
21
+ var ready: Bool { readinessSignalled && failure == nil }
22
+ /// Calls currently waiting for readiness or a result.
23
+ var waiters: Int { waiting.count + pending.count }
24
+ /// SDK errors keep their identity; a platform failure of the packaged renderer host reports `invalidResponse`, the
25
+ /// renderer-failure error of the other Swift hosts.
26
+ static func hostFailure(_ error: any Error) -> any Error {
27
+ error is IndianaClientError || error is IndianaFunnelError || error is IndianaStoreError ? error : IndianaClientError.invalidResponse
28
+ }
29
+ func markReady() throws {
30
+ if let failure { throw failure }
31
+ readinessSignalled = true
32
+ let current = waiting; waiting = [:]; for continuation in current.values { continuation.resume() }
33
+ }
34
+ /// Returns false when already stopped; the first cause wins.
35
+ @discardableResult func stop(_ cause: any Error) -> Bool {
36
+ guard failure == nil else { return false }
37
+ failure = cause
38
+ let readiness = waiting, calls = pending; waiting = [:]; pending = [:]
39
+ for continuation in readiness.values { continuation.resume(throwing: cause) }
40
+ for continuation in calls.values { continuation.resume(throwing: cause) }
41
+ return true
42
+ }
43
+ /// Each wait for readiness and for the result is bounded separately; a timeout fails only this call.
44
+ func call(_ send: (UUID) -> Void) async throws -> IndianaJSON {
45
+ if let failure { throw failure }
46
+ let delay = timeout
47
+ if !readinessSignalled {
48
+ let waiter = UUID()
49
+ try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, any Error>) in
50
+ waiting[waiter] = continuation
51
+ Task { [weak self] in
52
+ try? await Task.sleep(nanoseconds: delay)
53
+ self?.waiting.removeValue(forKey: waiter)?.resume(throwing: IndianaClientError.temporaryUnavailable)
54
+ }
55
+ }
56
+ }
57
+ if let failure { throw failure }
58
+ let id = UUID()
59
+ return try await withCheckedThrowingContinuation { continuation in
60
+ pending[id] = continuation
61
+ send(id)
62
+ Task { [weak self] in
63
+ try? await Task.sleep(nanoseconds: delay)
64
+ self?.pending.removeValue(forKey: id)?.resume(throwing: IndianaClientError.temporaryUnavailable)
65
+ }
66
+ }
67
+ }
68
+ func settle(_ id: UUID, result: IndianaJSON, failed: Bool) {
69
+ guard let continuation = pending.removeValue(forKey: id) else { return }
70
+ if failed { continuation.resume(throwing: IndianaClientError.invalidResponse) } else { continuation.resume(returning: result) }
71
+ }
72
+ }
73
+
12
74
  #if canImport(UIKit)
13
75
  import UIKit
14
76
 
@@ -19,18 +81,18 @@ import UIKit
19
81
  private let attributes: [String: IndianaJSON]
20
82
  private let operation: @Sendable (IndianaCustomerOperation, [String: IndianaJSON]) async throws -> IndianaJSON
21
83
  private var host: IndianaBundledWebView!
84
+ private let calls = IndianaFeatureCalls()
22
85
  public var onError: (@MainActor (Error) -> Void)?
23
- private var ready = false
24
- private var stopped = false
25
- private var readiness: [CheckedContinuation<Void, Error>] = []
26
- private var pending: [UUID: CheckedContinuation<IndianaJSON, Error>] = [:]
27
86
  init(configuration: IndianaConfiguration, attributes: [String: IndianaJSON], operation: @escaping @Sendable (IndianaCustomerOperation, [String: IndianaJSON]) async throws -> IndianaJSON) {
28
87
  self.configuration = configuration; self.attributes = attributes; self.operation = operation
29
88
  host = IndianaBundledWebView(label: "Longplay Feature evaluator", dispatch: { [weak self] method, payload in
30
89
  guard let self else { throw IndianaClientError.identityChanged }; return try await self.dispatch(method, payload)
31
- }, onError: { [weak self] error in guard let self else { return }; if !self.ready { self.close() } else { self.onError?(error) } })
90
+ }, onError: { [weak self] error in
91
+ guard let self else { return }
92
+ // Before readiness a host failure ends this client with its actual cause rather than an identity change.
93
+ if !self.calls.ready { self.stop(IndianaFeatureCalls.hostFailure(error)) } else { self.onError?(error) }
94
+ })
32
95
  host.start()
33
- Task { [weak self] in try? await Task.sleep(nanoseconds: 45_000_000_000); if let self, !self.ready { self.close() } }
34
96
  }
35
97
  public func evalFeature(_ key: String) async throws -> IndianaFeatureResult {
36
98
  let value = try await call(["method": .string("evalFeature"), "key": .string(key)])
@@ -44,41 +106,25 @@ import UIKit
44
106
  public func setAttributes(_ value: [String: IndianaJSON]) async throws { _ = try await call(["method": .string("setAttributes"), "attributes": .object(value)]) }
45
107
  public func refresh() async throws { _ = try await call(["method": .string("refresh")]) }
46
108
  public func flush() async throws { _ = try await call(["method": .string("flush")]) }
47
- public func close() {
48
- guard !stopped else { return }; stopped = true; host?.destroy()
49
- let waiting = readiness; readiness = []; for continuation in waiting { continuation.resume(throwing: IndianaClientError.identityChanged) }
50
- let calls = pending; pending = [:]; for continuation in calls.values { continuation.resume(throwing: IndianaClientError.identityChanged) }
51
- }
109
+ public func close() { stop(IndianaClientError.identityChanged) }
110
+ private func stop(_ cause: any Error) { if calls.stop(cause) { host?.destroy() } }
52
111
  private func call(_ command: [String: IndianaJSON]) async throws -> IndianaJSON {
53
- guard !stopped else { throw IndianaClientError.identityChanged }
54
- if !ready { try await withCheckedThrowingContinuation { readiness.append($0) } }
55
- guard !stopped else { throw IndianaClientError.identityChanged }
56
- let id = UUID()
57
- return try await withCheckedThrowingContinuation { continuation in
58
- pending[id] = continuation
59
- host.send(.object(["event": .string("feature_call"), "id": .string(id.uuidString.lowercased()), "input": .object(command)]))
60
- Task { [weak self] in
61
- try? await Task.sleep(nanoseconds: 45_000_000_000)
62
- self?.pending.removeValue(forKey: id)?.resume(throwing: IndianaClientError.temporaryUnavailable)
63
- }
64
- }
112
+ try await calls.call { id in host.send(.object(["event": .string("feature_call"), "id": .string(id.uuidString.lowercased()), "input": .object(command)])) }
65
113
  }
66
114
  private func dispatch(_ method: String, _ value: IndianaJSON) async throws -> IndianaJSON {
67
115
  switch method {
68
116
  case "ready":
69
117
  host.send(.object(["event": .string("init"), "surface": .string("features"), "scope": .object(["projectId": .string(configuration.scope.projectId.uuidString.lowercased()), "environment": .string(configuration.scope.environment.rawValue)]), "apiOrigin": .string(configuration.apiOrigin), "attributes": .object(attributes)])); return .null
70
118
  case "featureOperation":
71
- guard !stopped, let name = value.object?["operation"]?.string,
119
+ guard calls.failure == nil, let name = value.object?["operation"]?.string,
72
120
  ["getFeatures", "evaluateFeatures", "trackEvents"].contains(name), let selected = IndianaCustomerOperation(rawValue: name),
73
121
  let input = value.object?["input"]?.object else { throw IndianaFunnelError.invalidCommand }
74
122
  return try await operation(selected, input)
75
123
  case "featuresReady":
76
- guard !stopped else { throw IndianaClientError.identityChanged }; ready = true
77
- let waiting = readiness; readiness = []; for continuation in waiting { continuation.resume() }; return .null
124
+ try calls.markReady(); return .null
78
125
  case "featureResult":
79
- guard !stopped, let id = value.object?["id"]?.string.flatMap(UUID.init(uuidString:)), let continuation = pending.removeValue(forKey: id) else { return .null }
80
- if value.object?["error"] != nil { continuation.resume(throwing: IndianaClientError.invalidResponse) }
81
- else { continuation.resume(returning: value.object?["result"] ?? .null) }; return .null
126
+ guard let id = value.object?["id"]?.string.flatMap(UUID.init(uuidString:)) else { return .null }
127
+ calls.settle(id, result: value.object?["result"] ?? .null, failed: value.object?["error"] != nil); return .null
82
128
  default: throw IndianaFunnelError.invalidCommand
83
129
  }
84
130
  }
@@ -162,11 +162,9 @@ public actor IndianaClient {
162
162
  private var pushObservationID = UUID()
163
163
  public private(set) var pushSynchronizationError: IndianaClientError?
164
164
  private let nativeStore: any IndianaAppleStore
165
- private let nativeStorage: any IndianaNativePendingStorage
166
165
  private let durableFunnels: Bool
167
166
  private var storeRestorePending: [String: (UUID, Data)] = [:]
168
167
  private var recoveringNative = false
169
- private var nativeReceiptPending: [UUID: (UUID, Data)] = [:]
170
168
  private struct WeakFunnel { weak var value: IndianaFunnelSession? }
171
169
  private var funnels: [WeakFunnel] = []
172
170
  private struct WeakPaywall { weak var value: IndianaPaywallSession? }
@@ -177,8 +175,8 @@ public actor IndianaClient {
177
175
  private struct WeakFeature { weak var value: IndianaFeatureClient? }
178
176
  private var featuresClients: [WeakFeature] = []
179
177
  #endif
180
- public init(configuration: IndianaConfiguration, sessionConfiguration: URLSessionConfiguration = .ephemeral) { self.configuration = configuration; self.http = IndianaSessionTransport(IndianaURLTransport(configuration: configuration, sessionConfiguration: sessionConfiguration), scope: configuration.scope); self.nativeStore = AppleStorefront(); self.nativeStorage = IndianaNativePendingKeychain(scope: configuration.scope); self.durableFunnels = true }
181
- init(configuration: IndianaConfiguration, http: any IndianaHTTP, store: (any IndianaAppleStore)? = nil, nativeStorage: (any IndianaNativePendingStorage)? = nil, publicPaywallContent: (@Sendable (IndianaJSON) async throws -> IndianaJSON)? = nil, sessionNow: @escaping @Sendable () -> Date = { Date() }) { self.publicPaywallContent = publicPaywallContent; self.configuration = configuration; self.http = IndianaSessionTransport(http, scope: configuration.scope, now: sessionNow); self.nativeStore = store ?? AppleStorefront(); self.nativeStorage = nativeStorage ?? IndianaNativePendingKeychain(scope: configuration.scope); self.durableFunnels = false }
178
+ public init(configuration: IndianaConfiguration, sessionConfiguration: URLSessionConfiguration = .ephemeral) { self.configuration = configuration; self.http = IndianaSessionTransport(IndianaURLTransport(configuration: configuration, sessionConfiguration: sessionConfiguration), scope: configuration.scope); self.nativeStore = AppleStorefront(); self.durableFunnels = true }
179
+ init(configuration: IndianaConfiguration, http: any IndianaHTTP, store: (any IndianaAppleStore)? = nil, publicPaywallContent: (@Sendable (IndianaJSON) async throws -> IndianaJSON)? = nil, sessionNow: @escaping @Sendable () -> Date = { Date() }) { self.publicPaywallContent = publicPaywallContent; self.configuration = configuration; self.http = IndianaSessionTransport(http, scope: configuration.scope, now: sessionNow); self.nativeStore = store ?? AppleStorefront(); self.durableFunnels = false }
182
180
  public func identify(customerToken token: String) async throws {
183
181
  guard token.range(of: "^gg_ct_[A-Za-z0-9_-]{32,128}$", options: .regularExpression) != nil else { throw IndianaClientError.unauthenticated }
184
182
  try await installIdentity(token)
@@ -186,7 +184,7 @@ public actor IndianaClient {
186
184
  private func installIdentity(_ token: String, session: IndianaCustomerSession? = nil) async throws {
187
185
  analyticsTimer?.cancel(); analyticsTimer = nil; analyticsEvents = []
188
186
  if let pushIdentity { pushBindingSequence = try pushIdentity.reserve(); pushSynchronizationError = nil }
189
- epoch = UUID(); pushObservationID = UUID(); customerToken = token; nativeReceiptPending = [:]; storeRestorePending = [:]
187
+ epoch = UUID(); pushObservationID = UUID(); customerToken = token; storeRestorePending = [:]
190
188
  let previous = takeViews()
191
189
  credentialRevision += 1; let revision = credentialRevision, original = epoch
192
190
  let invalidated: @Sendable () async -> Void = { [weak self] in await self?.invalidateSession(revision: revision) }
@@ -302,7 +300,7 @@ public actor IndianaClient {
302
300
  analyticsTimer?.cancel(); analyticsTimer = nil; analyticsEvents = []
303
301
  do { if let pushIdentity { _ = try pushIdentity.reserve(detach: true) }; pushSynchronizationError = nil }
304
302
  catch { pushSynchronizationError = .protectedStorageUnavailable }
305
- epoch = UUID(); pushObservationID = UUID(); customerToken = nil; pushBindingSequence = 0; nativeReceiptPending = [:]; storeRestorePending = [:]
303
+ epoch = UUID(); pushObservationID = UUID(); customerToken = nil; pushBindingSequence = 0; storeRestorePending = [:]
306
304
  return takeViews()
307
305
  }
308
306
  private func closeViews(_ previous: CustomerViews) async {
@@ -326,60 +324,14 @@ public actor IndianaClient {
326
324
  schedulePush()
327
325
  await closeViews(previous)
328
326
  }
329
- /// The relay runs on your backend and adds its API key. A Funnel run token is never used for order recovery.
327
+ /// StoreKit's current transactions are the recovery source: every SDK purchase carries its order binding as its app
328
+ /// account token and stays unfinished until the server confirms it, so `restorePurchases` and a retried
329
+ /// `purchaseSubscription` recover it. No local order journal exists; this compatibility entry relays nothing and
330
+ /// returns no orders.
331
+ @available(*, deprecated, message: "StoreKit's current transactions are the recovery source; call restorePurchases(synchronizeStore:).")
330
332
  public func recoverNativeOrders(using relay: @Sendable (Data) async throws -> Data, synchronizeStore: Bool = false) async throws -> [Data] {
331
- guard let token = customerToken, configuration.scope.environment == .sandbox, !recoveringNative else { throw IndianaClientError.unauthenticated }
332
- recoveringNative = true; defer { recoveringNative = false }; let original = epoch
333
- let storage = nativeStorage
334
- let records = try await storage.orders()
335
- let scanned = synchronizeStore ? try await nativeStore.restore() : await nativeStore.scanUnfinished()
336
- var responses: [Data] = []
337
- for record in records {
338
- let matching = scanned.filter { $0.productID == record.productId && $0.accountToken == record.accountToken }
339
- let items: [ApplePurchaseEvidence?] = matching.isEmpty ? [nil] : matching.map { Optional($0) }
340
- for item in items {
341
- guard original == epoch else { throw IndianaClientError.identityChanged }
342
- let command: (UUID, Data)
343
- if let pending = nativeReceiptPending[record.orderId] { command = pending }
344
- else {
345
- let requestId = UUID()
346
- var proof: IndianaJSON = .null
347
- if let item {
348
- guard item.signedTransaction.utf8.count <= 131072 else { throw IndianaClientError.invalidResponse }
349
- proof = .object(["provider":.string("apple"), "orderId":.string(record.orderId.uuidString.lowercased()), "receiptId":.string(record.receiptId.uuidString.lowercased()), "signedTransaction":.string(item.signedTransaction)])
350
- }
351
- let body: IndianaJSON = .object(["operation":.string("native_receipt"), "requestId":.string(requestId.uuidString.lowercased()), "customerToken":.string(token), "orderId":.string(record.orderId.uuidString.lowercased()), "receiptId":.string(record.receiptId.uuidString.lowercased()), "nativeEvidence":proof])
352
- command = (requestId, try JSONEncoder().encode(body)); nativeReceiptPending[record.orderId] = command
353
- }
354
- let credential = try await preparedCustomerToken()
355
- guard original == epoch else { throw IndianaClientError.identityChanged }
356
- var outgoing = try JSONSerialization.jsonObject(with: command.1) as! [String: Any]
357
- outgoing["customerToken"] = credential
358
- let payload = try JSONSerialization.data(withJSONObject: outgoing)
359
- let bytes: Data
360
- do { bytes = try await relay(payload) }
361
- catch let error as IndianaClientError {
362
- guard original == epoch else { throw IndianaClientError.identityChanged }
363
- if case .requestFailed(let status) = error, status < 500, status != 425 {
364
- nativeReceiptPending.removeValue(forKey: record.orderId)
365
- if status == 403 || status == 404 { continue }
366
- }
367
- throw error
368
- }
369
- guard original == epoch else { throw IndianaClientError.identityChanged }
370
- struct Reply: Decodable { let scope: IndianaScope; let requestId: UUID; let orderId: UUID; let receiptId: UUID; let orderStatus: String; let refunded: Bool; let nativeCompletion: IndianaNativeCompletion? }
371
- guard bytes.count <= 262144, let reply = try? JSONDecoder().decode(Reply.self, from: bytes), reply.scope == configuration.scope, reply.requestId == command.0, reply.orderId == record.orderId, reply.receiptId == record.receiptId, ["created", "pending", "trialing", "paid", "cancelled", "failed"].contains(reply.orderStatus) else { throw IndianaClientError.invalidResponse }
372
- if let completion = reply.nativeCompletion {
373
- guard completion.orderId == record.orderId, completion.receiptId == record.receiptId, (1...50).contains(completion.transactionIds.count), Set(completion.transactionIds).count == completion.transactionIds.count, completion.transactionIds.allSatisfy({ !$0.isEmpty && $0.count <= 255 }) else { throw IndianaClientError.invalidResponse }
374
- for transaction in scanned where transaction.productID == record.productId && transaction.accountToken == record.accountToken && completion.transactionIds.contains(transaction.transactionID) {
375
- try await nativeStore.finish(transactionID: transaction.transactionID, confirmedTransactionIDs: Set(completion.transactionIds))
376
- }
377
- if !completion.membership { try await storage.remove(record.orderId) }
378
- }
379
- nativeReceiptPending.removeValue(forKey: record.orderId); responses.append(bytes)
380
- }
381
- }
382
- return responses
333
+ guard customerToken != nil else { throw IndianaClientError.unauthenticated }
334
+ return []
383
335
  }
384
336
  /// Restores store transactions after reinstall or a device change. No local order journal is required.
385
337
  public func restorePurchases(synchronizeStore: Bool = false) async throws -> [Data] {
@@ -395,10 +347,23 @@ public actor IndianaClient {
395
347
  guard original == epoch else { throw IndianaClientError.identityChanged }
396
348
  // Unbound historical or family-shared transactions are not automatically transferred to an app account.
397
349
  guard let intent = purchase.accountToken else { continue }
398
- replies.append(try await submitStorePurchase(purchase, intent: intent, original: original))
350
+ if let reply = try await restoreOne(purchase, intent: intent, original: original) { replies.append(reply) }
399
351
  }
400
352
  return replies
401
353
  }
354
+ /// The App Store account can hold transactions bound to another Customer or scope. Their definite refusal (403/404)
355
+ /// skips that transaction; any other refusal or unknown result ends the restore.
356
+ private func restoreOne(_ purchase: ApplePurchaseEvidence, intent: UUID, original: UUID) async throws -> Data? {
357
+ do { return try await submitStorePurchase(purchase, intent: intent, original: original) }
358
+ catch let error as IndianaClientError {
359
+ guard original == epoch else { throw IndianaClientError.identityChanged }
360
+ guard case .requestFailed(let status) = error else { throw error }
361
+ // A definite refusal settles that request; a later attempt starts a new one.
362
+ if (400..<500).contains(status), status != 425 { storeRestorePending.removeValue(forKey: purchase.transactionID) }
363
+ guard status == 403 || status == 404 else { throw error }
364
+ return nil
365
+ }
366
+ }
402
367
  private func submitStorePurchase(_ purchase: ApplePurchaseEvidence, intent: UUID, original: UUID) async throws -> Data {
403
368
  guard purchase.signedTransaction.utf8.count <= 131072 else { throw IndianaClientError.invalidResponse }
404
369
  let command: (UUID, Data)
@@ -1,7 +1,5 @@
1
1
  import Foundation
2
2
 
3
- struct IndianaNativeCompletion: Codable, Sendable { let orderId: UUID; let receiptId: UUID; let membership: Bool; let transactionIds: [String] }
4
-
5
3
  /// HTTPS endpoint on your own backend. It authenticates the customer and attaches the Indiana server key there.
6
4
  public final class IndianaNativeReceiptRelay: Sendable {
7
5
  private let endpoint: URL; private let session: URLSession
@@ -1,4 +1,4 @@
1
1
  // Generated by scripts/sdk/generate-native-protocol.mjs. Do not edit.
2
2
  public enum IndianaSDK {
3
- public static let version = "0.10.11"
3
+ public static let version = "0.10.14"
4
4
  }