@tinkerlandai/longplay-react-native 0.10.8 → 0.10.13
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.cjs +2 -2
- package/dist/index.d.cts +26549 -12669
- package/dist/index.d.ts +26549 -12669
- package/dist/index.js +2 -2
- package/native-sdk/android/assets/indiana/index.html +42 -42
- package/native-sdk/android/java/ai/tinkerland/indiana/BundledWebView.kt +6 -2
- package/native-sdk/android/java/ai/tinkerland/indiana/CustomerSession.kt +9 -3
- package/native-sdk/android/java/ai/tinkerland/indiana/FeatureClient.kt +46 -22
- package/native-sdk/android/java/ai/tinkerland/indiana/FunnelConfigurationRelay.kt +47 -21
- package/native-sdk/android/java/ai/tinkerland/indiana/FunnelProcess.kt +120 -0
- package/native-sdk/android/java/ai/tinkerland/indiana/FunnelSession.kt +57 -16
- package/native-sdk/android/java/ai/tinkerland/indiana/GoogleStorefront.kt +18 -2
- package/native-sdk/android/java/ai/tinkerland/indiana/HTTPTransport.kt +48 -20
- package/native-sdk/android/java/ai/tinkerland/indiana/IndianaClient.kt +36 -44
- package/native-sdk/android/java/ai/tinkerland/indiana/PaywallSession.kt +82 -59
- package/native-sdk/android/java/ai/tinkerland/indiana/SDKVersion.kt +1 -1
- package/native-sdk/android/java/ai/tinkerland/indiana/WireSchema.kt +3 -1
- package/native-sdk/android/java/ai/tinkerland/longplay/Longplay.kt +3 -0
- package/native-sdk/android/resources/funnel-wire-schemas.json +1 -1
- package/native-sdk/ios/BundledWebView.swift +5 -1
- package/native-sdk/ios/CustomerSession.swift +7 -3
- package/native-sdk/ios/FeatureClient.swift +75 -29
- package/native-sdk/ios/FunnelConfigurationRelay.swift +1 -0
- package/native-sdk/ios/FunnelProcess.swift +148 -0
- package/native-sdk/ios/FunnelSession.swift +37 -9
- package/native-sdk/ios/HTTPTransport.swift +2 -0
- package/native-sdk/ios/IndianaClient.swift +25 -60
- package/native-sdk/ios/NativeReceiptRelay.swift +3 -3
- package/native-sdk/ios/PaywallSession.swift +5 -4
- package/native-sdk/ios/SDKVersion.swift +1 -1
- package/native-sdk/ios/WebView/index.html +42 -42
- package/native-sdk/ios/funnel-wire-schemas.json +1 -1
- package/package.json +1 -1
- package/native-sdk/android/java/ai/tinkerland/indiana/NativePendingKeystore.kt +0 -81
- package/native-sdk/ios/NativePendingKeychain.swift +0 -52
|
@@ -56,7 +56,11 @@ import UIKit
|
|
|
56
56
|
let data=try? JSONSerialization.data(withJSONObject:message.body), data.count <= 262144,
|
|
57
57
|
let value=try? JSONDecoder().decode(IndianaJSON.self,from:data).object,
|
|
58
58
|
Set(value.keys) == Set(["id","method","payload"]), let id=value["id"]?.string.flatMap(UUID.init(uuidString:)),
|
|
59
|
-
let method=value["method"]?.string, requests.
|
|
59
|
+
let method=value["method"]?.string, !requests.contains(id) else { return }
|
|
60
|
+
guard requests.count < 8 else {
|
|
61
|
+
send(.object(["id":.string(id.uuidString.lowercased()),"error":.object(["code":.string("NATIVE_BUSY"),"status":.number(429)])])); return
|
|
62
|
+
}
|
|
63
|
+
requests.insert(id)
|
|
60
64
|
Task {
|
|
61
65
|
defer { requests.remove(id) }
|
|
62
66
|
do {
|
|
@@ -2,6 +2,8 @@ import Foundation
|
|
|
2
2
|
|
|
3
3
|
/// Opaque credentials issued by the merchant backend; keep them in memory.
|
|
4
4
|
public struct IndianaCustomerSession: Codable, Sendable {
|
|
5
|
+
private static let fractionalDate = Date.ISO8601FormatStyle(includingFractionalSeconds: true)
|
|
6
|
+
private static let wholeDate = Date.ISO8601FormatStyle()
|
|
5
7
|
public let customerId: UUID
|
|
6
8
|
public let sessionId: UUID
|
|
7
9
|
public let accessToken: String
|
|
@@ -12,8 +14,7 @@ public struct IndianaCustomerSession: Codable, Sendable {
|
|
|
12
14
|
self.customerId = customerId; self.sessionId = sessionId; self.accessToken = accessToken; self.refreshToken = refreshToken; self.expiresAt = expiresAt; self.refreshExpiresAt = refreshExpiresAt
|
|
13
15
|
}
|
|
14
16
|
internal func deadline(_ value: String) -> Date? {
|
|
15
|
-
|
|
16
|
-
return parser.date(from: value) ?? ISO8601DateFormatter().date(from: value)
|
|
17
|
+
(try? Self.fractionalDate.parse(value)) ?? (try? Self.wholeDate.parse(value))
|
|
17
18
|
}
|
|
18
19
|
internal func validate(fresh: Bool = false) throws {
|
|
19
20
|
guard accessToken.range(of: "^gg_ct_[A-Za-z0-9_-]{43}$", options: .regularExpression) != nil,
|
|
@@ -39,6 +40,7 @@ internal actor IndianaSessionTransport: IndianaHTTP {
|
|
|
39
40
|
init(_ upstream: any IndianaHTTP, scope: IndianaScope, now: @escaping @Sendable () -> Date = { Date() }) { self.upstream = upstream; self.scope = scope; self.now = now }
|
|
40
41
|
func identify(_ access: String?, revision: UInt64? = nil, onInvalidated: (@Sendable () async -> Void)? = nil) {
|
|
41
42
|
if let revision { guard revision >= identityRevision else { return }; identityRevision = revision } else { identityRevision += 1 }
|
|
43
|
+
pending?.1.cancel()
|
|
42
44
|
epoch = UUID(); credentials = nil; originalAccess = access; pending = nil; self.onInvalidated = onInvalidated
|
|
43
45
|
}
|
|
44
46
|
func identify(_ value: IndianaCustomerSession, revision: UInt64? = nil, onInvalidated: (@Sendable () async -> Void)? = nil) throws {
|
|
@@ -54,6 +56,7 @@ internal actor IndianaSessionTransport: IndianaHTTP {
|
|
|
54
56
|
do {
|
|
55
57
|
let next = try await task.value
|
|
56
58
|
guard version == epoch else { throw IndianaClientError.identityChanged }
|
|
59
|
+
try Task.checkCancellation()
|
|
57
60
|
return next
|
|
58
61
|
} catch {
|
|
59
62
|
guard revision == identityRevision else { throw IndianaClientError.identityChanged }
|
|
@@ -62,6 +65,7 @@ internal actor IndianaSessionTransport: IndianaHTTP {
|
|
|
62
65
|
}
|
|
63
66
|
func refresh() async throws -> IndianaCustomerSession {
|
|
64
67
|
guard accessProvider == nil else { throw IndianaClientError.unsupportedCapability }
|
|
68
|
+
try Task.checkCancellation()
|
|
65
69
|
if let pending {
|
|
66
70
|
return try await awaitRefresh(pending.1, epoch: epoch, revision: identityRevision)
|
|
67
71
|
}
|
|
@@ -97,7 +101,7 @@ internal actor IndianaSessionTransport: IndianaHTTP {
|
|
|
97
101
|
guard originalAccess != nil else { throw IndianaClientError.unauthenticated }
|
|
98
102
|
let token = try await accessProvider()
|
|
99
103
|
guard version == epoch else { throw IndianaClientError.identityChanged }
|
|
100
|
-
guard token.range(of: "^gg_ct_[A-Za-z0-9_-]{
|
|
104
|
+
guard token.range(of: "^gg_ct_[A-Za-z0-9_-]{32,128}$", options: .regularExpression) != nil else { throw IndianaClientError.invalidResponse }
|
|
101
105
|
return token
|
|
102
106
|
}
|
|
103
107
|
if let current = credentials, (current.deadline(current.expiresAt) ?? .distantPast) <= now().addingTimeInterval(30) { _ = try await refresh() }
|
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
80
|
-
|
|
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
|
}
|
|
@@ -23,6 +23,7 @@ final class IndianaFunnelConfigurationRelay: Sendable {
|
|
|
23
23
|
request.cachePolicy = path.contains("/artifacts/") ? .useProtocolCachePolicy : .reloadIgnoringLocalCacheData
|
|
24
24
|
do {
|
|
25
25
|
let (bytes, response) = try await session.bytes(for: request)
|
|
26
|
+
defer { bytes.task.cancel() }
|
|
26
27
|
guard let http = response as? HTTPURLResponse else { throw IndianaClientError.temporaryUnavailable }
|
|
27
28
|
var headers: [String: IndianaJSON] = [:]
|
|
28
29
|
for (key, value) in http.allHeaderFields {
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
|
|
3
|
+
/// Calls the merchant backend's process adapter with its acknowledged durable handle and authenticated merchant session.
|
|
4
|
+
/// The backend resolves the subject; no Server Key, Guest proof or Customer token enters this interface.
|
|
5
|
+
public protocol IndianaFunnelProcessBridge: Sendable {
|
|
6
|
+
func invoke(_ operation: String, input: [String: IndianaJSON]) async throws -> Data
|
|
7
|
+
func detach() async throws
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/// Process identity and its credential-free command journal survive Customer-session replacement.
|
|
11
|
+
public actor IndianaFunnelProcess {
|
|
12
|
+
private let configuration: IndianaConfiguration
|
|
13
|
+
private let bridge: any IndianaFunnelProcessBridge
|
|
14
|
+
private let storage: any IndianaFunnelCommandStorage
|
|
15
|
+
private var epoch = UUID()
|
|
16
|
+
private var presentation: IndianaFunnelSession?
|
|
17
|
+
private var runToken: String?
|
|
18
|
+
public private(set) var current: [String: IndianaJSON]?
|
|
19
|
+
private let onAuthenticationRequired: (@Sendable ([String: IndianaJSON]) -> Void)?
|
|
20
|
+
public init(configuration: IndianaConfiguration, bridge: any IndianaFunnelProcessBridge, commandStorage: any IndianaFunnelCommandStorage,
|
|
21
|
+
onAuthenticationRequired: (@Sendable ([String: IndianaJSON]) -> Void)? = nil) {
|
|
22
|
+
self.configuration=configuration; self.bridge=bridge; storage=commandStorage; self.onAuthenticationRequired=onAuthenticationRequired
|
|
23
|
+
}
|
|
24
|
+
private func bytes(_ value: [String: IndianaJSON]) throws -> Data { try JSONEncoder().encode(value) }
|
|
25
|
+
private func update(_ change: ([String: IndianaJSON]) throws -> [String: IndianaJSON]) async throws {
|
|
26
|
+
for _ in 0..<8 {
|
|
27
|
+
let saved=try await storage.read()
|
|
28
|
+
let record=try saved.entry.map { try JSONDecoder().decode([String: IndianaJSON].self,from:$0) } ?? [:]
|
|
29
|
+
let next=try change(record)
|
|
30
|
+
if next==record { return }
|
|
31
|
+
if try await storage.write(expectedRevision:saved.revision,entry:bytes(next)) != nil { return }
|
|
32
|
+
}
|
|
33
|
+
throw IndianaFunnelError.commandPending
|
|
34
|
+
}
|
|
35
|
+
private func record() async throws -> [String: IndianaJSON] {
|
|
36
|
+
let saved=try await storage.read()
|
|
37
|
+
return try saved.entry.map { try JSONDecoder().decode([String: IndianaJSON].self,from:$0) } ?? [:]
|
|
38
|
+
}
|
|
39
|
+
private func accept(_ data: Data, epoch original: UUID) async throws -> [String: IndianaJSON] {
|
|
40
|
+
guard original==epoch else { throw IndianaFunnelError.identityChanged }
|
|
41
|
+
try IndianaSchema.validate("processResponse",data:data)
|
|
42
|
+
let reply=try JSONDecoder().decode([String: IndianaJSON].self,from:data)
|
|
43
|
+
guard let run=reply["run"]?.object, let identity=run["identity"]?.object,
|
|
44
|
+
let scope=reply["scope"], try JSONDecoder().decode(IndianaScope.self,from:JSONEncoder().encode(scope))==configuration.scope else { throw IndianaFunnelError.invalidResponse }
|
|
45
|
+
let tuple: [String: IndianaJSON] = ["scope":scope,"runId":run["id"]!,"source":run["source"]!,"participantId":identity["participantId"]!,"startedAs":identity["startedAs"]!,"runExpiresAt":run["runExpiresAt"]!]
|
|
46
|
+
let binding=identity["binding"]?.object?["id"] ?? .null
|
|
47
|
+
try await update { record in
|
|
48
|
+
var next=record
|
|
49
|
+
if let previous=record["origin"], previous != .object(tuple) { throw IndianaFunnelError.invalidResponse }
|
|
50
|
+
if let old=record["bindingId"], old != .null, binding != old { throw IndianaFunnelError.identityChanged }
|
|
51
|
+
next["origin"] = .object(tuple); next["bindingId"] = binding
|
|
52
|
+
if let settlement=reply["settlement"]?.object, settlement["state"] == .string("settled"),
|
|
53
|
+
let id=settlement["requestId"] {
|
|
54
|
+
for lane in ["pending","close"] where record[lane]?.object?["command"]?.object?["requestId"]==id { next.removeValue(forKey:lane) }
|
|
55
|
+
}
|
|
56
|
+
return next
|
|
57
|
+
}
|
|
58
|
+
guard original==epoch else { throw IndianaFunnelError.identityChanged }
|
|
59
|
+
let revision: (IndianaJSON?) -> Double = { if case .number(let n)=$0 { return n }; return -1 }
|
|
60
|
+
if current==nil || revision(run["revision"])>=revision(current?["revision"]) { current=run }
|
|
61
|
+
if let value=reply["runToken"]?.string { if let old=runToken,old != value { throw IndianaFunnelError.invalidResponse };runToken=value }
|
|
62
|
+
if run["instruction"]?.object?["type"] == .string("authentication_required") { onAuthenticationRequired?(run) }
|
|
63
|
+
return reply
|
|
64
|
+
}
|
|
65
|
+
private func invoke(_ operation: String, _ input: [String: IndianaJSON]) async throws -> [String: IndianaJSON] {
|
|
66
|
+
let original=epoch
|
|
67
|
+
do { return try await accept(bridge.invoke(operation,input:input),epoch:original) }
|
|
68
|
+
catch {
|
|
69
|
+
if let failure=error as? IndianaClientError {
|
|
70
|
+
switch failure { case .identityChanged,.unauthenticated: current=nil;await presentation?.reset();presentation=nil;default:break }
|
|
71
|
+
}
|
|
72
|
+
throw error
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
public func open(_ input: [String: IndianaJSON]) async throws -> [String: IndianaJSON] {
|
|
76
|
+
try IndianaSchema.validate("processOpen",data:bytes(input));_ = try await invoke("open",input)
|
|
77
|
+
try await settlePending(); return try await invoke("get",[:])
|
|
78
|
+
}
|
|
79
|
+
public func syncIdentity() async throws -> [String: IndianaJSON] {
|
|
80
|
+
_ = try await invoke("syncIdentity",[:]);try await settlePending()
|
|
81
|
+
let result=try await invoke("get",[:]);_ = try await presentation?.get();return result
|
|
82
|
+
}
|
|
83
|
+
public func get(_ input: [String: IndianaJSON] = [:]) async throws -> [String: IndianaJSON] {
|
|
84
|
+
var input=input
|
|
85
|
+
if let id=input["settlementRequestId"] {
|
|
86
|
+
let saved=try await record()
|
|
87
|
+
for lane in ["pending","close"] where saved[lane]?.object?["command"]?.object?["requestId"]==id { input["settlementBindingId"]=saved[lane]?.object?["bindingId"] ?? .null }
|
|
88
|
+
}
|
|
89
|
+
return try await invoke("get",input)
|
|
90
|
+
}
|
|
91
|
+
public func command(_ command: [String: IndianaJSON]) async throws -> [String: IndianaJSON] {
|
|
92
|
+
try IndianaSchema.validate("processMutation",data:bytes(command))
|
|
93
|
+
guard let current else { throw IndianaFunnelError.invalidCommand }
|
|
94
|
+
let original=epoch
|
|
95
|
+
let envelope: [String: IndianaJSON] = ["command":.object(command),"bindingId":current["identity"]?.object?["binding"]?.object?["id"] ?? .null]
|
|
96
|
+
let lane=command["operation"] == .string("close") ? "close" : "pending"
|
|
97
|
+
var accepted=envelope
|
|
98
|
+
try await update { record in
|
|
99
|
+
var next=record
|
|
100
|
+
if let pending=record[lane]?.object {
|
|
101
|
+
guard pending["command"]==envelope["command"] else { throw IndianaFunnelError.commandPending };accepted=pending
|
|
102
|
+
} else { next[lane] = .object(envelope) }
|
|
103
|
+
return next
|
|
104
|
+
}
|
|
105
|
+
guard original==epoch else { throw IndianaFunnelError.identityChanged }
|
|
106
|
+
return try await invoke("command",accepted)
|
|
107
|
+
}
|
|
108
|
+
private func settlePending() async throws {
|
|
109
|
+
let saved=try await record()
|
|
110
|
+
for lane in ["pending","close"] {
|
|
111
|
+
guard let pending=saved[lane]?.object, let id=pending["command"]?.object?["requestId"] else { continue }
|
|
112
|
+
_ = try await get(["settlementRequestId":id,"settlementBindingId":pending["bindingId"] ?? .null])
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
/// Reuses the native renderer without a second command journal or a Customer-token installation.
|
|
116
|
+
public func presentationSession() throws -> IndianaFunnelSession {
|
|
117
|
+
if let presentation { return presentation }
|
|
118
|
+
guard let runToken else { throw IndianaFunnelError.invalidCommand }
|
|
119
|
+
let value=try IndianaFunnelSession(configuration:configuration,http:ProcessHTTP(process:self),runToken:runToken)
|
|
120
|
+
presentation=value;return value
|
|
121
|
+
}
|
|
122
|
+
public func detach() async throws {
|
|
123
|
+
epoch=UUID();current=nil;await presentation?.reset();presentation=nil;runToken=nil
|
|
124
|
+
try await bridge.detach()
|
|
125
|
+
}
|
|
126
|
+
fileprivate func rendered(_ command: [String: IndianaJSON]) async throws -> Data {
|
|
127
|
+
let operation=command["operation"]?.string ?? ""
|
|
128
|
+
var input=command;input.removeValue(forKey:"operation");input.removeValue(forKey:"customerToken")
|
|
129
|
+
var reply: [String: IndianaJSON]
|
|
130
|
+
if operation=="get" { reply=try await get(input) }
|
|
131
|
+
else if operation=="review" { reply=try await invoke("review",input) }
|
|
132
|
+
else { var mutation=command;mutation.removeValue(forKey:"customerToken");reply=try await self.command(mutation) }
|
|
133
|
+
reply.removeValue(forKey:"contract")
|
|
134
|
+
var run=reply["run"]!.object!;let identity=run.removeValue(forKey:"identity")!.object!
|
|
135
|
+
run["authenticated"] = .bool(identity["binding"] != .null)
|
|
136
|
+
if run["instruction"]?.object?["type"] == .string("authentication_required") { run["instruction"] = .null }
|
|
137
|
+
reply["run"] = .object(run)
|
|
138
|
+
if var settlement=reply["settlement"]?.object { settlement.removeValue(forKey:"accepted");reply["settlement"] = .object(settlement) }
|
|
139
|
+
return try bytes(reply)
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
private struct ProcessHTTP: IndianaHTTP {
|
|
143
|
+
let process: IndianaFunnelProcess
|
|
144
|
+
func send(path: String,method: String,authorization: String,body: Data?) async throws -> Data {
|
|
145
|
+
guard path=="funnels",method=="POST",let body else { throw IndianaFunnelError.invalidCommand }
|
|
146
|
+
return try await process.rendered(JSONDecoder().decode([String: IndianaJSON].self,from:body))
|
|
147
|
+
}
|
|
148
|
+
}
|
|
@@ -40,6 +40,7 @@ public actor IndianaFunnelSession {
|
|
|
40
40
|
public private(set) var pendingPurchase: [String: IndianaJSON]?
|
|
41
41
|
private var customerToken: String?; private var epoch = UUID(); private var pending: [String: IndianaJSON]?; private var sending = false; private var closingCommand: [String: IndianaJSON]?; private var closeRequested = false; private var uncertain = Set<String>(); private var settlements: [String: String] = [:]
|
|
42
42
|
private var invalidated = false; private var invalidation: (@Sendable () -> Void)?; private var response: Data?
|
|
43
|
+
private var closingSending = false
|
|
43
44
|
public private(set) var current: IndianaFunnelRun?
|
|
44
45
|
public init(configuration: IndianaConfiguration, runToken: String, commandStorage: (any IndianaFunnelCommandStorage)? = nil) throws {
|
|
45
46
|
guard runToken.range(of: "^gg_fr_[A-Za-z0-9_-]{43}$", options: .regularExpression) != nil else { throw IndianaFunnelError.invalidCommand }
|
|
@@ -59,7 +60,10 @@ public actor IndianaFunnelSession {
|
|
|
59
60
|
let record = try await restoration!.value
|
|
60
61
|
guard original == epoch, !invalidated else { throw IndianaFunnelError.identityChanged }
|
|
61
62
|
if restored { return }; restored = true
|
|
62
|
-
|
|
63
|
+
let command = record?.command
|
|
64
|
+
pending = command?["operation"] == .string("close") ? nil : command
|
|
65
|
+
closingCommand = command?["operation"] == .string("close") ? command : record?.closeCommand
|
|
66
|
+
pendingPurchase = record?.purchase; closeRequested = record?.closed == true || closingCommand != nil
|
|
63
67
|
for command in [pending, closingCommand].compactMap({ $0 }) { if let id = command["requestId"]?.string { uncertain.insert(id) } }
|
|
64
68
|
}
|
|
65
69
|
public func identify(customerToken: String) throws {
|
|
@@ -70,10 +74,11 @@ public actor IndianaFunnelSession {
|
|
|
70
74
|
func hostConfiguration() -> IndianaConfiguration { configuration }
|
|
71
75
|
func onInvalidation(_ callback: @escaping @Sendable () -> Void) { invalidation = callback;if invalidated { callback() } }
|
|
72
76
|
func hostPendingCommand() -> IndianaJSON { (closingCommand ?? pending).map(IndianaJSON.object) ?? .null }
|
|
73
|
-
private func request(_ command: [String: IndianaJSON]) async throws -> IndianaFunnelRun {
|
|
77
|
+
private func request(_ command: [String: IndianaJSON], expectedEpoch: UUID? = nil) async throws -> IndianaFunnelRun {
|
|
74
78
|
try await restoreCommands()
|
|
75
79
|
guard !invalidated else { throw IndianaFunnelError.identityChanged }
|
|
76
80
|
let original = epoch;var body = command;if let customerToken { body["customerToken"] = .string(customerToken) }
|
|
81
|
+
guard expectedEpoch == nil || expectedEpoch == original else { throw IndianaFunnelError.identityChanged }
|
|
77
82
|
let encoder = JSONEncoder();encoder.outputFormatting = [.sortedKeys];let input = try encoder.encode(body)
|
|
78
83
|
guard input.count <= 262144 else { throw IndianaFunnelError.invalidCommand };try IndianaSchema.validate("command", data: input)
|
|
79
84
|
let bytes = try await http.send(path: "funnels", method: "POST", authorization: runToken, body: input)
|
|
@@ -133,38 +138,61 @@ public actor IndianaFunnelSession {
|
|
|
133
138
|
}
|
|
134
139
|
private func mutate(_ command: [String: IndianaJSON]) async throws -> IndianaFunnelRun {
|
|
135
140
|
try await restoreCommands()
|
|
141
|
+
guard !invalidated else { throw IndianaFunnelError.identityChanged }
|
|
136
142
|
let closing = command["operation"] == .string("close"), id = command["requestId"]?.string ?? ""
|
|
137
143
|
if closeRequested && !closing { throw IndianaFunnelError.commandPending }
|
|
138
|
-
if
|
|
144
|
+
if closing ? closingSending : sending { throw IndianaFunnelError.commandPending }
|
|
139
145
|
if closing {
|
|
146
|
+
if pending?["requestId"] == .string(id) { throw IndianaFunnelError.invalidCommand }
|
|
140
147
|
closeRequested = true
|
|
141
148
|
if let closingCommand, closingCommand != command { throw IndianaFunnelError.commandPending }
|
|
142
149
|
closingCommand = command
|
|
150
|
+
closingSending = true
|
|
143
151
|
} else {
|
|
144
152
|
if let pending, pending != command { throw IndianaFunnelError.commandPending };pending = command
|
|
153
|
+
sending = true
|
|
145
154
|
}
|
|
146
|
-
let wasUncertain = uncertain.contains(id)
|
|
147
|
-
defer { sending = false }
|
|
155
|
+
let wasUncertain = uncertain.contains(id), original = epoch
|
|
156
|
+
defer { if closing { closingSending = false } else { sending = false } }
|
|
148
157
|
do {
|
|
149
158
|
if let journal { guard let current else { throw IndianaFunnelError.invalidCommand }; try await journal.retain(command, run: current) }
|
|
150
|
-
|
|
159
|
+
guard original == epoch, !invalidated else { throw IndianaFunnelError.identityChanged }
|
|
160
|
+
uncertain.insert(id)
|
|
161
|
+
let run = try await request(command, expectedEpoch: original)
|
|
151
162
|
let settled = settlements[id] == "settled"
|
|
152
163
|
if original == epoch, run.pendingRequest?.requestId.uuidString.lowercased() != id,
|
|
153
164
|
settled,
|
|
154
165
|
(!closeRequested || closing || settled && !["running", "waiting"].contains(run.status)) {
|
|
155
166
|
try await journal?.settle(id)
|
|
167
|
+
guard original == epoch, !invalidated else { throw IndianaFunnelError.identityChanged }
|
|
156
168
|
if pending == command { pending = nil };if closingCommand == command { closingCommand = nil };uncertain.remove(id)
|
|
157
169
|
}
|
|
170
|
+
guard original == epoch, !invalidated else { throw IndianaFunnelError.identityChanged }
|
|
158
171
|
return run
|
|
159
172
|
} catch {
|
|
160
|
-
if original == epoch, !
|
|
173
|
+
if original == epoch, !invalidated, !wasUncertain,
|
|
174
|
+
pending == command || closingCommand == command,
|
|
175
|
+
let failure = error as? IndianaClientError, case .requestFailed(let status) = failure,
|
|
176
|
+
status < 500, status != 401, status != 425 {
|
|
177
|
+
try await journal?.settle(id)
|
|
178
|
+
guard original == epoch, !invalidated else { throw IndianaFunnelError.identityChanged }
|
|
179
|
+
if pending == command { pending = nil };if closingCommand == command { closingCommand = nil };uncertain.remove(id)
|
|
180
|
+
}
|
|
161
181
|
throw error
|
|
162
182
|
}
|
|
163
183
|
}
|
|
164
184
|
public func settle(requestId: UUID) async throws -> IndianaFunnelRun {
|
|
165
|
-
|
|
185
|
+
try await restoreCommands()
|
|
186
|
+
let original = epoch, id = requestId.uuidString.lowercased();settlements.removeValue(forKey: id)
|
|
187
|
+
let run = try await request(["operation": .string("get"), "settlementRequestId": .string(id)], expectedEpoch: original)
|
|
166
188
|
guard let state = settlements[id] else { throw IndianaFunnelError.invalidResponse }
|
|
167
|
-
if state == "settled"
|
|
189
|
+
if state == "settled", run.pendingRequest?.requestId != requestId {
|
|
190
|
+
try await journal?.settle(id)
|
|
191
|
+
guard original == epoch, !invalidated else { throw IndianaFunnelError.identityChanged }
|
|
192
|
+
if pending?["requestId"] == .string(id) { pending = nil };if closingCommand?["requestId"] == .string(id) { closingCommand = nil };uncertain.remove(id)
|
|
193
|
+
}
|
|
194
|
+
guard original == epoch, !invalidated else { throw IndianaFunnelError.identityChanged }
|
|
195
|
+
return run
|
|
168
196
|
}
|
|
169
197
|
private func base(_ operation: String, requestId: UUID) throws -> [String: IndianaJSON] {
|
|
170
198
|
guard let current, !invalidated else { throw IndianaFunnelError.invalidCommand }
|
|
@@ -42,6 +42,7 @@ final class IndianaURLTransport: IndianaHTTP, Sendable {
|
|
|
42
42
|
config.timeoutIntervalForRequest = configuration.timeout; config.timeoutIntervalForResource = configuration.timeout
|
|
43
43
|
self.session = URLSession(configuration: config, delegate: NoRedirect(), delegateQueue: nil)
|
|
44
44
|
}
|
|
45
|
+
deinit { session.invalidateAndCancel() }
|
|
45
46
|
func send(path: String, method: String, authorization: String, body: Data?) async throws -> Data {
|
|
46
47
|
guard !path.contains(".."), !path.contains("?"), !path.contains("#"), !path.hasPrefix("/") else { throw IndianaClientError.invalidConfiguration }
|
|
47
48
|
var request = URLRequest(url: requestOrigin.appendingPathComponent("api/v1").appendingPathComponent(path))
|
|
@@ -54,6 +55,7 @@ final class IndianaURLTransport: IndianaHTTP, Sendable {
|
|
|
54
55
|
request.setValue(IndianaSDK.version, forHTTPHeaderField: "X-Indiana-SDK-Version")
|
|
55
56
|
do {
|
|
56
57
|
let (bytes, response) = try await session.bytes(for: request)
|
|
58
|
+
defer { bytes.task.cancel() }
|
|
57
59
|
guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else {
|
|
58
60
|
throw IndianaClientError.requestFailed((response as? HTTPURLResponse)?.statusCode ?? 0)
|
|
59
61
|
}
|
|
@@ -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.
|
|
181
|
-
init(configuration: IndianaConfiguration, http: any IndianaHTTP, store: (any IndianaAppleStore)? = nil,
|
|
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;
|
|
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;
|
|
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
|
-
///
|
|
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
|
|
332
|
-
|
|
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
|
-
|
|
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)
|