@tinkerlandai/indiana-react-native 0.9.1 → 0.9.5
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/IndianaReactNative.podspec +19 -0
- package/README.md +61 -4
- package/THIRD_PARTY_NOTICES.md +74 -0
- package/android/build.gradle +24 -0
- package/android/src/main/AndroidManifest.xml +3 -0
- package/android/src/main/java/ai/tinkerland/indiana/reactnative/IndianaBridgeModule.kt +237 -0
- package/android/src/main/java/ai/tinkerland/indiana/reactnative/IndianaBridgePackage.kt +11 -0
- package/dist/index.cjs +17 -1
- package/dist/index.d.cts +57408 -604
- package/dist/index.d.ts +57408 -604
- package/dist/index.js +17 -1
- package/ios/IndianaBridge.m +11 -0
- package/ios/IndianaBridge.swift +275 -0
- package/native-sdk/android/assets/indiana/index.html +268 -0
- package/native-sdk/android/java/ai/tinkerland/indiana/BillingAuthorizationTerms.kt +55 -0
- package/native-sdk/android/java/ai/tinkerland/indiana/BundledWebView.kt +90 -0
- package/native-sdk/android/java/ai/tinkerland/indiana/ContentSession.kt +45 -0
- package/native-sdk/android/java/ai/tinkerland/indiana/ContentView.kt +36 -0
- package/native-sdk/android/java/ai/tinkerland/indiana/CustomerOperations.generated.kt +84 -0
- package/native-sdk/android/java/ai/tinkerland/indiana/CustomerResponseSemantics.kt +193 -0
- package/native-sdk/android/java/ai/tinkerland/indiana/CustomerSemantics.kt +120 -0
- package/native-sdk/android/java/ai/tinkerland/indiana/CustomerSession.kt +106 -0
- package/native-sdk/android/java/ai/tinkerland/indiana/FeatureClient.kt +65 -0
- package/native-sdk/android/java/ai/tinkerland/indiana/FunnelCommandStorage.kt +163 -0
- package/native-sdk/android/java/ai/tinkerland/indiana/FunnelConfigurationRelay.kt +50 -0
- package/native-sdk/android/java/ai/tinkerland/indiana/FunnelSession.kt +208 -0
- package/native-sdk/android/java/ai/tinkerland/indiana/FunnelView.kt +71 -0
- package/native-sdk/android/java/ai/tinkerland/indiana/GeneratedProtocol.kt +16 -0
- package/native-sdk/android/java/ai/tinkerland/indiana/GoogleStorefront.kt +165 -0
- package/native-sdk/android/java/ai/tinkerland/indiana/HTTPTransport.kt +43 -0
- package/native-sdk/android/java/ai/tinkerland/indiana/IndianaClient.kt +526 -0
- package/native-sdk/android/java/ai/tinkerland/indiana/NativePendingKeystore.kt +81 -0
- package/native-sdk/android/java/ai/tinkerland/indiana/NativeReceiptRelay.kt +28 -0
- package/native-sdk/android/java/ai/tinkerland/indiana/NativeStoreOffer.kt +24 -0
- package/native-sdk/android/java/ai/tinkerland/indiana/PaywallContent.kt +75 -0
- package/native-sdk/android/java/ai/tinkerland/indiana/PaywallSession.kt +144 -0
- package/native-sdk/android/java/ai/tinkerland/indiana/PaywallView.kt +74 -0
- package/native-sdk/android/java/ai/tinkerland/indiana/Protocol.kt +28 -0
- package/native-sdk/android/java/ai/tinkerland/indiana/PushInstallation.kt +170 -0
- package/native-sdk/android/java/ai/tinkerland/indiana/RendererNavigation.kt +13 -0
- package/native-sdk/android/java/ai/tinkerland/indiana/SDKVersion.kt +6 -0
- package/native-sdk/android/java/ai/tinkerland/indiana/StoreSubscriptionManagement.kt +19 -0
- package/native-sdk/android/java/ai/tinkerland/indiana/WireSchema.kt +76 -0
- package/native-sdk/android/resources/funnel-wire-schemas.json +1 -0
- package/native-sdk/android/resources/indiana-third-party-notices.txt +101 -0
- package/native-sdk/android/resources/renderer-data-url.txt +1 -0
- package/native-sdk/ios/AnalyticsReceipt.swift +10 -0
- package/native-sdk/ios/AppleStorefront.swift +218 -0
- package/native-sdk/ios/BillingAuthorizationTerms.swift +57 -0
- package/native-sdk/ios/BundledWebView.swift +87 -0
- package/native-sdk/ios/ContentSession.swift +48 -0
- package/native-sdk/ios/ContentView.swift +51 -0
- package/native-sdk/ios/CustomerOperations.generated.swift +166 -0
- package/native-sdk/ios/CustomerResponseSemantics.swift +232 -0
- package/native-sdk/ios/CustomerSemantics.swift +145 -0
- package/native-sdk/ios/CustomerSession.swift +129 -0
- package/native-sdk/ios/FeatureClient.swift +86 -0
- package/native-sdk/ios/FunnelCommandStorage.swift +163 -0
- package/native-sdk/ios/FunnelConfigurationRelay.swift +48 -0
- package/native-sdk/ios/FunnelSession.swift +232 -0
- package/native-sdk/ios/FunnelView.swift +125 -0
- package/native-sdk/ios/GeneratedProtocol.swift +16 -0
- package/native-sdk/ios/HTTPTransport.swift +74 -0
- package/native-sdk/ios/IndianaClient.swift +601 -0
- package/native-sdk/ios/NativePendingKeychain.swift +52 -0
- package/native-sdk/ios/NativeReceiptRelay.swift +28 -0
- package/native-sdk/ios/NativeStoreOffer.swift +34 -0
- package/native-sdk/ios/PaywallContent.swift +75 -0
- package/native-sdk/ios/PaywallSession.swift +124 -0
- package/native-sdk/ios/PaywallView.swift +87 -0
- package/native-sdk/ios/Protocol.swift +49 -0
- package/native-sdk/ios/PushInstallation.swift +154 -0
- package/native-sdk/ios/RendererNavigation.swift +8 -0
- package/native-sdk/ios/Resources.swift +15 -0
- package/native-sdk/ios/SDKVersion.swift +4 -0
- package/native-sdk/ios/StoreSubscriptionManagement.swift +11 -0
- package/native-sdk/ios/WebView/THIRD_PARTY_NOTICES.txt +101 -0
- package/native-sdk/ios/WebView/index.html +268 -0
- package/native-sdk/ios/WebView/renderer-data-url.txt +1 -0
- package/native-sdk/ios/WireSchema.swift +107 -0
- package/native-sdk/ios/funnel-wire-schemas.json +1 -0
- package/package.json +8 -3
- package/react-native.config.cjs +12 -0
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
import Security
|
|
3
|
+
import CryptoKit
|
|
4
|
+
import Darwin
|
|
5
|
+
|
|
6
|
+
public struct IndianaFunnelStorageSnapshot: Sendable {
|
|
7
|
+
public let revision: Int
|
|
8
|
+
public let entry: Data?
|
|
9
|
+
public init(revision: Int, entry: Data?) { self.revision = revision; self.entry = entry }
|
|
10
|
+
}
|
|
11
|
+
/// One original Run per adapter. CAS must be atomic across all participating hosts.
|
|
12
|
+
public protocol IndianaFunnelCommandStorage: Sendable {
|
|
13
|
+
func read() async throws -> IndianaFunnelStorageSnapshot
|
|
14
|
+
func write(expectedRevision: Int, entry: Data?) async throws -> Int?
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/// This-device-only storage. Neither Run nor Customer credentials are persisted.
|
|
18
|
+
actor IndianaFunnelCommandKeychain: IndianaFunnelCommandStorage {
|
|
19
|
+
private let service: String
|
|
20
|
+
private let lockURL: URL
|
|
21
|
+
private struct Stored: Codable { let revision: Int; let entry: Data? }
|
|
22
|
+
init(configuration: IndianaConfiguration, runToken: String) throws {
|
|
23
|
+
let namespace = "\(configuration.baseURL.absoluteString)|\(configuration.scope.projectId)|\(configuration.scope.environment.rawValue)|\(runToken)"
|
|
24
|
+
let hash = SHA256.hash(data: Data(namespace.utf8)).map { String(format: "%02x", $0) }.joined()
|
|
25
|
+
service = "\(Bundle.main.bundleIdentifier ?? "Indiana").indiana.funnel.commands.v1.\(hash)"
|
|
26
|
+
let folder = try FileManager.default.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true).appendingPathComponent("IndianaFunnelCommandLocks", isDirectory: true)
|
|
27
|
+
try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true, attributes: [.posixPermissions: 0o700])
|
|
28
|
+
lockURL = folder.appendingPathComponent(hash + ".lock")
|
|
29
|
+
}
|
|
30
|
+
private var query: [String: Any] { [kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: service, kSecAttrAccount as String: "snapshot"] }
|
|
31
|
+
private func locked<T>(_ work: () throws -> T) throws -> T {
|
|
32
|
+
let fd = open(lockURL.path, O_CREAT | O_RDWR, 0o600)
|
|
33
|
+
guard fd >= 0 else { throw IndianaClientError.protectedStorageUnavailable }
|
|
34
|
+
defer { Darwin.close(fd) }
|
|
35
|
+
guard flock(fd, LOCK_EX) == 0 else { throw IndianaClientError.protectedStorageUnavailable }
|
|
36
|
+
defer { flock(fd, LOCK_UN) }
|
|
37
|
+
return try work()
|
|
38
|
+
}
|
|
39
|
+
private func stored() throws -> Stored {
|
|
40
|
+
var result: CFTypeRef?
|
|
41
|
+
let status = SecItemCopyMatching(query.merging([kSecReturnData as String: true]) { _, new in new } as CFDictionary, &result)
|
|
42
|
+
if status == errSecItemNotFound { return Stored(revision: 0, entry: nil) }
|
|
43
|
+
guard status == errSecSuccess, let bytes = result as? Data, bytes.count <= 1_048_576,
|
|
44
|
+
let value = try? JSONDecoder().decode(Stored.self, from: bytes), value.revision >= 0 else { throw IndianaClientError.protectedStorageUnavailable }
|
|
45
|
+
return value
|
|
46
|
+
}
|
|
47
|
+
func read() throws -> IndianaFunnelStorageSnapshot { try locked { let value = try stored(); return IndianaFunnelStorageSnapshot(revision: value.revision, entry: value.entry) } }
|
|
48
|
+
func write(expectedRevision: Int, entry: Data?) throws -> Int? {
|
|
49
|
+
try locked {
|
|
50
|
+
let previous = try stored()
|
|
51
|
+
guard previous.revision == expectedRevision else { return nil }
|
|
52
|
+
guard expectedRevision < Int.max, (entry?.count ?? 0) <= 600_000 else { throw IndianaClientError.protectedStorageUnavailable }
|
|
53
|
+
let bytes = try JSONEncoder().encode(Stored(revision: expectedRevision + 1, entry: entry))
|
|
54
|
+
let status = SecItemUpdate(query as CFDictionary, [kSecValueData as String: bytes] as CFDictionary)
|
|
55
|
+
if status == errSecItemNotFound {
|
|
56
|
+
let added = SecItemAdd(query.merging([kSecValueData as String: bytes, kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlockedThisDeviceOnly]) { _, new in new } as CFDictionary, nil)
|
|
57
|
+
guard added == errSecSuccess else { throw IndianaClientError.protectedStorageUnavailable }
|
|
58
|
+
} else if status != errSecSuccess { throw IndianaClientError.protectedStorageUnavailable }
|
|
59
|
+
return expectedRevision + 1
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
struct IndianaFunnelRecoveryRecord: Codable, Sendable, Equatable {
|
|
65
|
+
let schemaVersion: Int
|
|
66
|
+
let scope: IndianaScope
|
|
67
|
+
let runId: UUID
|
|
68
|
+
let funnelId: UUID
|
|
69
|
+
let versionId: UUID
|
|
70
|
+
var command: [String: IndianaJSON]?
|
|
71
|
+
var closeCommand: [String: IndianaJSON]?
|
|
72
|
+
var purchase: [String: IndianaJSON]?
|
|
73
|
+
var closed: Bool
|
|
74
|
+
func matches(_ run: IndianaFunnelRun) -> Bool { run.id == runId && run.source.id == funnelId && run.source.versionId == versionId }
|
|
75
|
+
}
|
|
76
|
+
actor IndianaFunnelCommandJournal {
|
|
77
|
+
private let storage: any IndianaFunnelCommandStorage
|
|
78
|
+
private let scope: IndianaScope
|
|
79
|
+
private var revision = 0
|
|
80
|
+
private var binding: IndianaFunnelRecoveryRecord?
|
|
81
|
+
private var updateTail: Task<Void, Never>?
|
|
82
|
+
init(storage: any IndianaFunnelCommandStorage, scope: IndianaScope) { self.storage = storage; self.scope = scope }
|
|
83
|
+
private func read() async throws -> (Int, IndianaFunnelRecoveryRecord?) {
|
|
84
|
+
let saved = try await storage.read()
|
|
85
|
+
guard saved.revision >= 0 else { throw IndianaFunnelError.invalidResponse }
|
|
86
|
+
guard let bytes = saved.entry else { return (saved.revision, nil) }
|
|
87
|
+
guard bytes.count <= 600_000 else { throw IndianaFunnelError.invalidResponse }
|
|
88
|
+
let record = try JSONDecoder().decode(IndianaFunnelRecoveryRecord.self, from: bytes)
|
|
89
|
+
guard record.schemaVersion == 1, record.scope == scope, record.command != nil || record.purchase != nil,
|
|
90
|
+
record.closeCommand == nil || record.command != nil && record.command?["operation"] != .string("close") && record.command?["requestId"] != record.closeCommand?["requestId"] else { throw IndianaFunnelError.invalidResponse }
|
|
91
|
+
for command in [record.command, record.closeCommand].compactMap({ $0 }) {
|
|
92
|
+
guard command["customerToken"] == nil, !["start", "get", "review"].contains(command["operation"]?.string ?? "") else { throw IndianaFunnelError.invalidCommand }
|
|
93
|
+
try IndianaSchema.validate("command", data: JSONEncoder().encode(IndianaJSON.object(command)))
|
|
94
|
+
}
|
|
95
|
+
if let purchase = record.purchase {
|
|
96
|
+
guard Set(purchase.keys) == Set(["receiptId", "operationId", "generation"]),
|
|
97
|
+
UUID(uuidString: purchase["receiptId"]?.string ?? "") != nil, UUID(uuidString: purchase["operationId"]?.string ?? "") != nil,
|
|
98
|
+
case .number(let generation) = purchase["generation"], generation >= 0, generation.rounded() == generation else { throw IndianaFunnelError.invalidResponse }
|
|
99
|
+
}
|
|
100
|
+
return (saved.revision, record)
|
|
101
|
+
}
|
|
102
|
+
func load() async throws -> IndianaFunnelRecoveryRecord? { let saved = try await read(); revision = saved.0; binding = saved.1; return saved.1 }
|
|
103
|
+
func bind(_ run: IndianaFunnelRun) throws { if let binding, !binding.matches(run) { throw IndianaFunnelError.invalidResponse } }
|
|
104
|
+
private func update(_ change: @escaping @Sendable (IndianaFunnelRecoveryRecord?, Int) throws -> IndianaFunnelRecoveryRecord?) async throws {
|
|
105
|
+
let previous = updateTail
|
|
106
|
+
let job = Task { if let previous { await previous.value }; try await apply(change) }
|
|
107
|
+
updateTail = Task { _ = try? await job.value }
|
|
108
|
+
try await job.value
|
|
109
|
+
}
|
|
110
|
+
private func apply(_ change: @Sendable (IndianaFunnelRecoveryRecord?, Int) throws -> IndianaFunnelRecoveryRecord?) async throws {
|
|
111
|
+
for _ in 0..<4 {
|
|
112
|
+
let saved = try await read()
|
|
113
|
+
if let binding, let current = saved.1, binding.runId != current.runId || binding.versionId != current.versionId || binding.funnelId != current.funnelId { throw IndianaFunnelError.commandPending }
|
|
114
|
+
let next = try change(saved.1, saved.0)
|
|
115
|
+
if next == saved.1 { revision = saved.0; return }
|
|
116
|
+
let bytes = try next.map { try JSONEncoder().encode($0) }
|
|
117
|
+
if let nextRevision = try await storage.write(expectedRevision: saved.0, entry: bytes) { revision = nextRevision; binding = next ?? binding; return }
|
|
118
|
+
}
|
|
119
|
+
throw IndianaFunnelError.commandPending
|
|
120
|
+
}
|
|
121
|
+
func retain(_ command: [String: IndianaJSON], run: IndianaFunnelRun) async throws {
|
|
122
|
+
let bytes = try JSONEncoder().encode(IndianaJSON.object(command))
|
|
123
|
+
guard command["customerToken"] == nil, bytes.count <= 262144, !["start", "get", "review"].contains(command["operation"]?.string ?? "") else { throw IndianaFunnelError.invalidCommand }
|
|
124
|
+
try IndianaSchema.validate("command", data: bytes)
|
|
125
|
+
try bind(run)
|
|
126
|
+
let scope = self.scope
|
|
127
|
+
let knownRevision = revision
|
|
128
|
+
try await update { saved, currentRevision in
|
|
129
|
+
var value = saved ?? IndianaFunnelRecoveryRecord(schemaVersion: 1, scope: scope, runId: run.id, funnelId: run.source.id, versionId: run.source.versionId, closed: false)
|
|
130
|
+
guard value.matches(run), saved != nil || currentRevision == knownRevision || command["operation"] == .string("close") else { throw IndianaFunnelError.commandPending }
|
|
131
|
+
if command["operation"] == .string("close") {
|
|
132
|
+
value.closed = true
|
|
133
|
+
if let original = value.command, original["operation"] != .string("close") {
|
|
134
|
+
guard original["requestId"] != command["requestId"], value.closeCommand == nil || value.closeCommand == command else { throw IndianaFunnelError.commandPending }; value.closeCommand = command
|
|
135
|
+
} else { guard value.command == nil || value.command == command else { throw IndianaFunnelError.commandPending }; value.command = command }
|
|
136
|
+
} else { guard value.command == nil || value.command == command else { throw IndianaFunnelError.commandPending }; value.command = command }
|
|
137
|
+
return value
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
func purchase(_ purchase: [String: IndianaJSON], terminal: Bool, run: IndianaFunnelRun) async throws {
|
|
141
|
+
try bind(run); let knownRevision = revision
|
|
142
|
+
let scope = self.scope
|
|
143
|
+
try await update { saved, currentRevision in
|
|
144
|
+
if saved == nil && currentRevision != knownRevision { throw IndianaFunnelError.commandPending }
|
|
145
|
+
var value = saved ?? IndianaFunnelRecoveryRecord(schemaVersion: 1, scope: scope, runId: run.id, funnelId: run.source.id, versionId: run.source.versionId, closed: false)
|
|
146
|
+
if case .number(let old) = value.purchase?["generation"], case .number(let next) = purchase["generation"] {
|
|
147
|
+
if old > next { return saved }
|
|
148
|
+
if old == next && value.purchase?["operationId"] != purchase["operationId"] { throw IndianaFunnelError.commandPending }
|
|
149
|
+
}
|
|
150
|
+
if terminal { if value.purchase?["operationId"] == purchase["operationId"] { value.purchase = nil } }
|
|
151
|
+
else { value.purchase = purchase }
|
|
152
|
+
return value.command == nil && value.purchase == nil ? nil : value
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
func settle(_ id: String) async throws {
|
|
156
|
+
try await update { saved, _ in
|
|
157
|
+
guard var value = saved else { return nil }
|
|
158
|
+
if value.command?["requestId"] == .string(id) { value.command = value.closeCommand; value.closeCommand = nil }
|
|
159
|
+
else if value.closeCommand?["requestId"] == .string(id) { value.closeCommand = nil }
|
|
160
|
+
return value.command == nil && value.purchase == nil ? nil : value
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
|
|
3
|
+
/// The Funnel shell has no network access (`connect-src 'none'`). For Local rules it asks the native host
|
|
4
|
+
/// for public configuration: one exact GET, without credentials, cookies or redirects.
|
|
5
|
+
final class IndianaFunnelConfigurationRelay: Sendable {
|
|
6
|
+
static let maxBytes = 1_048_576
|
|
7
|
+
static let headers: Set<String> = ["age", "content-type", "date", "etag", "retry-after", "x-indiana-config-valid-until", "x-indiana-config-validated-at"]
|
|
8
|
+
private let baseURL: URL
|
|
9
|
+
private let session: URLSession
|
|
10
|
+
init(configuration: IndianaConfiguration, sessionConfiguration: URLSessionConfiguration = .ephemeral) {
|
|
11
|
+
baseURL = configuration.baseURL
|
|
12
|
+
let config = sessionConfiguration.copy() as! URLSessionConfiguration
|
|
13
|
+
config.urlCache = nil; config.httpCookieStorage = nil; config.httpShouldSetCookies = false; config.requestCachePolicy = .reloadIgnoringLocalCacheData
|
|
14
|
+
config.timeoutIntervalForRequest = configuration.timeout; config.timeoutIntervalForResource = configuration.timeout
|
|
15
|
+
session = URLSession(configuration: config, delegate: RefuseRedirect(), delegateQueue: nil)
|
|
16
|
+
}
|
|
17
|
+
deinit { session.invalidateAndCancel() }
|
|
18
|
+
func read(_ payload: IndianaJSON) async throws -> IndianaJSON {
|
|
19
|
+
guard let object = payload.object, object.count == 1, let path = object["path"]?.string,
|
|
20
|
+
path.range(of: "^/api/v1/configuration/[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}(/artifacts/[a-f0-9]{64})?$", options: .regularExpression) != nil,
|
|
21
|
+
let url = URL(string: path, relativeTo: baseURL)?.absoluteURL else { throw IndianaFunnelError.invalidCommand }
|
|
22
|
+
var request = URLRequest(url: url); request.httpMethod = "GET"
|
|
23
|
+
request.cachePolicy = path.contains("/artifacts/") ? .useProtocolCachePolicy : .reloadIgnoringLocalCacheData
|
|
24
|
+
do {
|
|
25
|
+
let (bytes, response) = try await session.bytes(for: request)
|
|
26
|
+
guard let http = response as? HTTPURLResponse else { throw IndianaClientError.temporaryUnavailable }
|
|
27
|
+
var headers: [String: IndianaJSON] = [:]
|
|
28
|
+
for (key, value) in http.allHeaderFields {
|
|
29
|
+
if let key = (key as? String)?.lowercased(), Self.headers.contains(key), let value = value as? String { headers[key] = .string(value) }
|
|
30
|
+
}
|
|
31
|
+
var data = Data()
|
|
32
|
+
// The shell reports other statuses (including 429 Retry-After) by status alone.
|
|
33
|
+
if (200..<300).contains(http.statusCode) {
|
|
34
|
+
if response.expectedContentLength > Self.maxBytes { throw IndianaClientError.invalidResponse }
|
|
35
|
+
for try await byte in bytes {
|
|
36
|
+
guard data.count < Self.maxBytes else { throw IndianaClientError.invalidResponse }
|
|
37
|
+
data.append(byte)
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
guard let body = String(data: data, encoding: .utf8) else { throw IndianaClientError.invalidResponse }
|
|
41
|
+
return .object(["status": .number(Double(http.statusCode)), "headers": .object(headers), "body": .string(body)])
|
|
42
|
+
} catch let error as IndianaClientError { throw error }
|
|
43
|
+
catch { throw IndianaClientError.temporaryUnavailable }
|
|
44
|
+
}
|
|
45
|
+
private final class RefuseRedirect: NSObject, URLSessionTaskDelegate, Sendable {
|
|
46
|
+
func urlSession(_ session: URLSession, task: URLSessionTask, willPerformHTTPRedirection response: HTTPURLResponse, newRequest request: URLRequest, completionHandler: @escaping (URLRequest?) -> Void) { completionHandler(nil) }
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
|
|
3
|
+
public indirect enum IndianaJSON: Codable, Sendable, Equatable {
|
|
4
|
+
case object([String: IndianaJSON]), array([IndianaJSON]), string(String), number(Double), bool(Bool), null
|
|
5
|
+
public init(from decoder: Decoder) throws {
|
|
6
|
+
let c = try decoder.singleValueContainer()
|
|
7
|
+
if c.decodeNil() { self = .null }
|
|
8
|
+
else if let v = try? c.decode(Bool.self) { self = .bool(v) }
|
|
9
|
+
else if let v = try? c.decode(String.self) { self = .string(v) }
|
|
10
|
+
else if let v = try? c.decode(Double.self), v.isFinite { self = .number(v) }
|
|
11
|
+
else if let v = try? c.decode([String: IndianaJSON].self) { self = .object(v) }
|
|
12
|
+
else { self = .array(try c.decode([IndianaJSON].self)) }
|
|
13
|
+
}
|
|
14
|
+
public func encode(to encoder: Encoder) throws {
|
|
15
|
+
var c = encoder.singleValueContainer()
|
|
16
|
+
switch self { case .object(let v): try c.encode(v); case .array(let v): try c.encode(v); case .string(let v): try c.encode(v); case .number(let v): try c.encode(v); case .bool(let v): try c.encode(v); case .null: try c.encodeNil() }
|
|
17
|
+
}
|
|
18
|
+
var object: [String: IndianaJSON]? { if case .object(let value) = self { return value }; return nil }
|
|
19
|
+
var string: String? { if case .string(let value) = self { return value }; return nil }
|
|
20
|
+
}
|
|
21
|
+
public struct IndianaExecutionSource: Codable, Sendable, Equatable { public let type: String; public let id: UUID; public let versionId: UUID }
|
|
22
|
+
public struct IndianaFunnelRun: Codable, Sendable {
|
|
23
|
+
public let id: UUID; public let source: IndianaExecutionSource
|
|
24
|
+
public let revision: Int; public let visit: Int; public let nodeId: String; public let nodeType: String; public let status: String
|
|
25
|
+
public let canBack: Bool; public let authenticated: Bool; public let locale: String
|
|
26
|
+
public let answers: [String: IndianaJSON]; public let parameters: [String: IndianaJSON]; public let variables: [String: IndianaJSON]
|
|
27
|
+
public let output: IndianaJSON?; public let screen: IndianaJSON?; public let paywall: IndianaJSON?; public let instruction: IndianaJSON?
|
|
28
|
+
public let runExpiresAt: String; public let credentialExpiresAt: String?; public let pendingRequest: Pending?
|
|
29
|
+
public struct Pending: Codable, Sendable { public let requestId: UUID; public let authenticationRequired: Bool }
|
|
30
|
+
}
|
|
31
|
+
public enum IndianaFunnelError: Error { case invalidCommand, commandPending, invalidResponse, identityChanged, pendingPayment }
|
|
32
|
+
/// A Funnel session owns navigation only. Native receipt recovery and financial operations belong to IndianaClient.
|
|
33
|
+
public actor IndianaFunnelSession {
|
|
34
|
+
private let configuration: IndianaConfiguration; private let http: any IndianaHTTP; private let runToken: String
|
|
35
|
+
private var publicPaywallContent: (@Sendable (IndianaJSON) async throws -> IndianaJSON)?
|
|
36
|
+
private var publicPresentation: [String: IndianaJSON]?
|
|
37
|
+
private let journal: IndianaFunnelCommandJournal?
|
|
38
|
+
private var restoration: Task<IndianaFunnelRecoveryRecord?, Error>?
|
|
39
|
+
private var restored = false
|
|
40
|
+
public private(set) var pendingPurchase: [String: IndianaJSON]?
|
|
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
|
+
private var invalidated = false; private var invalidation: (@Sendable () -> Void)?; private var response: Data?
|
|
43
|
+
public private(set) var current: IndianaFunnelRun?
|
|
44
|
+
public init(configuration: IndianaConfiguration, runToken: String, commandStorage: (any IndianaFunnelCommandStorage)? = nil) throws {
|
|
45
|
+
guard runToken.range(of: "^gg_fr_[A-Za-z0-9_-]{43}$", options: .regularExpression) != nil else { throw IndianaFunnelError.invalidCommand }
|
|
46
|
+
self.configuration = configuration;self.http = IndianaURLTransport(configuration: configuration);self.runToken = runToken
|
|
47
|
+
journal = IndianaFunnelCommandJournal(storage: try commandStorage ?? IndianaFunnelCommandKeychain(configuration: configuration, runToken: runToken), scope: configuration.scope)
|
|
48
|
+
}
|
|
49
|
+
init(configuration: IndianaConfiguration, http: any IndianaHTTP, runToken: String, commandStorage: (any IndianaFunnelCommandStorage)? = nil, publicPaywallContent: (@Sendable (IndianaJSON) async throws -> IndianaJSON)? = nil) throws {
|
|
50
|
+
self.publicPaywallContent = publicPaywallContent
|
|
51
|
+
guard runToken.range(of: "^gg_fr_[A-Za-z0-9_-]{43}$", options: .regularExpression) != nil else { throw IndianaFunnelError.invalidCommand }
|
|
52
|
+
self.configuration = configuration;self.http = http;self.runToken = runToken
|
|
53
|
+
journal = commandStorage.map { IndianaFunnelCommandJournal(storage: $0, scope: configuration.scope) }
|
|
54
|
+
}
|
|
55
|
+
private func restoreCommands() async throws {
|
|
56
|
+
guard !restored, let journal else { return }
|
|
57
|
+
if restoration == nil { restoration = Task { try await journal.load() } }
|
|
58
|
+
let original = epoch
|
|
59
|
+
let record = try await restoration!.value
|
|
60
|
+
guard original == epoch, !invalidated else { throw IndianaFunnelError.identityChanged }
|
|
61
|
+
if restored { return }; restored = true
|
|
62
|
+
pending = record?.command; closingCommand = record?.closeCommand; pendingPurchase = record?.purchase; closeRequested = record?.closed == true || pending?["operation"] == .string("close")
|
|
63
|
+
for command in [pending, closingCommand].compactMap({ $0 }) { if let id = command["requestId"]?.string { uncertain.insert(id) } }
|
|
64
|
+
}
|
|
65
|
+
public func identify(customerToken: String) throws {
|
|
66
|
+
guard !invalidated, customerToken.range(of: "^gg_ct_[A-Za-z0-9_-]{32,128}$", options: .regularExpression) != nil else { throw IndianaClientError.unauthenticated }
|
|
67
|
+
epoch = UUID();self.customerToken = customerToken;response = nil;publicPresentation = nil
|
|
68
|
+
}
|
|
69
|
+
public func reset() { epoch = UUID();invalidated = true;customerToken = nil;pending = nil;closingCommand = nil;pendingPurchase = nil;uncertain.removeAll();settlements.removeAll();current = nil;response = nil;publicPresentation = nil;invalidation?();invalidation = nil }
|
|
70
|
+
func hostConfiguration() -> IndianaConfiguration { configuration }
|
|
71
|
+
func onInvalidation(_ callback: @escaping @Sendable () -> Void) { invalidation = callback;if invalidated { callback() } }
|
|
72
|
+
func hostPendingCommand() -> IndianaJSON { (closingCommand ?? pending).map(IndianaJSON.object) ?? .null }
|
|
73
|
+
private func request(_ command: [String: IndianaJSON]) async throws -> IndianaFunnelRun {
|
|
74
|
+
try await restoreCommands()
|
|
75
|
+
guard !invalidated else { throw IndianaFunnelError.identityChanged }
|
|
76
|
+
let original = epoch;var body = command;if let customerToken { body["customerToken"] = .string(customerToken) }
|
|
77
|
+
let encoder = JSONEncoder();encoder.outputFormatting = [.sortedKeys];let input = try encoder.encode(body)
|
|
78
|
+
guard input.count <= 262144 else { throw IndianaFunnelError.invalidCommand };try IndianaSchema.validate("command", data: input)
|
|
79
|
+
let bytes = try await http.send(path: "funnels", method: "POST", authorization: runToken, body: input)
|
|
80
|
+
guard original == epoch, !invalidated else { throw IndianaFunnelError.identityChanged }
|
|
81
|
+
try IndianaSchema.validate("response", data: bytes)
|
|
82
|
+
struct WireScope: Decodable { let scope: IndianaScope }
|
|
83
|
+
guard try JSONDecoder().decode(WireScope.self, from: bytes).scope == configuration.scope else { throw IndianaFunnelError.invalidResponse }
|
|
84
|
+
struct Reply: Decodable { let scope: IndianaScope; let run: IndianaFunnelRun }
|
|
85
|
+
let reply = try JSONDecoder().decode(Reply.self, from: bytes)
|
|
86
|
+
let object = try JSONDecoder().decode(IndianaJSON.self, from: bytes).object
|
|
87
|
+
if let id = command["settlementRequestId"]?.string ?? command["requestId"]?.string {
|
|
88
|
+
guard object?["settlement"]?.object?["requestId"] == .string(id) else { throw IndianaFunnelError.invalidResponse }
|
|
89
|
+
}
|
|
90
|
+
guard reply.scope == configuration.scope, reply.run.source.type == "funnel" else { throw IndianaFunnelError.invalidResponse }
|
|
91
|
+
try await journal?.bind(reply.run)
|
|
92
|
+
guard original == epoch, !invalidated else { throw IndianaFunnelError.identityChanged }
|
|
93
|
+
if let current, current.id != reply.run.id || current.source != reply.run.source || current.locale != reply.run.locale || current.parameters != reply.run.parameters { throw IndianaFunnelError.invalidResponse }
|
|
94
|
+
if let instruction = reply.run.instruction?.object, instruction["type"] == .string("open_url") {
|
|
95
|
+
guard let text = instruction["url"]?.string, let url = URL(string: text), url.scheme == "https", url.host != nil, url.user == nil, url.password == nil else { throw IndianaFunnelError.invalidResponse }
|
|
96
|
+
}
|
|
97
|
+
if let settlement = object?["settlement"]?.object, let id = settlement["requestId"]?.string, let state = settlement["state"]?.string { settlements[id] = state }
|
|
98
|
+
if let previous = current?.paywall?.object, let incoming = reply.run.paywall?.object, previous["receiptId"] == incoming["receiptId"],
|
|
99
|
+
previous["resolution"]?.object?["content"]?.object?["artifact"]?.object?["sha256"] != incoming["resolution"]?.object?["content"]?.object?["artifact"]?.object?["sha256"] { throw IndianaFunnelError.invalidResponse }
|
|
100
|
+
if let current, let previous = current.paywall?.object, let incoming = reply.run.paywall?.object,
|
|
101
|
+
previous["receiptId"] == incoming["receiptId"], case .number(let old) = previous["selectionGeneration"],
|
|
102
|
+
case .number(let next) = incoming["selectionGeneration"], next < old { return current }
|
|
103
|
+
if journal != nil, command["operation"] != .string("review"), current == nil || reply.run.revision >= current!.revision {
|
|
104
|
+
let purchase = reply.run.paywall?.object ?? object?["purchase"]?.object
|
|
105
|
+
let operation = purchase?["operation"]?.object
|
|
106
|
+
let rawSettlement = object?["settlement"]?.object
|
|
107
|
+
let paywall = reply.run.paywall?.object
|
|
108
|
+
let settlement = (paywall == nil || paywall?["purchaseRequestId"] == rawSettlement?["requestId"]) &&
|
|
109
|
+
(pendingPurchase == nil || pendingPurchase?["operationId"] == rawSettlement?["operationId"]) ? rawSettlement : nil
|
|
110
|
+
if let receipt = purchase?["receiptId"] ?? settlement?["receiptId"], receipt != .null,
|
|
111
|
+
let operationId = operation?["operationId"] ?? purchase?["quote"]?.object?["operationId"] ?? settlement?["operationId"], operationId != .null {
|
|
112
|
+
let generation = reply.run.paywall?.object?["selectionGeneration"] ?? pendingPurchase?["generation"] ?? .number(0)
|
|
113
|
+
let reference: [String: IndianaJSON] = ["receiptId": receipt, "operationId": operationId, "generation": generation]
|
|
114
|
+
let terminal = operation?["status"] == .string("complete") || purchase?["canReselect"] == .bool(true)
|
|
115
|
+
let stale: Bool
|
|
116
|
+
if case .number(let old) = pendingPurchase?["generation"], case .number(let next) = generation { stale = next < old } else { stale = false }
|
|
117
|
+
if !stale {
|
|
118
|
+
try await journal?.purchase(reference, terminal: terminal, run: reply.run)
|
|
119
|
+
guard original == epoch, !invalidated else { throw IndianaFunnelError.identityChanged }
|
|
120
|
+
if !terminal { pendingPurchase = reference } else if pendingPurchase?["operationId"] == operationId { pendingPurchase = nil }
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
guard original == epoch, !invalidated else { throw IndianaFunnelError.identityChanged }
|
|
125
|
+
if current == nil || reply.run.revision >= current!.revision { response = bytes; current = reply.run };return current!
|
|
126
|
+
}
|
|
127
|
+
public func get(data: [String: IndianaJSON]? = nil, purchase: [String: IndianaJSON]? = nil) async throws -> IndianaFunnelRun {
|
|
128
|
+
try await restoreCommands()
|
|
129
|
+
var command: [String: IndianaJSON] = ["operation": .string("get")];if let data { command["data"] = .object(data) }
|
|
130
|
+
if let purchase { command["purchase"] = .object(purchase) }
|
|
131
|
+
else if let reference = pendingPurchase, let receipt = reference["receiptId"], let operation = reference["operationId"] { command["purchase"] = .object(["receiptId": receipt, "operationId": operation]) }
|
|
132
|
+
return try await request(command)
|
|
133
|
+
}
|
|
134
|
+
private func mutate(_ command: [String: IndianaJSON]) async throws -> IndianaFunnelRun {
|
|
135
|
+
try await restoreCommands()
|
|
136
|
+
let closing = command["operation"] == .string("close"), id = command["requestId"]?.string ?? ""
|
|
137
|
+
if closeRequested && !closing { throw IndianaFunnelError.commandPending }
|
|
138
|
+
if sending && !closing { throw IndianaFunnelError.commandPending }
|
|
139
|
+
if closing {
|
|
140
|
+
closeRequested = true
|
|
141
|
+
if let closingCommand, closingCommand != command { throw IndianaFunnelError.commandPending }
|
|
142
|
+
closingCommand = command
|
|
143
|
+
} else {
|
|
144
|
+
if let pending, pending != command { throw IndianaFunnelError.commandPending };pending = command
|
|
145
|
+
}
|
|
146
|
+
let wasUncertain = uncertain.contains(id);uncertain.insert(id);sending = true;let original = epoch
|
|
147
|
+
defer { sending = false }
|
|
148
|
+
do {
|
|
149
|
+
if let journal { guard let current else { throw IndianaFunnelError.invalidCommand }; try await journal.retain(command, run: current) }
|
|
150
|
+
let run = try await request(command)
|
|
151
|
+
let settled = settlements[id] == "settled"
|
|
152
|
+
if original == epoch, run.pendingRequest?.requestId.uuidString.lowercased() != id,
|
|
153
|
+
settled,
|
|
154
|
+
(!closeRequested || closing || settled && !["running", "waiting"].contains(run.status)) {
|
|
155
|
+
try await journal?.settle(id)
|
|
156
|
+
if pending == command { pending = nil };if closingCommand == command { closingCommand = nil };uncertain.remove(id)
|
|
157
|
+
}
|
|
158
|
+
return run
|
|
159
|
+
} catch {
|
|
160
|
+
if original == epoch, !wasUncertain, !closeRequested, let failure = error as? IndianaClientError, case .requestFailed(let status) = failure, status < 500, status != 401, status != 425 { try await journal?.settle(id); if pending == command { pending = nil };uncertain.remove(id) }
|
|
161
|
+
throw error
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
public func settle(requestId: UUID) async throws -> IndianaFunnelRun {
|
|
165
|
+
let id = requestId.uuidString.lowercased();settlements.removeValue(forKey: id);let run = try await request(["operation": .string("get"), "settlementRequestId": .string(id)])
|
|
166
|
+
guard let state = settlements[id] else { throw IndianaFunnelError.invalidResponse }
|
|
167
|
+
if state == "settled" { try await journal?.settle(id); if pending?["requestId"] == .string(id) { pending = nil };if closingCommand?["requestId"] == .string(id) { closingCommand = nil };uncertain.remove(id) };return run
|
|
168
|
+
}
|
|
169
|
+
private func base(_ operation: String, requestId: UUID) throws -> [String: IndianaJSON] {
|
|
170
|
+
guard let current, !invalidated else { throw IndianaFunnelError.invalidCommand }
|
|
171
|
+
return ["operation": .string(operation), "requestId": .string(requestId.uuidString.lowercased()), "nodeId": .string(current.nodeId), "visit": .number(Double(current.visit)), "revision": .number(Double(current.revision))]
|
|
172
|
+
}
|
|
173
|
+
public func submit(output: String, answers: [String: IndianaJSON], selection: [String: IndianaJSON] = [:], requestId: UUID = UUID()) async throws -> IndianaFunnelRun {
|
|
174
|
+
var command = try base("submit", requestId: requestId);command["input"] = .object(["type": .string("screen"), "output": .string(output), "answers": .object(answers), "selection": .object(selection)]);return try await mutate(command)
|
|
175
|
+
}
|
|
176
|
+
public func submit(input: [String: IndianaJSON], requestId: UUID = UUID()) async throws -> IndianaFunnelRun { var command = try base("submit", requestId: requestId);command["input"] = .object(input);return try await mutate(command) }
|
|
177
|
+
public func back(requestId: UUID = UUID()) async throws -> IndianaFunnelRun { try await mutate(base("back", requestId: requestId)) }
|
|
178
|
+
public func shown(requestId: UUID = UUID()) async throws -> IndianaFunnelRun { try await mutate(base("shown", requestId: requestId)) }
|
|
179
|
+
public func close(requestId: UUID = UUID()) async throws -> IndianaFunnelRun { try await mutate(base("close", requestId: requestId)) }
|
|
180
|
+
public func retry() async throws -> IndianaFunnelRun { try await restoreCommands();if current == nil { _ = try await get() };if let closingCommand { return try await mutate(closingCommand) };guard let pending else { throw IndianaFunnelError.invalidCommand };if closeRequested, let id = pending["requestId"]?.string, let requestId = UUID(uuidString: id) { return try await settle(requestId: requestId) };return try await mutate(pending) }
|
|
181
|
+
public func resume(requestId: UUID) async throws -> IndianaFunnelRun { try await restoreCommands();if current == nil { _ = try await get() };if closeRequested { return try await settle(requestId: requestId) };if let pending { guard pending["requestId"] == .string(requestId.uuidString.lowercased()) else { throw IndianaFunnelError.commandPending };return try await mutate(pending) };return try await mutate(["operation": .string("resume"), "requestId": .string(requestId.uuidString.lowercased())]) }
|
|
182
|
+
func hostCommand(_ command: [String: IndianaJSON]) async throws -> Data {
|
|
183
|
+
guard let operation = command["operation"]?.string, operation != "start", command["customerToken"] == nil else { throw IndianaFunnelError.invalidCommand }
|
|
184
|
+
if operation == "get" { if let id = command["settlementRequestId"]?.string, let requestId = UUID(uuidString: id) { _ = try await settle(requestId: requestId) } else { _ = try await get(data: command["data"]?.object, purchase: command["purchase"]?.object) } }
|
|
185
|
+
else if operation == "review" { _ = try await request(command) }
|
|
186
|
+
else { _ = try await mutate(command) };return try hostResponse()
|
|
187
|
+
}
|
|
188
|
+
func hostResponse() throws -> Data { guard let response, !invalidated else { throw IndianaFunnelError.identityChanged };return response }
|
|
189
|
+
/// Display-only read. Never called by command settlement or purchase recovery.
|
|
190
|
+
func hostPaywallContent(_ payload: IndianaJSON) async throws -> IndianaJSON {
|
|
191
|
+
guard !invalidated, let request = payload.object, Set(request.keys) == ["id", "sha256"],
|
|
192
|
+
let paywall = current?.paywall?.object, let delivery = paywall["resolution"], let value = delivery.object,
|
|
193
|
+
request["id"] == value["id"], request["sha256"] == value["content"]?.object?["artifact"]?.object?["sha256"] else { throw IndianaFunnelError.invalidCommand }
|
|
194
|
+
let original = epoch
|
|
195
|
+
let loaded = try await (publicPaywallContent ?? { try await IndianaPaywallContent(configuration: self.configuration).resolve($0) })(delivery)
|
|
196
|
+
guard original == epoch, !invalidated else { throw IndianaFunnelError.identityChanged }
|
|
197
|
+
try IndianaSchema.validate("paywallResolution", data: JSONEncoder().encode(loaded))
|
|
198
|
+
guard let hydrated = loaded.object, IndianaPaywallContent.validatesHydration(hydrated, delivery: value),
|
|
199
|
+
let active = current?.paywall?.object, active["receiptId"] == paywall["receiptId"],
|
|
200
|
+
var latest = active["resolution"]?.object, latest["id"] == value["id"], latest["content"] == value["content"] else { throw IndianaFunnelError.invalidResponse }
|
|
201
|
+
IndianaPaywallContent.applyPresentation(hydrated, to: &latest)
|
|
202
|
+
publicPresentation = ["receiptId": active["receiptId"]!, "resolution": .object(latest)]
|
|
203
|
+
return .object(latest)
|
|
204
|
+
}
|
|
205
|
+
private func presentationButtons() -> [String: IndianaJSON] {
|
|
206
|
+
guard let active = current?.paywall?.object, let cached = publicPresentation,
|
|
207
|
+
active["receiptId"] == cached["receiptId"], let value = active["resolution"]?.object,
|
|
208
|
+
let loaded = cached["resolution"]?.object, loaded["id"] == value["id"], loaded["content"] == value["content"] else { return [:] }
|
|
209
|
+
return loaded["document"]?.object?["buttons"]?.object ?? [:]
|
|
210
|
+
}
|
|
211
|
+
private func responseObject() throws -> [String: IndianaJSON] { guard let response else { throw IndianaFunnelError.invalidCommand };return try JSONDecoder().decode(IndianaJSON.self, from: response).object ?? [:] }
|
|
212
|
+
func trustedPurchase(_ offered: IndianaJSON) throws -> IndianaJSON {
|
|
213
|
+
let object = try responseObject(), operation = object["purchase"]?.object?["operation"] ?? current?.paywall?.object?["operation"]
|
|
214
|
+
guard operation == offered, offered != .null else { throw IndianaFunnelError.invalidCommand };return offered
|
|
215
|
+
}
|
|
216
|
+
func trustedCheckoutURL(_ offered: String) throws -> URL {
|
|
217
|
+
let object = try responseObject(), operation = object["purchase"]?.object?["operation"] ?? current?.paywall?.object?["operation"]
|
|
218
|
+
guard operation?.object?["checkoutUrl"] == .string(offered), let url = URL(string: offered), url.scheme == "https", url.host != nil, url.user == nil, url.password == nil else { throw IndianaFunnelError.invalidCommand };return url
|
|
219
|
+
}
|
|
220
|
+
func trustedOpen(_ offered: IndianaJSON) throws -> URL {
|
|
221
|
+
guard let object = offered.object, let raw = object["url"]?.string, let url = URL(string: raw), url.scheme == "https", url.host != nil, url.user == nil, url.password == nil else { throw IndianaFunnelError.invalidCommand }
|
|
222
|
+
let instruction = current?.instruction?.object
|
|
223
|
+
let actions = presentationButtons()
|
|
224
|
+
guard instruction?["type"] == .string("open_url") && instruction?["url"] == .string(raw) && instruction?["receiptId"] == object["receiptId"] && instruction?["openIn"] == object["openIn"] || actions.values.contains(where: { $0.object?["type"] == .string("action") && $0.object?["action"]?.object?["type"] == .string("open_url") && $0.object?["action"]?.object?["url"] == .string(raw) }) else { throw IndianaFunnelError.invalidCommand };return url
|
|
225
|
+
}
|
|
226
|
+
func trustedEvent(_ name: String) throws -> String {
|
|
227
|
+
let instruction = current?.instruction?.object
|
|
228
|
+
let buttons = presentationButtons()
|
|
229
|
+
guard instruction?["type"] == .string("event") && instruction?["event"] == .string(name) || buttons.values.contains(where: { $0.object?["type"] == .string("action") && $0.object?["action"]?.object?["type"] == .string("event") && $0.object?["action"]?.object?["event"] == .string(name) }) else { throw IndianaFunnelError.invalidCommand }
|
|
230
|
+
return name
|
|
231
|
+
}
|
|
232
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
#if canImport(UIKit)
|
|
2
|
+
import Foundation
|
|
3
|
+
import UIKit
|
|
4
|
+
@preconcurrency import WebKit
|
|
5
|
+
@preconcurrency import SafariServices
|
|
6
|
+
import SwiftUI
|
|
7
|
+
|
|
8
|
+
/// A native container for the single published Funnel renderer. Embed as a child or present over the app.
|
|
9
|
+
@MainActor public final class IndianaFunnelViewController: UIViewController, @preconcurrency SFSafariViewControllerDelegate {
|
|
10
|
+
public let session: IndianaFunnelSession
|
|
11
|
+
public var onPurchase: (@MainActor (IndianaJSON) async throws -> Void)?
|
|
12
|
+
public var onEvent: (@MainActor (String) async throws -> Void)?
|
|
13
|
+
/// Opens an external page or Paywall link (`openIn` is `same_window` or `new_window`). Return false when it could
|
|
14
|
+
/// not be opened; an external page then takes its failed route. Default: in-app browser for `same_window`,
|
|
15
|
+
/// the system browser for `new_window`.
|
|
16
|
+
public var onOpenURL: (@MainActor (URL, String) async throws -> Bool)?
|
|
17
|
+
public var onComplete: (@MainActor (IndianaFunnelRun) -> Void)?
|
|
18
|
+
public var onDismiss: (@MainActor () -> Void)?
|
|
19
|
+
public var onError: (@MainActor (Error) -> Void)?
|
|
20
|
+
private var host: IndianaBundledWebView!
|
|
21
|
+
private var configurationRelay: IndianaFunnelConfigurationRelay?
|
|
22
|
+
private var stopped = false; private var completed = false; private var dismissed = false
|
|
23
|
+
private var observer: IndianaForegroundObserver?
|
|
24
|
+
public init(session: IndianaFunnelSession) { self.session = session; super.init(nibName: nil, bundle: nil) }
|
|
25
|
+
required init?(coder: NSCoder) { fatalError("Use init(session:)") }
|
|
26
|
+
public override func loadView() {
|
|
27
|
+
host = IndianaBundledWebView(label:"Funnel", dispatch: { [weak self] method,payload in
|
|
28
|
+
guard let self, !self.stopped else { throw IndianaFunnelError.identityChanged }
|
|
29
|
+
return try await self.dispatch(method,payload:payload)
|
|
30
|
+
},onError: { [weak self] error in self?.onError?(error) })
|
|
31
|
+
view = host.webView
|
|
32
|
+
Task {
|
|
33
|
+
await session.onInvalidation { [weak self] in Task { @MainActor in self?.invalidate() } }
|
|
34
|
+
guard !stopped else { return }; host.start()
|
|
35
|
+
}
|
|
36
|
+
observer = IndianaForegroundObserver { [weak self] in
|
|
37
|
+
Task { @MainActor in self?.sendEvent("refresh") }
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
public func close() async {
|
|
41
|
+
guard !stopped else { return }
|
|
42
|
+
do { _ = try await session.close() } catch { onError?(error) }
|
|
43
|
+
invalidate()
|
|
44
|
+
}
|
|
45
|
+
private func invalidate() {
|
|
46
|
+
guard !stopped else { return }
|
|
47
|
+
host?.destroy(); stopped = true; configurationRelay = nil
|
|
48
|
+
presentedViewController?.dismiss(animated: false)
|
|
49
|
+
if !dismissed { dismissed = true; onDismiss?() }
|
|
50
|
+
}
|
|
51
|
+
private func dispatch(_ method: String, payload: IndianaJSON) async throws -> IndianaJSON {
|
|
52
|
+
switch method {
|
|
53
|
+
case "ready":
|
|
54
|
+
let config = await session.hostConfiguration()
|
|
55
|
+
// UUID's synthesized Codable form is uppercase; the wire scope is canonical lowercase.
|
|
56
|
+
let scope: IndianaJSON = .object([
|
|
57
|
+
"projectId": .string(config.scope.projectId.uuidString.lowercased()),
|
|
58
|
+
"environment": .string(config.scope.environment.rawValue),
|
|
59
|
+
])
|
|
60
|
+
send(.object(["event":.string("init"),"scope":scope,"apiOrigin":.string(config.apiOrigin)])); return .null
|
|
61
|
+
case "pendingCommand": return await session.hostPendingCommand()
|
|
62
|
+
case "configuration":
|
|
63
|
+
if configurationRelay == nil { configurationRelay = IndianaFunnelConfigurationRelay(configuration: await session.hostConfiguration()) }
|
|
64
|
+
guard let relay = configurationRelay, !stopped else { throw IndianaFunnelError.identityChanged }
|
|
65
|
+
return try await relay.read(payload)
|
|
66
|
+
case "paywallContent":
|
|
67
|
+
return try await session.hostPaywallContent(payload)
|
|
68
|
+
case "command":
|
|
69
|
+
guard let command = payload.object else { throw IndianaFunnelError.invalidCommand }
|
|
70
|
+
return try JSONDecoder().decode(IndianaJSON.self, from: await session.hostCommand(command))
|
|
71
|
+
case "purchase":
|
|
72
|
+
guard let operation = payload.object?["operation"], let onPurchase else { throw IndianaClientError.unsupportedCapability }
|
|
73
|
+
let trusted = try await session.trustedPurchase(operation)
|
|
74
|
+
try await onPurchase(trusted);return .null
|
|
75
|
+
case "checkout":
|
|
76
|
+
guard let raw = payload.object?["url"]?.string else { throw IndianaFunnelError.invalidCommand }
|
|
77
|
+
let url = try await session.trustedCheckoutURL(raw)
|
|
78
|
+
let browser = SFSafariViewController(url: url);browser.delegate = self;present(browser, animated: true);return .null
|
|
79
|
+
case "open":
|
|
80
|
+
let url = try await session.trustedOpen(payload), openIn = payload.object?["openIn"]?.string ?? "same_window"
|
|
81
|
+
if let onOpenURL { return .bool(try await onOpenURL(url, openIn)) }
|
|
82
|
+
if openIn == "new_window" { return .bool(await UIApplication.shared.open(url)) }
|
|
83
|
+
let browser = SFSafariViewController(url: url);browser.delegate = self;present(browser, animated: true);return .bool(true)
|
|
84
|
+
case "event":
|
|
85
|
+
guard let name = payload.object?["event"]?.string, let onEvent else { throw IndianaClientError.unsupportedCapability }
|
|
86
|
+
try await onEvent(try await session.trustedEvent(name)); return .null
|
|
87
|
+
case "complete":
|
|
88
|
+
guard let run = await session.current, run.status == "completed", run.instruction == nil || run.instruction == .null else { throw IndianaFunnelError.invalidCommand }
|
|
89
|
+
if !completed { completed = true; onComplete?(run) }; return .null
|
|
90
|
+
case "dismiss":
|
|
91
|
+
if !dismissed { dismissed = true; onDismiss?() }; return .null
|
|
92
|
+
default: throw IndianaFunnelError.invalidCommand
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
private func send(_ value: IndianaJSON) { host?.send(value) }
|
|
96
|
+
private func sendEvent(_ event: String) { send(.object(["event":.string(event)])) }
|
|
97
|
+
public func safariViewControllerDidFinish(_ controller: SFSafariViewController) { controller.dismiss(animated: true); sendEvent("refresh") }
|
|
98
|
+
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
final class IndianaForegroundObserver {
|
|
102
|
+
private let token: NSObjectProtocol
|
|
103
|
+
init(_ action: @escaping @Sendable () -> Void) {
|
|
104
|
+
token = NotificationCenter.default.addObserver(forName: UIApplication.didBecomeActiveNotification, object: nil, queue: .main) { _ in action() }
|
|
105
|
+
}
|
|
106
|
+
deinit { NotificationCenter.default.removeObserver(token) }
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/// Supports embedding in an existing product screen; node presentation stays authored by the Funnel.
|
|
110
|
+
@MainActor public struct IndianaFunnelView: UIViewControllerRepresentable {
|
|
111
|
+
public let session: IndianaFunnelSession
|
|
112
|
+
public var onPurchase: (@MainActor (IndianaJSON) async throws -> Void)?
|
|
113
|
+
public var onEvent: (@MainActor (String) async throws -> Void)?
|
|
114
|
+
public var onComplete: (@MainActor (IndianaFunnelRun) -> Void)?
|
|
115
|
+
public var onDismiss: (@MainActor () -> Void)?
|
|
116
|
+
public var onError: (@MainActor (Error) -> Void)?
|
|
117
|
+
public var onOpenURL: (@MainActor (URL, String) async throws -> Bool)?
|
|
118
|
+
public init(session: IndianaFunnelSession, onPurchase: (@MainActor (IndianaJSON) async throws -> Void)? = nil, onEvent: (@MainActor (String) async throws -> Void)? = nil, onComplete: (@MainActor (IndianaFunnelRun) -> Void)? = nil, onDismiss: (@MainActor () -> Void)? = nil, onError: (@MainActor (Error) -> Void)? = nil, onOpenURL: (@MainActor (URL, String) async throws -> Bool)? = nil) {
|
|
119
|
+
self.session = session;self.onPurchase = onPurchase;self.onEvent = onEvent;self.onComplete = onComplete;self.onDismiss = onDismiss;self.onError = onError;self.onOpenURL = onOpenURL
|
|
120
|
+
}
|
|
121
|
+
public func makeUIViewController(context: Context) -> IndianaFunnelViewController { let controller = IndianaFunnelViewController(session: session); updateUIViewController(controller, context: context); return controller }
|
|
122
|
+
public func updateUIViewController(_ controller: IndianaFunnelViewController, context: Context) { controller.onPurchase = onPurchase; controller.onEvent = onEvent; controller.onComplete = onComplete; controller.onDismiss = onDismiss; controller.onError = onError; controller.onOpenURL = onOpenURL }
|
|
123
|
+
public static func dismantleUIViewController(_ controller: IndianaFunnelViewController, coordinator: ()) { Task { await controller.close() } }
|
|
124
|
+
}
|
|
125
|
+
#endif
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// Generated by scripts/sdk/generate-native-protocol.mjs. Do not edit.
|
|
2
|
+
import Foundation
|
|
3
|
+
public enum IndianaEnvironment: String, Codable, Sendable { case sandbox, live }
|
|
4
|
+
enum IndianaProtocol {
|
|
5
|
+
static let wireProtocol = "0.9"
|
|
6
|
+
static let uuidPattern = "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"
|
|
7
|
+
static let utcPattern = "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$"
|
|
8
|
+
static let scopeKeys: Set<String> = ["projectId", "environment"]
|
|
9
|
+
static let runKeys: Set<String> = ["id", "source", "revision", "visit", "nodeId", "nodeType", "status", "stopped", "canBack", "authenticated", "locale", "answers", "parameters", "variables", "output", "screen", "paywall", "instruction", "pendingRequest", "runExpiresAt", "credentialExpiresAt"]
|
|
10
|
+
static let runRequiredKeys: Set<String> = ["id", "source", "revision", "visit", "nodeId", "nodeType", "status", "stopped", "canBack", "authenticated", "locale", "answers", "parameters", "variables", "output", "screen", "paywall", "instruction", "pendingRequest", "runExpiresAt", "credentialExpiresAt"]
|
|
11
|
+
static let nodeTypes: Set<String> = ["entry", "screen", "condition", "split", "feature", "action", "external_page", "wait", "end", "offering", "coupon", "coupons", "output"]
|
|
12
|
+
static let runStatuses: Set<String> = ["running", "waiting", "completed", "closed", "ineligible", "failed"]
|
|
13
|
+
static let nonCanonicalLocaleLanguages: Set<String> = ["aam", "aar", "abk", "adp", "afr", "agp", "ais", "ajp", "ajt", "aju", "aka", "alb", "als", "amh", "ara", "arb", "arg", "arm", "asd", "asm", "aue", "ava", "ave", "aym", "ayr", "ayx", "aze", "azj", "bak", "bam", "baq", "baz", "bcc", "bcl", "bel", "ben", "bgm", "bhk", "bic", "bih", "bis", "bjd", "bjq", "bkb", "blg", "bod", "bos", "bre", "btb", "bul", "bur", "bxk", "bxr", "cat", "ccq", "ces", "cha", "che", "chi", "chu", "chv", "cjr", "cka", "cld", "cls", "cmk", "cmn", "cnr", "cor", "cos", "coy", "cqu", "cre", "cwd", "cym", "cze", "daf", "dan", "dap", "deu", "dgo", "dhd", "dik", "diq", "dit", "div", "djl", "dkl", "drh", "drr", "drw", "dud", "duj", "dut", "dwl", "dzo", "ekk", "ell", "elp", "emk", "eng", "epo", "esk", "est", "eus", "ewe", "fao", "fas", "fat", "fij", "fin", "fra", "fre", "fry", "fuc", "ful", "gav", "gaz", "gbc", "gbo", "geo", "ger", "gfx", "ggn", "ggo", "ggr", "gio", "gla", "gle", "glg", "gli", "glv", "gno", "gom", "gre", "grn", "gti", "gug", "guj", "guv", "gya", "hat", "hau", "hbs", "hdn", "hea", "heb", "her", "him", "hin", "hmo", "hrr", "hrv", "hun", "hye", "ibi", "ibo", "ice", "ido", "iii", "ike", "iku", "ile", "ill", "ilw", "in", "ina", "ind", "ipk", "isl", "ita", "iw", "izi", "jar", "jav", "jaw", "jeg", "ji", "jpn", "jw", "kal", "kan", "kas", "kat", "kau", "kaz", "kdv", "kgc", "kgd", "kgh", "kgm", "khk", "khm", "kik", "kin", "kir", "kmr", "knc", "kng", "koj", "kom", "kon", "kor", "kpp", "kpv", "krm", "ktr", "kua", "kur", "kvs", "kwq", "kxe", "kxl", "kzh", "kzj", "kzt", "lak", "lao", "lat", "lav", "lbk", "leg", "lii", "lim", "lin", "lit", "llo", "lmm", "ltz", "lub", "lug", "lvs", "mac", "mah", "mal", "mao", "mar", "may", "meg", "mgx", "mhr", "mkd", "mlg", "mlt", "mnk", "mnt", "mo", "mof", "mol", "mon", "mri", "msa", "mst", "mup", "mwd", "mwj", "mya", "myd", "myt", "nad", "nau", "nav", "nbf", "nbl", "nbx", "ncp", "nde", "ndo", "nep", "nld", "nln", "nlr", "nno", "nns", "nnx", "nob", "nom", "noo", "nor", "npi", "nts", "nxu", "nya", "oci", "ojg", "oji", "ori", "orm", "ory", "oss", "oun", "pan", "pat", "pbu", "pcr", "per", "pes", "pli", "plt", "pmc", "pmk", "pmu", "pnb", "pol", "por", "ppa", "ppr", "prp", "prs", "pry", "pus", "puz", "que", "quz", "rmr", "rmy", "roh", "ron", "rum", "run", "rus", "sag", "san", "sap", "sca", "scc", "scr", "sgl", "sh", "sin", "skk", "slk", "slo", "slv", "smd", "sme", "smo", "sna", "snb", "snd", "som", "sot", "spa", "spy", "sqi", "src", "srd", "srp", "ssw", "sul", "sum", "sun", "swa", "swc", "swe", "swh", "szd", "tah", "tam", "tat", "tdu", "tel", "tgg", "tgk", "tgl", "tha", "thc", "thw", "thx", "tib", "tid", "tie", "tir", "tkk", "tl", "tlw", "tmk", "tmp", "tne", "tnf", "ton", "tpw", "tsf", "tsn", "tso", "ttq", "tuk", "tur", "twi", "uig", "ukr", "umu", "unp", "uok", "urd", "uzb", "uzn", "ven", "vie", "vol", "wel", "wgw", "wit", "wiw", "wln", "wol", "xba", "xho", "xia", "xkh", "xpe", "xrq", "xsj", "xsl", "xss", "ybd", "ydd", "yen", "yid", "yiy", "yma", "ymt", "yor", "yos", "yuu", "zai", "zha", "zho", "zir", "zkb", "zsm", "zul", "zyb"]
|
|
14
|
+
static let nonCanonicalLocaleScripts: Set<String> = ["Qaai"]
|
|
15
|
+
static let nonCanonicalLocaleRegions: Set<String> = ["AN", "BU", "CS", "CT", "DD", "DY", "FQ", "FX", "HV", "JT", "MI", "NH", "NQ", "NT", "PC", "PU", "PZ", "QU", "RH", "SU", "TP", "UK", "VD", "WK", "YD", "YU", "ZR", "004", "008", "010", "012", "016", "020", "024", "028", "031", "032", "036", "040", "044", "048", "050", "051", "052", "056", "060", "062", "064", "068", "070", "072", "074", "076", "084", "086", "090", "092", "096", "100", "104", "108", "112", "116", "120", "124", "132", "136", "140", "144", "148", "152", "156", "158", "162", "166", "170", "172", "174", "175", "178", "180", "184", "188", "191", "192", "196", "200", "203", "204", "208", "212", "214", "218", "222", "226", "230", "231", "232", "233", "234", "238", "239", "242", "246", "248", "249", "250", "254", "258", "260", "262", "266", "268", "270", "275", "276", "278", "280", "288", "292", "296", "300", "304", "308", "312", "316", "320", "324", "328", "332", "334", "336", "340", "344", "348", "352", "356", "360", "364", "368", "372", "376", "380", "384", "388", "392", "398", "400", "404", "408", "410", "414", "417", "418", "422", "426", "428", "430", "434", "438", "440", "442", "446", "450", "454", "458", "462", "466", "470", "474", "478", "480", "484", "492", "496", "498", "499", "500", "504", "508", "512", "516", "520", "524", "528", "530", "531", "532", "533", "534", "535", "536", "540", "548", "554", "558", "562", "566", "570", "574", "578", "580", "581", "582", "583", "584", "585", "586", "591", "598", "600", "604", "608", "612", "616", "620", "624", "626", "630", "634", "638", "642", "643", "646", "652", "654", "659", "660", "662", "663", "666", "670", "674", "678", "682", "686", "688", "690", "694", "702", "703", "704", "705", "706", "710", "716", "720", "724", "728", "729", "732", "736", "740", "744", "748", "752", "756", "760", "762", "764", "768", "772", "776", "780", "784", "788", "792", "795", "796", "798", "800", "804", "807", "810", "818", "826", "830", "831", "832", "833", "834", "840", "850", "854", "858", "860", "862", "876", "882", "886", "887", "890", "891", "894", "958", "959", "960", "962", "963", "964", "965", "966", "967", "968", "969", "970", "971", "972", "973", "974", "975", "976", "977", "978", "979", "980", "981", "982", "983", "984", "985", "986", "987", "988", "989", "990", "991", "992", "993", "994", "995", "996", "997", "998", "999"]
|
|
16
|
+
}
|