@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,52 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
import Security
|
|
3
|
+
|
|
4
|
+
struct IndianaNativePendingOrder: Codable, Sendable, Equatable {
|
|
5
|
+
let runId: UUID; let orderId: UUID; let receiptId: UUID; let productId: String; let accountToken: UUID
|
|
6
|
+
}
|
|
7
|
+
/// Stores only current order bindings; never a run token, customer token or JWS.
|
|
8
|
+
protocol IndianaNativePendingStorage: Sendable {
|
|
9
|
+
func read(_ order: UUID) async throws -> IndianaNativePendingOrder?
|
|
10
|
+
func orders() async throws -> [IndianaNativePendingOrder]
|
|
11
|
+
func orders(runId: UUID) async throws -> [IndianaNativePendingOrder]
|
|
12
|
+
func contains(_ record: IndianaNativePendingOrder) async throws -> Bool
|
|
13
|
+
func put(_ record: IndianaNativePendingOrder) async throws
|
|
14
|
+
func remove(_ order: UUID) async throws
|
|
15
|
+
}
|
|
16
|
+
actor IndianaNativePendingKeychain: IndianaNativePendingStorage {
|
|
17
|
+
private let service: String
|
|
18
|
+
init(scope: IndianaScope) { service = "\(Bundle.main.bundleIdentifier ?? "Indiana").grow.funnel.orders.v1.\(scope.projectId.uuidString.lowercased()).\(scope.environment.rawValue)" }
|
|
19
|
+
private func query(_ order: UUID) -> [String: Any] { [kSecClass as String:kSecClassGenericPassword,kSecAttrService as String:service,kSecAttrAccount as String:order.uuidString.lowercased()] }
|
|
20
|
+
func read(_ order: UUID) throws -> IndianaNativePendingOrder? {
|
|
21
|
+
var result: CFTypeRef?
|
|
22
|
+
let status = SecItemCopyMatching(query(order).merging([kSecReturnData as String:true]) { _, new in new } as CFDictionary, &result)
|
|
23
|
+
if status == errSecItemNotFound { return nil }
|
|
24
|
+
guard status == errSecSuccess, let data = result as? Data, data.count <= 4096, let original = try? JSONDecoder().decode(IndianaNativePendingOrder.self, from: data), original.orderId == order else { throw IndianaClientError.protectedStorageUnavailable }
|
|
25
|
+
return original
|
|
26
|
+
}
|
|
27
|
+
private func allOrders() throws -> [IndianaNativePendingOrder] {
|
|
28
|
+
let query: [String: Any] = [kSecClass as String:kSecClassGenericPassword,kSecAttrService as String:service,kSecReturnData as String:true,kSecMatchLimit as String:kSecMatchLimitAll]
|
|
29
|
+
var result: CFTypeRef?
|
|
30
|
+
let status = SecItemCopyMatching(query as CFDictionary, &result)
|
|
31
|
+
if status == errSecItemNotFound { return [] }
|
|
32
|
+
guard status == errSecSuccess, let bytes = result as? [Data], bytes.count <= 100 else { throw IndianaClientError.protectedStorageUnavailable }
|
|
33
|
+
return try bytes.map { data in
|
|
34
|
+
guard data.count <= 4096 else { throw IndianaClientError.protectedStorageUnavailable }
|
|
35
|
+
return try JSONDecoder().decode(IndianaNativePendingOrder.self, from: data)
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
func orders() throws -> [IndianaNativePendingOrder] { try allOrders() }
|
|
39
|
+
func orders(runId: UUID) throws -> [IndianaNativePendingOrder] { try allOrders().filter { $0.runId == runId } }
|
|
40
|
+
func contains(_ record: IndianaNativePendingOrder) throws -> Bool {
|
|
41
|
+
guard let original = try read(record.orderId) else { return false }; guard original == record else { throw IndianaClientError.protectedStorageUnavailable }; return true
|
|
42
|
+
}
|
|
43
|
+
func put(_ record: IndianaNativePendingOrder) throws {
|
|
44
|
+
if try contains(record) { return }
|
|
45
|
+
guard try allOrders().count < 100 else { throw IndianaClientError.protectedStorageUnavailable }
|
|
46
|
+
let bytes = try JSONEncoder().encode(record)
|
|
47
|
+
let status = SecItemAdd(query(record.orderId).merging([kSecValueData as String:bytes,kSecAttrAccessible as String:kSecAttrAccessibleWhenUnlockedThisDeviceOnly]) { _, new in new } as CFDictionary, nil)
|
|
48
|
+
if status == errSecDuplicateItem { guard try contains(record) else { throw IndianaClientError.protectedStorageUnavailable }; return }
|
|
49
|
+
guard status == errSecSuccess else { throw IndianaClientError.protectedStorageUnavailable }
|
|
50
|
+
}
|
|
51
|
+
func remove(_ order: UUID) throws { let status = SecItemDelete(query(order) as CFDictionary); guard status == errSecSuccess || status == errSecItemNotFound else { throw IndianaClientError.protectedStorageUnavailable } }
|
|
52
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
|
|
3
|
+
struct IndianaNativeCompletion: Codable, Sendable { let orderId: UUID; let receiptId: UUID; let membership: Bool; let transactionIds: [String] }
|
|
4
|
+
|
|
5
|
+
/// HTTPS endpoint on your own backend. It authenticates the customer and attaches the Indiana server key there.
|
|
6
|
+
public final class IndianaNativeReceiptRelay: Sendable {
|
|
7
|
+
private let endpoint: URL; private let session: URLSession
|
|
8
|
+
public init(endpoint: URL) throws {
|
|
9
|
+
guard endpoint.scheme == "https", endpoint.host != nil, endpoint.user == nil, endpoint.password == nil, endpoint.query == nil, endpoint.fragment == nil else { throw IndianaClientError.invalidConfiguration }
|
|
10
|
+
self.endpoint = endpoint
|
|
11
|
+
let config = URLSessionConfiguration.ephemeral; config.urlCache = nil; config.httpCookieStorage = nil; config.requestCachePolicy = .reloadIgnoringLocalCacheData; config.timeoutIntervalForRequest = 35
|
|
12
|
+
session = URLSession(configuration: config, delegate: NoRedirect(), delegateQueue: nil)
|
|
13
|
+
}
|
|
14
|
+
public func send(_ body: Data) async throws -> Data {
|
|
15
|
+
guard body.count <= 262144, let input = try? JSONDecoder().decode(IndianaJSON.self, from: body).object, input["operation"] == .string("native_receipt"), let token = input["customerToken"]?.string, token.range(of: "^gg_ct_[A-Za-z0-9_-]{32,128}$", options: .regularExpression) != nil else { throw IndianaClientError.invalidConfiguration }
|
|
16
|
+
var request = URLRequest(url: endpoint); request.httpMethod = "POST"; request.httpBody = body
|
|
17
|
+
request.setValue("application/json", forHTTPHeaderField: "Content-Type"); request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
|
18
|
+
do {
|
|
19
|
+
let (stream, response) = try await session.bytes(for: request)
|
|
20
|
+
guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else { throw IndianaClientError.requestFailed((response as? HTTPURLResponse)?.statusCode ?? 0) }
|
|
21
|
+
guard response.expectedContentLength <= 262144 else { throw IndianaClientError.invalidResponse }
|
|
22
|
+
var result = Data(); for try await byte in stream { guard result.count < 262144 else { throw IndianaClientError.invalidResponse }; result.append(byte) }; return result
|
|
23
|
+
} catch let error as IndianaClientError { throw error } catch { throw IndianaClientError.temporaryUnavailable }
|
|
24
|
+
}
|
|
25
|
+
private final class NoRedirect: NSObject, URLSessionTaskDelegate, Sendable {
|
|
26
|
+
func urlSession(_ session: URLSession, task: URLSessionTask, willPerformHTTPRedirection response: HTTPURLResponse, newRequest request: URLRequest, completionHandler: @escaping (URLRequest?) -> Void) { completionHandler(nil) }
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
|
|
3
|
+
public enum IndianaNativeOfferKind: String, Sendable { case none, introductory, promotional }
|
|
4
|
+
|
|
5
|
+
/// The selected store quote belongs to this purchase, not the reusable SKU binding.
|
|
6
|
+
public struct IndianaNativeStoreOffer: Sendable {
|
|
7
|
+
public let offerKind: IndianaNativeOfferKind
|
|
8
|
+
public let offerID: String?
|
|
9
|
+
public let discountMinor: Int
|
|
10
|
+
public let trialDays: Int?
|
|
11
|
+
|
|
12
|
+
public init(offerKind: IndianaNativeOfferKind, offerID: String? = nil, discountMinor: Int = 0, trialDays: Int? = nil) throws {
|
|
13
|
+
guard (0...100000000).contains(discountMinor), trialDays == nil || (1...730).contains(trialDays!),
|
|
14
|
+
offerID == nil || offerID!.range(of: "^[A-Za-z0-9][A-Za-z0-9._-]{0,254}$", options: .regularExpression) != nil,
|
|
15
|
+
trialDays == nil || discountMinor == 0,
|
|
16
|
+
offerKind != .none || (offerID == nil && discountMinor == 0 && trialDays == nil) else { throw IndianaClientError.invalidResponse }
|
|
17
|
+
self.offerKind = offerKind; self.offerID = offerID; self.discountMinor = discountMinor; self.trialDays = trialDays
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
var wire: IndianaJSON { .object([
|
|
21
|
+
"offerKind": .string(offerKind.rawValue), "offerId": offerID.map(IndianaJSON.string) ?? .null,
|
|
22
|
+
"discountMinor": .number(Double(discountMinor)), "trialDays": trialDays.map { .number(Double($0)) } ?? .null
|
|
23
|
+
]) }
|
|
24
|
+
|
|
25
|
+
func matches(_ provider: IndianaSubscriptionProvider) -> Bool {
|
|
26
|
+
switch provider {
|
|
27
|
+
case .stripe: return false
|
|
28
|
+
case .apple: return (offerKind == .promotional) == (offerID != nil)
|
|
29
|
+
case .google:
|
|
30
|
+
return (offerKind == .none) == (offerID == nil) &&
|
|
31
|
+
(offerID == nil || offerID!.range(of: "^[a-z0-9][a-z0-9-]*$", options: .regularExpression) != nil)
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
import CryptoKit
|
|
3
|
+
|
|
4
|
+
/// Only immutable JSON crosses this public transport. Business credentials never enter it.
|
|
5
|
+
final class IndianaPaywallContent: Sendable {
|
|
6
|
+
private static let presentationFields = ["amountMinor", "interval", "intervalCount", "prepaid"]
|
|
7
|
+
/// Hydration may add catalog presentation fields, but cannot change authenticated delivery facts.
|
|
8
|
+
static func validatesHydration(_ loaded: [String: IndianaJSON], delivery: [String: IndianaJSON]) -> Bool {
|
|
9
|
+
var protected = loaded.filter { !["document", "offering"].contains($0.key) }
|
|
10
|
+
if var capabilities = protected["capabilities"]?.object, var plans = capabilities["plans"]?.object {
|
|
11
|
+
let original = delivery["capabilities"]?.object?["plans"]?.object ?? [:]
|
|
12
|
+
for (id, entry) in plans {
|
|
13
|
+
guard var plan = entry.object else { return false }
|
|
14
|
+
for key in presentationFields where original[id]?.object?[key] == nil { plan.removeValue(forKey: key) }
|
|
15
|
+
plans[id] = .object(plan)
|
|
16
|
+
}
|
|
17
|
+
capabilities["plans"] = .object(plans); protected["capabilities"] = .object(capabilities)
|
|
18
|
+
}
|
|
19
|
+
return protected == delivery
|
|
20
|
+
}
|
|
21
|
+
static func applyPresentation(_ loaded: [String: IndianaJSON], to latest: inout [String: IndianaJSON]) {
|
|
22
|
+
latest["document"] = loaded["document"]; latest["offering"] = loaded["offering"]
|
|
23
|
+
guard var capabilities = latest["capabilities"]?.object, var plans = capabilities["plans"]?.object,
|
|
24
|
+
let hydrated = loaded["capabilities"]?.object?["plans"]?.object else { return }
|
|
25
|
+
for (id, entry) in plans {
|
|
26
|
+
guard var plan = entry.object, let source = hydrated[id]?.object, source["currency"] == plan["currency"] else { continue }
|
|
27
|
+
for key in presentationFields where source[key] != nil { plan[key] = source[key] }
|
|
28
|
+
plans[id] = .object(plan)
|
|
29
|
+
}
|
|
30
|
+
capabilities["plans"] = .object(plans); latest["capabilities"] = .object(capabilities)
|
|
31
|
+
}
|
|
32
|
+
private let relay: IndianaFunnelConfigurationRelay
|
|
33
|
+
init(configuration: IndianaConfiguration) { relay = IndianaFunnelConfigurationRelay(configuration: configuration) }
|
|
34
|
+
func resolve(_ delivery: IndianaJSON) async throws -> IndianaJSON {
|
|
35
|
+
try IndianaSchema.validate("paywallDelivery", data: JSONEncoder().encode(delivery))
|
|
36
|
+
guard let value = delivery.object, let content = value["content"]?.object,
|
|
37
|
+
let environment = content["environmentId"]?.string, let artifact = content["artifact"]?.object,
|
|
38
|
+
let digest = artifact["sha256"]?.string, let path = artifact["url"]?.string,
|
|
39
|
+
artifact["kind"] == .string("paywall"), artifact["formatVersion"] == .number(1),
|
|
40
|
+
path == "/api/v1/configuration/\(environment)/artifacts/\(digest)",
|
|
41
|
+
artifact["byteLength"] != nil else { throw IndianaClientError.invalidResponse }
|
|
42
|
+
let reply = try await relay.read(.object(["path": .string(path)]))
|
|
43
|
+
guard reply.object?["status"] == .number(200),
|
|
44
|
+
let type = reply.object?["headers"]?.object?["content-type"]?.string, type.lowercased().hasPrefix("application/json"),
|
|
45
|
+
let text = reply.object?["body"]?.string, let data = text.data(using: .utf8) else { throw IndianaClientError.invalidResponse }
|
|
46
|
+
return try Self.hydrate(delivery, bytes: data)
|
|
47
|
+
}
|
|
48
|
+
static func hydrate(_ delivery: IndianaJSON, bytes data: Data) throws -> IndianaJSON {
|
|
49
|
+
try IndianaSchema.validate("paywallDelivery", data: JSONEncoder().encode(delivery))
|
|
50
|
+
guard var value = delivery.object, let content = value["content"]?.object, let artifact = content["artifact"]?.object,
|
|
51
|
+
artifact["kind"] == .string("paywall"), artifact["formatVersion"] == .number(1),
|
|
52
|
+
let digest = artifact["sha256"]?.string, case .number(let expected) = artifact["byteLength"], data.count == Int(expected),
|
|
53
|
+
SHA256.hash(data: data).map({ String(format: "%02x", $0) }).joined() == digest else { throw IndianaClientError.invalidResponse }
|
|
54
|
+
try IndianaSchema.validate("publicPaywall", data: data)
|
|
55
|
+
guard let file = try JSONDecoder().decode(IndianaJSON.self, from: data).object,
|
|
56
|
+
file["offeringVersionId"] == content["offeringVersionId"] else { throw IndianaClientError.invalidResponse }
|
|
57
|
+
let keys: Set<String> = ["kind", "name", "presentation", "products", "offeringVersionId", "screen", "buttons"]
|
|
58
|
+
var document = file.filter { keys.contains($0.key) }; document["schemaVersion"] = .number(3)
|
|
59
|
+
value["document"] = .object(document); value["offering"] = .object(["id": file["offeringVersionId"]!])
|
|
60
|
+
if var capabilities = value["capabilities"]?.object, var plans = capabilities["plans"]?.object, let catalog = file["catalog"]?.object {
|
|
61
|
+
for (id, entry) in catalog {
|
|
62
|
+
guard var plan = plans[id]?.object, let product = entry.object,
|
|
63
|
+
let currency = plan["currency"]?.string,
|
|
64
|
+
case .array(let prices) = product["channels"]?.object?["stripe"]?.object?["prices"],
|
|
65
|
+
let price = prices.first(where: { $0.object?["currency"]?.string == currency })?.object else { continue }
|
|
66
|
+
plan["amountMinor"] = price["amountMinor"]; plan["interval"] = product["interval"]; plan["intervalCount"] = product["intervalCount"]
|
|
67
|
+
plan["prepaid"] = product["prepaid"]; plans[id] = .object(plan)
|
|
68
|
+
}
|
|
69
|
+
capabilities["plans"] = .object(plans); value["capabilities"] = .object(capabilities)
|
|
70
|
+
}
|
|
71
|
+
let hydrated = IndianaJSON.object(value)
|
|
72
|
+
try IndianaSchema.validate("paywallResolution", data: JSONEncoder().encode(hydrated))
|
|
73
|
+
return hydrated
|
|
74
|
+
}
|
|
75
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
|
|
3
|
+
/// Independent Paywall identity and commercial state. No Funnel or browser credential is created.
|
|
4
|
+
public actor IndianaPaywallSession {
|
|
5
|
+
private let configuration: IndianaConfiguration
|
|
6
|
+
private let http: any IndianaHTTP
|
|
7
|
+
private let content: @Sendable (IndianaJSON) async throws -> IndianaJSON
|
|
8
|
+
private var token: String?
|
|
9
|
+
private let selector: IndianaJSON
|
|
10
|
+
private let provider: IndianaSubscriptionProvider
|
|
11
|
+
private let currency: String
|
|
12
|
+
private let locale: String
|
|
13
|
+
private var busy = false
|
|
14
|
+
private var closed = false
|
|
15
|
+
private var invalidation: (@Sendable () -> Void)?
|
|
16
|
+
private var pendingPreview: Data?
|
|
17
|
+
private var quote: IndianaJSON?
|
|
18
|
+
private var operation: IndianaJSON?
|
|
19
|
+
public private(set) var current: IndianaJSON?
|
|
20
|
+
|
|
21
|
+
init(configuration: IndianaConfiguration, http: any IndianaHTTP, customerToken: String, paywallId: UUID?, versionId: UUID?, provider: IndianaSubscriptionProvider, currency: String, locale: String, content: (@Sendable (IndianaJSON) async throws -> IndianaJSON)? = nil) throws {
|
|
22
|
+
guard (paywallId == nil) != (versionId == nil), !locale.isEmpty, locale.count <= 35,
|
|
23
|
+
currency.range(of: "^[A-Z]{3}$", options: .regularExpression) != nil else { throw IndianaClientError.invalidConfiguration }
|
|
24
|
+
self.configuration = configuration; self.http = http; self.token = customerToken
|
|
25
|
+
let reader = IndianaPaywallContent(configuration: configuration)
|
|
26
|
+
self.content = content ?? { try await reader.resolve($0) }
|
|
27
|
+
self.provider = provider; self.currency = currency; self.locale = locale
|
|
28
|
+
selector = paywallId.map { .object(["type":.string("paywall"),"paywallId":.string($0.uuidString.lowercased())]) }
|
|
29
|
+
?? .object(["type":.string("version"),"versionId":.string(versionId!.uuidString.lowercased())])
|
|
30
|
+
}
|
|
31
|
+
public func close() { closed = true; token = nil; current = nil; quote = nil; operation = nil; pendingPreview = nil; invalidation?(); invalidation = nil }
|
|
32
|
+
internal func onInvalidation(_ callback: @escaping @Sendable () -> Void) { if closed { callback() } else { invalidation = callback } }
|
|
33
|
+
internal func hostConfiguration() -> IndianaConfiguration { configuration }
|
|
34
|
+
internal func hostInit() -> IndianaJSON {
|
|
35
|
+
.object(["event":.string("init"),"surface":.string("paywall"),"scope":.object(["projectId":.string(configuration.scope.projectId.uuidString.lowercased()),"environment":.string(configuration.scope.environment.rawValue)]),"apiOrigin":.string(configuration.apiOrigin)])
|
|
36
|
+
}
|
|
37
|
+
private func send(_ name: String, path: String, body: IndianaJSON) async throws -> IndianaJSON {
|
|
38
|
+
guard let token, !closed else { throw IndianaClientError.identityChanged }
|
|
39
|
+
let data = try JSONEncoder().encode(body)
|
|
40
|
+
try IndianaSchema.validate(name + "Request", data: data)
|
|
41
|
+
let result: Data
|
|
42
|
+
do { result = try await http.send(path: path, method: "POST", authorization: token, body: data) }
|
|
43
|
+
catch { guard !closed else { throw IndianaClientError.identityChanged }; throw error }
|
|
44
|
+
guard !closed else { throw IndianaClientError.identityChanged }
|
|
45
|
+
try IndianaSchema.validate(name + "Response", data: result)
|
|
46
|
+
struct Scoped: Decodable { let scope: IndianaScope }
|
|
47
|
+
guard try JSONDecoder().decode(Scoped.self, from: result).scope == configuration.scope else { throw IndianaClientError.invalidResponse }
|
|
48
|
+
var reply = try JSONDecoder().decode(IndianaJSON.self, from: result).object!
|
|
49
|
+
if name == "resolvePaywall" { reply["paywall"] = try await content(reply["paywall"]!); guard !closed else { throw IndianaClientError.identityChanged } }
|
|
50
|
+
return .object(reply)
|
|
51
|
+
}
|
|
52
|
+
private func canonical(_ value: IndianaJSON) throws -> Data { let encoder = JSONEncoder(); encoder.outputFormatting = .sortedKeys; return try encoder.encode(value) }
|
|
53
|
+
public func refresh() async throws -> IndianaJSON { try await hostCommand(.object(["operation":.string("resolve"),"input":.object([:])])) }
|
|
54
|
+
internal func hostCommand(_ payload: IndianaJSON) async throws -> IndianaJSON {
|
|
55
|
+
guard !closed, !busy, let envelope = payload.object, Set(envelope.keys) == Set(["operation","input"]), let action = envelope["operation"]?.string, let input = envelope["input"]?.object else { throw IndianaFunnelError.invalidCommand }
|
|
56
|
+
busy = true; defer { busy = false }
|
|
57
|
+
switch action {
|
|
58
|
+
case "resolve":
|
|
59
|
+
guard input.isEmpty else { throw IndianaFunnelError.invalidCommand }
|
|
60
|
+
let selected = current?.object?["id"].map { IndianaJSON.object(["type":.string("version"),"versionId":$0]) } ?? selector
|
|
61
|
+
let reply = try await send("resolvePaywall", path: "paywalls/resolve", body: .object(["selector":selected,"commerce":.object(["provider":.string(provider.rawValue),"currency":.string(currency)]),"locale":.string(locale)]))
|
|
62
|
+
guard let value = reply.object?["paywall"], let wall = value.object else { throw IndianaClientError.invalidResponse }
|
|
63
|
+
let key = selector.object?["type"]?.string == "version" ? "id" : "paywallId"
|
|
64
|
+
guard wall[key] == selector.object?[key == "id" ? "versionId" : "paywallId"],
|
|
65
|
+
current == nil || (wall["id"] == current?.object?["id"] && wall["offering"]?.object?["id"] == current?.object?["offering"]?.object?["id"] && wall["content"]?.object?["artifact"]?.object?["sha256"] == current?.object?["content"]?.object?["artifact"]?.object?["sha256"]),
|
|
66
|
+
wall["document"]?.object?["offeringVersionId"] == wall["offering"]?.object?["id"] else { throw IndianaClientError.invalidResponse }
|
|
67
|
+
current = value; return value
|
|
68
|
+
case "preview":
|
|
69
|
+
guard Set(input.keys).isSubset(of: ["actionId","productVersionId","requestId","offerId","nativeOffer","featureQuantities"]),
|
|
70
|
+
let actionId = input["actionId"]?.string, let product = input["productVersionId"]?.string,
|
|
71
|
+
let policy = current?.object?["document"]?.object?["buttons"]?.object?[actionId]?.object, policy["type"]?.string == "checkout",
|
|
72
|
+
let selection = policy["product"]?.object else { throw IndianaFunnelError.invalidCommand }
|
|
73
|
+
let allowed: Bool
|
|
74
|
+
if selection["type"]?.string == "fixed" { allowed = selection["productVersionId"]?.string == product }
|
|
75
|
+
else if case .array(let ids) = selection["allowedProductVersionIds"] { allowed = ids.contains(.string(product)) }
|
|
76
|
+
else { allowed = false }
|
|
77
|
+
guard allowed else { throw IndianaFunnelError.invalidCommand }
|
|
78
|
+
let fingerprint = try canonical(payload)
|
|
79
|
+
if let pendingPreview, pendingPreview != fingerprint {
|
|
80
|
+
guard let status = operation?.object?["status"]?.string, status != "pending" else { throw IndianaFunnelError.commandPending }
|
|
81
|
+
quote = nil; operation = nil
|
|
82
|
+
}
|
|
83
|
+
if pendingPreview == fingerprint, let quote { return quote }
|
|
84
|
+
var selectedPlan: [String:IndianaJSON] = ["productVersionId":.string(product)]
|
|
85
|
+
selectedPlan["featureQuantities"] = input["featureQuantities"]
|
|
86
|
+
var fields: [String:IndianaJSON] = ["requestId":input["requestId"] ?? .null,"plans":.array([.object(selectedPlan)]),"provider":.string(provider.rawValue),"currency":.string(currency)]
|
|
87
|
+
fields["offerId"] = input["offerId"]; fields["nativeOffer"] = input["nativeOffer"]
|
|
88
|
+
let body = IndianaJSON.object(fields)
|
|
89
|
+
try IndianaSchema.validate("previewAttachRequest", data: JSONEncoder().encode(body))
|
|
90
|
+
pendingPreview = fingerprint
|
|
91
|
+
let reply = try await send("previewAttach", path:"subscriptions/preview-attach",body:body)
|
|
92
|
+
guard let value = reply.object?["quote"], let q = value.object, q["currency"]?.string == currency, q["provider"]?.string == provider.rawValue,
|
|
93
|
+
case .array(let lines) = q["lines"], lines.count == 1, lines[0].object?["productVersionId"]?.string == product else { throw IndianaClientError.invalidResponse }
|
|
94
|
+
quote = value; operation = nil; return value
|
|
95
|
+
case "confirm", "cancel", "read":
|
|
96
|
+
guard input.isEmpty, let id = quote?.object?["operationId"] else { throw IndianaFunnelError.invalidCommand }
|
|
97
|
+
let body = action == "read" ? IndianaJSON.object(["operationId":id]) : .object(["operationId":id,"intent":.string(action)])
|
|
98
|
+
let reply = try await send(action == "read" ? "readSubscriptionOperation" : "attach", path: action == "read" ? "subscriptions/operations/read" : "subscriptions/attach", body:body)
|
|
99
|
+
guard let value = reply.object?["operation"], value.object?["operationId"] == id, value.object?["currency"]?.string == currency else { throw IndianaClientError.invalidResponse }
|
|
100
|
+
if operation?.object?["status"]?.string == "complete" { return operation! }
|
|
101
|
+
operation = value
|
|
102
|
+
if value.object?["status"]?.string != "pending" && value.object?["status"]?.string != "complete" { pendingPreview = nil; quote = nil }
|
|
103
|
+
return value
|
|
104
|
+
default: throw IndianaFunnelError.invalidCommand
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
internal func trustedPurchase(_ value: IndianaJSON) throws -> IndianaJSON {
|
|
108
|
+
guard !closed, value == operation, value.object?["status"]?.string == "pending" else { throw IndianaFunnelError.invalidCommand }; return value
|
|
109
|
+
}
|
|
110
|
+
internal func trustedCheckout(_ value: String) throws -> URL {
|
|
111
|
+
guard !closed, operation?.object?["status"]?.string == "pending", operation?.object?["checkoutUrl"]?.string == value, let url=URL(string:value), url.scheme == "https", url.user == nil, url.password == nil else { throw IndianaFunnelError.invalidCommand }; return url
|
|
112
|
+
}
|
|
113
|
+
internal func trustedAction(_ value: String, type: String) throws -> String {
|
|
114
|
+
guard !closed, let buttons=current?.object?["document"]?.object?["buttons"]?.object else { throw IndianaFunnelError.invalidCommand }
|
|
115
|
+
for policy in buttons.values {
|
|
116
|
+
let action=policy.object?["action"]?.object
|
|
117
|
+
if policy.object?["type"]?.string == "action", action?["type"]?.string == type, action?[type == "event" ? "event" : "url"]?.string == value { return value }
|
|
118
|
+
}
|
|
119
|
+
throw IndianaFunnelError.invalidCommand
|
|
120
|
+
}
|
|
121
|
+
internal func completedOperation() throws -> IndianaJSON {
|
|
122
|
+
guard !closed, let operation, operation.object?["status"]?.string == "complete" else { throw IndianaFunnelError.invalidCommand }; return operation
|
|
123
|
+
}
|
|
124
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
#if canImport(UIKit)
|
|
2
|
+
import UIKit
|
|
3
|
+
@preconcurrency import SafariServices
|
|
4
|
+
import SwiftUI
|
|
5
|
+
|
|
6
|
+
/// Independent Paywall UI using the same packaged renderer as Funnel.
|
|
7
|
+
@MainActor public final class IndianaPaywallViewController: UIViewController, @preconcurrency SFSafariViewControllerDelegate {
|
|
8
|
+
public let session: IndianaPaywallSession
|
|
9
|
+
public var onPurchase: (@MainActor (IndianaJSON) async throws -> Void)?
|
|
10
|
+
public var onEvent: (@MainActor (String) async throws -> Void)?
|
|
11
|
+
public var onOpenURL: (@MainActor (URL) async throws -> Void)?
|
|
12
|
+
public var onComplete: (@MainActor (IndianaJSON) -> Void)?
|
|
13
|
+
public var onDismiss: (@MainActor () -> Void)?
|
|
14
|
+
public var onError: (@MainActor (Error) -> Void)?
|
|
15
|
+
private var host: IndianaBundledWebView!
|
|
16
|
+
private var stopped = false, dismissed = false
|
|
17
|
+
private var completions = Set<String>()
|
|
18
|
+
private var observer: IndianaForegroundObserver?
|
|
19
|
+
public init(session:IndianaPaywallSession) { self.session=session; super.init(nibName:nil,bundle:nil) }
|
|
20
|
+
required init?(coder:NSCoder) { fatalError("Use init(session:)") }
|
|
21
|
+
public override func loadView() {
|
|
22
|
+
host = IndianaBundledWebView(label:"Paywall",dispatch:{ [weak self] method,payload in
|
|
23
|
+
guard let self, !self.stopped else { throw IndianaClientError.identityChanged }
|
|
24
|
+
return try await self.dispatch(method,payload)
|
|
25
|
+
},onError:{ [weak self] error in self?.onError?(error) })
|
|
26
|
+
view=host.webView
|
|
27
|
+
Task {
|
|
28
|
+
await session.onInvalidation { [weak self] in Task { @MainActor in self?.invalidate() } }
|
|
29
|
+
guard !stopped else { return }; host.start()
|
|
30
|
+
}
|
|
31
|
+
observer=IndianaForegroundObserver { [weak self] in
|
|
32
|
+
Task { @MainActor in self?.host?.event("refresh") }
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
public func close() async { await session.close(); invalidate() }
|
|
36
|
+
private func invalidate() {
|
|
37
|
+
guard !stopped else { return }; stopped=true; host?.destroy()
|
|
38
|
+
observer=nil
|
|
39
|
+
presentedViewController?.dismiss(animated:false)
|
|
40
|
+
if !dismissed { dismissed=true; onDismiss?() }
|
|
41
|
+
}
|
|
42
|
+
private func dispatch(_ method:String,_ payload:IndianaJSON) async throws -> IndianaJSON {
|
|
43
|
+
switch method {
|
|
44
|
+
case "ready": host.send(await session.hostInit()); return .null
|
|
45
|
+
case "paywall": return try await session.hostCommand(payload)
|
|
46
|
+
case "purchase":
|
|
47
|
+
guard let value=payload.object?["operation"], let onPurchase else { throw IndianaClientError.unsupportedCapability }
|
|
48
|
+
try await onPurchase(try await session.trustedPurchase(value)); return .null
|
|
49
|
+
case "checkout":
|
|
50
|
+
guard let raw=payload.object?["url"]?.string else { throw IndianaFunnelError.invalidCommand }
|
|
51
|
+
open(try await session.trustedCheckout(raw)); return .null
|
|
52
|
+
case "open":
|
|
53
|
+
guard let raw=payload.object?["url"]?.string else { throw IndianaFunnelError.invalidCommand }
|
|
54
|
+
let trusted=try await session.trustedAction(raw,type:"open_url")
|
|
55
|
+
guard let url=URL(string:trusted),url.scheme == "https" else { throw IndianaFunnelError.invalidCommand }
|
|
56
|
+
if let onOpenURL { try await onOpenURL(url) } else { open(url) }; return .null
|
|
57
|
+
case "event":
|
|
58
|
+
guard let raw=payload.object?["event"]?.string,let onEvent else { throw IndianaClientError.unsupportedCapability }
|
|
59
|
+
try await onEvent(try await session.trustedAction(raw,type:"event")); return .null
|
|
60
|
+
case "complete":
|
|
61
|
+
let result=try await session.completedOperation()
|
|
62
|
+
if let id=result.object?["operationId"]?.string, completions.insert(id).inserted { onComplete?(result) }; return .null
|
|
63
|
+
case "dismiss": await close(); return .null
|
|
64
|
+
case "error": onError?(IndianaClientError.invalidResponse); return .null
|
|
65
|
+
default: throw IndianaFunnelError.invalidCommand
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
private func open(_ url:URL) { let browser=SFSafariViewController(url:url);browser.delegate=self;present(browser,animated:true) }
|
|
69
|
+
public func safariViewControllerDidFinish(_ controller:SFSafariViewController) { controller.dismiss(animated:true);host?.event("refresh") }
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
@MainActor public struct IndianaPaywallView: UIViewControllerRepresentable {
|
|
73
|
+
public let session:IndianaPaywallSession
|
|
74
|
+
public var onPurchase:(@MainActor (IndianaJSON) async throws -> Void)?
|
|
75
|
+
public var onEvent:(@MainActor (String) async throws -> Void)?
|
|
76
|
+
public var onOpenURL:(@MainActor (URL) async throws -> Void)?
|
|
77
|
+
public var onComplete:(@MainActor (IndianaJSON) -> Void)?
|
|
78
|
+
public var onDismiss:(@MainActor () -> Void)?
|
|
79
|
+
public var onError:(@MainActor (Error) -> Void)?
|
|
80
|
+
public init(session:IndianaPaywallSession,onPurchase:(@MainActor (IndianaJSON) async throws -> Void)?=nil,onEvent:(@MainActor (String) async throws -> Void)?=nil,onOpenURL:(@MainActor (URL) async throws -> Void)?=nil,onComplete:(@MainActor (IndianaJSON) -> Void)?=nil,onDismiss:(@MainActor () -> Void)?=nil,onError:(@MainActor (Error) -> Void)?=nil) {
|
|
81
|
+
self.session=session;self.onPurchase=onPurchase;self.onEvent=onEvent;self.onOpenURL=onOpenURL;self.onComplete=onComplete;self.onDismiss=onDismiss;self.onError=onError
|
|
82
|
+
}
|
|
83
|
+
public func makeUIViewController(context:Context)->IndianaPaywallViewController { let controller=IndianaPaywallViewController(session:session);updateUIViewController(controller,context:context);return controller }
|
|
84
|
+
public func updateUIViewController(_ controller:IndianaPaywallViewController,context:Context) { controller.onPurchase=onPurchase;controller.onEvent=onEvent;controller.onOpenURL=onOpenURL;controller.onComplete=onComplete;controller.onDismiss=onDismiss;controller.onError=onError }
|
|
85
|
+
public static func dismantleUIViewController(_ controller:IndianaPaywallViewController,coordinator:()) { Task { await controller.close() } }
|
|
86
|
+
}
|
|
87
|
+
#endif
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
|
|
3
|
+
public struct IndianaScope: Codable, Sendable, Equatable {
|
|
4
|
+
public let projectId: UUID
|
|
5
|
+
public let environment: IndianaEnvironment
|
|
6
|
+
public init(projectId: UUID, environment: IndianaEnvironment) { self.projectId = projectId; self.environment = environment }
|
|
7
|
+
public init(from decoder: Decoder) throws {
|
|
8
|
+
try IndianaWire.keys(decoder, IndianaProtocol.scopeKeys)
|
|
9
|
+
let c = try decoder.container(keyedBy: CodingKeys.self)
|
|
10
|
+
projectId = try IndianaWire.uuid(c.decode(String.self, forKey: .projectId)); environment = try c.decode(IndianaEnvironment.self, forKey: .environment)
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
public enum IndianaClientError: Error, Sendable {
|
|
14
|
+
case invalidConfiguration, unauthenticated, identityChanged, unsupportedCapability, invalidResponse, requestFailed(Int), temporaryUnavailable, protectedStorageUnavailable
|
|
15
|
+
}
|
|
16
|
+
enum IndianaDates {
|
|
17
|
+
static func parse(_ value: String) -> Date? {
|
|
18
|
+
guard value.range(of: IndianaProtocol.utcPattern, options: .regularExpression) != nil else { return nil }
|
|
19
|
+
// The current contract permits ISO timestamps with fractional precision and UTC offsets. Preserve the wire
|
|
20
|
+
// string and reduce only the Date conversion to nanoseconds after validating its calendar.
|
|
21
|
+
var parseValue = value
|
|
22
|
+
if let match = value.range(of: "(?<=\\.)[0-9]+", options: .regularExpression) {
|
|
23
|
+
parseValue.replaceSubrange(match, with: value[match].prefix(9))
|
|
24
|
+
}
|
|
25
|
+
let formatter = ISO8601DateFormatter()
|
|
26
|
+
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
|
27
|
+
if let date = formatter.date(from: parseValue) { return date }
|
|
28
|
+
formatter.formatOptions = [.withInternetDateTime]
|
|
29
|
+
return formatter.date(from: parseValue)
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
enum IndianaWire {
|
|
33
|
+
static func uuid(_ value: String) throws -> UUID {
|
|
34
|
+
guard value.range(of: IndianaProtocol.uuidPattern, options: .regularExpression) != nil,
|
|
35
|
+
let id = UUID(uuidString: value) else { throw IndianaClientError.invalidResponse }
|
|
36
|
+
return id
|
|
37
|
+
}
|
|
38
|
+
private struct Key: CodingKey { let stringValue: String; let intValue: Int? = nil; init?(stringValue: String) { self.stringValue = stringValue }; init?(intValue: Int) { return nil } }
|
|
39
|
+
static func keys(_ decoder: Decoder, _ expected: Set<String>) throws {
|
|
40
|
+
let c = try decoder.container(keyedBy: Key.self)
|
|
41
|
+
guard Set(c.allKeys.map(\.stringValue)) == expected else { throw IndianaClientError.invalidResponse }
|
|
42
|
+
}
|
|
43
|
+
static func fields(_ decoder: Decoder, allowed: Set<String>, required: Set<String>) throws {
|
|
44
|
+
let c = try decoder.container(keyedBy: Key.self)
|
|
45
|
+
let actual = Set(c.allKeys.map(\.stringValue))
|
|
46
|
+
guard actual.isSubset(of: allowed), required.isSubset(of: actual) else { throw IndianaClientError.invalidResponse }
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
import CryptoKit
|
|
3
|
+
import Security
|
|
4
|
+
|
|
5
|
+
public enum IndianaPushPermission: String, Codable, Sendable { case unknown, denied, provisional, granted }
|
|
6
|
+
public enum IndianaPushAddressKind: String, Sendable { case apnsToken = "apns_token", fcmToken = "fcm_token" }
|
|
7
|
+
public struct IndianaPushObservation: Sendable {
|
|
8
|
+
public let connectionId: UUID?
|
|
9
|
+
public let kind: IndianaPushAddressKind?
|
|
10
|
+
public let token: String?
|
|
11
|
+
public let permission: IndianaPushPermission
|
|
12
|
+
public init(connectionId: UUID?, kind: IndianaPushAddressKind?, token: String?, permission: IndianaPushPermission) throws {
|
|
13
|
+
guard (connectionId == nil && kind == nil && token == nil) ||
|
|
14
|
+
(connectionId != nil && kind != nil && token != nil && (16...4096).contains(token!.utf8.count) && token!.utf8.allSatisfy({ (33...126).contains($0) })) else { throw IndianaClientError.invalidConfiguration }
|
|
15
|
+
self.connectionId = connectionId; self.kind = kind; self.token = token; self.permission = permission
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
public struct IndianaPushInstallationState: Decodable, Sendable {
|
|
19
|
+
public let installationId: UUID
|
|
20
|
+
public let bound: Bool
|
|
21
|
+
public let identitySeq: Int64
|
|
22
|
+
public let bindingGeneration: Int64
|
|
23
|
+
public let addressSeq: Int64
|
|
24
|
+
public let addressRevision: Int64
|
|
25
|
+
public let connectionId: UUID?
|
|
26
|
+
public let addressStatus: String?
|
|
27
|
+
public let permission: IndianaPushPermission
|
|
28
|
+
func validate(installation: UUID) throws {
|
|
29
|
+
guard installationId == installation, (1...IndianaPushIdentity.maxSequence).contains(identitySeq),
|
|
30
|
+
(1...IndianaPushIdentity.maxSequence).contains(bindingGeneration), (0...IndianaPushIdentity.maxSequence).contains(addressSeq),
|
|
31
|
+
(0...IndianaPushIdentity.maxSequence).contains(addressRevision), addressStatus == nil || ["active", "invalid"].contains(addressStatus!) else { throw IndianaClientError.invalidResponse }
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
/// Capture before starting asynchronous OS token work; an old callback cannot replace a newer observation.
|
|
35
|
+
public struct IndianaPushObservationTicket: Sendable { let client: UUID; let identity: UUID; let observation: UUID }
|
|
36
|
+
public struct IndianaPushRecoveryRequest: Decodable, Sendable {
|
|
37
|
+
public let requested: Bool
|
|
38
|
+
public let expiresAt: String
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
struct IndianaPushJournal: Codable, Sendable {
|
|
42
|
+
var installationId: UUID
|
|
43
|
+
var privateKey: Data
|
|
44
|
+
var identitySeq: Int64
|
|
45
|
+
var addressSeq: Int64
|
|
46
|
+
var detachSeq: Int64?
|
|
47
|
+
func validate() throws {
|
|
48
|
+
guard (0...IndianaPushIdentity.maxSequence).contains(identitySeq), (0...IndianaPushIdentity.maxSequence).contains(addressSeq),
|
|
49
|
+
detachSeq == nil || (detachSeq! > 0 && detachSeq! <= identitySeq),
|
|
50
|
+
(try? P256.Signing.PrivateKey(rawRepresentation: privateKey)) != nil else { throw IndianaClientError.protectedStorageUnavailable }
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
protocol IndianaPushJournalStorage: Sendable {
|
|
54
|
+
/// A synchronous durable write: lifecycle changes cannot cross an await before the intent is saved.
|
|
55
|
+
func transaction(_ update: (IndianaPushJournal) throws -> IndianaPushJournal) throws -> IndianaPushJournal
|
|
56
|
+
}
|
|
57
|
+
final class IndianaPushKeychain: IndianaPushJournalStorage, @unchecked Sendable {
|
|
58
|
+
private let service: String
|
|
59
|
+
private static let lock = NSLock()
|
|
60
|
+
init(scope: IndianaScope) { service = "\(Bundle.main.bundleIdentifier ?? "Indiana").indiana.push.v1.\(scope.projectId.uuidString.lowercased()).\(scope.environment.rawValue)" }
|
|
61
|
+
func transaction(_ update: (IndianaPushJournal) throws -> IndianaPushJournal) throws -> IndianaPushJournal {
|
|
62
|
+
Self.lock.lock(); defer { Self.lock.unlock() }
|
|
63
|
+
let query: [String: Any] = [kSecClass as String:kSecClassGenericPassword, kSecAttrService as String:service, kSecAttrAccount as String:"installation"]
|
|
64
|
+
var result: CFTypeRef?
|
|
65
|
+
let status = SecItemCopyMatching(query.merging([kSecReturnData as String:true]) { _, new in new } as CFDictionary, &result)
|
|
66
|
+
let current: IndianaPushJournal
|
|
67
|
+
if status == errSecItemNotFound {
|
|
68
|
+
current = IndianaPushJournal(installationId: UUID(), privateKey: P256.Signing.PrivateKey().rawRepresentation, identitySeq: 0, addressSeq: 0, detachSeq: nil)
|
|
69
|
+
} else {
|
|
70
|
+
guard status == errSecSuccess, let data = result as? Data, data.count <= 4096,
|
|
71
|
+
let decoded = try? JSONDecoder().decode(IndianaPushJournal.self, from: data) else { throw IndianaClientError.protectedStorageUnavailable }
|
|
72
|
+
current = decoded
|
|
73
|
+
}
|
|
74
|
+
try current.validate()
|
|
75
|
+
let next = try update(current); try next.validate()
|
|
76
|
+
guard next.installationId == current.installationId, next.privateKey == current.privateKey,
|
|
77
|
+
next.identitySeq >= current.identitySeq, next.addressSeq >= current.addressSeq else { throw IndianaClientError.protectedStorageUnavailable }
|
|
78
|
+
if status == errSecItemNotFound || next.identitySeq != current.identitySeq || next.addressSeq != current.addressSeq || next.detachSeq != current.detachSeq {
|
|
79
|
+
let data = try JSONEncoder().encode(next)
|
|
80
|
+
let saved = status == errSecItemNotFound
|
|
81
|
+
? SecItemAdd(query.merging([kSecValueData as String:data, kSecAttrAccessible as String:kSecAttrAccessibleWhenUnlockedThisDeviceOnly]) { _, new in new } as CFDictionary, nil)
|
|
82
|
+
: SecItemUpdate(query as CFDictionary, [kSecValueData as String:data] as CFDictionary)
|
|
83
|
+
guard saved == errSecSuccess else { throw IndianaClientError.protectedStorageUnavailable }
|
|
84
|
+
}
|
|
85
|
+
return next
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/// Owned by the existing IndianaClient actor. No credential, transport or provider token is persisted here.
|
|
90
|
+
final class IndianaPushIdentity {
|
|
91
|
+
static let maxSequence: Int64 = 9_007_199_254_740_991
|
|
92
|
+
let storage: any IndianaPushJournalStorage
|
|
93
|
+
init(storage: any IndianaPushJournalStorage) throws { self.storage = storage; _ = try state() }
|
|
94
|
+
func state() throws -> IndianaPushJournal { try storage.transaction { $0 } }
|
|
95
|
+
func reserve(detach: Bool = false) throws -> Int64 {
|
|
96
|
+
try storage.transaction { current in
|
|
97
|
+
guard current.identitySeq < Self.maxSequence else { throw IndianaClientError.protectedStorageUnavailable }
|
|
98
|
+
var next = current; next.identitySeq += 1
|
|
99
|
+
if detach { next.detachSeq = next.identitySeq }
|
|
100
|
+
return next
|
|
101
|
+
}.identitySeq
|
|
102
|
+
}
|
|
103
|
+
func reserveAddress() throws -> Int64 {
|
|
104
|
+
try storage.transaction { current in
|
|
105
|
+
guard current.addressSeq < Self.maxSequence else { throw IndianaClientError.protectedStorageUnavailable }
|
|
106
|
+
var next = current; next.addressSeq += 1; return next
|
|
107
|
+
}.addressSeq
|
|
108
|
+
}
|
|
109
|
+
func clearDetach(_ sequence: Int64) throws {
|
|
110
|
+
_ = try storage.transaction { current in var next = current; if next.detachSeq == sequence { next.detachSeq = nil }; return next }
|
|
111
|
+
}
|
|
112
|
+
func recover(remote: IndianaPushInstallationState, expected: Int64) throws -> Int64 {
|
|
113
|
+
try storage.transaction { current in
|
|
114
|
+
guard current.identitySeq == expected else { throw IndianaClientError.identityChanged }
|
|
115
|
+
let floor = max(current.identitySeq, remote.identitySeq)
|
|
116
|
+
guard floor < Self.maxSequence else { throw IndianaClientError.protectedStorageUnavailable }
|
|
117
|
+
var next = current; next.identitySeq = floor + 1; next.addressSeq = max(current.addressSeq, remote.addressSeq); return next
|
|
118
|
+
}.identitySeq
|
|
119
|
+
}
|
|
120
|
+
static func digest(_ credential: String) -> String { SHA256.hash(data: Data(credential.utf8)).map { String(format: "%02x", $0) }.joined() }
|
|
121
|
+
static func base64url(_ bytes: Data) -> String { bytes.base64EncodedString().replacingOccurrences(of: "+", with: "-").replacingOccurrences(of: "/", with: "_").replacingOccurrences(of: "=", with: "") }
|
|
122
|
+
func command(_ operation: String, scope: IndianaScope, sequence: Int64? = nil, credential: String? = nil, observation: IndianaPushObservation? = nil, challenge: String? = nil) throws -> Data {
|
|
123
|
+
let journal = try state(), key = try P256.Signing.PrivateKey(rawRepresentation: journal.privateKey)
|
|
124
|
+
let publicKey = Self.base64url(key.publicKey.derRepresentation), installation = journal.installationId.uuidString.lowercased()
|
|
125
|
+
let formatter = ISO8601DateFormatter(); formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
|
126
|
+
let issuedAt = formatter.string(from: Date())
|
|
127
|
+
var fields: [Any] = ["indiana_push_v1", operation, scope.projectId.uuidString.lowercased(), scope.environment.rawValue, installation, publicKey, issuedAt]
|
|
128
|
+
var input: [String: Any] = ["installationId":installation, "publicKey":publicKey, "issuedAt":issuedAt]
|
|
129
|
+
let null = NSNull()
|
|
130
|
+
switch operation {
|
|
131
|
+
case "bind", "detach":
|
|
132
|
+
guard let sequence else { throw IndianaClientError.invalidConfiguration }
|
|
133
|
+
fields += [sequence, "ios"]; input["sequence"] = sequence; input["platform"] = "ios"
|
|
134
|
+
if operation == "bind" { guard let credential else { throw IndianaClientError.unauthenticated }; let digest = Self.digest(credential); fields.append(digest); input["credentialDigest"] = digest }
|
|
135
|
+
case "read":
|
|
136
|
+
guard let credential else { throw IndianaClientError.unauthenticated }; let digest = Self.digest(credential); fields.append(digest); input["credentialDigest"] = digest
|
|
137
|
+
case "address", "recovery_request", "recovery_confirm":
|
|
138
|
+
guard let sequence, let credential, let observation else { throw IndianaClientError.invalidConfiguration }
|
|
139
|
+
let digest = Self.digest(credential)
|
|
140
|
+
fields += [sequence, digest, observation.connectionId?.uuidString.lowercased() as Any? ?? null, observation.kind?.rawValue as Any? ?? null, observation.token as Any? ?? null, observation.permission.rawValue]
|
|
141
|
+
input["sequence"] = sequence; input["credentialDigest"] = digest; input["connectionId"] = observation.connectionId?.uuidString.lowercased() ?? null as Any
|
|
142
|
+
input["kind"] = observation.kind?.rawValue ?? null as Any; input["token"] = observation.token ?? null as Any; input["permission"] = observation.permission.rawValue
|
|
143
|
+
if operation == "recovery_confirm" {
|
|
144
|
+
guard let challenge, (100...3500).contains(challenge.utf8.count), challenge.utf8.allSatisfy({ (48...57).contains($0) || (65...90).contains($0) || (97...122).contains($0) || $0 == 45 || $0 == 95 }) else { throw IndianaClientError.invalidConfiguration }
|
|
145
|
+
fields.append(challenge); input["challenge"] = challenge
|
|
146
|
+
}
|
|
147
|
+
default: throw IndianaClientError.invalidConfiguration
|
|
148
|
+
}
|
|
149
|
+
let bytes = try JSONSerialization.data(withJSONObject: fields, options: [.withoutEscapingSlashes])
|
|
150
|
+
input["signature"] = Self.base64url(try key.signature(for: bytes).rawRepresentation)
|
|
151
|
+
if operation == "detach" { input["projectId"] = scope.projectId.uuidString.lowercased(); input["environment"] = scope.environment.rawValue }
|
|
152
|
+
return try JSONSerialization.data(withJSONObject: input, options: [.withoutEscapingSlashes])
|
|
153
|
+
}
|
|
154
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
|
|
3
|
+
/// Subframes can execute only the renderer bytes shipped with this SDK.
|
|
4
|
+
func indianaAllowsFunnelRendererNavigation(_ offered: URL, baseURL: URL) -> Bool {
|
|
5
|
+
guard baseURL.absoluteString == "https://indiana-sdk.invalid/",
|
|
6
|
+
let expected = try? IndianaBundledWebViewResources.rendererURL() else { return false }
|
|
7
|
+
return offered.absoluteString.components(separatedBy: "#").first == expected
|
|
8
|
+
}
|