@tinkerlandai/indiana-react-native 0.9.2 → 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.
Files changed (83) hide show
  1. package/IndianaReactNative.podspec +19 -0
  2. package/README.md +61 -4
  3. package/THIRD_PARTY_NOTICES.md +74 -0
  4. package/android/build.gradle +24 -0
  5. package/android/src/main/AndroidManifest.xml +3 -0
  6. package/android/src/main/java/ai/tinkerland/indiana/reactnative/IndianaBridgeModule.kt +237 -0
  7. package/android/src/main/java/ai/tinkerland/indiana/reactnative/IndianaBridgePackage.kt +11 -0
  8. package/dist/index.cjs +17 -1
  9. package/dist/index.d.cts +57408 -604
  10. package/dist/index.d.ts +57408 -604
  11. package/dist/index.js +17 -1
  12. package/ios/IndianaBridge.m +11 -0
  13. package/ios/IndianaBridge.swift +275 -0
  14. package/native-sdk/android/assets/indiana/index.html +268 -0
  15. package/native-sdk/android/java/ai/tinkerland/indiana/BillingAuthorizationTerms.kt +55 -0
  16. package/native-sdk/android/java/ai/tinkerland/indiana/BundledWebView.kt +90 -0
  17. package/native-sdk/android/java/ai/tinkerland/indiana/ContentSession.kt +45 -0
  18. package/native-sdk/android/java/ai/tinkerland/indiana/ContentView.kt +36 -0
  19. package/native-sdk/android/java/ai/tinkerland/indiana/CustomerOperations.generated.kt +84 -0
  20. package/native-sdk/android/java/ai/tinkerland/indiana/CustomerResponseSemantics.kt +193 -0
  21. package/native-sdk/android/java/ai/tinkerland/indiana/CustomerSemantics.kt +120 -0
  22. package/native-sdk/android/java/ai/tinkerland/indiana/CustomerSession.kt +106 -0
  23. package/native-sdk/android/java/ai/tinkerland/indiana/FeatureClient.kt +65 -0
  24. package/native-sdk/android/java/ai/tinkerland/indiana/FunnelCommandStorage.kt +163 -0
  25. package/native-sdk/android/java/ai/tinkerland/indiana/FunnelConfigurationRelay.kt +50 -0
  26. package/native-sdk/android/java/ai/tinkerland/indiana/FunnelSession.kt +208 -0
  27. package/native-sdk/android/java/ai/tinkerland/indiana/FunnelView.kt +71 -0
  28. package/native-sdk/android/java/ai/tinkerland/indiana/GeneratedProtocol.kt +16 -0
  29. package/native-sdk/android/java/ai/tinkerland/indiana/GoogleStorefront.kt +165 -0
  30. package/native-sdk/android/java/ai/tinkerland/indiana/HTTPTransport.kt +43 -0
  31. package/native-sdk/android/java/ai/tinkerland/indiana/IndianaClient.kt +526 -0
  32. package/native-sdk/android/java/ai/tinkerland/indiana/NativePendingKeystore.kt +81 -0
  33. package/native-sdk/android/java/ai/tinkerland/indiana/NativeReceiptRelay.kt +28 -0
  34. package/native-sdk/android/java/ai/tinkerland/indiana/NativeStoreOffer.kt +24 -0
  35. package/native-sdk/android/java/ai/tinkerland/indiana/PaywallContent.kt +75 -0
  36. package/native-sdk/android/java/ai/tinkerland/indiana/PaywallSession.kt +144 -0
  37. package/native-sdk/android/java/ai/tinkerland/indiana/PaywallView.kt +74 -0
  38. package/native-sdk/android/java/ai/tinkerland/indiana/Protocol.kt +28 -0
  39. package/native-sdk/android/java/ai/tinkerland/indiana/PushInstallation.kt +170 -0
  40. package/native-sdk/android/java/ai/tinkerland/indiana/RendererNavigation.kt +13 -0
  41. package/native-sdk/android/java/ai/tinkerland/indiana/SDKVersion.kt +6 -0
  42. package/native-sdk/android/java/ai/tinkerland/indiana/StoreSubscriptionManagement.kt +19 -0
  43. package/native-sdk/android/java/ai/tinkerland/indiana/WireSchema.kt +76 -0
  44. package/native-sdk/android/resources/funnel-wire-schemas.json +1 -0
  45. package/native-sdk/android/resources/indiana-third-party-notices.txt +101 -0
  46. package/native-sdk/android/resources/renderer-data-url.txt +1 -0
  47. package/native-sdk/ios/AnalyticsReceipt.swift +10 -0
  48. package/native-sdk/ios/AppleStorefront.swift +218 -0
  49. package/native-sdk/ios/BillingAuthorizationTerms.swift +57 -0
  50. package/native-sdk/ios/BundledWebView.swift +87 -0
  51. package/native-sdk/ios/ContentSession.swift +48 -0
  52. package/native-sdk/ios/ContentView.swift +51 -0
  53. package/native-sdk/ios/CustomerOperations.generated.swift +166 -0
  54. package/native-sdk/ios/CustomerResponseSemantics.swift +232 -0
  55. package/native-sdk/ios/CustomerSemantics.swift +145 -0
  56. package/native-sdk/ios/CustomerSession.swift +129 -0
  57. package/native-sdk/ios/FeatureClient.swift +86 -0
  58. package/native-sdk/ios/FunnelCommandStorage.swift +163 -0
  59. package/native-sdk/ios/FunnelConfigurationRelay.swift +48 -0
  60. package/native-sdk/ios/FunnelSession.swift +232 -0
  61. package/native-sdk/ios/FunnelView.swift +125 -0
  62. package/native-sdk/ios/GeneratedProtocol.swift +16 -0
  63. package/native-sdk/ios/HTTPTransport.swift +74 -0
  64. package/native-sdk/ios/IndianaClient.swift +601 -0
  65. package/native-sdk/ios/NativePendingKeychain.swift +52 -0
  66. package/native-sdk/ios/NativeReceiptRelay.swift +28 -0
  67. package/native-sdk/ios/NativeStoreOffer.swift +34 -0
  68. package/native-sdk/ios/PaywallContent.swift +75 -0
  69. package/native-sdk/ios/PaywallSession.swift +124 -0
  70. package/native-sdk/ios/PaywallView.swift +87 -0
  71. package/native-sdk/ios/Protocol.swift +49 -0
  72. package/native-sdk/ios/PushInstallation.swift +154 -0
  73. package/native-sdk/ios/RendererNavigation.swift +8 -0
  74. package/native-sdk/ios/Resources.swift +15 -0
  75. package/native-sdk/ios/SDKVersion.swift +4 -0
  76. package/native-sdk/ios/StoreSubscriptionManagement.swift +11 -0
  77. package/native-sdk/ios/WebView/THIRD_PARTY_NOTICES.txt +101 -0
  78. package/native-sdk/ios/WebView/index.html +268 -0
  79. package/native-sdk/ios/WebView/renderer-data-url.txt +1 -0
  80. package/native-sdk/ios/WireSchema.swift +107 -0
  81. package/native-sdk/ios/funnel-wire-schemas.json +1 -0
  82. package/package.json +8 -3
  83. 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
+ }