From 5cf1dfd3a5001b0811b8119907e8093ae5ca5a77 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 15 Jul 2026 20:18:13 +0000 Subject: [PATCH 01/35] feat(paywalls): public API surface for web_view messages (1/7) Add PaywallWebViewMessage/Value/Controller/Action types and API baselines. Controller post APIs are no-ops until the session PR wires WebKit. Co-authored-by: Jacob Rakidzich --- RevenueCat.xcodeproj/project.pbxproj | 14 ++ .../WebView/PaywallWebViewAPI.swift | 205 ++++++++++++++++++ .../AllAPITests.xcodeproj/project.pbxproj | 4 + .../PaywallWebViewAPI.swift | 77 +++++++ .../PaywallsV2/PaywallWebViewValueTests.swift | 59 +++++ ...enuecatui-api-ios-simulator.swiftinterface | 62 ++++++ api/revenuecatui-api-ios.swiftinterface | 62 ++++++ api/revenuecatui-api-macos.swiftinterface | 62 ++++++ ...atui-api-visionos-simulator.swiftinterface | 62 ++++++ api/revenuecatui-api-visionos.swiftinterface | 62 ++++++ ...catui-api-watchos-simulator.swiftinterface | 62 ++++++ api/revenuecatui-api-watchos.swiftinterface | 62 ++++++ 12 files changed, 793 insertions(+) create mode 100644 RevenueCatUI/Templates/V2/Components/WebView/PaywallWebViewAPI.swift create mode 100644 Tests/APITesters/AllAPITests/RevenueCatUISwiftAPITester/PaywallWebViewAPI.swift create mode 100644 Tests/RevenueCatUITests/PaywallsV2/PaywallWebViewValueTests.swift diff --git a/RevenueCat.xcodeproj/project.pbxproj b/RevenueCat.xcodeproj/project.pbxproj index 996c1ac30c..0f4bd8f8c2 100644 --- a/RevenueCat.xcodeproj/project.pbxproj +++ b/RevenueCat.xcodeproj/project.pbxproj @@ -107,6 +107,7 @@ 16DA8ECD2E4EE33700283940 /* ImageComponentTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 16DA8ECC2E4EE33700283940 /* ImageComponentTests.swift */; }; 16DA8EDF2E4F6A6600283940 /* PaywallVideoComponent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 16DA8EDE2E4F6A6600283940 /* PaywallVideoComponent.swift */; }; 16DA8EF42E4F7A2500283940 /* VideoComponentTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 16DA8EF32E4F7A2500283940 /* VideoComponentTests.swift */; }; + FA4A2DF41FC434A9401F08CC /* PaywallWebViewAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9D0C57ECDF0E7F1A5B4418FF /* PaywallWebViewAPI.swift */; }; 16DA8F1E2E4FB6E200283940 /* ImageComponent.json in Resources */ = {isa = PBXBuildFile; fileRef = 16DA8F1B2E4FB6E200283940 /* ImageComponent.json */; }; 16DA8F1F2E4FB6E200283940 /* VideoComponent.json in Resources */ = {isa = PBXBuildFile; fileRef = 16DA8F1C2E4FB6E200283940 /* VideoComponent.json */; }; 16E146AD2E99F3480089B609 /* TransactionNotifications.swift in Sources */ = {isa = PBXBuildFile; fileRef = 16E146AB2E99F1E20089B609 /* TransactionNotifications.swift */; }; @@ -1636,6 +1637,8 @@ 16DA8ECC2E4EE33700283940 /* ImageComponentTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageComponentTests.swift; sourceTree = ""; }; 16DA8EDE2E4F6A6600283940 /* PaywallVideoComponent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaywallVideoComponent.swift; sourceTree = ""; }; 16DA8EF32E4F7A2500283940 /* VideoComponentTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoComponentTests.swift; sourceTree = ""; }; + 9D0C57ECDF0E7F1A5B4418FF /* PaywallWebViewAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaywallWebViewAPI.swift; sourceTree = ""; }; + 686562C08D953A27986DB276 /* PaywallWebViewValueTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaywallWebViewValueTests.swift; sourceTree = ""; }; 16DA8F1B2E4FB6E200283940 /* ImageComponent.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = ImageComponent.json; sourceTree = ""; }; 16DA8F1C2E4FB6E200283940 /* VideoComponent.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = VideoComponent.json; sourceTree = ""; }; 16E146AB2E99F1E20089B609 /* TransactionNotifications.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransactionNotifications.swift; sourceTree = ""; }; @@ -3165,6 +3168,7 @@ 030890822D2B77DD0069677B /* PaywallsV2 */ = { isa = PBXGroup; children = ( + 686562C08D953A27986DB276 /* PaywallWebViewValueTests.swift */, 802593842FE07057005AF6DF /* PaywallStateStoreTests.swift */, 16A9F7B62FAA22D0008E8A4D /* VideoComponentViewTests.swift */, A9D45E4EAFAD4C0385BEDFB3 /* VideoPlayerViewTests.swift */, @@ -3297,6 +3301,14 @@ path = Video; sourceTree = ""; }; + EB23E44596DD9714E81840CF /* WebView */ = { + isa = PBXGroup; + children = ( + 9D0C57ECDF0E7F1A5B4418FF /* PaywallWebViewAPI.swift */, + ); + path = WebView; + sourceTree = ""; + }; 1699BD5C2EEFBEEC002001FB /* DefaultPaywall */ = { isa = PBXGroup; children = ( @@ -3387,6 +3399,7 @@ 0387D4AD2EC58AA5008E4A6B /* Countdown */, E1C0A00BBEEF0001CCDD0001 /* Header */, 164681CD2E6B577600854AA5 /* Video */, + EB23E44596DD9714E81840CF /* WebView */, 2C7457472CEA66AB004ACE52 /* ComponentsView.swift */, 7707A94A2CAD936A006E0313 /* Button */, 03C06FC82D479C6300600693 /* Carousel */, @@ -8233,6 +8246,7 @@ 164681D02E6B577600854AA5 /* VideoComponentView.swift in Sources */, 164681D12E6B577600854AA5 /* VideoPlayerViewUIView.swift in Sources */, 164681D62E6B577600854AA5 /* VideoPlayerLayerUIView.swift in Sources */, + FA4A2DF41FC434A9401F08CC /* PaywallWebViewAPI.swift in Sources */, DBAA1FC22F8D4ED8000E8C81 /* HeaderNestedHeroZLayerSafeAreaPreview.swift in Sources */, 164681D22E6B577600854AA5 /* VideoComponentViewModel.swift in Sources */, 2C7457422CE81107004ACE52 /* IntroOfferEligibilityContext.swift in Sources */, diff --git a/RevenueCatUI/Templates/V2/Components/WebView/PaywallWebViewAPI.swift b/RevenueCatUI/Templates/V2/Components/WebView/PaywallWebViewAPI.swift new file mode 100644 index 0000000000..5ba2a3fe3b --- /dev/null +++ b/RevenueCatUI/Templates/V2/Components/WebView/PaywallWebViewAPI.swift @@ -0,0 +1,205 @@ +import Foundation +import SwiftUI +// swiftlint:disable missing_docs + +#if !os(tvOS) // For Paywalls V2 + +/// A validated message sent from a Paywalls V2 `web_view` component to your app. +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) +public struct PaywallWebViewMessage: Sendable, Equatable { + + /// The identifier of the `web_view` component that produced this message. + public let componentID: String + + /// The message type, e.g. `"rc:step-complete"`. + public let type: String + + /// The responses collected by the web flow. Only populated for `"rc:step-complete"` messages. + public let responses: [String: PaywallWebViewValue]? + + /// The error reported by the web content. Only populated for `"rc:error"` messages. + public let error: String? + + public init( + componentID: String, + type: String, + responses: [String: PaywallWebViewValue]? = nil, + error: String? = nil + ) { + self.componentID = componentID + self.type = type + self.responses = responses + self.error = error + } + +} + +/// A JSON-compatible value exchanged between a Paywalls V2 `web_view` component and your app. +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) +public struct PaywallWebViewValue: Sendable, Equatable, Hashable, Codable { + + private indirect enum Storage: Sendable, Equatable, Hashable { + case string(String) + case number(Double) + case bool(Bool) + case array([PaywallWebViewValue]) + case object([String: PaywallWebViewValue]) + case null + } + + private let storage: Storage + + private init(_ storage: Storage) { + self.storage = storage + } + + /// Creates a string value. + public static func string(_ value: String) -> Self { Self(.string(value)) } + + /// Creates a numeric value. Non-finite values normalize to ``null`` because JSON cannot encode them. + public static func number(_ value: Double) -> Self { value.isFinite ? Self(.number(value)) : .null } + + /// Creates a boolean value. + public static func bool(_ value: Bool) -> Self { Self(.bool(value)) } + + /// Creates an array value. + public static func array(_ value: [PaywallWebViewValue]) -> Self { Self(.array(value)) } + + /// Creates an object (dictionary) value. + public static func object(_ value: [String: PaywallWebViewValue]) -> Self { Self(.object(value)) } + + /// A null value. + public static var null: Self { Self(.null) } + + public var stringValue: String? { + if case .string(let value) = self.storage { return value } + return nil + } + + public var numberValue: Double? { + if case .number(let value) = self.storage { return value } + return nil + } + + public var boolValue: Bool? { + if case .bool(let value) = self.storage { return value } + return nil + } + + public var arrayValue: [PaywallWebViewValue]? { + if case .array(let value) = self.storage { return value } + return nil + } + + public var objectValue: [String: PaywallWebViewValue]? { + if case .object(let value) = self.storage { return value } + return nil + } + + public var isNull: Bool { + if case .null = self.storage { return true } + return false + } + + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + + if container.decodeNil() { + self = .null + } else if let value = try? container.decode(Bool.self) { + self = .bool(value) + } else if let value = try? container.decode(Double.self) { + self = .number(value) + } else if let value = try? container.decode(String.self) { + self = .string(value) + } else if let value = try? container.decode([PaywallWebViewValue].self) { + self = .array(value) + } else { + self = .object(try container.decode([String: PaywallWebViewValue].self)) + } + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + + switch self.storage { + case .string(let value): + try container.encode(value) + case .number(let value): + try container.encode(value) + case .bool(let value): + try container.encode(value) + case .array(let value): + try container.encode(value) + case .object(let value): + try container.encode(value) + case .null: + try container.encodeNil() + } + } + +} + +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) +@MainActor +public struct PaywallWebViewController { + + init() {} + + public func postVariables(componentID: String, variables: [String: PaywallWebViewValue]) { + // Session wiring lands in a later PR; public API is inert until then. + } + + public func postMessage(componentID: String, type: String, variables: [String: PaywallWebViewValue]) { + // Session wiring lands in a later PR; public API is inert until then. + } + +} + +/// A wrapper for the Paywalls V2 `web_view` message handler. +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) +public struct PaywallWebViewMessageAction { + + private let action: @MainActor (PaywallWebViewMessage, PaywallWebViewController) -> Void + + public init( + _ action: @escaping @MainActor (PaywallWebViewMessage, PaywallWebViewController) -> Void + ) { + self.action = action + } + + @MainActor + public func callAsFunction(_ message: PaywallWebViewMessage, _ controller: PaywallWebViewController) { + self.action(message, controller) + } + +} + +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) +struct PaywallWebViewMessageActionKey: EnvironmentKey { + static let defaultValue: PaywallWebViewMessageAction? = nil +} + +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) +extension EnvironmentValues { + + var paywallWebViewMessageAction: PaywallWebViewMessageAction? { + get { self[PaywallWebViewMessageActionKey.self] } + set { self[PaywallWebViewMessageActionKey.self] = newValue } + } + +} + +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) +extension View { + + /// Invokes the given closure when a Paywalls V2 `web_view` component sends a message. + public func onPaywallWebViewMessage( + _ action: @escaping @MainActor (PaywallWebViewMessage, PaywallWebViewController) -> Void + ) -> some View { + self.environment(\.paywallWebViewMessageAction, PaywallWebViewMessageAction(action)) + } + +} + +#endif diff --git a/Tests/APITesters/AllAPITests/AllAPITests.xcodeproj/project.pbxproj b/Tests/APITesters/AllAPITests/AllAPITests.xcodeproj/project.pbxproj index 9cc8116989..74a25fd257 100644 --- a/Tests/APITesters/AllAPITests/AllAPITests.xcodeproj/project.pbxproj +++ b/Tests/APITesters/AllAPITests/AllAPITests.xcodeproj/project.pbxproj @@ -8,6 +8,7 @@ /* Begin PBXBuildFile section */ 1E8A60812CDCF29D0034ACF3 /* RedeemWebPurchasesAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E8A60802CDCF29D0034ACF3 /* RedeemWebPurchasesAPI.swift */; }; + B096F58208E0AA6582F34133 /* PaywallWebViewAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = B0DC08831A62751743D823FA /* PaywallWebViewAPI.swift */; }; 1EB697C22CD26218003000FC /* WebPurchaseRedemptionResultAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1EB697C12CD26218003000FC /* WebPurchaseRedemptionResultAPI.swift */; }; 2D4C60E12C5AD1BC00A29FD2 /* AllAPITests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 2D4C60D82C5AD1BC00A29FD2 /* AllAPITests.framework */; }; 2D4C60E62C5AD1BC00A29FD2 /* AllAPITestsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D4C60E52C5AD1BC00A29FD2 /* AllAPITestsTests.swift */; }; @@ -228,6 +229,7 @@ /* Begin PBXFileReference section */ 1E8A60802CDCF29D0034ACF3 /* RedeemWebPurchasesAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RedeemWebPurchasesAPI.swift; sourceTree = ""; }; + B0DC08831A62751743D823FA /* PaywallWebViewAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaywallWebViewAPI.swift; sourceTree = ""; }; 1EB697C12CD26218003000FC /* WebPurchaseRedemptionResultAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebPurchaseRedemptionResultAPI.swift; sourceTree = ""; }; 2D4C60D82C5AD1BC00A29FD2 /* AllAPITests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = AllAPITests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 2D4C60DB2C5AD1BC00A29FD2 /* AllAPITests.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = AllAPITests.h; sourceTree = ""; }; @@ -645,6 +647,7 @@ children = ( FD33CD722D035876000D13A4 /* CustomerCenterViewControllerAPI.swift */, 2D4C622D2C5ADA6700A29FD2 /* PaywallViewAPI.swift */, + B0DC08831A62751743D823FA /* PaywallWebViewAPI.swift */, 1E8A60802CDCF29D0034ACF3 /* RedeemWebPurchasesAPI.swift */, 2D4C622B2C5ADA6700A29FD2 /* PaywallViewControllerAPI.swift */, 2D4C622C2C5ADA6700A29FD2 /* main.swift */, @@ -1119,6 +1122,7 @@ FDCC983A2D0A0FD500F5854B /* CustomerCenterViewAPI.swift in Sources */, 2D4C62302C5ADA6700A29FD2 /* PaywallViewControllerAPI.swift in Sources */, 1E8A60812CDCF29D0034ACF3 /* RedeemWebPurchasesAPI.swift in Sources */, + B096F58208E0AA6582F34133 /* PaywallWebViewAPI.swift in Sources */, 2D4C622E2C5ADA6700A29FD2 /* PaywallViewAPI.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/Tests/APITesters/AllAPITests/RevenueCatUISwiftAPITester/PaywallWebViewAPI.swift b/Tests/APITesters/AllAPITests/RevenueCatUISwiftAPITester/PaywallWebViewAPI.swift new file mode 100644 index 0000000000..a8a2986ca9 --- /dev/null +++ b/Tests/APITesters/AllAPITests/RevenueCatUISwiftAPITester/PaywallWebViewAPI.swift @@ -0,0 +1,77 @@ +// +// PaywallWebViewAPI.swift +// RevenueCatUISwiftAPITester +// +// Compile-time checks for the Paywalls V2 web_view public API surface. +// + +import RevenueCat +import RevenueCatUI +import SwiftUI + +#if !os(tvOS) // For Paywalls V2 + +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) +struct PaywallWebViewApp: View { + + var body: some View { + Text("") + .onPaywallWebViewMessage { (message: PaywallWebViewMessage, controller: PaywallWebViewController) in + let componentID: String = message.componentID + let type: String = message.type + let responses: [String: PaywallWebViewValue]? = message.responses + let error: String? = message.error + + controller.postVariables( + componentID: componentID, + variables: ["custom": .object(["plan": .string("annual")])] + ) + controller.postMessage(componentID: componentID, type: type, variables: [:]) + + _ = (responses, error) + } + } + +} + +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) +func checkPaywallWebViewMessageAPI() { + let message = PaywallWebViewMessage( + componentID: "", + type: "rc:step-complete", + responses: ["selected_plan": .string("annual")], + error: nil + ) + let sameMessage: Bool = message == message + _ = sameMessage +} + +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) +func checkPaywallWebViewValueAPI() { + let string: PaywallWebViewValue = .string("s") + let number: PaywallWebViewValue = .number(1.5) + let bool: PaywallWebViewValue = .bool(true) + let array: PaywallWebViewValue = .array([string, number]) + let object: PaywallWebViewValue = .object(["key": bool]) + let null: PaywallWebViewValue = .null + + let _: String? = string.stringValue + let _: Double? = number.numberValue + let _: Bool? = bool.boolValue + let _: [PaywallWebViewValue]? = array.arrayValue + let _: [String: PaywallWebViewValue]? = object.objectValue + let _: Bool = null.isNull + let _: Set = [string, number] +} + +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) +@MainActor +func checkPaywallWebViewMessageActionAPI( + message: PaywallWebViewMessage, + controller: PaywallWebViewController +) { + let action = PaywallWebViewMessageAction { (_: PaywallWebViewMessage, _: PaywallWebViewController) in } + action(message, controller) +} + +#endif diff --git a/Tests/RevenueCatUITests/PaywallsV2/PaywallWebViewValueTests.swift b/Tests/RevenueCatUITests/PaywallsV2/PaywallWebViewValueTests.swift new file mode 100644 index 0000000000..a593eeb9c5 --- /dev/null +++ b/Tests/RevenueCatUITests/PaywallsV2/PaywallWebViewValueTests.swift @@ -0,0 +1,59 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// + +@testable import RevenueCatUI +import XCTest + +#if !os(tvOS) + +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) +final class PaywallWebViewValueTests: TestCase { + + func testFactoryAccessorsAndCodableRoundTrip() throws { + let value: PaywallWebViewValue = .object([ + "string": .string("value"), + "number": .number(1.25), + "bool": .bool(true), + "array": .array([.null, .string("x")]), + "object": .object(["nested": .bool(false)]), + "null": .null + ]) + + let decoded = try JSONDecoder().decode( + PaywallWebViewValue.self, + from: try JSONEncoder().encode(value) + ) + + XCTAssertEqual(decoded, value) + XCTAssertEqual(decoded.objectValue?["string"]?.stringValue, "value") + XCTAssertEqual(decoded.objectValue?["number"]?.numberValue, 1.25) + XCTAssertEqual(decoded.objectValue?["bool"]?.boolValue, true) + XCTAssertTrue(decoded.objectValue?["null"]?.isNull == true) + } + + func testNumberBoolDisambiguation() throws { + let decoded = try JSONDecoder().decode( + PaywallWebViewValue.self, + from: Data(#"{"bool":true,"number":1}"#.utf8) + ) + + XCTAssertEqual(decoded.objectValue?["bool"]?.boolValue, true) + XCTAssertNil(decoded.objectValue?["bool"]?.numberValue) + XCTAssertEqual(decoded.objectValue?["number"]?.numberValue, 1) + } + + func testNonFiniteNumbersNormalizeToNullAndEncode() throws { + XCTAssertTrue(PaywallWebViewValue.number(.nan).isNull) + XCTAssertTrue(PaywallWebViewValue.number(.infinity).isNull) + XCTAssertTrue(PaywallWebViewValue.number(-.infinity).isNull) + XCTAssertNil(PaywallWebViewValue.number(.nan).numberValue) + + let value: PaywallWebViewValue = .object(["bad": .number(.nan)]) + XCTAssertNoThrow(try JSONEncoder().encode(value)) + XCTAssertEqual(PaywallWebViewValue.number(-0.0), .number(0.0)) + } + +} + +#endif diff --git a/api/revenuecatui-api-ios-simulator.swiftinterface b/api/revenuecatui-api-ios-simulator.swiftinterface index 30bbb7549c..428cfba3b0 100644 --- a/api/revenuecatui-api-ios-simulator.swiftinterface +++ b/api/revenuecatui-api-ios-simulator.swiftinterface @@ -309,6 +309,66 @@ extension RevenueCat.Purchases { @available(iOS 15.0, macOS 12.0, *) @_Concurrency.MainActor @objc final public func presentPaywall(from url: Foundation.URL) -> Swift.Bool } +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) +public struct PaywallWebViewMessage : Swift.Sendable, Swift.Equatable { + public let componentID: Swift.String + public let type: Swift.String + public let responses: [Swift.String : RevenueCatUI.PaywallWebViewValue]? + public let error: Swift.String? + public init(componentID: Swift.String, type: Swift.String, responses: [Swift.String : RevenueCatUI.PaywallWebViewValue]? = nil, error: Swift.String? = nil) + public static func == (a: RevenueCatUI.PaywallWebViewMessage, b: RevenueCatUI.PaywallWebViewMessage) -> Swift.Bool +} +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) +public struct PaywallWebViewValue : Swift.Sendable, Swift.Equatable, Swift.Hashable, Swift.Codable { + public static func string(_ value: Swift.String) -> RevenueCatUI.PaywallWebViewValue + public static func number(_ value: Swift.Double) -> RevenueCatUI.PaywallWebViewValue + public static func bool(_ value: Swift.Bool) -> RevenueCatUI.PaywallWebViewValue + public static func array(_ value: [RevenueCatUI.PaywallWebViewValue]) -> RevenueCatUI.PaywallWebViewValue + public static func object(_ value: [Swift.String : RevenueCatUI.PaywallWebViewValue]) -> RevenueCatUI.PaywallWebViewValue + public static var null: RevenueCatUI.PaywallWebViewValue { + get + } + public var stringValue: Swift.String? { + get + } + public var numberValue: Swift.Double? { + get + } + public var boolValue: Swift.Bool? { + get + } + public var arrayValue: [RevenueCatUI.PaywallWebViewValue]? { + get + } + public var objectValue: [Swift.String : RevenueCatUI.PaywallWebViewValue]? { + get + } + public var isNull: Swift.Bool { + get + } + public init(from decoder: any Swift.Decoder) throws + public func encode(to encoder: any Swift.Encoder) throws + public static func == (a: RevenueCatUI.PaywallWebViewValue, b: RevenueCatUI.PaywallWebViewValue) -> Swift.Bool + public func hash(into hasher: inout Swift.Hasher) + public var hashValue: Swift.Int { + get + } +} +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) +@_Concurrency.MainActor public struct PaywallWebViewController { + @_Concurrency.MainActor public func postVariables(componentID: Swift.String, variables: [Swift.String : RevenueCatUI.PaywallWebViewValue]) + @_Concurrency.MainActor public func postMessage(componentID: Swift.String, type: Swift.String, variables: [Swift.String : RevenueCatUI.PaywallWebViewValue]) +} +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) +public struct PaywallWebViewMessageAction { + public init(_ action: @escaping @_Concurrency.MainActor (RevenueCatUI.PaywallWebViewMessage, RevenueCatUI.PaywallWebViewController) -> Swift.Void) + @_Concurrency.MainActor public func callAsFunction(_ message: RevenueCatUI.PaywallWebViewMessage, _ controller: RevenueCatUI.PaywallWebViewController) +} +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) +extension SwiftUICore.View { + @_Concurrency.MainActor @preconcurrency public func onPaywallWebViewMessage(_ action: @escaping @_Concurrency.MainActor (RevenueCatUI.PaywallWebViewMessage, RevenueCatUI.PaywallWebViewController) -> Swift.Void) -> some SwiftUICore.View + +} @_inheritsConvenienceInitializers @_hasMissingDesignatedInitializers @available(iOS 15.0, macOS 12.0, tvOS 15.0, *) @objc(RCPaywallFooterViewController) @_Concurrency.MainActor @preconcurrency final public class PaywallFooterViewController : RevenueCatUI.PaywallViewController { @_Concurrency.MainActor @preconcurrency public init(offering: RevenueCat.Offering? = nil, performPurchase: @escaping RevenueCatUI.PerformPurchase, performRestore: @escaping RevenueCatUI.PerformRestore, dismissRequestedHandler: ((_ controller: RevenueCatUI.PaywallViewController) -> Swift.Void)? = nil) @@ -546,5 +606,7 @@ extension RevenueCatUI.CustomerCenterNavigationLink : Swift.Sendable {} @available(iOS 15.0, macOS 12.0, watchOS 8.0, *) @available(tvOS, unavailable, message: "RevenueCatUI does not support tvOS yet") extension RevenueCatUI.PaywallView : Swift.Sendable {} +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) +extension RevenueCatUI.PaywallWebViewController : Swift.Sendable {} extension RevenueCatUI.PaywallPresentationMode : Swift.Equatable {} extension RevenueCatUI.PaywallPresentationMode : Swift.Hashable {} diff --git a/api/revenuecatui-api-ios.swiftinterface b/api/revenuecatui-api-ios.swiftinterface index 5106fe465c..9514729189 100644 --- a/api/revenuecatui-api-ios.swiftinterface +++ b/api/revenuecatui-api-ios.swiftinterface @@ -309,6 +309,66 @@ extension RevenueCat.Purchases { @available(iOS 15.0, macOS 12.0, *) @_Concurrency.MainActor @objc final public func presentPaywall(from url: Foundation.URL) -> Swift.Bool } +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) +public struct PaywallWebViewMessage : Swift.Sendable, Swift.Equatable { + public let componentID: Swift.String + public let type: Swift.String + public let responses: [Swift.String : RevenueCatUI.PaywallWebViewValue]? + public let error: Swift.String? + public init(componentID: Swift.String, type: Swift.String, responses: [Swift.String : RevenueCatUI.PaywallWebViewValue]? = nil, error: Swift.String? = nil) + public static func == (a: RevenueCatUI.PaywallWebViewMessage, b: RevenueCatUI.PaywallWebViewMessage) -> Swift.Bool +} +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) +public struct PaywallWebViewValue : Swift.Sendable, Swift.Equatable, Swift.Hashable, Swift.Codable { + public static func string(_ value: Swift.String) -> RevenueCatUI.PaywallWebViewValue + public static func number(_ value: Swift.Double) -> RevenueCatUI.PaywallWebViewValue + public static func bool(_ value: Swift.Bool) -> RevenueCatUI.PaywallWebViewValue + public static func array(_ value: [RevenueCatUI.PaywallWebViewValue]) -> RevenueCatUI.PaywallWebViewValue + public static func object(_ value: [Swift.String : RevenueCatUI.PaywallWebViewValue]) -> RevenueCatUI.PaywallWebViewValue + public static var null: RevenueCatUI.PaywallWebViewValue { + get + } + public var stringValue: Swift.String? { + get + } + public var numberValue: Swift.Double? { + get + } + public var boolValue: Swift.Bool? { + get + } + public var arrayValue: [RevenueCatUI.PaywallWebViewValue]? { + get + } + public var objectValue: [Swift.String : RevenueCatUI.PaywallWebViewValue]? { + get + } + public var isNull: Swift.Bool { + get + } + public init(from decoder: any Swift.Decoder) throws + public func encode(to encoder: any Swift.Encoder) throws + public static func == (a: RevenueCatUI.PaywallWebViewValue, b: RevenueCatUI.PaywallWebViewValue) -> Swift.Bool + public func hash(into hasher: inout Swift.Hasher) + public var hashValue: Swift.Int { + get + } +} +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) +@_Concurrency.MainActor public struct PaywallWebViewController { + @_Concurrency.MainActor public func postVariables(componentID: Swift.String, variables: [Swift.String : RevenueCatUI.PaywallWebViewValue]) + @_Concurrency.MainActor public func postMessage(componentID: Swift.String, type: Swift.String, variables: [Swift.String : RevenueCatUI.PaywallWebViewValue]) +} +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) +public struct PaywallWebViewMessageAction { + public init(_ action: @escaping @_Concurrency.MainActor (RevenueCatUI.PaywallWebViewMessage, RevenueCatUI.PaywallWebViewController) -> Swift.Void) + @_Concurrency.MainActor public func callAsFunction(_ message: RevenueCatUI.PaywallWebViewMessage, _ controller: RevenueCatUI.PaywallWebViewController) +} +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) +extension SwiftUICore.View { + @_Concurrency.MainActor @preconcurrency public func onPaywallWebViewMessage(_ action: @escaping @_Concurrency.MainActor (RevenueCatUI.PaywallWebViewMessage, RevenueCatUI.PaywallWebViewController) -> Swift.Void) -> some SwiftUICore.View + +} @_inheritsConvenienceInitializers @_hasMissingDesignatedInitializers @available(iOS 15.0, macOS 12.0, tvOS 15.0, *) @objc(RCPaywallFooterViewController) @_Concurrency.MainActor @preconcurrency final public class PaywallFooterViewController : RevenueCatUI.PaywallViewController { @_Concurrency.MainActor @preconcurrency public init(offering: RevenueCat.Offering? = nil, performPurchase: @escaping RevenueCatUI.PerformPurchase, performRestore: @escaping RevenueCatUI.PerformRestore, dismissRequestedHandler: ((_ controller: RevenueCatUI.PaywallViewController) -> Swift.Void)? = nil) @@ -546,5 +606,7 @@ extension RevenueCatUI.CustomerCenterNavigationLink : Swift.Sendable {} @available(iOS 15.0, macOS 12.0, watchOS 8.0, *) @available(tvOS, unavailable, message: "RevenueCatUI does not support tvOS yet") extension RevenueCatUI.PaywallView : Swift.Sendable {} +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) +extension RevenueCatUI.PaywallWebViewController : Swift.Sendable {} extension RevenueCatUI.PaywallPresentationMode : Swift.Equatable {} extension RevenueCatUI.PaywallPresentationMode : Swift.Hashable {} diff --git a/api/revenuecatui-api-macos.swiftinterface b/api/revenuecatui-api-macos.swiftinterface index e64db71c52..d7553c3e37 100644 --- a/api/revenuecatui-api-macos.swiftinterface +++ b/api/revenuecatui-api-macos.swiftinterface @@ -168,6 +168,66 @@ open class CustomPaywallFontProvider : RevenueCatUI.PaywallFontProvider { @available(tvOS, unavailable, introduced: 15.0, message: "RevenueCatUI does not support tvOS yet") public typealias Body = @_opaqueReturnTypeOf("$s12RevenueCatUI11PaywallViewV4bodyQrvp", 0) __ } +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) +public struct PaywallWebViewMessage : Swift.Sendable, Swift.Equatable { + public let componentID: Swift.String + public let type: Swift.String + public let responses: [Swift.String : RevenueCatUI.PaywallWebViewValue]? + public let error: Swift.String? + public init(componentID: Swift.String, type: Swift.String, responses: [Swift.String : RevenueCatUI.PaywallWebViewValue]? = nil, error: Swift.String? = nil) + public static func == (a: RevenueCatUI.PaywallWebViewMessage, b: RevenueCatUI.PaywallWebViewMessage) -> Swift.Bool +} +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) +public struct PaywallWebViewValue : Swift.Sendable, Swift.Equatable, Swift.Hashable, Swift.Codable { + public static func string(_ value: Swift.String) -> RevenueCatUI.PaywallWebViewValue + public static func number(_ value: Swift.Double) -> RevenueCatUI.PaywallWebViewValue + public static func bool(_ value: Swift.Bool) -> RevenueCatUI.PaywallWebViewValue + public static func array(_ value: [RevenueCatUI.PaywallWebViewValue]) -> RevenueCatUI.PaywallWebViewValue + public static func object(_ value: [Swift.String : RevenueCatUI.PaywallWebViewValue]) -> RevenueCatUI.PaywallWebViewValue + public static var null: RevenueCatUI.PaywallWebViewValue { + get + } + public var stringValue: Swift.String? { + get + } + public var numberValue: Swift.Double? { + get + } + public var boolValue: Swift.Bool? { + get + } + public var arrayValue: [RevenueCatUI.PaywallWebViewValue]? { + get + } + public var objectValue: [Swift.String : RevenueCatUI.PaywallWebViewValue]? { + get + } + public var isNull: Swift.Bool { + get + } + public init(from decoder: any Swift.Decoder) throws + public func encode(to encoder: any Swift.Encoder) throws + public static func == (a: RevenueCatUI.PaywallWebViewValue, b: RevenueCatUI.PaywallWebViewValue) -> Swift.Bool + public func hash(into hasher: inout Swift.Hasher) + public var hashValue: Swift.Int { + get + } +} +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) +@_Concurrency.MainActor public struct PaywallWebViewController { + @_Concurrency.MainActor public func postVariables(componentID: Swift.String, variables: [Swift.String : RevenueCatUI.PaywallWebViewValue]) + @_Concurrency.MainActor public func postMessage(componentID: Swift.String, type: Swift.String, variables: [Swift.String : RevenueCatUI.PaywallWebViewValue]) +} +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) +public struct PaywallWebViewMessageAction { + public init(_ action: @escaping @_Concurrency.MainActor (RevenueCatUI.PaywallWebViewMessage, RevenueCatUI.PaywallWebViewController) -> Swift.Void) + @_Concurrency.MainActor public func callAsFunction(_ message: RevenueCatUI.PaywallWebViewMessage, _ controller: RevenueCatUI.PaywallWebViewController) +} +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) +extension SwiftUICore.View { + @_Concurrency.MainActor @preconcurrency public func onPaywallWebViewMessage(_ action: @escaping @_Concurrency.MainActor (RevenueCatUI.PaywallWebViewMessage, RevenueCatUI.PaywallWebViewController) -> Swift.Void) -> some SwiftUICore.View + +} @available(iOS 14.0, macOS 11.0, tvOS 14.0, watchOS 7.0, *) extension SwiftUICore.View { @_Concurrency.MainActor @preconcurrency public func onWebPurchaseRedemptionAttempt(perform completion: @escaping @Sendable (RevenueCat.WebPurchaseRedemptionResult) -> Swift.Void) -> some SwiftUICore.View @@ -324,5 +384,7 @@ extension SwiftUICore.View { @available(iOS 15.0, macOS 12.0, watchOS 8.0, *) @available(tvOS, unavailable, message: "RevenueCatUI does not support tvOS yet") extension RevenueCatUI.PaywallView : Swift.Sendable {} +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) +extension RevenueCatUI.PaywallWebViewController : Swift.Sendable {} extension RevenueCatUI.PaywallPresentationMode : Swift.Equatable {} extension RevenueCatUI.PaywallPresentationMode : Swift.Hashable {} diff --git a/api/revenuecatui-api-visionos-simulator.swiftinterface b/api/revenuecatui-api-visionos-simulator.swiftinterface index b87d2b82d9..7369428ec5 100644 --- a/api/revenuecatui-api-visionos-simulator.swiftinterface +++ b/api/revenuecatui-api-visionos-simulator.swiftinterface @@ -179,6 +179,66 @@ extension RevenueCat.Purchases { @available(iOS 15.0, macOS 12.0, *) @_Concurrency.MainActor @objc final public func presentPaywall(from url: Foundation.URL) -> Swift.Bool } +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) +public struct PaywallWebViewMessage : Swift.Sendable, Swift.Equatable { + public let componentID: Swift.String + public let type: Swift.String + public let responses: [Swift.String : RevenueCatUI.PaywallWebViewValue]? + public let error: Swift.String? + public init(componentID: Swift.String, type: Swift.String, responses: [Swift.String : RevenueCatUI.PaywallWebViewValue]? = nil, error: Swift.String? = nil) + public static func == (a: RevenueCatUI.PaywallWebViewMessage, b: RevenueCatUI.PaywallWebViewMessage) -> Swift.Bool +} +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) +public struct PaywallWebViewValue : Swift.Sendable, Swift.Equatable, Swift.Hashable, Swift.Codable { + public static func string(_ value: Swift.String) -> RevenueCatUI.PaywallWebViewValue + public static func number(_ value: Swift.Double) -> RevenueCatUI.PaywallWebViewValue + public static func bool(_ value: Swift.Bool) -> RevenueCatUI.PaywallWebViewValue + public static func array(_ value: [RevenueCatUI.PaywallWebViewValue]) -> RevenueCatUI.PaywallWebViewValue + public static func object(_ value: [Swift.String : RevenueCatUI.PaywallWebViewValue]) -> RevenueCatUI.PaywallWebViewValue + public static var null: RevenueCatUI.PaywallWebViewValue { + get + } + public var stringValue: Swift.String? { + get + } + public var numberValue: Swift.Double? { + get + } + public var boolValue: Swift.Bool? { + get + } + public var arrayValue: [RevenueCatUI.PaywallWebViewValue]? { + get + } + public var objectValue: [Swift.String : RevenueCatUI.PaywallWebViewValue]? { + get + } + public var isNull: Swift.Bool { + get + } + public init(from decoder: any Swift.Decoder) throws + public func encode(to encoder: any Swift.Encoder) throws + public static func == (a: RevenueCatUI.PaywallWebViewValue, b: RevenueCatUI.PaywallWebViewValue) -> Swift.Bool + public func hash(into hasher: inout Swift.Hasher) + public var hashValue: Swift.Int { + get + } +} +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) +@_Concurrency.MainActor public struct PaywallWebViewController { + @_Concurrency.MainActor public func postVariables(componentID: Swift.String, variables: [Swift.String : RevenueCatUI.PaywallWebViewValue]) + @_Concurrency.MainActor public func postMessage(componentID: Swift.String, type: Swift.String, variables: [Swift.String : RevenueCatUI.PaywallWebViewValue]) +} +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) +public struct PaywallWebViewMessageAction { + public init(_ action: @escaping @_Concurrency.MainActor (RevenueCatUI.PaywallWebViewMessage, RevenueCatUI.PaywallWebViewController) -> Swift.Void) + @_Concurrency.MainActor public func callAsFunction(_ message: RevenueCatUI.PaywallWebViewMessage, _ controller: RevenueCatUI.PaywallWebViewController) +} +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) +extension SwiftUICore.View { + @_Concurrency.MainActor @preconcurrency public func onPaywallWebViewMessage(_ action: @escaping @_Concurrency.MainActor (RevenueCatUI.PaywallWebViewMessage, RevenueCatUI.PaywallWebViewController) -> Swift.Void) -> some SwiftUICore.View + +} @_inheritsConvenienceInitializers @_hasMissingDesignatedInitializers @available(iOS 15.0, macOS 12.0, tvOS 15.0, *) @objc(RCPaywallFooterViewController) @_Concurrency.MainActor @preconcurrency final public class PaywallFooterViewController : RevenueCatUI.PaywallViewController { @_Concurrency.MainActor @preconcurrency public init(offering: RevenueCat.Offering? = nil, performPurchase: @escaping RevenueCatUI.PerformPurchase, performRestore: @escaping RevenueCatUI.PerformRestore, dismissRequestedHandler: ((_ controller: RevenueCatUI.PaywallViewController) -> Swift.Void)? = nil) @@ -406,5 +466,7 @@ extension SwiftUICore.View { @available(iOS 15.0, macOS 12.0, watchOS 8.0, *) @available(tvOS, unavailable, message: "RevenueCatUI does not support tvOS yet") extension RevenueCatUI.PaywallView : Swift.Sendable {} +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) +extension RevenueCatUI.PaywallWebViewController : Swift.Sendable {} extension RevenueCatUI.PaywallPresentationMode : Swift.Equatable {} extension RevenueCatUI.PaywallPresentationMode : Swift.Hashable {} diff --git a/api/revenuecatui-api-visionos.swiftinterface b/api/revenuecatui-api-visionos.swiftinterface index ec8d35ca1f..cccba66c64 100644 --- a/api/revenuecatui-api-visionos.swiftinterface +++ b/api/revenuecatui-api-visionos.swiftinterface @@ -179,6 +179,66 @@ extension RevenueCat.Purchases { @available(iOS 15.0, macOS 12.0, *) @_Concurrency.MainActor @objc final public func presentPaywall(from url: Foundation.URL) -> Swift.Bool } +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) +public struct PaywallWebViewMessage : Swift.Sendable, Swift.Equatable { + public let componentID: Swift.String + public let type: Swift.String + public let responses: [Swift.String : RevenueCatUI.PaywallWebViewValue]? + public let error: Swift.String? + public init(componentID: Swift.String, type: Swift.String, responses: [Swift.String : RevenueCatUI.PaywallWebViewValue]? = nil, error: Swift.String? = nil) + public static func == (a: RevenueCatUI.PaywallWebViewMessage, b: RevenueCatUI.PaywallWebViewMessage) -> Swift.Bool +} +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) +public struct PaywallWebViewValue : Swift.Sendable, Swift.Equatable, Swift.Hashable, Swift.Codable { + public static func string(_ value: Swift.String) -> RevenueCatUI.PaywallWebViewValue + public static func number(_ value: Swift.Double) -> RevenueCatUI.PaywallWebViewValue + public static func bool(_ value: Swift.Bool) -> RevenueCatUI.PaywallWebViewValue + public static func array(_ value: [RevenueCatUI.PaywallWebViewValue]) -> RevenueCatUI.PaywallWebViewValue + public static func object(_ value: [Swift.String : RevenueCatUI.PaywallWebViewValue]) -> RevenueCatUI.PaywallWebViewValue + public static var null: RevenueCatUI.PaywallWebViewValue { + get + } + public var stringValue: Swift.String? { + get + } + public var numberValue: Swift.Double? { + get + } + public var boolValue: Swift.Bool? { + get + } + public var arrayValue: [RevenueCatUI.PaywallWebViewValue]? { + get + } + public var objectValue: [Swift.String : RevenueCatUI.PaywallWebViewValue]? { + get + } + public var isNull: Swift.Bool { + get + } + public init(from decoder: any Swift.Decoder) throws + public func encode(to encoder: any Swift.Encoder) throws + public static func == (a: RevenueCatUI.PaywallWebViewValue, b: RevenueCatUI.PaywallWebViewValue) -> Swift.Bool + public func hash(into hasher: inout Swift.Hasher) + public var hashValue: Swift.Int { + get + } +} +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) +@_Concurrency.MainActor public struct PaywallWebViewController { + @_Concurrency.MainActor public func postVariables(componentID: Swift.String, variables: [Swift.String : RevenueCatUI.PaywallWebViewValue]) + @_Concurrency.MainActor public func postMessage(componentID: Swift.String, type: Swift.String, variables: [Swift.String : RevenueCatUI.PaywallWebViewValue]) +} +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) +public struct PaywallWebViewMessageAction { + public init(_ action: @escaping @_Concurrency.MainActor (RevenueCatUI.PaywallWebViewMessage, RevenueCatUI.PaywallWebViewController) -> Swift.Void) + @_Concurrency.MainActor public func callAsFunction(_ message: RevenueCatUI.PaywallWebViewMessage, _ controller: RevenueCatUI.PaywallWebViewController) +} +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) +extension SwiftUICore.View { + @_Concurrency.MainActor @preconcurrency public func onPaywallWebViewMessage(_ action: @escaping @_Concurrency.MainActor (RevenueCatUI.PaywallWebViewMessage, RevenueCatUI.PaywallWebViewController) -> Swift.Void) -> some SwiftUICore.View + +} @_inheritsConvenienceInitializers @_hasMissingDesignatedInitializers @available(iOS 15.0, macOS 12.0, tvOS 15.0, *) @objc(RCPaywallFooterViewController) @_Concurrency.MainActor @preconcurrency final public class PaywallFooterViewController : RevenueCatUI.PaywallViewController { @_Concurrency.MainActor @preconcurrency public init(offering: RevenueCat.Offering? = nil, performPurchase: @escaping RevenueCatUI.PerformPurchase, performRestore: @escaping RevenueCatUI.PerformRestore, dismissRequestedHandler: ((_ controller: RevenueCatUI.PaywallViewController) -> Swift.Void)? = nil) @@ -406,5 +466,7 @@ extension SwiftUICore.View { @available(iOS 15.0, macOS 12.0, watchOS 8.0, *) @available(tvOS, unavailable, message: "RevenueCatUI does not support tvOS yet") extension RevenueCatUI.PaywallView : Swift.Sendable {} +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) +extension RevenueCatUI.PaywallWebViewController : Swift.Sendable {} extension RevenueCatUI.PaywallPresentationMode : Swift.Equatable {} extension RevenueCatUI.PaywallPresentationMode : Swift.Hashable {} diff --git a/api/revenuecatui-api-watchos-simulator.swiftinterface b/api/revenuecatui-api-watchos-simulator.swiftinterface index 03d128c761..7038282c5d 100644 --- a/api/revenuecatui-api-watchos-simulator.swiftinterface +++ b/api/revenuecatui-api-watchos-simulator.swiftinterface @@ -166,6 +166,66 @@ open class CustomPaywallFontProvider : RevenueCatUI.PaywallFontProvider { @available(tvOS, unavailable, introduced: 15.0, message: "RevenueCatUI does not support tvOS yet") public typealias Body = @_opaqueReturnTypeOf("$s12RevenueCatUI11PaywallViewV4bodyQrvp", 0) __ } +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) +public struct PaywallWebViewMessage : Swift.Sendable, Swift.Equatable { + public let componentID: Swift.String + public let type: Swift.String + public let responses: [Swift.String : RevenueCatUI.PaywallWebViewValue]? + public let error: Swift.String? + public init(componentID: Swift.String, type: Swift.String, responses: [Swift.String : RevenueCatUI.PaywallWebViewValue]? = nil, error: Swift.String? = nil) + public static func == (a: RevenueCatUI.PaywallWebViewMessage, b: RevenueCatUI.PaywallWebViewMessage) -> Swift.Bool +} +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) +public struct PaywallWebViewValue : Swift.Sendable, Swift.Equatable, Swift.Hashable, Swift.Codable { + public static func string(_ value: Swift.String) -> RevenueCatUI.PaywallWebViewValue + public static func number(_ value: Swift.Double) -> RevenueCatUI.PaywallWebViewValue + public static func bool(_ value: Swift.Bool) -> RevenueCatUI.PaywallWebViewValue + public static func array(_ value: [RevenueCatUI.PaywallWebViewValue]) -> RevenueCatUI.PaywallWebViewValue + public static func object(_ value: [Swift.String : RevenueCatUI.PaywallWebViewValue]) -> RevenueCatUI.PaywallWebViewValue + public static var null: RevenueCatUI.PaywallWebViewValue { + get + } + public var stringValue: Swift.String? { + get + } + public var numberValue: Swift.Double? { + get + } + public var boolValue: Swift.Bool? { + get + } + public var arrayValue: [RevenueCatUI.PaywallWebViewValue]? { + get + } + public var objectValue: [Swift.String : RevenueCatUI.PaywallWebViewValue]? { + get + } + public var isNull: Swift.Bool { + get + } + public init(from decoder: any Swift.Decoder) throws + public func encode(to encoder: any Swift.Encoder) throws + public static func == (a: RevenueCatUI.PaywallWebViewValue, b: RevenueCatUI.PaywallWebViewValue) -> Swift.Bool + public func hash(into hasher: inout Swift.Hasher) + public var hashValue: Swift.Int { + get + } +} +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) +@_Concurrency.MainActor public struct PaywallWebViewController { + @_Concurrency.MainActor public func postVariables(componentID: Swift.String, variables: [Swift.String : RevenueCatUI.PaywallWebViewValue]) + @_Concurrency.MainActor public func postMessage(componentID: Swift.String, type: Swift.String, variables: [Swift.String : RevenueCatUI.PaywallWebViewValue]) +} +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) +public struct PaywallWebViewMessageAction { + public init(_ action: @escaping @_Concurrency.MainActor (RevenueCatUI.PaywallWebViewMessage, RevenueCatUI.PaywallWebViewController) -> Swift.Void) + @_Concurrency.MainActor public func callAsFunction(_ message: RevenueCatUI.PaywallWebViewMessage, _ controller: RevenueCatUI.PaywallWebViewController) +} +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) +extension SwiftUICore.View { + @_Concurrency.MainActor @preconcurrency public func onPaywallWebViewMessage(_ action: @escaping @_Concurrency.MainActor (RevenueCatUI.PaywallWebViewMessage, RevenueCatUI.PaywallWebViewController) -> Swift.Void) -> some SwiftUICore.View + +} @available(iOS 14.0, macOS 11.0, tvOS 14.0, watchOS 7.0, *) extension SwiftUICore.View { @_Concurrency.MainActor @preconcurrency public func onWebPurchaseRedemptionAttempt(perform completion: @escaping @Sendable (RevenueCat.WebPurchaseRedemptionResult) -> Swift.Void) -> some SwiftUICore.View @@ -279,5 +339,7 @@ extension SwiftUICore.View { @available(iOS 15.0, macOS 12.0, watchOS 8.0, *) @available(tvOS, unavailable, message: "RevenueCatUI does not support tvOS yet") extension RevenueCatUI.PaywallView : Swift.Sendable {} +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) +extension RevenueCatUI.PaywallWebViewController : Swift.Sendable {} extension RevenueCatUI.PaywallPresentationMode : Swift.Equatable {} extension RevenueCatUI.PaywallPresentationMode : Swift.Hashable {} diff --git a/api/revenuecatui-api-watchos.swiftinterface b/api/revenuecatui-api-watchos.swiftinterface index 39b2ed3084..0490cf9e59 100644 --- a/api/revenuecatui-api-watchos.swiftinterface +++ b/api/revenuecatui-api-watchos.swiftinterface @@ -166,6 +166,66 @@ open class CustomPaywallFontProvider : RevenueCatUI.PaywallFontProvider { @available(tvOS, unavailable, introduced: 15.0, message: "RevenueCatUI does not support tvOS yet") public typealias Body = @_opaqueReturnTypeOf("$s12RevenueCatUI11PaywallViewV4bodyQrvp", 0) __ } +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) +public struct PaywallWebViewMessage : Swift.Sendable, Swift.Equatable { + public let componentID: Swift.String + public let type: Swift.String + public let responses: [Swift.String : RevenueCatUI.PaywallWebViewValue]? + public let error: Swift.String? + public init(componentID: Swift.String, type: Swift.String, responses: [Swift.String : RevenueCatUI.PaywallWebViewValue]? = nil, error: Swift.String? = nil) + public static func == (a: RevenueCatUI.PaywallWebViewMessage, b: RevenueCatUI.PaywallWebViewMessage) -> Swift.Bool +} +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) +public struct PaywallWebViewValue : Swift.Sendable, Swift.Equatable, Swift.Hashable, Swift.Codable { + public static func string(_ value: Swift.String) -> RevenueCatUI.PaywallWebViewValue + public static func number(_ value: Swift.Double) -> RevenueCatUI.PaywallWebViewValue + public static func bool(_ value: Swift.Bool) -> RevenueCatUI.PaywallWebViewValue + public static func array(_ value: [RevenueCatUI.PaywallWebViewValue]) -> RevenueCatUI.PaywallWebViewValue + public static func object(_ value: [Swift.String : RevenueCatUI.PaywallWebViewValue]) -> RevenueCatUI.PaywallWebViewValue + public static var null: RevenueCatUI.PaywallWebViewValue { + get + } + public var stringValue: Swift.String? { + get + } + public var numberValue: Swift.Double? { + get + } + public var boolValue: Swift.Bool? { + get + } + public var arrayValue: [RevenueCatUI.PaywallWebViewValue]? { + get + } + public var objectValue: [Swift.String : RevenueCatUI.PaywallWebViewValue]? { + get + } + public var isNull: Swift.Bool { + get + } + public init(from decoder: any Swift.Decoder) throws + public func encode(to encoder: any Swift.Encoder) throws + public static func == (a: RevenueCatUI.PaywallWebViewValue, b: RevenueCatUI.PaywallWebViewValue) -> Swift.Bool + public func hash(into hasher: inout Swift.Hasher) + public var hashValue: Swift.Int { + get + } +} +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) +@_Concurrency.MainActor public struct PaywallWebViewController { + @_Concurrency.MainActor public func postVariables(componentID: Swift.String, variables: [Swift.String : RevenueCatUI.PaywallWebViewValue]) + @_Concurrency.MainActor public func postMessage(componentID: Swift.String, type: Swift.String, variables: [Swift.String : RevenueCatUI.PaywallWebViewValue]) +} +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) +public struct PaywallWebViewMessageAction { + public init(_ action: @escaping @_Concurrency.MainActor (RevenueCatUI.PaywallWebViewMessage, RevenueCatUI.PaywallWebViewController) -> Swift.Void) + @_Concurrency.MainActor public func callAsFunction(_ message: RevenueCatUI.PaywallWebViewMessage, _ controller: RevenueCatUI.PaywallWebViewController) +} +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) +extension SwiftUICore.View { + @_Concurrency.MainActor @preconcurrency public func onPaywallWebViewMessage(_ action: @escaping @_Concurrency.MainActor (RevenueCatUI.PaywallWebViewMessage, RevenueCatUI.PaywallWebViewController) -> Swift.Void) -> some SwiftUICore.View + +} @available(iOS 14.0, macOS 11.0, tvOS 14.0, watchOS 7.0, *) extension SwiftUICore.View { @_Concurrency.MainActor @preconcurrency public func onWebPurchaseRedemptionAttempt(perform completion: @escaping @Sendable (RevenueCat.WebPurchaseRedemptionResult) -> Swift.Void) -> some SwiftUICore.View @@ -279,5 +339,7 @@ extension SwiftUICore.View { @available(iOS 15.0, macOS 12.0, watchOS 8.0, *) @available(tvOS, unavailable, message: "RevenueCatUI does not support tvOS yet") extension RevenueCatUI.PaywallView : Swift.Sendable {} +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) +extension RevenueCatUI.PaywallWebViewController : Swift.Sendable {} extension RevenueCatUI.PaywallPresentationMode : Swift.Equatable {} extension RevenueCatUI.PaywallPresentationMode : Swift.Hashable {} From f7f5d16f422944c7691068048844e509caaca0f7 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 15 Jul 2026 20:29:47 +0000 Subject: [PATCH 02/35] feat(paywalls): web_view wire envelope + JSON hardening (2/7) Add WebViewEnvelope encode/decode and input hardening tests. Inert until Part 7; builds on the public API from Part 1. Co-authored-by: Jacob Rakidzich --- RevenueCat.xcodeproj/project.pbxproj | 6 + .../Components/WebView/WebViewEnvelope.swift | 176 ++++++++++++++++++ .../PaywallsV2/WebViewEnvelopeTests.swift | 135 ++++++++++++++ 3 files changed, 317 insertions(+) create mode 100644 RevenueCatUI/Templates/V2/Components/WebView/WebViewEnvelope.swift create mode 100644 Tests/RevenueCatUITests/PaywallsV2/WebViewEnvelopeTests.swift diff --git a/RevenueCat.xcodeproj/project.pbxproj b/RevenueCat.xcodeproj/project.pbxproj index 0f4bd8f8c2..0c85a7d13c 100644 --- a/RevenueCat.xcodeproj/project.pbxproj +++ b/RevenueCat.xcodeproj/project.pbxproj @@ -108,6 +108,7 @@ 16DA8EDF2E4F6A6600283940 /* PaywallVideoComponent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 16DA8EDE2E4F6A6600283940 /* PaywallVideoComponent.swift */; }; 16DA8EF42E4F7A2500283940 /* VideoComponentTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 16DA8EF32E4F7A2500283940 /* VideoComponentTests.swift */; }; FA4A2DF41FC434A9401F08CC /* PaywallWebViewAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9D0C57ECDF0E7F1A5B4418FF /* PaywallWebViewAPI.swift */; }; + 11CC2EA83C56933E40D8E5D8 /* WebViewEnvelope.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89DD761C9E00E61032AAEB44 /* WebViewEnvelope.swift */; }; 16DA8F1E2E4FB6E200283940 /* ImageComponent.json in Resources */ = {isa = PBXBuildFile; fileRef = 16DA8F1B2E4FB6E200283940 /* ImageComponent.json */; }; 16DA8F1F2E4FB6E200283940 /* VideoComponent.json in Resources */ = {isa = PBXBuildFile; fileRef = 16DA8F1C2E4FB6E200283940 /* VideoComponent.json */; }; 16E146AD2E99F3480089B609 /* TransactionNotifications.swift in Sources */ = {isa = PBXBuildFile; fileRef = 16E146AB2E99F1E20089B609 /* TransactionNotifications.swift */; }; @@ -1638,6 +1639,8 @@ 16DA8EDE2E4F6A6600283940 /* PaywallVideoComponent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaywallVideoComponent.swift; sourceTree = ""; }; 16DA8EF32E4F7A2500283940 /* VideoComponentTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoComponentTests.swift; sourceTree = ""; }; 9D0C57ECDF0E7F1A5B4418FF /* PaywallWebViewAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaywallWebViewAPI.swift; sourceTree = ""; }; + 89DD761C9E00E61032AAEB44 /* WebViewEnvelope.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebViewEnvelope.swift; sourceTree = ""; }; + C1B939C7DAAFEA1DE2DE2674 /* WebViewEnvelopeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebViewEnvelopeTests.swift; sourceTree = ""; }; 686562C08D953A27986DB276 /* PaywallWebViewValueTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaywallWebViewValueTests.swift; sourceTree = ""; }; 16DA8F1B2E4FB6E200283940 /* ImageComponent.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = ImageComponent.json; sourceTree = ""; }; 16DA8F1C2E4FB6E200283940 /* VideoComponent.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = VideoComponent.json; sourceTree = ""; }; @@ -3169,6 +3172,7 @@ isa = PBXGroup; children = ( 686562C08D953A27986DB276 /* PaywallWebViewValueTests.swift */, + C1B939C7DAAFEA1DE2DE2674 /* WebViewEnvelopeTests.swift */, 802593842FE07057005AF6DF /* PaywallStateStoreTests.swift */, 16A9F7B62FAA22D0008E8A4D /* VideoComponentViewTests.swift */, A9D45E4EAFAD4C0385BEDFB3 /* VideoPlayerViewTests.swift */, @@ -3305,6 +3309,7 @@ isa = PBXGroup; children = ( 9D0C57ECDF0E7F1A5B4418FF /* PaywallWebViewAPI.swift */, + 89DD761C9E00E61032AAEB44 /* WebViewEnvelope.swift */, ); path = WebView; sourceTree = ""; @@ -8247,6 +8252,7 @@ 164681D12E6B577600854AA5 /* VideoPlayerViewUIView.swift in Sources */, 164681D62E6B577600854AA5 /* VideoPlayerLayerUIView.swift in Sources */, FA4A2DF41FC434A9401F08CC /* PaywallWebViewAPI.swift in Sources */, + 11CC2EA83C56933E40D8E5D8 /* WebViewEnvelope.swift in Sources */, DBAA1FC22F8D4ED8000E8C81 /* HeaderNestedHeroZLayerSafeAreaPreview.swift in Sources */, 164681D22E6B577600854AA5 /* VideoComponentViewModel.swift in Sources */, 2C7457422CE81107004ACE52 /* IntroOfferEligibilityContext.swift in Sources */, diff --git a/RevenueCatUI/Templates/V2/Components/WebView/WebViewEnvelope.swift b/RevenueCatUI/Templates/V2/Components/WebView/WebViewEnvelope.swift new file mode 100644 index 0000000000..7905029df5 --- /dev/null +++ b/RevenueCatUI/Templates/V2/Components/WebView/WebViewEnvelope.swift @@ -0,0 +1,176 @@ +import Foundation +// swiftlint:disable nesting + +#if !os(tvOS) // For Paywalls V2 + +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) +enum WebViewEnvelope { + + static let channel = "rc-web-components" + static let messageHandlerName = "rcWebComponents" + static let receiveFunction = "__rcWebComponentsReceive" + static let defaultProtocolVersion = 1 + + static let messageTypeStepLoaded = "rc:step-loaded" + static let messageTypeStepComplete = "rc:step-complete" + static let messageTypeRequestVariables = "rc:request-variables" + static let messageTypeVariables = "rc:variables" + static let messageTypeError = "rc:error" + static let messageTypeResize = "resize" + static let messageTypeFit = "fit" + + static let maxInboundFrameBytes = 65_536 + static let maxJSONDepth = 16 + /// Structural depth budget for a whole inbound frame: the envelope object itself (1 level) + /// plus a payload tree of at most `maxJSONDepth` levels. + static let maxFrameDepth = maxJSONDepth + 1 + static let maxResizePoints: CGFloat = 10_000 + static let resizeThreshold: CGFloat = 1 + static let fallbackFitHeight: CGFloat = 100 + static let fallbackFitWidth: CGFloat = 300 + + enum Kind: String, Codable { + case connect + case `init` + case reject + case message + case request + case response + case error + } + + struct Envelope: Codable, Equatable { + let channel: String + let protocolVersion: Int + let kind: Kind + let componentID: String + let type: String? + let id: String? + let payload: [String: PaywallWebViewValue]? + let error: String? + + init( + kind: Kind, + componentID: String, + type: String? = nil, + id: String? = nil, + payload: [String: PaywallWebViewValue]? = nil, + error: String? = nil, + protocolVersion: Int = WebViewEnvelope.defaultProtocolVersion + ) { + self.channel = WebViewEnvelope.channel + self.protocolVersion = protocolVersion + self.kind = kind + self.componentID = componentID + self.type = type + self.id = id + self.payload = payload + self.error = error + } + + enum CodingKeys: String, CodingKey { + case channel + case protocolVersion = "protocol_version" + case kind + case componentID = "component_id" + case type + case id + case payload + case error + } + } + + static func decode(rawMessage: Any) -> Envelope? { + let data: Data + if let string = rawMessage as? String { + guard string.utf8.count <= Self.maxInboundFrameBytes, + let stringData = string.data(using: .utf8) else { + return nil + } + data = stringData + } else if let dictionary = rawMessage as? [String: Any] { + guard JSONSerialization.isValidJSONObject(dictionary), + let serialized = try? JSONSerialization.data(withJSONObject: dictionary), + serialized.count <= Self.maxInboundFrameBytes else { + return nil + } + data = serialized + } else { + return nil + } + + // Enforce the nesting limit BEFORE decoding: `PaywallWebViewValue.init(from:)` recurses + // per nesting level, so a hostile deeply-nested frame (tens of thousands of levels fit in + // 64 KiB) could otherwise overflow the stack before any post-decode check runs. + guard !Self.exceedsMaxDepth(data) else { + return nil + } + + guard let envelope = try? JSONDecoder().decode(Envelope.self, from: data), + envelope.channel == Self.channel else { + return nil + } + + return envelope + } + + /// Non-recursive scan of the raw JSON bytes, tracking `{`/`[` nesting outside string + /// literals (honoring `\` escapes). Returns `true` when the depth exceeds ``maxFrameDepth``. + static func exceedsMaxDepth(_ data: Data) -> Bool { + var depth = 0 + var inString = false + var escaped = false + + for byte in data { + if inString { + if escaped { + escaped = false + } else if byte == UInt8(ascii: "\\") { + escaped = true + } else if byte == UInt8(ascii: "\"") { + inString = false + } + continue + } + + switch byte { + case UInt8(ascii: "\""): + inString = true + case UInt8(ascii: "{"), UInt8(ascii: "["): + depth += 1 + if depth > Self.maxFrameDepth { + return true + } + case UInt8(ascii: "}"), UInt8(ascii: "]"): + depth -= 1 + default: + break + } + } + + return false + } + + static func receiveScript(for envelope: Envelope) -> String? { + let encoder = JSONEncoder() + guard let data = try? encoder.encode(envelope), + let json = String(data: data, encoding: .utf8) else { + return nil + } + let escaped = json + .replacingOccurrences(of: "\u{2028}", with: "\\u2028") + .replacingOccurrences(of: "\u{2029}", with: "\\u2029") + + return """ + (function(){var m=\(escaped);if(typeof window.\(Self.receiveFunction)==='function'){\ + window.\(Self.receiveFunction)(m);}})(); + """ + } + + static let reservedPayloadKeys: Set = [ + "channel", "protocol_version", "kind", "type", "component_id", "id", "error", "variables" + ] + +} + +#endif diff --git a/Tests/RevenueCatUITests/PaywallsV2/WebViewEnvelopeTests.swift b/Tests/RevenueCatUITests/PaywallsV2/WebViewEnvelopeTests.swift new file mode 100644 index 0000000000..509b97964e --- /dev/null +++ b/Tests/RevenueCatUITests/PaywallsV2/WebViewEnvelopeTests.swift @@ -0,0 +1,135 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// + +@testable import RevenueCatUI +import XCTest +// swiftlint:disable force_try + +#if !os(tvOS) + +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) +final class WebViewEnvelopeTests: TestCase { + + func testDecodesValidConnectFrame() throws { + let envelope = WebViewEnvelope.decode(rawMessage: Self.json([ + "channel": WebViewEnvelope.channel, + "protocol_version": 1, + "kind": "connect", + "component_id": "" + ])) + + XCTAssertEqual(envelope?.kind, .connect) + XCTAssertEqual(envelope?.protocolVersion, 1) + XCTAssertEqual(envelope?.componentID, "") + } + + func testRejectsMalformedFrames() { + XCTAssertNil(WebViewEnvelope.decode(rawMessage: Self.json([ + "channel": "wrong", + "protocol_version": 1, + "kind": "connect", + "component_id": "" + ]))) + XCTAssertNil(WebViewEnvelope.decode(rawMessage: Self.json([ + "channel": WebViewEnvelope.channel, + "protocol_version": 1, + "kind": "unknown", + "component_id": "" + ]))) + XCTAssertNil(WebViewEnvelope.decode(rawMessage: Self.json([ + "channel": WebViewEnvelope.channel, + "protocol_version": 1, + "kind": "message" + ]))) + XCTAssertNil(WebViewEnvelope.decode(rawMessage: "{not json")) + XCTAssertNil(WebViewEnvelope.decode(rawMessage: [ + "channel": WebViewEnvelope.channel, + "protocol_version": 1, + "kind": "message", + "component_id": "web", + "payload": ["nested": ["bad": Date()]] + ])) + } + + func testByteLimit() { + let underLimitPayload = String(repeating: "x", count: WebViewEnvelope.maxInboundFrameBytes - 250) + XCTAssertNotNil(WebViewEnvelope.decode(rawMessage: Self.json([ + "channel": WebViewEnvelope.channel, + "protocol_version": 1, + "kind": "message", + "component_id": "web", + "type": "rc:step-loaded", + "payload": ["value": underLimitPayload] + ]))) + + let overLimitPayload = String(repeating: "x", count: WebViewEnvelope.maxInboundFrameBytes + 1) + XCTAssertNil(WebViewEnvelope.decode(rawMessage: Self.json([ + "channel": WebViewEnvelope.channel, + "protocol_version": 1, + "kind": "message", + "component_id": "web", + "type": "rc:step-loaded", + "payload": ["value": overLimitPayload] + ]))) + } + + func testDepthLimit() { + XCTAssertNotNil(WebViewEnvelope.decode( + rawMessage: Self.json(Self.frame(payload: Self.nestedObject(depth: 15))) + )) + XCTAssertNil(WebViewEnvelope.decode(rawMessage: Self.json(Self.frame(payload: Self.nestedObject(depth: 16))))) + } + + func testEncodeDecodeRoundTripAndEscaping() throws { + let hostile = "annual\" }); alert('xss'); //\n\\ end\u{2028}\u{2029}" + let envelope = WebViewEnvelope.Envelope( + kind: .message, + componentID: "web", + type: "rc:variables", + payload: ["value": .string(hostile)] + ) + + let script = try XCTUnwrap(WebViewEnvelope.receiveScript(for: envelope)) + XCTAssertFalse(script.contains("\u{2028}")) + XCTAssertFalse(script.contains("\u{2029}")) + + let decoded = try Self.decodeEnvelope(fromScript: script) + XCTAssertEqual(decoded, envelope) + XCTAssertEqual(decoded.payload?["value"]?.stringValue, hostile) + } + + private static func frame(payload: [String: Any]) -> [String: Any] { + [ + "channel": WebViewEnvelope.channel, + "protocol_version": 1, + "kind": "message", + "component_id": "web", + "type": "rc:step-loaded", + "payload": payload + ] + } + + private static func nestedObject(depth: Int) -> [String: Any] { + var value: [String: Any] = ["leaf": true] + for _ in 0.. String { + let data = try! JSONSerialization.data(withJSONObject: object) + return String(data: data, encoding: .utf8)! + } + + static func decodeEnvelope(fromScript script: String) throws -> WebViewEnvelope.Envelope { + let start = try XCTUnwrap(script.range(of: "var m=")?.upperBound) + let end = try XCTUnwrap(script.range(of: ";if", range: start.. Date: Wed, 15 Jul 2026 20:30:22 +0000 Subject: [PATCH 03/35] feat(paywalls): web_view navigation policy + content-rule isolation (3/7) Extract origin normalization, main-frame navigation policy, and WK content rule isolation as inert helpers used by later session/view PRs. Co-authored-by: Jacob Rakidzich --- RevenueCat.xcodeproj/project.pbxproj | 20 ++++++ RevenueCatUI/Data/Strings.swift | 6 ++ .../Components/WebView/WebViewIsolation.swift | 67 +++++++++++++++++++ .../WebView/WebViewOriginPolicy.swift | 51 ++++++++++++++ .../PaywallsV2/WebViewIsolationTests.swift | 53 +++++++++++++++ .../WebViewNavigationPolicyTests.swift | 55 +++++++++++++++ 6 files changed, 252 insertions(+) create mode 100644 RevenueCatUI/Templates/V2/Components/WebView/WebViewIsolation.swift create mode 100644 RevenueCatUI/Templates/V2/Components/WebView/WebViewOriginPolicy.swift create mode 100644 Tests/RevenueCatUITests/PaywallsV2/WebViewIsolationTests.swift create mode 100644 Tests/RevenueCatUITests/PaywallsV2/WebViewNavigationPolicyTests.swift diff --git a/RevenueCat.xcodeproj/project.pbxproj b/RevenueCat.xcodeproj/project.pbxproj index 996c1ac30c..c732d57493 100644 --- a/RevenueCat.xcodeproj/project.pbxproj +++ b/RevenueCat.xcodeproj/project.pbxproj @@ -107,6 +107,8 @@ 16DA8ECD2E4EE33700283940 /* ImageComponentTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 16DA8ECC2E4EE33700283940 /* ImageComponentTests.swift */; }; 16DA8EDF2E4F6A6600283940 /* PaywallVideoComponent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 16DA8EDE2E4F6A6600283940 /* PaywallVideoComponent.swift */; }; 16DA8EF42E4F7A2500283940 /* VideoComponentTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 16DA8EF32E4F7A2500283940 /* VideoComponentTests.swift */; }; + 1401E951ACE24C7B84F94B7F /* WebViewIsolation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 833251CB0C6FE0BA16C2AC41 /* WebViewIsolation.swift */; }; + FC5AE63E8F81C3EC2FFDA408 /* WebViewOriginPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 28661C6CE3F64078A0138080 /* WebViewOriginPolicy.swift */; }; 16DA8F1E2E4FB6E200283940 /* ImageComponent.json in Resources */ = {isa = PBXBuildFile; fileRef = 16DA8F1B2E4FB6E200283940 /* ImageComponent.json */; }; 16DA8F1F2E4FB6E200283940 /* VideoComponent.json in Resources */ = {isa = PBXBuildFile; fileRef = 16DA8F1C2E4FB6E200283940 /* VideoComponent.json */; }; 16E146AD2E99F3480089B609 /* TransactionNotifications.swift in Sources */ = {isa = PBXBuildFile; fileRef = 16E146AB2E99F1E20089B609 /* TransactionNotifications.swift */; }; @@ -1636,6 +1638,10 @@ 16DA8ECC2E4EE33700283940 /* ImageComponentTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageComponentTests.swift; sourceTree = ""; }; 16DA8EDE2E4F6A6600283940 /* PaywallVideoComponent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaywallVideoComponent.swift; sourceTree = ""; }; 16DA8EF32E4F7A2500283940 /* VideoComponentTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoComponentTests.swift; sourceTree = ""; }; + 833251CB0C6FE0BA16C2AC41 /* WebViewIsolation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebViewIsolation.swift; sourceTree = ""; }; + 28661C6CE3F64078A0138080 /* WebViewOriginPolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebViewOriginPolicy.swift; sourceTree = ""; }; + 19B79CAD4C423F576E7DFBA9 /* WebViewIsolationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebViewIsolationTests.swift; sourceTree = ""; }; + 9E7AD021B4580265DFDF6A02 /* WebViewNavigationPolicyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebViewNavigationPolicyTests.swift; sourceTree = ""; }; 16DA8F1B2E4FB6E200283940 /* ImageComponent.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = ImageComponent.json; sourceTree = ""; }; 16DA8F1C2E4FB6E200283940 /* VideoComponent.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = VideoComponent.json; sourceTree = ""; }; 16E146AB2E99F1E20089B609 /* TransactionNotifications.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransactionNotifications.swift; sourceTree = ""; }; @@ -3165,6 +3171,8 @@ 030890822D2B77DD0069677B /* PaywallsV2 */ = { isa = PBXGroup; children = ( + 19B79CAD4C423F576E7DFBA9 /* WebViewIsolationTests.swift */, + 9E7AD021B4580265DFDF6A02 /* WebViewNavigationPolicyTests.swift */, 802593842FE07057005AF6DF /* PaywallStateStoreTests.swift */, 16A9F7B62FAA22D0008E8A4D /* VideoComponentViewTests.swift */, A9D45E4EAFAD4C0385BEDFB3 /* VideoPlayerViewTests.swift */, @@ -3297,6 +3305,15 @@ path = Video; sourceTree = ""; }; + 2561352F00BE1ECE1BAC4E9A /* WebView */ = { + isa = PBXGroup; + children = ( + 833251CB0C6FE0BA16C2AC41 /* WebViewIsolation.swift */, + 28661C6CE3F64078A0138080 /* WebViewOriginPolicy.swift */, + ); + path = WebView; + sourceTree = ""; + }; 1699BD5C2EEFBEEC002001FB /* DefaultPaywall */ = { isa = PBXGroup; children = ( @@ -3387,6 +3404,7 @@ 0387D4AD2EC58AA5008E4A6B /* Countdown */, E1C0A00BBEEF0001CCDD0001 /* Header */, 164681CD2E6B577600854AA5 /* Video */, + 2561352F00BE1ECE1BAC4E9A /* WebView */, 2C7457472CEA66AB004ACE52 /* ComponentsView.swift */, 7707A94A2CAD936A006E0313 /* Button */, 03C06FC82D479C6300600693 /* Carousel */, @@ -8233,6 +8251,8 @@ 164681D02E6B577600854AA5 /* VideoComponentView.swift in Sources */, 164681D12E6B577600854AA5 /* VideoPlayerViewUIView.swift in Sources */, 164681D62E6B577600854AA5 /* VideoPlayerLayerUIView.swift in Sources */, + 1401E951ACE24C7B84F94B7F /* WebViewIsolation.swift in Sources */, + FC5AE63E8F81C3EC2FFDA408 /* WebViewOriginPolicy.swift in Sources */, DBAA1FC22F8D4ED8000E8C81 /* HeaderNestedHeroZLayerSafeAreaPreview.swift in Sources */, 164681D22E6B577600854AA5 /* VideoComponentViewModel.swift in Sources */, 2C7457422CE81107004ACE52 /* IntroOfferEligibilityContext.swift in Sources */, diff --git a/RevenueCatUI/Data/Strings.swift b/RevenueCatUI/Data/Strings.swift index 64ecf81dbf..856d89fc92 100644 --- a/RevenueCatUI/Data/Strings.swift +++ b/RevenueCatUI/Data/Strings.swift @@ -109,6 +109,9 @@ enum Strings { case video_failed_to_set_audio_session_category(Error) case video_failed_to_cache(URL, Error) + // WebView + case paywall_web_view_content_rules_failed(String) + // Exit Offers case errorFetchingOfferings(Error) case exitOfferNotFound(String) @@ -364,6 +367,9 @@ extension Strings: CustomStringConvertible { case .video_failed_to_cache(let url, let error): return "Failed to cache video at \(url): \(error)" + case .paywall_web_view_content_rules_failed(let error): + return "Paywalls V2 web_view content rules failed: \(error)" + case .errorFetchingOfferings(let error): return "Error fetching offerings: \(error)" case .exitOfferNotFound(let offeringId): diff --git a/RevenueCatUI/Templates/V2/Components/WebView/WebViewIsolation.swift b/RevenueCatUI/Templates/V2/Components/WebView/WebViewIsolation.swift new file mode 100644 index 0000000000..a455af7471 --- /dev/null +++ b/RevenueCatUI/Templates/V2/Components/WebView/WebViewIsolation.swift @@ -0,0 +1,67 @@ +import Foundation +@_spi(Internal) import RevenueCat + +#if canImport(WebKit) +import WebKit +#endif + +#if !os(tvOS) && canImport(WebKit) // For Paywalls V2 + +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) +enum WebViewIsolation { + + static let contentRuleListIdentifier = "rc-webview-v2-isolation" + + static var contentBlockingRules: String? { + """ + [ + {"trigger": {"url-filter": ".*", "resource-type": ["image", "script", "font"], "load-type": ["third-party"]}, + "action": {"type": "block"}}, + {"trigger": {"url-filter": ".*", "resource-type": ["raw"], "load-type": ["third-party"]}, + "action": {"type": "block"}} + ] + """ + } + + @MainActor + static var compileRuleList: @MainActor (String, String) async -> WKContentRuleList? = { identifier, rules in + await withCheckedContinuation { continuation in + WKContentRuleListStore.default().compileContentRuleList( + forIdentifier: identifier, + encodedContentRuleList: rules + ) { ruleList, error in + if let error { + Logger.debug(Strings.paywall_web_view_content_rules_failed(String(describing: error))) + } + continuation.resume(returning: ruleList) + } + } + } + + @MainActor + private static var ruleListTask: Task? + + @MainActor + static func ruleList() async -> WKContentRuleList? { + if let ruleListTask { + return await ruleListTask.value + } + + let task = Task { @MainActor in + guard let rules = Self.contentBlockingRules else { + return nil + } + return await Self.compileRuleList(Self.contentRuleListIdentifier, rules) + } + self.ruleListTask = task + return await task.value + } + + @MainActor + static func resetRuleListCacheForTests() { + self.ruleListTask = nil + } + +} + +#endif diff --git a/RevenueCatUI/Templates/V2/Components/WebView/WebViewOriginPolicy.swift b/RevenueCatUI/Templates/V2/Components/WebView/WebViewOriginPolicy.swift new file mode 100644 index 0000000000..806303b9a3 --- /dev/null +++ b/RevenueCatUI/Templates/V2/Components/WebView/WebViewOriginPolicy.swift @@ -0,0 +1,51 @@ +import Foundation + +#if canImport(WebKit) +import WebKit +#endif + +#if !os(tvOS) && canImport(WebKit) // For Paywalls V2 + +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) +enum WebViewOrigin { + + nonisolated static func origin(of url: URL) -> String? { + guard let scheme = url.scheme?.lowercased(), + let host = url.host?.lowercased(), + !host.isEmpty else { + return nil + } + + let port = url.port + let suffix: String + if let port, !Self.isDefaultPort(port, scheme: scheme) { + suffix = ":\(port)" + } else { + suffix = "" + } + return "\(scheme)://\(host)\(suffix)" + } + + nonisolated private static func isDefaultPort(_ port: Int, scheme: String) -> Bool { + (scheme == "https" && port == 443) || (scheme == "http" && port == 80) + } + +} + +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) +enum WebViewNavigationPolicy { + + static func policy(for url: URL?, isMainFrame: Bool, expectedOrigin: String) -> WKNavigationActionPolicy { + guard isMainFrame else { + return .allow + } + guard let url, + WebViewOrigin.origin(of: url) == expectedOrigin else { + return .cancel + } + return .allow + } + +} + +#endif diff --git a/Tests/RevenueCatUITests/PaywallsV2/WebViewIsolationTests.swift b/Tests/RevenueCatUITests/PaywallsV2/WebViewIsolationTests.swift new file mode 100644 index 0000000000..cf0764f36d --- /dev/null +++ b/Tests/RevenueCatUITests/PaywallsV2/WebViewIsolationTests.swift @@ -0,0 +1,53 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// + +@testable import RevenueCatUI +import XCTest + +#if !os(tvOS) && canImport(WebKit) + +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) +@MainActor +final class WebViewIsolationTests: TestCase { + + func testContentRulesShape() throws { + XCTAssertEqual(WebViewIsolation.contentRuleListIdentifier, "rc-webview-v2-isolation") + let rules = try XCTUnwrap(WebViewIsolation.contentBlockingRules) + let data = try XCTUnwrap(rules.data(using: .utf8)) + let object = try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [[String: Any]]) + + XCTAssertEqual(object.count, 2) + let firstTrigger = try XCTUnwrap(object[0]["trigger"] as? [String: Any]) + let secondTrigger = try XCTUnwrap(object[1]["trigger"] as? [String: Any]) + XCTAssertEqual(firstTrigger["resource-type"] as? [String], ["image", "script", "font"]) + XCTAssertEqual(firstTrigger["load-type"] as? [String], ["third-party"]) + XCTAssertEqual(secondTrigger["resource-type"] as? [String], ["raw"]) + XCTAssertEqual(secondTrigger["load-type"] as? [String], ["third-party"]) + XCTAssertFalse(rules.contains(#""url-filter": "data:"#)) + } + + func testCompileFailureReturnsNilAndCaches() async { + WebViewIsolation.resetRuleListCacheForTests() + var calls = 0 + let original = WebViewIsolation.compileRuleList + WebViewIsolation.compileRuleList = { _, _ in + calls += 1 + return nil + } + defer { + WebViewIsolation.compileRuleList = original + WebViewIsolation.resetRuleListCacheForTests() + } + + let first = await WebViewIsolation.ruleList() + let second = await WebViewIsolation.ruleList() + + XCTAssertNil(first) + XCTAssertNil(second) + XCTAssertEqual(calls, 1) + } + +} + +#endif diff --git a/Tests/RevenueCatUITests/PaywallsV2/WebViewNavigationPolicyTests.swift b/Tests/RevenueCatUITests/PaywallsV2/WebViewNavigationPolicyTests.swift new file mode 100644 index 0000000000..843beeff35 --- /dev/null +++ b/Tests/RevenueCatUITests/PaywallsV2/WebViewNavigationPolicyTests.swift @@ -0,0 +1,55 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// + +@testable import RevenueCatUI +import XCTest + +#if !os(tvOS) && canImport(WebKit) +import WebKit + +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) +final class WebViewNavigationPolicyTests: TestCase { + + func testMainFrameSameOriginAllowedCrossOriginCancelledSubFrameAllowed() { + XCTAssertEqual( + WebViewNavigationPolicy.policy( + for: URL(string: "https://example.com/next")!, + isMainFrame: true, + expectedOrigin: "https://example.com" + ), + .allow + ) + XCTAssertEqual( + WebViewNavigationPolicy.policy( + for: URL(string: "https://evil.example/next")!, + isMainFrame: true, + expectedOrigin: "https://example.com" + ), + .cancel + ) + XCTAssertEqual( + WebViewNavigationPolicy.policy( + for: URL(string: "https://evil.example/next")!, + isMainFrame: false, + expectedOrigin: "https://example.com" + ), + .allow + ) + } + + func testOriginStripsDefaultPortKeepsNonDefaultAndNormalizesCase() { + XCTAssertEqual( + WebViewOrigin.origin(of: URL(string: "https://Example.COM:443/path")!), + "https://example.com" + ) + XCTAssertEqual( + WebViewOrigin.origin(of: URL(string: "HTTPS://Example.COM:8443/path")!), + "https://example.com:8443" + ) + XCTAssertNil(WebViewOrigin.origin(of: URL(string: "https:///no-host")!)) + } + +} + +#endif From 8d2452a3b6726ac1325388b13ffd6799816c6009 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 15 Jul 2026 20:30:45 +0000 Subject: [PATCH 04/35] feat(paywalls): web_view schema component, not yet registered (4/7) Add PaywallComponent.WebViewComponent as dead code without registering ComponentType.webView or PaywallComponent.webView, so paywall JSON is unchanged until the activation PR. Co-authored-by: Jacob Rakidzich --- RevenueCat.xcodeproj/project.pbxproj | 6 + .../Components/PaywallWebViewComponent.swift | 116 ++++++++++++++++++ .../PaywallsV2/WebViewComponentTests.swift | 49 ++++++++ 3 files changed, 171 insertions(+) create mode 100644 Sources/Paywalls/Components/PaywallWebViewComponent.swift create mode 100644 Tests/RevenueCatUITests/PaywallsV2/WebViewComponentTests.swift diff --git a/RevenueCat.xcodeproj/project.pbxproj b/RevenueCat.xcodeproj/project.pbxproj index 996c1ac30c..e539028160 100644 --- a/RevenueCat.xcodeproj/project.pbxproj +++ b/RevenueCat.xcodeproj/project.pbxproj @@ -106,6 +106,7 @@ 16DA8EC92E4EE24100283940 /* JsonLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 16DA8EC82E4EE24100283940 /* JsonLoader.swift */; }; 16DA8ECD2E4EE33700283940 /* ImageComponentTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 16DA8ECC2E4EE33700283940 /* ImageComponentTests.swift */; }; 16DA8EDF2E4F6A6600283940 /* PaywallVideoComponent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 16DA8EDE2E4F6A6600283940 /* PaywallVideoComponent.swift */; }; + 66EAEED2B259D54A9E789F4A /* PaywallWebViewComponent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 14C1ACED1916C08B0E8E2776 /* PaywallWebViewComponent.swift */; }; 16DA8EF42E4F7A2500283940 /* VideoComponentTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 16DA8EF32E4F7A2500283940 /* VideoComponentTests.swift */; }; 16DA8F1E2E4FB6E200283940 /* ImageComponent.json in Resources */ = {isa = PBXBuildFile; fileRef = 16DA8F1B2E4FB6E200283940 /* ImageComponent.json */; }; 16DA8F1F2E4FB6E200283940 /* VideoComponent.json in Resources */ = {isa = PBXBuildFile; fileRef = 16DA8F1C2E4FB6E200283940 /* VideoComponent.json */; }; @@ -1635,6 +1636,8 @@ 16DA8EC82E4EE24100283940 /* JsonLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JsonLoader.swift; sourceTree = ""; }; 16DA8ECC2E4EE33700283940 /* ImageComponentTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageComponentTests.swift; sourceTree = ""; }; 16DA8EDE2E4F6A6600283940 /* PaywallVideoComponent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaywallVideoComponent.swift; sourceTree = ""; }; + 14C1ACED1916C08B0E8E2776 /* PaywallWebViewComponent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaywallWebViewComponent.swift; sourceTree = ""; }; + E7EB64E9D44783DD16A28B05 /* WebViewComponentTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebViewComponentTests.swift; sourceTree = ""; }; 16DA8EF32E4F7A2500283940 /* VideoComponentTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoComponentTests.swift; sourceTree = ""; }; 16DA8F1B2E4FB6E200283940 /* ImageComponent.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = ImageComponent.json; sourceTree = ""; }; 16DA8F1C2E4FB6E200283940 /* VideoComponent.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = VideoComponent.json; sourceTree = ""; }; @@ -3165,6 +3168,7 @@ 030890822D2B77DD0069677B /* PaywallsV2 */ = { isa = PBXGroup; children = ( + E7EB64E9D44783DD16A28B05 /* WebViewComponentTests.swift */, 802593842FE07057005AF6DF /* PaywallStateStoreTests.swift */, 16A9F7B62FAA22D0008E8A4D /* VideoComponentViewTests.swift */, A9D45E4EAFAD4C0385BEDFB3 /* VideoPlayerViewTests.swift */, @@ -5919,6 +5923,7 @@ isa = PBXGroup; children = ( 16DA8EDE2E4F6A6600283940 /* PaywallVideoComponent.swift */, + 14C1ACED1916C08B0E8E2776 /* PaywallWebViewComponent.swift */, 16A7F4C02E563B89001F9FC8 /* Transitions */, 03C730F22D35F13F00297FEC /* PaywallV2CacheWarming.swift */, 2CC791612CC0493600FBE120 /* Common */, @@ -7436,6 +7441,7 @@ 4F8929192A65EF3000A91EA2 /* EnsureNonEmptyCollectionDecodable.swift in Sources */, 757E73CE2F0FE2410066DCDC /* LocalTransactionMetadata.swift in Sources */, 16DA8EDF2E4F6A6600283940 /* PaywallVideoComponent.swift in Sources */, + 66EAEED2B259D54A9E789F4A /* PaywallWebViewComponent.swift in Sources */, 35F82BB626A9B8040051DF03 /* AttributionDataMigrator.swift in Sources */, 4F5F47082AA91F8A005649D8 /* HealthOperation.swift in Sources */, 4FEF41AB2B4F2F3400CD699F /* MapAppStoreDetector.swift in Sources */, diff --git a/Sources/Paywalls/Components/PaywallWebViewComponent.swift b/Sources/Paywalls/Components/PaywallWebViewComponent.swift new file mode 100644 index 0000000000..26b2ba87e2 --- /dev/null +++ b/Sources/Paywalls/Components/PaywallWebViewComponent.swift @@ -0,0 +1,116 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// PaywallWebViewComponent.swift +// +// swiftlint:disable missing_docs nesting + +import Foundation + +@_spi(Internal) public extension PaywallComponent { + + final class WebViewComponent: PaywallComponentBase { + + /// Wire `type` value. `ComponentType.webView` is registered in the activation PR. + let type: String + public let id: String? + public let name: String? + public let visible: Bool? + + /// The declared Paywalls web component protocol version. + /// Decoded and preserved; native host capability is fixed by the SDK build. + public let protocolVersion: Int + + /// The static HTTPS URL of the web bundle entry point. + public let url: String + + public let size: Size + + public init( + id: String? = nil, + name: String? = nil, + visible: Bool? = nil, + protocolVersion: Int = 1, + url: String, + size: Size = .init(width: .fill, height: .fit) + ) { + self.type = "web_view" + self.id = id + self.name = name + self.visible = visible + self.protocolVersion = protocolVersion + self.url = url + self.size = size + } + + private enum CodingKeys: String, CodingKey { + case type + case id + case name + case visible + case protocolVersion + case url + case size + } + + required public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let decodedType = try container.decode(String.self, forKey: .type) + guard decodedType == "web_view" else { + throw DecodingError.dataCorrupted( + .init( + codingPath: container.codingPath, + debugDescription: "Expected type web_view, got \(decodedType)" + ) + ) + } + self.type = decodedType + self.id = try container.decodeIfPresent(String.self, forKey: .id) + self.name = try container.decodeIfPresent(String.self, forKey: .name) + self.visible = try container.decodeIfPresent(Bool.self, forKey: .visible) + self.protocolVersion = try container.decodeIfPresent(Int.self, forKey: .protocolVersion) ?? 1 + self.url = try container.decode(String.self, forKey: .url) + self.size = try container.decodeIfPresent(Size.self, forKey: .size) + ?? .init(width: .fill, height: .fit) + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(type, forKey: .type) + try container.encodeIfPresent(id, forKey: .id) + try container.encodeIfPresent(name, forKey: .name) + try container.encodeIfPresent(visible, forKey: .visible) + try container.encode(protocolVersion, forKey: .protocolVersion) + try container.encode(url, forKey: .url) + try container.encode(size, forKey: .size) + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(type) + hasher.combine(id) + hasher.combine(name) + hasher.combine(visible) + hasher.combine(protocolVersion) + hasher.combine(url) + hasher.combine(size) + } + + public static func == (lhs: WebViewComponent, rhs: WebViewComponent) -> Bool { + return lhs.type == rhs.type && + lhs.id == rhs.id && + lhs.name == rhs.name && + lhs.visible == rhs.visible && + lhs.protocolVersion == rhs.protocolVersion && + lhs.url == rhs.url && + lhs.size == rhs.size + } + + } + +} diff --git a/Tests/RevenueCatUITests/PaywallsV2/WebViewComponentTests.swift b/Tests/RevenueCatUITests/PaywallsV2/WebViewComponentTests.swift new file mode 100644 index 0000000000..e98fe3135b --- /dev/null +++ b/Tests/RevenueCatUITests/PaywallsV2/WebViewComponentTests.swift @@ -0,0 +1,49 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// + +@_spi(Internal) @testable import RevenueCat +import XCTest + +#if !os(tvOS) + +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) +final class WebViewComponentTests: TestCase { + + func testDecodesMinimalJSONDefaultsAndIgnoresUnknownKeys() throws { + let minimal = try JSONDecoder.default.decode(PaywallComponent.WebViewComponent.self, from: Data(""" + { "type": "web_view", "url": "https://example.com", "unknown": true } + """.utf8)) + + XCTAssertNil(minimal.id) + XCTAssertNil(minimal.visible) + XCTAssertEqual(minimal.protocolVersion, 1) + XCTAssertEqual(minimal.size.width, .fill) + XCTAssertEqual(minimal.size.height, .fit) + XCTAssertEqual(minimal.url, "https://example.com") + XCTAssertEqual(minimal.type, "web_view") + } + + func testDecodesExplicitFields() throws { + let component = try JSONDecoder.default.decode(PaywallComponent.WebViewComponent.self, from: Data(""" + { + "type": "web_view", + "id": "web", + "name": "Survey", + "visible": false, + "protocol_version": 2, + "url": "https://example.com/index.html", + "size": { "width": { "type": "fixed", "value": 320 }, "height": { "type": "fit" } } + } + """.utf8)) + + XCTAssertEqual(component.id, "web") + XCTAssertEqual(component.name, "Survey") + XCTAssertEqual(component.visible, false) + XCTAssertEqual(component.protocolVersion, 2) + XCTAssertEqual(component.url, "https://example.com/index.html") + } + +} + +#endif From b033efab545d0d3d1b7194e2673d9fddc4a9b6a7 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 15 Jul 2026 20:32:29 +0000 Subject: [PATCH 05/35] feat(paywalls): web_view bridge session with document-reset lifecycle (5/7) Add WebViewSession (using WebViewOrigin), restore controller session wiring, and document-reset so reloads can re-handshake. Includes session tests plus reconnect/resize-threshold/onDocumentReset coverage. Co-authored-by: Jacob Rakidzich --- RevenueCat.xcodeproj/project.pbxproj | 6 + RevenueCatUI/Data/Strings.swift | 12 + .../WebView/PaywallWebViewAPI.swift | 18 +- .../Components/WebView/WebViewSession.swift | 390 +++++++++++++ .../PaywallsV2/WebViewSessionTests.swift | 523 ++++++++++++++++++ 5 files changed, 947 insertions(+), 2 deletions(-) create mode 100644 RevenueCatUI/Templates/V2/Components/WebView/WebViewSession.swift create mode 100644 Tests/RevenueCatUITests/PaywallsV2/WebViewSessionTests.swift diff --git a/RevenueCat.xcodeproj/project.pbxproj b/RevenueCat.xcodeproj/project.pbxproj index 482f3d3882..78e0cdbff3 100644 --- a/RevenueCat.xcodeproj/project.pbxproj +++ b/RevenueCat.xcodeproj/project.pbxproj @@ -111,6 +111,7 @@ 11CC2EA83C56933E40D8E5D8 /* WebViewEnvelope.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89DD761C9E00E61032AAEB44 /* WebViewEnvelope.swift */; }; 1401E951ACE24C7B84F94B7F /* WebViewIsolation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 833251CB0C6FE0BA16C2AC41 /* WebViewIsolation.swift */; }; FC5AE63E8F81C3EC2FFDA408 /* WebViewOriginPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 28661C6CE3F64078A0138080 /* WebViewOriginPolicy.swift */; }; + 0F4E490A741F00F18C4985D8 /* WebViewSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = 72CC1CD0385BF18D1392EB43 /* WebViewSession.swift */; }; 16DA8F1E2E4FB6E200283940 /* ImageComponent.json in Resources */ = {isa = PBXBuildFile; fileRef = 16DA8F1B2E4FB6E200283940 /* ImageComponent.json */; }; 16DA8F1F2E4FB6E200283940 /* VideoComponent.json in Resources */ = {isa = PBXBuildFile; fileRef = 16DA8F1C2E4FB6E200283940 /* VideoComponent.json */; }; 16E146AD2E99F3480089B609 /* TransactionNotifications.swift in Sources */ = {isa = PBXBuildFile; fileRef = 16E146AB2E99F1E20089B609 /* TransactionNotifications.swift */; }; @@ -1646,6 +1647,8 @@ 686562C08D953A27986DB276 /* PaywallWebViewValueTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaywallWebViewValueTests.swift; sourceTree = ""; }; 833251CB0C6FE0BA16C2AC41 /* WebViewIsolation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebViewIsolation.swift; sourceTree = ""; }; 28661C6CE3F64078A0138080 /* WebViewOriginPolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebViewOriginPolicy.swift; sourceTree = ""; }; + 72CC1CD0385BF18D1392EB43 /* WebViewSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebViewSession.swift; sourceTree = ""; }; + F75819E258F47D1DB058500D /* WebViewSessionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebViewSessionTests.swift; sourceTree = ""; }; 19B79CAD4C423F576E7DFBA9 /* WebViewIsolationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebViewIsolationTests.swift; sourceTree = ""; }; 9E7AD021B4580265DFDF6A02 /* WebViewNavigationPolicyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebViewNavigationPolicyTests.swift; sourceTree = ""; }; 16DA8F1B2E4FB6E200283940 /* ImageComponent.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = ImageComponent.json; sourceTree = ""; }; @@ -3181,6 +3184,7 @@ C1B939C7DAAFEA1DE2DE2674 /* WebViewEnvelopeTests.swift */, 19B79CAD4C423F576E7DFBA9 /* WebViewIsolationTests.swift */, 9E7AD021B4580265DFDF6A02 /* WebViewNavigationPolicyTests.swift */, + F75819E258F47D1DB058500D /* WebViewSessionTests.swift */, 802593842FE07057005AF6DF /* PaywallStateStoreTests.swift */, 16A9F7B62FAA22D0008E8A4D /* VideoComponentViewTests.swift */, A9D45E4EAFAD4C0385BEDFB3 /* VideoPlayerViewTests.swift */, @@ -3320,6 +3324,7 @@ 89DD761C9E00E61032AAEB44 /* WebViewEnvelope.swift */, 833251CB0C6FE0BA16C2AC41 /* WebViewIsolation.swift */, 28661C6CE3F64078A0138080 /* WebViewOriginPolicy.swift */, + 72CC1CD0385BF18D1392EB43 /* WebViewSession.swift */, ); path = WebView; sourceTree = ""; @@ -8265,6 +8270,7 @@ 11CC2EA83C56933E40D8E5D8 /* WebViewEnvelope.swift in Sources */, 1401E951ACE24C7B84F94B7F /* WebViewIsolation.swift in Sources */, FC5AE63E8F81C3EC2FFDA408 /* WebViewOriginPolicy.swift in Sources */, + 0F4E490A741F00F18C4985D8 /* WebViewSession.swift in Sources */, DBAA1FC22F8D4ED8000E8C81 /* HeaderNestedHeroZLayerSafeAreaPreview.swift in Sources */, 164681D22E6B577600854AA5 /* VideoComponentViewModel.swift in Sources */, 2C7457422CE81107004ACE52 /* IntroOfferEligibilityContext.swift in Sources */, diff --git a/RevenueCatUI/Data/Strings.swift b/RevenueCatUI/Data/Strings.swift index 856d89fc92..6f7840c37f 100644 --- a/RevenueCatUI/Data/Strings.swift +++ b/RevenueCatUI/Data/Strings.swift @@ -111,6 +111,10 @@ enum Strings { // WebView case paywall_web_view_content_rules_failed(String) + case paywall_web_view_message_rejected(reason: String) + case paywall_web_view_post_message_failed(String) + case paywall_web_view_post_message_skipped + case paywall_web_view_reserved_locale_stripped // Exit Offers case errorFetchingOfferings(Error) @@ -369,6 +373,14 @@ extension Strings: CustomStringConvertible { case .paywall_web_view_content_rules_failed(let error): return "Paywalls V2 web_view content rules failed: \(error)" + case .paywall_web_view_message_rejected(let reason): + return "Paywalls V2 web_view message rejected: \(reason)" + case .paywall_web_view_post_message_failed(let error): + return "Paywalls V2 web_view postMessage failed: \(error)" + case .paywall_web_view_post_message_skipped: + return "Paywalls V2 web_view postMessage skipped because the channel is not ready or origin changed." + case .paywall_web_view_reserved_locale_stripped: + return "Paywalls V2 web_view variable 'locale' is SDK-managed and was stripped." case .errorFetchingOfferings(let error): return "Error fetching offerings: \(error)" diff --git a/RevenueCatUI/Templates/V2/Components/WebView/PaywallWebViewAPI.swift b/RevenueCatUI/Templates/V2/Components/WebView/PaywallWebViewAPI.swift index 5ba2a3fe3b..94265d2bb1 100644 --- a/RevenueCatUI/Templates/V2/Components/WebView/PaywallWebViewAPI.swift +++ b/RevenueCatUI/Templates/V2/Components/WebView/PaywallWebViewAPI.swift @@ -144,14 +144,28 @@ public struct PaywallWebViewValue: Sendable, Equatable, Hashable, Codable { @MainActor public struct PaywallWebViewController { + #if canImport(WebKit) + weak var session: WebViewSession? + #endif + init() {} + #if canImport(WebKit) + init(session: WebViewSession?) { + self.session = session + } + #endif + public func postVariables(componentID: String, variables: [String: PaywallWebViewValue]) { - // Session wiring lands in a later PR; public API is inert until then. + #if canImport(WebKit) + self.session?.postVariables(componentID: componentID, variables: variables) + #endif } public func postMessage(componentID: String, type: String, variables: [String: PaywallWebViewValue]) { - // Session wiring lands in a later PR; public API is inert until then. + #if canImport(WebKit) + self.session?.post(componentID: componentID, type: type, variables: variables) + #endif } } diff --git a/RevenueCatUI/Templates/V2/Components/WebView/WebViewSession.swift b/RevenueCatUI/Templates/V2/Components/WebView/WebViewSession.swift new file mode 100644 index 0000000000..d43508eb6d --- /dev/null +++ b/RevenueCatUI/Templates/V2/Components/WebView/WebViewSession.swift @@ -0,0 +1,390 @@ +import Foundation +@_spi(Internal) import RevenueCat + +#if canImport(WebKit) +import WebKit +#endif + +#if !os(tvOS) && canImport(WebKit) // For Paywalls V2 + +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) +@MainActor +final class WebViewSession: NSObject, ObservableObject, WKScriptMessageHandler { + + let componentID: String + let protocolVersion: Int + let expectedOrigin: String + var messageHandler: PaywallWebViewMessageAction? + var onContentResize: (@MainActor (CGFloat?, CGFloat?) -> Void)? + /// Invoked from ``resetForNewDocument()`` so the SwiftUI host can clear measured fit sizes. + var onDocumentReset: (@MainActor () -> Void)? + private(set) var channelOpen = false + + var evaluateJavaScript: (String) -> Void + var currentURL: () -> URL? + + let fitAxes: (width: Bool, height: Bool) + + private let localeIdentifier: String + private var lastAppliedWidth: CGFloat? + private var lastAppliedHeight: CGFloat? + + init( + componentID: String, + protocolVersion: Int, + expectedOrigin: String, + localeIdentifier: String, + fitAxes: (width: Bool, height: Bool), + messageHandler: PaywallWebViewMessageAction? = nil, + evaluateJavaScript: @escaping (String) -> Void = { _ in }, + currentURL: @escaping () -> URL? = { nil } + ) { + self.componentID = componentID + self.protocolVersion = protocolVersion + self.expectedOrigin = expectedOrigin + self.localeIdentifier = localeIdentifier + self.fitAxes = fitAxes + self.messageHandler = messageHandler + self.evaluateJavaScript = evaluateJavaScript + self.currentURL = currentURL + } + + /// Resets handshake and resize thresholds for a new main-frame document. + /// + /// Each committed main-frame navigation creates a new JS document that must re-handshake. + /// Without this, `connect` after a reload is dropped by the `channelOpen` guard forever. + func resetForNewDocument() { + self.channelOpen = false + self.lastAppliedWidth = nil + self.lastAppliedHeight = nil + self.onDocumentReset?() + } + + nonisolated func userContentController( + _ userContentController: WKUserContentController, + didReceive message: WKScriptMessage + ) { + MainActor.assumeIsolated { + self.handle( + rawMessage: message.body, + isMainFrame: message.frameInfo.isMainFrame, + currentURL: message.webView?.url + ) + } + } + + func handle(rawMessage: Any, isMainFrame: Bool, currentURL: URL?) { + guard isMainFrame else { + self.logRejected("non-main-frame") + return + } + guard let envelope = WebViewEnvelope.decode(rawMessage: rawMessage) else { + self.logRejected("malformed-envelope") + return + } + + if envelope.kind == .connect { + self.handleConnect(envelope, currentURL: currentURL) + return + } + + guard let type = self.validatedAppFrameType(envelope, currentURL: currentURL) else { + return + } + + switch type { + case WebViewEnvelope.messageTypeResize: + // SDK-internal, regardless of kind; never forwarded to the app handler. + self.handleResize(envelope.payload) + case WebViewEnvelope.messageTypeStepLoaded: + self.deliver(type: type) + case WebViewEnvelope.messageTypeStepComplete: + self.handleStepComplete(envelope) + case WebViewEnvelope.messageTypeRequestVariables: + self.handleRequestVariables(envelope) + case WebViewEnvelope.messageTypeError: + self.handleError(envelope) + default: + self.logRejected("unknown-message-type") + } + } + + /// Validates a post-handshake frame and returns its app message type, or `nil` (logged) when + /// the frame must be dropped. + private func validatedAppFrameType( + _ envelope: WebViewEnvelope.Envelope, + currentURL: URL? + ) -> String? { + guard self.channelOpen else { + self.logRejected("channel-closed") + return nil + } + // App frames ride only `message`/`request`; other kinds (init/reject/response/error) + // are host-to-content or reply framing and must never reach the app handler. + guard envelope.kind == .message || envelope.kind == .request else { + self.logRejected("unsupported-kind") + return nil + } + if envelope.kind == .request, envelope.id == nil { + self.logRejected("request-without-id") + return nil + } + guard envelope.componentID == self.componentID else { + self.logRejected("component-id-mismatch") + return nil + } + guard self.originMatches(currentURL: currentURL, allowBeforeNavigation: false) else { + self.logRejected("origin-mismatch") + return nil + } + guard let type = envelope.type else { + self.logRejected("missing-type") + return nil + } + return type + } + + func postVariables(componentID: String, variables: [String: PaywallWebViewValue]) { + self.post( + componentID: componentID, + type: WebViewEnvelope.messageTypeVariables, + variables: Self.sanitizeAppProvidedVariables(variables) + ) + } + + func post(componentID: String, type: String, variables: [String: PaywallWebViewValue]) { + self.send( + .init(kind: .message, componentID: componentID, type: type, payload: variables), + allowBeforeNavigation: false + ) + } + + static func sanitizeAppProvidedVariables( + _ variables: [String: PaywallWebViewValue] + ) -> [String: PaywallWebViewValue] { + guard variables.keys.contains("locale") else { + return variables + } + + Logger.debug(Strings.paywall_web_view_reserved_locale_stripped) + return variables.filter { $0.key != "locale" } + } + + private func handleConnect(_ envelope: WebViewEnvelope.Envelope, currentURL: URL?) { + guard self.originMatches(currentURL: currentURL, allowBeforeNavigation: true) else { + self.logRejected("origin-mismatch") + return + } + guard !self.channelOpen else { + return + } + + if envelope.protocolVersion == WebViewEnvelope.defaultProtocolVersion { + self.channelOpen = true + self.send(.init(kind: .`init`, componentID: self.componentID), allowBeforeNavigation: true) + self.sendFitMessageIfNeeded() + } else { + let error = "Unsupported protocol_version \(envelope.protocolVersion); native host supports 1" + self.send(.init(kind: .reject, componentID: "", error: error), allowBeforeNavigation: true) + } + } + + private func handleStepComplete(_ envelope: WebViewEnvelope.Envelope) { + let responses: [String: PaywallWebViewValue] + if let value = envelope.payload?["responses"] { + guard let object = value.objectValue else { + self.logRejected("malformed-responses") + return + } + responses = object + } else if let payload = envelope.payload { + guard WebViewEnvelope.reservedPayloadKeys.isDisjoint(with: payload.keys) else { + self.logRejected("reserved-response-key") + return + } + responses = payload + } else { + responses = [:] + } + + self.deliver(type: WebViewEnvelope.messageTypeStepComplete, responses: responses) + } + + private func handleRequestVariables(_ envelope: WebViewEnvelope.Envelope) { + switch envelope.kind { + case .request: + guard let id = envelope.id else { + self.logRejected("request-without-id") + return + } + self.send( + .init( + kind: .response, + componentID: self.componentID, + type: WebViewEnvelope.messageTypeRequestVariables, + id: id, + payload: self.sdkVariables + ), + allowBeforeNavigation: false + ) + case .message: + self.send( + .init( + kind: .message, + componentID: self.componentID, + type: WebViewEnvelope.messageTypeVariables, + payload: self.sdkVariables + ), + allowBeforeNavigation: false + ) + default: + self.logRejected("unsupported-request-variables-kind") + return + } + + self.deliver(type: WebViewEnvelope.messageTypeRequestVariables) + } + + private func handleError(_ envelope: WebViewEnvelope.Envelope) { + let error = envelope.payload?["error"]?.stringValue ?? envelope.error + guard let error else { + self.logRejected("missing-error") + return + } + self.deliver(type: WebViewEnvelope.messageTypeError, error: error) + } + + private func handleResize(_ payload: [String: PaywallWebViewValue]?) { + guard let payload else { + return + } + + let width = self.validResizeValue(payload["width"]?.numberValue, axisIsFit: self.fitAxes.width) + let height = self.validResizeValue(payload["height"]?.numberValue, axisIsFit: self.fitAxes.height) + let appliedWidth = self.resizeValue(width, lastApplied: &self.lastAppliedWidth) + let appliedHeight = self.resizeValue(height, lastApplied: &self.lastAppliedHeight) + + if appliedWidth != nil || appliedHeight != nil { + self.onContentResize?(appliedWidth, appliedHeight) + } + } + + private func sendFitMessageIfNeeded() { + var payload: [String: PaywallWebViewValue] = [:] + if self.fitAxes.width { + payload["width"] = .bool(true) + } + if self.fitAxes.height { + payload["height"] = .bool(true) + } + guard !payload.isEmpty else { + return + } + self.send( + .init( + kind: .message, + componentID: self.componentID, + type: WebViewEnvelope.messageTypeFit, + payload: payload + ), + allowBeforeNavigation: true + ) + } + + private func deliver( + type: String, + responses: [String: PaywallWebViewValue]? = nil, + error: String? = nil + ) { + self.messageHandler?( + PaywallWebViewMessage( + componentID: self.componentID, + type: type, + responses: responses, + error: error + ), + PaywallWebViewController(session: self) + ) + } + + private func send(_ envelope: WebViewEnvelope.Envelope, allowBeforeNavigation: Bool) { + guard self.channelOpen || envelope.kind == .reject else { + Logger.debug(Strings.paywall_web_view_post_message_skipped) + return + } + guard self.originMatches(currentURL: self.currentURL(), allowBeforeNavigation: allowBeforeNavigation) else { + Logger.debug(Strings.paywall_web_view_post_message_skipped) + return + } + guard let script = WebViewEnvelope.receiveScript(for: envelope) else { + Logger.debug(Strings.paywall_web_view_post_message_failed("encoding failed")) + return + } + + self.evaluateJavaScript(script) + } + + private var sdkVariables: [String: PaywallWebViewValue] { + ["locale": .string(Self.bcp47Tag(fromLocaleIdentifier: self.localeIdentifier))] + } + + nonisolated static func bcp47Tag(fromLocaleIdentifier identifier: String) -> String { + if #available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *) { + return Locale(identifier: identifier).identifier(.bcp47) + } else { + return identifier.replacingOccurrences(of: "_", with: "-") + } + } + + private func validResizeValue(_ value: Double?, axisIsFit: Bool) -> CGFloat? { + guard axisIsFit, + let value, + value.isFinite, + value > 0 else { + return nil + } + + return min(CGFloat(value), WebViewEnvelope.maxResizePoints) + } + + private func resizeValue(_ value: CGFloat?, lastApplied: inout CGFloat?) -> CGFloat? { + guard let value else { + return nil + } + if let lastApplied, abs(value - lastApplied) < WebViewEnvelope.resizeThreshold { + return nil + } + lastApplied = value + return value + } + + private func originMatches(currentURL: URL?, allowBeforeNavigation: Bool) -> Bool { + guard let currentURL else { + return allowBeforeNavigation + } + return WebViewOrigin.origin(of: currentURL) == self.expectedOrigin + } + + + private func logRejected(_ reason: String) { + Logger.debug(Strings.paywall_web_view_message_rejected(reason: reason)) + } + +} + +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) +final class WeakScriptMessageHandler: NSObject, WKScriptMessageHandler { + + weak var target: WKScriptMessageHandler? + + init(_ target: WKScriptMessageHandler) { + self.target = target + } + + func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) { + self.target?.userContentController(userContentController, didReceive: message) + } + +} + +#endif diff --git a/Tests/RevenueCatUITests/PaywallsV2/WebViewSessionTests.swift b/Tests/RevenueCatUITests/PaywallsV2/WebViewSessionTests.swift new file mode 100644 index 0000000000..69ab74d808 --- /dev/null +++ b/Tests/RevenueCatUITests/PaywallsV2/WebViewSessionTests.swift @@ -0,0 +1,523 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// + +@testable import RevenueCatUI +import XCTest +// swiftlint:disable force_try + +#if canImport(WebKit) +import WebKit +#endif + +#if !os(tvOS) && canImport(WebKit) + +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) +@MainActor +final class WebViewSessionTests: TestCase { + + func testConnectV1SendsInitAndFit() throws { + let harness = Harness(size: (width: false, height: true)) + + harness.handle(.init(kind: .connect, componentID: "", protocolVersion: 1)) + + XCTAssertTrue(harness.session.channelOpen) + let envelopes = try harness.outboundEnvelopes() + XCTAssertEqual(envelopes.map(\.kind), [.`init`, .message]) + XCTAssertEqual(envelopes[0].componentID, "web") + XCTAssertEqual(envelopes[1].type, WebViewEnvelope.messageTypeFit) + XCTAssertNil(envelopes[1].payload?["width"]) + XCTAssertEqual(envelopes[1].payload?["height"]?.boolValue, true) + } + + func testConnectV2Rejects() throws { + let harness = Harness() + + harness.handle(.init(kind: .connect, componentID: "", protocolVersion: 2)) + + XCTAssertFalse(harness.session.channelOpen) + let envelope = try XCTUnwrap(harness.outboundEnvelopes().first) + XCTAssertEqual(envelope.kind, .reject) + XCTAssertEqual(envelope.componentID, "") + XCTAssertEqual(envelope.error, "Unsupported protocol_version 2; native host supports 1") + } + + func testDropsBeforeConnectDuplicateConnectAndNonMainFrame() throws { + let harness = Harness() + + harness.handle(.init(kind: .message, componentID: "web", type: WebViewEnvelope.messageTypeStepLoaded)) + XCTAssertTrue(harness.capturedScripts.isEmpty) + + harness.handle(.init(kind: .connect, componentID: "")) + harness.handle(.init(kind: .connect, componentID: "")) + XCTAssertEqual(try harness.outboundEnvelopes().filter { $0.kind == .`init` }.count, 1) + + harness.handle( + .init(kind: .message, componentID: "web", type: WebViewEnvelope.messageTypeStepLoaded), + isMainFrame: false + ) + XCTAssertTrue(harness.messages.isEmpty) + } + + func testFitMessageDeclaresExactlyTheFitAxes() throws { + let widthOnly = Harness(size: (width: true, height: false)) + widthOnly.handle(.init(kind: .connect, componentID: "")) + let widthFit = try XCTUnwrap(widthOnly.outboundEnvelopes().last) + XCTAssertEqual(widthFit.type, WebViewEnvelope.messageTypeFit) + XCTAssertEqual(widthFit.payload?["width"]?.boolValue, true) + XCTAssertNil(widthFit.payload?["height"]) + + let both = Harness(size: (width: true, height: true)) + both.handle(.init(kind: .connect, componentID: "")) + let bothFit = try XCTUnwrap(both.outboundEnvelopes().last) + XCTAssertEqual(bothFit.type, WebViewEnvelope.messageTypeFit) + XCTAssertEqual(bothFit.payload?["width"]?.boolValue, true) + XCTAssertEqual(bothFit.payload?["height"]?.boolValue, true) + + let neither = Harness(size: (width: false, height: false)) + neither.handle(.init(kind: .connect, componentID: "")) + XCTAssertEqual(try neither.outboundEnvelopes().map(\.kind), [.`init`]) + } + + func testDropsAppFramesWithNonAppKinds() { + let harness = Harness() + harness.connect() + + for kind: WebViewEnvelope.Kind in [.`init`, .reject, .response, .error] { + harness.handle(.init( + kind: kind, + componentID: "web", + type: WebViewEnvelope.messageTypeStepLoaded, + id: "id-1" + )) + } + + XCTAssertTrue(harness.messages.isEmpty) + XCTAssertTrue(harness.capturedScripts.isEmpty) + } + + func testDropsAnyRequestWithoutID() { + let harness = Harness() + harness.connect() + + harness.handle(.init(kind: .request, componentID: "web", type: WebViewEnvelope.messageTypeStepLoaded)) + harness.handle(.init(kind: .request, componentID: "web", type: WebViewEnvelope.messageTypeRequestVariables)) + + XCTAssertTrue(harness.messages.isEmpty) + XCTAssertTrue(harness.capturedScripts.isEmpty) + } + + func testStepLoadedStepCompleteAndErrorReachHandler() { + let harness = Harness() + harness.connect() + + harness.handle(.init(kind: .message, componentID: "web", type: WebViewEnvelope.messageTypeStepLoaded)) + harness.handle(.init( + kind: .message, + componentID: "web", + type: WebViewEnvelope.messageTypeStepComplete, + payload: ["responses": .object(["choice": .string("annual")])] + )) + harness.handle(.init( + kind: .message, + componentID: "web", + type: WebViewEnvelope.messageTypeError, + payload: ["error": .string("boom")] + )) + + XCTAssertEqual(harness.messages.map(\.type), [ + WebViewEnvelope.messageTypeStepLoaded, + WebViewEnvelope.messageTypeStepComplete, + WebViewEnvelope.messageTypeError + ]) + XCTAssertEqual(harness.messages[1].responses?["choice"]?.stringValue, "annual") + XCTAssertEqual(harness.messages[2].error, "boom") + } + + func testStepCompleteFlatPayloadAndReservedKeyPolicy() { + let harness = Harness() + harness.connect() + + harness.handle(.init( + kind: .message, + componentID: "web", + type: WebViewEnvelope.messageTypeStepComplete, + payload: ["choice": .string("annual")] + )) + harness.handle(.init( + kind: .message, + componentID: "web", + type: WebViewEnvelope.messageTypeStepComplete, + payload: ["variables": .object([:])] + )) + + XCTAssertEqual(harness.messages.count, 1) + XCTAssertEqual(harness.messages[0].responses?["choice"]?.stringValue, "annual") + } + + func testRequestVariablesAutoRepliesAndForwards() throws { + let localeIdentifier = "zh_Hans_CN" + let harness = Harness(localeIdentifier: localeIdentifier) + harness.connect() + harness.capturedScripts.removeAll() + + harness.handle(.init( + kind: .request, + componentID: "web", + type: WebViewEnvelope.messageTypeRequestVariables, + id: "req-1" + )) + + let response = try XCTUnwrap(harness.outboundEnvelopes().first) + XCTAssertEqual(response.kind, .response) + XCTAssertEqual(response.id, "req-1") + // Compare against the same helper the session uses — BCP-47 canonical forms + // vary by OS (e.g. `zh-CN` vs `zh-Hans-CN`). + XCTAssertEqual( + response.payload?["locale"]?.stringValue, + WebViewSession.bcp47Tag(fromLocaleIdentifier: localeIdentifier) + ) + XCTAssertNil(response.payload?["variables"]) + XCTAssertEqual(harness.messages.last?.type, WebViewEnvelope.messageTypeRequestVariables) + } + + func testRequestVariablesMessageAutoRepliesWithNoHandler() throws { + let harness = Harness() + harness.session.messageHandler = nil + harness.connect() + harness.capturedScripts.removeAll() + + harness.handle(.init( + kind: .message, + componentID: "web", + type: WebViewEnvelope.messageTypeRequestVariables + )) + + let response = try XCTUnwrap(harness.outboundEnvelopes().first) + XCTAssertEqual(response.kind, .message) + XCTAssertEqual(response.type, WebViewEnvelope.messageTypeVariables) + XCTAssertEqual(response.payload?["locale"]?.stringValue, "en-US") + XCTAssertTrue(harness.messages.isEmpty) + } + + func testOutboundGatingOriginAndLocaleSanitizing() throws { + let harness = Harness() + harness.session.postVariables(componentID: "web", variables: ["x": .bool(true)]) + XCTAssertTrue(harness.capturedScripts.isEmpty) + + harness.connect() + harness.capturedScripts.removeAll() + harness.session.postVariables(componentID: "web", variables: [ + "locale": .string("app"), + "segment": .string("pro") + ]) + + var outbound = try XCTUnwrap(harness.outboundEnvelopes().first) + XCTAssertEqual(outbound.type, WebViewEnvelope.messageTypeVariables) + XCTAssertNil(outbound.payload?["locale"]) + XCTAssertEqual(outbound.payload?["segment"]?.stringValue, "pro") + + harness.capturedScripts.removeAll() + harness.currentURL = URL(string: "https://evil.example/path")! + harness.session.post(componentID: "web", type: "custom", variables: ["ok": .bool(true)]) + XCTAssertTrue(harness.capturedScripts.isEmpty) + + harness.currentURL = URL(string: "https://EXAMPLE.com:443/next")! + harness.session.post(componentID: "web", type: "custom", variables: ["ok": .bool(true)]) + outbound = try XCTUnwrap(harness.outboundEnvelopes().first) + XCTAssertEqual(outbound.payload?["ok"]?.boolValue, true) + } + + func testResizeAppliesOnlyFitAxesAndThreshold() { + let harness = Harness(size: (width: true, height: true)) + var resizes: [(CGFloat?, CGFloat?)] = [] + harness.session.onContentResize = { resizes.append(($0, $1)) } + harness.connect() + + harness.handle(.init( + kind: .message, + componentID: "web", + type: WebViewEnvelope.messageTypeResize, + payload: ["width": .number(200), "height": .number(99_999)] + )) + harness.handle(.init( + kind: .request, + componentID: "web", + type: WebViewEnvelope.messageTypeResize, + id: "resize-1", + payload: ["width": .number(200.5), "height": .number(10_000.5)] + )) + harness.handle(.init( + kind: .message, + componentID: "web", + type: WebViewEnvelope.messageTypeResize, + payload: ["width": .number(201), "height": .number(-1)] + )) + + XCTAssertEqual(resizes.count, 2) + XCTAssertEqual(resizes[0].0, 200) + XCTAssertEqual(resizes[0].1, 10_000) + XCTAssertEqual(resizes[1].0, 201) + XCTAssertNil(resizes[1].1) + XCTAssertTrue(harness.messages.isEmpty) + } + + func testResizeIgnoresWidthWhenWidthIsNotFit() { + let harness = Harness(size: (width: false, height: true)) + var resizes: [(CGFloat?, CGFloat?)] = [] + harness.session.onContentResize = { resizes.append(($0, $1)) } + harness.connect() + + harness.handle(.init( + kind: .message, + componentID: "web", + type: WebViewEnvelope.messageTypeResize, + payload: ["width": .number(400), "height": .number(500)] + )) + + XCTAssertEqual(resizes.count, 1) + XCTAssertNil(resizes[0].0) + XCTAssertEqual(resizes[0].1, 500) + } + + + func testReconnectAfterDocumentReset() throws { + let harness = Harness() + harness.connect() + XCTAssertTrue(harness.session.channelOpen) + + harness.session.resetForNewDocument() + XCTAssertFalse(harness.session.channelOpen) + + harness.handle(.init(kind: .message, componentID: "web", type: WebViewEnvelope.messageTypeStepLoaded)) + XCTAssertTrue(harness.messages.isEmpty) + + harness.capturedScripts.removeAll() + harness.handle(.init(kind: .connect, componentID: "")) + XCTAssertTrue(harness.session.channelOpen) + XCTAssertEqual(try harness.outboundEnvelopes().filter { $0.kind == .`init` }.count, 1) + } + + func testDocumentResetClearsResizeThresholds() { + let harness = Harness(size: (width: false, height: true)) + var resizes: [(CGFloat?, CGFloat?)] = [] + harness.session.onContentResize = { resizes.append(($0, $1)) } + harness.connect() + + harness.handle(.init( + kind: .message, + componentID: "web", + type: WebViewEnvelope.messageTypeResize, + payload: ["height": .number(200)] + )) + XCTAssertEqual(resizes.count, 1) + XCTAssertEqual(resizes[0].1, 200) + + harness.session.resetForNewDocument() + harness.connect() + harness.handle(.init( + kind: .message, + componentID: "web", + type: WebViewEnvelope.messageTypeResize, + payload: ["height": .number(200)] + )) + + XCTAssertEqual(resizes.count, 2) + XCTAssertEqual(resizes[1].1, 200) + } + + func testDocumentResetInvokesOnDocumentReset() { + let harness = Harness() + var resets = 0 + harness.session.onDocumentReset = { resets += 1 } + harness.connect() + harness.session.resetForNewDocument() + XCTAssertEqual(resets, 1) + XCTAssertFalse(harness.session.channelOpen) + } + + // MARK: - Round trip through a real WKWebView + + func testRoundTripThroughRealWebView() throws { + let expectedURL = URL(string: "https://example.com/index.html")! + var received: [PaywallWebViewMessage] = [] + let session = WebViewSession( + componentID: "web", + protocolVersion: 1, + expectedOrigin: "https://example.com", + localeIdentifier: "en_US", + fitAxes: (width: false, height: false), + messageHandler: PaywallWebViewMessageAction { message, _ in received.append(message) } + ) + + let configuration = WKWebViewConfiguration() + configuration.userContentController.add( + WeakScriptMessageHandler(session), + name: WebViewEnvelope.messageHandlerName + ) + let webView = WKWebView(frame: .zero, configuration: configuration) + session.evaluateJavaScript = { [weak webView] script in + webView?.evaluateJavaScript(script) + } + session.currentURL = { [weak webView] in + webView?.url + } + + let loaded = self.expectation(description: "web view finished loading") + let delegate = RoundTripNavigationDelegate { loaded.fulfill() } + webView.navigationDelegate = delegate + webView.loadHTMLString("hi", baseURL: expectedURL) + self.wait(for: [loaded], timeout: 10) + + webView.evaluateJavaScript( + """ + window.webkit.messageHandlers.\(WebViewEnvelope.messageHandlerName).postMessage( + '{"channel":"rc-web-components","protocol_version":1,"kind":"connect","component_id":""}' + ); + window.webkit.messageHandlers.\(WebViewEnvelope.messageHandlerName).postMessage( + '{"channel":"rc-web-components","protocol_version":1,"kind":"message",\ + "component_id":"web","type":"rc:step-loaded"}' + ); true + """ + ) + + let delivered = self.expectation(description: "message delivered to the app handler") + func poll() { + if received.contains(where: { $0.type == WebViewEnvelope.messageTypeStepLoaded }) { + delivered.fulfill() + } else { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { poll() } + } + } + poll() + self.wait(for: [delivered], timeout: 10) + + XCTAssertTrue(session.channelOpen) + XCTAssertEqual(received.last?.type, WebViewEnvelope.messageTypeStepLoaded) + withExtendedLifetime(delegate) {} + configuration.userContentController.removeScriptMessageHandler( + forName: WebViewEnvelope.messageHandlerName + ) + } + + func testRenderOnlyWebViewExposesNoBridgeSurface() throws { + // Render-only mode (`componentID == nil`) registers NO script message handler, so page + // JavaScript must see no native bridge surface at all. + let webView = WKWebView(frame: .zero, configuration: WKWebViewConfiguration()) + + let loaded = self.expectation(description: "web view finished loading") + loaded.assertForOverFulfill = false + let delegate = RoundTripNavigationDelegate( + onFinish: { loaded.fulfill() }, + onFail: { loaded.fulfill() } + ) + webView.navigationDelegate = delegate + // about:blank avoids network/ATS flakiness seen with https base URLs in CI. + webView.load(URLRequest(url: URL(string: "about:blank")!)) + self.wait(for: [loaded], timeout: 10) + + let probed = self.expectation(description: "probed bridge surface") + var bridgeExposed: Bool? + webView.evaluateJavaScript( + """ + (typeof window.webkit !== 'undefined' \ + && typeof window.webkit.messageHandlers !== 'undefined' \ + && typeof window.webkit.messageHandlers.\(WebViewEnvelope.messageHandlerName) !== 'undefined') + """ + ) { result, _ in + bridgeExposed = result as? Bool + probed.fulfill() + } + self.wait(for: [probed], timeout: 10) + + XCTAssertEqual(bridgeExposed, false) + withExtendedLifetime(delegate) {} + } + +} + +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) +private final class RoundTripNavigationDelegate: NSObject, WKNavigationDelegate { + + private let onFinish: () -> Void + private let onFail: (() -> Void)? + + init(onFinish: @escaping () -> Void, onFail: (() -> Void)? = nil) { + self.onFinish = onFinish + self.onFail = onFail + } + + func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { + self.onFinish() + } + + func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) { + self.onFail?() + } + + func webView( + _ webView: WKWebView, + didFailProvisionalNavigation navigation: WKNavigation!, + withError error: Error + ) { + self.onFail?() + } + +} + +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) +@MainActor +private final class Harness { + + let session: WebViewSession + var capturedScripts: [String] = [] + var messages: [PaywallWebViewMessage] = [] + var currentURL = URL(string: "https://example.com/path")! + + init( + size: (width: Bool, height: Bool) = (false, false), + localeIdentifier: String = "en_US", + messageHandler: PaywallWebViewMessageAction? = nil + ) { + self.session = WebViewSession( + componentID: "web", + protocolVersion: 99, + expectedOrigin: "https://example.com", + localeIdentifier: localeIdentifier, + fitAxes: size, + messageHandler: nil + ) + self.session.messageHandler = messageHandler ?? PaywallWebViewMessageAction { [weak self] message, _ in + self?.messages.append(message) + } + self.session.evaluateJavaScript = { [weak self] script in + self?.capturedScripts.append(script) + } + self.session.currentURL = { [weak self] in + self?.currentURL + } + } + + func connect() { + self.handle(.init(kind: .connect, componentID: "")) + self.capturedScripts.removeAll() + } + + func handle(_ envelope: WebViewEnvelope.Envelope, isMainFrame: Bool = true) { + let data = try! JSONEncoder().encode(envelope) + self.session.handle( + rawMessage: String(data: data, encoding: .utf8)!, + isMainFrame: isMainFrame, + currentURL: self.currentURL + ) + } + + func outboundEnvelopes() throws -> [WebViewEnvelope.Envelope] { + try self.capturedScripts.map { + try WebViewEnvelopeTests.decodeEnvelope(fromScript: $0) + } + } + +} + +#endif From 37aa8103b99f050c8225674edbcc16ea97373f37 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 15 Jul 2026 20:33:58 +0000 Subject: [PATCH 06/35] feat(paywalls): web_view component view + view model, not yet wired (6/7) Add WebViewComponentView/ViewModel with document-reset and content-process termination lifecycle, fit-axes identity, and coordinator tests. Still unreferenced by the render pipeline until Part 7. Co-authored-by: Jacob Rakidzich --- RevenueCat.xcodeproj/project.pbxproj | 8 + RevenueCatUI/Data/Strings.swift | 6 + .../WebView/WebViewComponentView.swift | 390 ++++++++++++++++++ .../WebView/WebViewComponentViewModel.swift | 44 ++ .../PaywallsV2/WebViewComponentTests.swift | 101 +++++ 5 files changed, 549 insertions(+) create mode 100644 RevenueCatUI/Templates/V2/Components/WebView/WebViewComponentView.swift create mode 100644 RevenueCatUI/Templates/V2/Components/WebView/WebViewComponentViewModel.swift diff --git a/RevenueCat.xcodeproj/project.pbxproj b/RevenueCat.xcodeproj/project.pbxproj index 7593e14674..fa9169ae7b 100644 --- a/RevenueCat.xcodeproj/project.pbxproj +++ b/RevenueCat.xcodeproj/project.pbxproj @@ -113,6 +113,8 @@ 1401E951ACE24C7B84F94B7F /* WebViewIsolation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 833251CB0C6FE0BA16C2AC41 /* WebViewIsolation.swift */; }; FC5AE63E8F81C3EC2FFDA408 /* WebViewOriginPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 28661C6CE3F64078A0138080 /* WebViewOriginPolicy.swift */; }; 0F4E490A741F00F18C4985D8 /* WebViewSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = 72CC1CD0385BF18D1392EB43 /* WebViewSession.swift */; }; + D969194D68D6967E81788B1F /* WebViewComponentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0A2779D02050D28BF369658 /* WebViewComponentView.swift */; }; + B53032B7D38B14FE9850F314 /* WebViewComponentViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F6E5F9ABE99E8F2BC1DE237 /* WebViewComponentViewModel.swift */; }; 16DA8F1E2E4FB6E200283940 /* ImageComponent.json in Resources */ = {isa = PBXBuildFile; fileRef = 16DA8F1B2E4FB6E200283940 /* ImageComponent.json */; }; 16DA8F1F2E4FB6E200283940 /* VideoComponent.json in Resources */ = {isa = PBXBuildFile; fileRef = 16DA8F1C2E4FB6E200283940 /* VideoComponent.json */; }; 16E146AD2E99F3480089B609 /* TransactionNotifications.swift in Sources */ = {isa = PBXBuildFile; fileRef = 16E146AB2E99F1E20089B609 /* TransactionNotifications.swift */; }; @@ -1651,6 +1653,8 @@ 833251CB0C6FE0BA16C2AC41 /* WebViewIsolation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebViewIsolation.swift; sourceTree = ""; }; 28661C6CE3F64078A0138080 /* WebViewOriginPolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebViewOriginPolicy.swift; sourceTree = ""; }; 72CC1CD0385BF18D1392EB43 /* WebViewSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebViewSession.swift; sourceTree = ""; }; + D0A2779D02050D28BF369658 /* WebViewComponentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebViewComponentView.swift; sourceTree = ""; }; + 6F6E5F9ABE99E8F2BC1DE237 /* WebViewComponentViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebViewComponentViewModel.swift; sourceTree = ""; }; F75819E258F47D1DB058500D /* WebViewSessionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebViewSessionTests.swift; sourceTree = ""; }; 19B79CAD4C423F576E7DFBA9 /* WebViewIsolationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebViewIsolationTests.swift; sourceTree = ""; }; 9E7AD021B4580265DFDF6A02 /* WebViewNavigationPolicyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebViewNavigationPolicyTests.swift; sourceTree = ""; }; @@ -3329,6 +3333,8 @@ 833251CB0C6FE0BA16C2AC41 /* WebViewIsolation.swift */, 28661C6CE3F64078A0138080 /* WebViewOriginPolicy.swift */, 72CC1CD0385BF18D1392EB43 /* WebViewSession.swift */, + D0A2779D02050D28BF369658 /* WebViewComponentView.swift */, + 6F6E5F9ABE99E8F2BC1DE237 /* WebViewComponentViewModel.swift */, ); path = WebView; sourceTree = ""; @@ -8277,6 +8283,8 @@ 1401E951ACE24C7B84F94B7F /* WebViewIsolation.swift in Sources */, FC5AE63E8F81C3EC2FFDA408 /* WebViewOriginPolicy.swift in Sources */, 0F4E490A741F00F18C4985D8 /* WebViewSession.swift in Sources */, + D969194D68D6967E81788B1F /* WebViewComponentView.swift in Sources */, + B53032B7D38B14FE9850F314 /* WebViewComponentViewModel.swift in Sources */, DBAA1FC22F8D4ED8000E8C81 /* HeaderNestedHeroZLayerSafeAreaPreview.swift in Sources */, 164681D22E6B577600854AA5 /* VideoComponentViewModel.swift in Sources */, 2C7457422CE81107004ACE52 /* IntroOfferEligibilityContext.swift in Sources */, diff --git a/RevenueCatUI/Data/Strings.swift b/RevenueCatUI/Data/Strings.swift index 6f7840c37f..4b9edf7d05 100644 --- a/RevenueCatUI/Data/Strings.swift +++ b/RevenueCatUI/Data/Strings.swift @@ -115,6 +115,8 @@ enum Strings { case paywall_web_view_post_message_failed(String) case paywall_web_view_post_message_skipped case paywall_web_view_reserved_locale_stripped + case paywall_web_view_missing_id + case paywall_web_view_content_process_terminated // Exit Offers case errorFetchingOfferings(Error) @@ -381,6 +383,10 @@ extension Strings: CustomStringConvertible { return "Paywalls V2 web_view postMessage skipped because the channel is not ready or origin changed." case .paywall_web_view_reserved_locale_stripped: return "Paywalls V2 web_view variable 'locale' is SDK-managed and was stripped." + case .paywall_web_view_missing_id: + return "Paywalls V2 web_view component is missing an id. Rendering without native bridge." + case .paywall_web_view_content_process_terminated: + return "Paywalls V2 web_view content process terminated. Removing the web view." case .errorFetchingOfferings(let error): return "Error fetching offerings: \(error)" diff --git a/RevenueCatUI/Templates/V2/Components/WebView/WebViewComponentView.swift b/RevenueCatUI/Templates/V2/Components/WebView/WebViewComponentView.swift new file mode 100644 index 0000000000..8ba44a47bd --- /dev/null +++ b/RevenueCatUI/Templates/V2/Components/WebView/WebViewComponentView.swift @@ -0,0 +1,390 @@ +@_spi(Internal) import RevenueCat +import SwiftUI + +#if canImport(WebKit) +import WebKit +#endif + +#if !os(tvOS) // For Paywalls V2 + +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) +struct WebViewComponentView: View { + + let viewModel: WebViewComponentViewModel + + @Environment(\.paywallWebViewMessageAction) + private var messageHandler + + var body: some View { + #if os(watchOS) || !canImport(WebKit) + EmptyView() + #else + if viewModel.visible, let url = viewModel.url { + if let componentID = viewModel.componentID { + BridgedWebViewComponentView( + viewModel: viewModel, + url: url, + componentID: componentID, + messageHandler: messageHandler + ) + .id("\(viewModel.urlString)-\(componentID)-\(viewModel.size.width == .fit)-\(viewModel.size.height == .fit)") + } else { + RenderOnlyWebViewComponentView(viewModel: viewModel, url: url) + .id("\(viewModel.urlString)-render-only") + .onAppear { + Logger.debug(Strings.paywall_web_view_missing_id) + } + } + } + #endif + } + +} + +#if canImport(WebKit) && !os(watchOS) + +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) +private struct BridgedWebViewComponentView: View { + + let viewModel: WebViewComponentViewModel + let url: URL + let componentID: String + let messageHandler: PaywallWebViewMessageAction? + + @StateObject + private var session: WebViewSession + + @State + private var measuredWidth: CGFloat? + + @State + private var measuredHeight: CGFloat? + + @State + private var processTerminated = false + + init( + viewModel: WebViewComponentViewModel, + url: URL, + componentID: String, + messageHandler: PaywallWebViewMessageAction? + ) { + self.viewModel = viewModel + self.url = url + self.componentID = componentID + self.messageHandler = messageHandler + + let origin = WebViewOrigin.origin(of: url) ?? "" + self._session = StateObject( + wrappedValue: WebViewSession( + componentID: componentID, + protocolVersion: viewModel.protocolVersion, + expectedOrigin: origin, + localeIdentifier: viewModel.locale.identifier, + fitAxes: ( + width: viewModel.size.width == .fit, + height: viewModel.size.height == .fit + ) + ) + ) + } + + var body: some View { + // The handler and resize sink are (re)assigned inside the representable's make/update + // (not `.onAppear`) so a handler swapped into the environment after first appearance — + // or a connect arriving before `.onAppear` fires — always sees the current values. + if !processTerminated { + WebViewRepresentable( + url: url, + expectedOrigin: session.expectedOrigin, + session: session, + messageHandler: messageHandler, + onContentResize: { width, height in + if let width { + self.measuredWidth = width + } + if let height { + self.measuredHeight = height + } + }, + onDocumentReset: { + self.measuredWidth = nil + self.measuredHeight = nil + }, + onProcessTerminated: { + self.processTerminated = true + } + ) + .webViewSize(viewModel.size, measuredWidth: measuredWidth, measuredHeight: measuredHeight) + // Content can momentarily overflow the exact frame mid-resize (fit axes animate through + // placeholder -> measured); never paint outside the component's box. + .clipped() + } + } + +} + +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) +private struct RenderOnlyWebViewComponentView: View { + + let viewModel: WebViewComponentViewModel + let url: URL + + @State + private var processTerminated = false + + var body: some View { + if !processTerminated { + WebViewRepresentable( + url: url, + expectedOrigin: WebViewOrigin.origin(of: url) ?? "", + session: nil, + onProcessTerminated: { + self.processTerminated = true + } + ) + .webViewSize(viewModel.size, measuredWidth: nil, measuredHeight: nil) + .clipped() + } + } + +} + +#if os(macOS) +typealias PlatformViewRepresentable = NSViewRepresentable +typealias PlatformWebView = WKWebView +#else +typealias PlatformViewRepresentable = UIViewRepresentable +typealias PlatformWebView = WKWebView +#endif + +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) +struct WebViewRepresentable: PlatformViewRepresentable { + + let url: URL + let expectedOrigin: String + weak var session: WebViewSession? + var messageHandler: PaywallWebViewMessageAction? + var onContentResize: (@MainActor (CGFloat?, CGFloat?) -> Void)? + var onDocumentReset: (@MainActor () -> Void)? + var onProcessTerminated: (@MainActor () -> Void)? + + func makeCoordinator() -> Coordinator { + let coordinator = Coordinator(expectedOrigin: expectedOrigin) + coordinator.session = session + coordinator.onProcessTerminated = onProcessTerminated + return coordinator + } + + #if os(macOS) + func makeNSView(context: Context) -> PlatformWebView { + self.makeWebView(context: context) + } + + func updateNSView(_ webView: PlatformWebView, context: Context) { + context.coordinator.onProcessTerminated = onProcessTerminated + self.update(webView) + } + + static func dismantleNSView(_ webView: PlatformWebView, coordinator: Coordinator) { + dismantle(webView) + } + #else + func makeUIView(context: Context) -> PlatformWebView { + self.makeWebView(context: context) + } + + func updateUIView(_ webView: PlatformWebView, context: Context) { + context.coordinator.onProcessTerminated = onProcessTerminated + self.update(webView) + } + + static func dismantleUIView(_ webView: PlatformWebView, coordinator: Coordinator) { + dismantle(webView) + } + #endif + + private func makeWebView(context: Context) -> PlatformWebView { + let configuration = WKWebViewConfiguration() + configuration.websiteDataStore = .nonPersistent() + configuration.userContentController = WKUserContentController() + + if let session { + configuration.userContentController.add( + WeakScriptMessageHandler(session), + name: WebViewEnvelope.messageHandlerName + ) + } + + #if os(iOS) + configuration.allowsInlineMediaPlayback = true + let disableZoomScript = """ + var meta=document.querySelector('meta[name=viewport]'); + if(!meta){meta=document.createElement('meta');meta.name='viewport';document.head.appendChild(meta);} + meta.content='width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no'; + """ + configuration.userContentController.addUserScript( + WKUserScript(source: disableZoomScript, injectionTime: .atDocumentEnd, forMainFrameOnly: true) + ) + #endif + + let webView = WKWebView(frame: .zero, configuration: configuration) + webView.navigationDelegate = context.coordinator + + #if os(macOS) + // There is no public AppKit API for a fully transparent WKWebView background; + // `drawsBackground` via KVC is the long-standing, widely-used workaround + // (`underPageBackgroundColor` only affects the under-page area, not the page background). + webView.setValue(false, forKey: "drawsBackground") + #else + webView.isOpaque = false + webView.backgroundColor = .clear + webView.scrollView.isScrollEnabled = false + webView.scrollView.bounces = false + webView.scrollView.minimumZoomScale = 1 + webView.scrollView.maximumZoomScale = 1 + #endif + + self.configureSession(for: webView) + self.load(webView) + return webView + } + + private func update(_ webView: PlatformWebView) { + self.configureSession(for: webView) + } + + private func configureSession(for webView: PlatformWebView) { + self.session?.messageHandler = self.messageHandler + self.session?.onContentResize = self.onContentResize + self.session?.onDocumentReset = self.onDocumentReset + self.session?.evaluateJavaScript = { [weak webView] script in + webView?.evaluateJavaScript(script) { _, error in + if let error { + Logger.debug(Strings.paywall_web_view_post_message_failed(String(describing: error))) + } + } + } + self.session?.currentURL = { [weak webView] in + webView?.url + } + } + + private func load(_ webView: PlatformWebView) { + Task { @MainActor in + guard let rules = await WebViewIsolation.ruleList() else { + Logger.debug(Strings.paywall_web_view_content_rules_failed("rule list unavailable")) + return + } + + webView.configuration.userContentController.add(rules) + webView.load(URLRequest(url: url)) + } + } + + private static func dismantle(_ webView: PlatformWebView) { + webView.configuration.userContentController.removeScriptMessageHandler( + forName: WebViewEnvelope.messageHandlerName + ) + + #if os(macOS) + webView.configuration.websiteDataStore.removeData( + ofTypes: WKWebsiteDataStore.allWebsiteDataTypes(), + modifiedSince: .distantPast + ) {} + #endif + } + + final class Coordinator: NSObject, WKNavigationDelegate { + + let expectedOrigin: String + weak var session: WebViewSession? + var onProcessTerminated: (@MainActor () -> Void)? + + init(expectedOrigin: String) { + self.expectedOrigin = expectedOrigin + } + + func webView( + _ webView: WKWebView, + decidePolicyFor navigationAction: WKNavigationAction, + decisionHandler: @escaping (WKNavigationActionPolicy) -> Void + ) { + let policy = WebViewNavigationPolicy.policy( + for: navigationAction.request.url, + isMainFrame: navigationAction.targetFrame?.isMainFrame ?? true, + expectedOrigin: expectedOrigin + ) + decisionHandler(policy) + } + + func webView(_ webView: WKWebView, didCommit navigation: WKNavigation!) { + MainActor.assumeIsolated { + self.session?.resetForNewDocument() + } + } + + func webViewWebContentProcessDidTerminate(_ webView: WKWebView) { + MainActor.assumeIsolated { + Logger.debug(Strings.paywall_web_view_content_process_terminated) + self.onProcessTerminated?() + } + } + + } + +} + +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) +private extension View { + + @ViewBuilder + func webViewSize( + _ size: PaywallComponent.Size, + measuredWidth: CGFloat?, + measuredHeight: CGFloat? + ) -> some View { + self + .webViewWidth(size.width, measuredWidth: measuredWidth) + .webViewHeight(size.height, measuredHeight: measuredHeight) + } + + @ViewBuilder + func webViewWidth( + _ constraint: PaywallComponent.SizeConstraint, + measuredWidth: CGFloat? + ) -> some View { + switch constraint { + case .fit: + self.frame(width: measuredWidth ?? WebViewEnvelope.fallbackFitWidth) + case .fill: + self.frame(maxWidth: .infinity) + case .fixed(let value): + self.frame(width: CGFloat(value)) + case .relative: + self + } + } + + @ViewBuilder + func webViewHeight( + _ constraint: PaywallComponent.SizeConstraint, + measuredHeight: CGFloat? + ) -> some View { + switch constraint { + case .fit: + self.frame(height: measuredHeight ?? WebViewEnvelope.fallbackFitHeight) + case .fill: + self.frame(maxHeight: .infinity) + case .fixed(let value): + self.frame(height: CGFloat(value)) + case .relative: + self + } + } + +} + +#endif + +#endif diff --git a/RevenueCatUI/Templates/V2/Components/WebView/WebViewComponentViewModel.swift b/RevenueCatUI/Templates/V2/Components/WebView/WebViewComponentViewModel.swift new file mode 100644 index 0000000000..8228643ee4 --- /dev/null +++ b/RevenueCatUI/Templates/V2/Components/WebView/WebViewComponentViewModel.swift @@ -0,0 +1,44 @@ +import Foundation +@_spi(Internal) import RevenueCat +#if !os(tvOS) // For Paywalls V2 + +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) +final class WebViewComponentViewModel: Hashable { + + let urlString: String + let size: PaywallComponent.Size + let visible: Bool + let componentID: String? + let protocolVersion: Int + let locale: Locale + + var url: URL? { + guard !self.urlString.contains("{{"), + let url = URL(string: self.urlString), + url.scheme?.lowercased() == "https", + url.host?.isEmpty == false else { + return nil + } + return url + } + + init(component: PaywallComponent.WebViewComponent, localizationProvider: LocalizationProvider) { + self.urlString = component.url + self.size = component.size + self.visible = component.visible ?? true + self.componentID = component.id + self.protocolVersion = component.protocolVersion + self.locale = localizationProvider.locale + } + + func hash(into hasher: inout Hasher) { + hasher.combine(self.urlString) + hasher.combine(self.componentID) + } + + static func == (lhs: WebViewComponentViewModel, rhs: WebViewComponentViewModel) -> Bool { + lhs.urlString == rhs.urlString && lhs.componentID == rhs.componentID + } +} + +#endif diff --git a/Tests/RevenueCatUITests/PaywallsV2/WebViewComponentTests.swift b/Tests/RevenueCatUITests/PaywallsV2/WebViewComponentTests.swift index e98fe3135b..86f71fb69b 100644 --- a/Tests/RevenueCatUITests/PaywallsV2/WebViewComponentTests.swift +++ b/Tests/RevenueCatUITests/PaywallsV2/WebViewComponentTests.swift @@ -3,6 +3,7 @@ // @_spi(Internal) @testable import RevenueCat +@testable import RevenueCatUI import XCTest #if !os(tvOS) @@ -44,6 +45,106 @@ final class WebViewComponentTests: TestCase { XCTAssertEqual(component.url, "https://example.com/index.html") } + func testViewModelURLValidationHashingAndLocale() { + let component = PaywallComponent.WebViewComponent( + id: "web", + protocolVersion: 2, + url: "https://example.com/path", + size: .init(width: .fill, height: .fit) + ) + let viewModel = WebViewComponentViewModel( + component: component, + localizationProvider: .init(locale: Locale(identifier: "en_US"), localizedStrings: [:]) + ) + + XCTAssertEqual(viewModel.url?.absoluteString, "https://example.com/path") + XCTAssertEqual(viewModel.componentID, "web") + XCTAssertEqual(viewModel.protocolVersion, 2) + XCTAssertEqual(viewModel.locale.identifier, "en_US") + + let differentID = WebViewComponentViewModel( + component: .init(id: "other", url: "https://example.com/path"), + localizationProvider: .init(locale: Locale(identifier: "en_US"), localizedStrings: [:]) + ) + XCTAssertNotEqual(viewModel, differentID) + + for invalidURL in [ + "http://example.com", + "file:///tmp/index.html", + "custom://example.com", + "https:///missing-host", + "https://example.com/{{ custom.url }}" + ] { + let invalid = WebViewComponentViewModel( + component: .init(url: invalidURL), + localizationProvider: .init(locale: Locale(identifier: "en_US"), localizedStrings: [:]) + ) + XCTAssertNil(invalid.url) + } + } + + func testViewModelWithoutIDSignalsRenderOnlyMode() { + // A missing schema `id` puts the component in render-only mode: the view still renders + // the (isolated) web view but installs no session/bridge. The view switches on exactly + // these two properties, so pin them. + let viewModel = WebViewComponentViewModel( + component: .init(url: "https://example.com/index.html"), + localizationProvider: .init(locale: Locale(identifier: "en_US"), localizedStrings: [:]) + ) + + XCTAssertNil(viewModel.componentID) + XCTAssertNotNil(viewModel.url) + } + +} + +#if canImport(WebKit) && !os(watchOS) +import WebKit + +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) +@MainActor +final class WebViewCoordinatorLifecycleTests: TestCase { + + func testDidCommitResetsConnectedSessionChannel() { + let session = WebViewSession( + componentID: "web", + protocolVersion: 1, + expectedOrigin: "https://example.com", + localeIdentifier: "en_US", + fitAxes: (width: false, height: false) + ) + session.channelOpen = true + // Simulate a connected channel without going through connect handshake helpers. + // `channelOpen` is private(set); open it via connect. + let data = try! JSONEncoder().encode(WebViewEnvelope.Envelope(kind: .connect, componentID: "")) + session.handle( + rawMessage: String(data: data, encoding: .utf8)!, + isMainFrame: true, + currentURL: URL(string: "https://example.com/") + ) + XCTAssertTrue(session.channelOpen) + + let coordinator = WebViewRepresentable.Coordinator(expectedOrigin: "https://example.com") + coordinator.session = session + coordinator.webView(WKWebView(frame: .zero), didCommit: nil) + + XCTAssertFalse(session.channelOpen) + } + + func testProcessTerminationInvokesCallbackOncePerCall() { + let coordinator = WebViewRepresentable.Coordinator(expectedOrigin: "https://example.com") + var calls = 0 + coordinator.onProcessTerminated = { calls += 1 } + + let webView = WKWebView(frame: .zero) + coordinator.webViewWebContentProcessDidTerminate(webView) + coordinator.webViewWebContentProcessDidTerminate(webView) + + XCTAssertEqual(calls, 2) + } + } #endif + +#endif From c132a31a02eb015ae7b13145657fedbe80f72c33 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 15 Jul 2026 20:34:09 +0000 Subject: [PATCH 07/35] fix(tests): open session via connect in coordinator lifecycle test Co-authored-by: Jacob Rakidzich --- Tests/RevenueCatUITests/PaywallsV2/WebViewComponentTests.swift | 3 --- 1 file changed, 3 deletions(-) diff --git a/Tests/RevenueCatUITests/PaywallsV2/WebViewComponentTests.swift b/Tests/RevenueCatUITests/PaywallsV2/WebViewComponentTests.swift index 86f71fb69b..3151dc6cd0 100644 --- a/Tests/RevenueCatUITests/PaywallsV2/WebViewComponentTests.swift +++ b/Tests/RevenueCatUITests/PaywallsV2/WebViewComponentTests.swift @@ -113,9 +113,6 @@ final class WebViewCoordinatorLifecycleTests: TestCase { localeIdentifier: "en_US", fitAxes: (width: false, height: false) ) - session.channelOpen = true - // Simulate a connected channel without going through connect handshake helpers. - // `channelOpen` is private(set); open it via connect. let data = try! JSONEncoder().encode(WebViewEnvelope.Envelope(kind: .connect, componentID: "")) session.handle( rawMessage: String(data: data, encoding: .utf8)!, From 24f352c9c2b606c4b3450635c5dcdf47eacb99da Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 15 Jul 2026 20:36:06 +0000 Subject: [PATCH 08/35] fix: remove extra vertical whitespace for SwiftLint Co-authored-by: Jacob Rakidzich --- .../Templates/V2/Components/WebView/WebViewSession.swift | 1 - Tests/RevenueCatUITests/PaywallsV2/WebViewSessionTests.swift | 1 - 2 files changed, 2 deletions(-) diff --git a/RevenueCatUI/Templates/V2/Components/WebView/WebViewSession.swift b/RevenueCatUI/Templates/V2/Components/WebView/WebViewSession.swift index d43508eb6d..fd75c35342 100644 --- a/RevenueCatUI/Templates/V2/Components/WebView/WebViewSession.swift +++ b/RevenueCatUI/Templates/V2/Components/WebView/WebViewSession.swift @@ -365,7 +365,6 @@ final class WebViewSession: NSObject, ObservableObject, WKScriptMessageHandler { return WebViewOrigin.origin(of: currentURL) == self.expectedOrigin } - private func logRejected(_ reason: String) { Logger.debug(Strings.paywall_web_view_message_rejected(reason: reason)) } diff --git a/Tests/RevenueCatUITests/PaywallsV2/WebViewSessionTests.swift b/Tests/RevenueCatUITests/PaywallsV2/WebViewSessionTests.swift index 69ab74d808..2af9cc0fc8 100644 --- a/Tests/RevenueCatUITests/PaywallsV2/WebViewSessionTests.swift +++ b/Tests/RevenueCatUITests/PaywallsV2/WebViewSessionTests.swift @@ -280,7 +280,6 @@ final class WebViewSessionTests: TestCase { XCTAssertEqual(resizes[0].1, 500) } - func testReconnectAfterDocumentReset() throws { let harness = Harness() harness.connect() From 07383d329f1638a89a77219e24604dedeb6703e3 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 15 Jul 2026 20:36:08 +0000 Subject: [PATCH 09/35] fix: SwiftLint whitespace, line length, and force_try Co-authored-by: Jacob Rakidzich --- .../V2/Components/WebView/WebViewComponentView.swift | 4 +++- .../Templates/V2/Components/WebView/WebViewSession.swift | 1 - .../RevenueCatUITests/PaywallsV2/WebViewComponentTests.swift | 1 + Tests/RevenueCatUITests/PaywallsV2/WebViewSessionTests.swift | 1 - 4 files changed, 4 insertions(+), 3 deletions(-) diff --git a/RevenueCatUI/Templates/V2/Components/WebView/WebViewComponentView.swift b/RevenueCatUI/Templates/V2/Components/WebView/WebViewComponentView.swift index 8ba44a47bd..6e98921d2f 100644 --- a/RevenueCatUI/Templates/V2/Components/WebView/WebViewComponentView.swift +++ b/RevenueCatUI/Templates/V2/Components/WebView/WebViewComponentView.swift @@ -27,7 +27,9 @@ struct WebViewComponentView: View { componentID: componentID, messageHandler: messageHandler ) - .id("\(viewModel.urlString)-\(componentID)-\(viewModel.size.width == .fit)-\(viewModel.size.height == .fit)") + .id( + "\(viewModel.urlString)-\(componentID)-\(viewModel.size.width == .fit)-\(viewModel.size.height == .fit)" + ) } else { RenderOnlyWebViewComponentView(viewModel: viewModel, url: url) .id("\(viewModel.urlString)-render-only") diff --git a/RevenueCatUI/Templates/V2/Components/WebView/WebViewSession.swift b/RevenueCatUI/Templates/V2/Components/WebView/WebViewSession.swift index d43508eb6d..fd75c35342 100644 --- a/RevenueCatUI/Templates/V2/Components/WebView/WebViewSession.swift +++ b/RevenueCatUI/Templates/V2/Components/WebView/WebViewSession.swift @@ -365,7 +365,6 @@ final class WebViewSession: NSObject, ObservableObject, WKScriptMessageHandler { return WebViewOrigin.origin(of: currentURL) == self.expectedOrigin } - private func logRejected(_ reason: String) { Logger.debug(Strings.paywall_web_view_message_rejected(reason: reason)) } diff --git a/Tests/RevenueCatUITests/PaywallsV2/WebViewComponentTests.swift b/Tests/RevenueCatUITests/PaywallsV2/WebViewComponentTests.swift index 3151dc6cd0..f2ecc96560 100644 --- a/Tests/RevenueCatUITests/PaywallsV2/WebViewComponentTests.swift +++ b/Tests/RevenueCatUITests/PaywallsV2/WebViewComponentTests.swift @@ -5,6 +5,7 @@ @_spi(Internal) @testable import RevenueCat @testable import RevenueCatUI import XCTest +// swiftlint:disable force_try #if !os(tvOS) diff --git a/Tests/RevenueCatUITests/PaywallsV2/WebViewSessionTests.swift b/Tests/RevenueCatUITests/PaywallsV2/WebViewSessionTests.swift index 69ab74d808..2af9cc0fc8 100644 --- a/Tests/RevenueCatUITests/PaywallsV2/WebViewSessionTests.swift +++ b/Tests/RevenueCatUITests/PaywallsV2/WebViewSessionTests.swift @@ -280,7 +280,6 @@ final class WebViewSessionTests: TestCase { XCTAssertEqual(resizes[0].1, 500) } - func testReconnectAfterDocumentReset() throws { let harness = Harness() harness.connect() From 96d24e12d32935beca75dc347a62596d0778d107 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 15 Jul 2026 20:36:50 +0000 Subject: [PATCH 10/35] fix: wrap web_view identity string under 120 columns Co-authored-by: Jacob Rakidzich --- .../Templates/V2/Components/WebView/WebViewComponentView.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/RevenueCatUI/Templates/V2/Components/WebView/WebViewComponentView.swift b/RevenueCatUI/Templates/V2/Components/WebView/WebViewComponentView.swift index 6e98921d2f..adcf79f918 100644 --- a/RevenueCatUI/Templates/V2/Components/WebView/WebViewComponentView.swift +++ b/RevenueCatUI/Templates/V2/Components/WebView/WebViewComponentView.swift @@ -28,7 +28,8 @@ struct WebViewComponentView: View { messageHandler: messageHandler ) .id( - "\(viewModel.urlString)-\(componentID)-\(viewModel.size.width == .fit)-\(viewModel.size.height == .fit)" + "\(viewModel.urlString)-\(componentID)-" + + "\(viewModel.size.width == .fit)-\(viewModel.size.height == .fit)" ) } else { RenderOnlyWebViewComponentView(viewModel: viewModel, url: url) From e67137891461869e80a0d07e2ff6b6d8bb071d6b Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 16 Jul 2026 20:01:56 +0000 Subject: [PATCH 11/35] refactor(paywalls): keep web view bridge internal Co-authored-by: Jacob Rakidzich --- .../WebView/PaywallWebViewAPI.swift | 61 ++++++++------- .../AllAPITests.xcodeproj/project.pbxproj | 4 - .../PaywallWebViewAPI.swift | 77 ------------------- ...enuecatui-api-ios-simulator.swiftinterface | 62 --------------- api/revenuecatui-api-ios.swiftinterface | 62 --------------- api/revenuecatui-api-macos.swiftinterface | 62 --------------- ...atui-api-visionos-simulator.swiftinterface | 62 --------------- api/revenuecatui-api-visionos.swiftinterface | 62 --------------- ...catui-api-watchos-simulator.swiftinterface | 62 --------------- api/revenuecatui-api-watchos.swiftinterface | 62 --------------- 10 files changed, 30 insertions(+), 546 deletions(-) delete mode 100644 Tests/APITesters/AllAPITests/RevenueCatUISwiftAPITester/PaywallWebViewAPI.swift diff --git a/RevenueCatUI/Templates/V2/Components/WebView/PaywallWebViewAPI.swift b/RevenueCatUI/Templates/V2/Components/WebView/PaywallWebViewAPI.swift index 5ba2a3fe3b..4aed083814 100644 --- a/RevenueCatUI/Templates/V2/Components/WebView/PaywallWebViewAPI.swift +++ b/RevenueCatUI/Templates/V2/Components/WebView/PaywallWebViewAPI.swift @@ -1,26 +1,25 @@ import Foundation import SwiftUI -// swiftlint:disable missing_docs #if !os(tvOS) // For Paywalls V2 /// A validated message sent from a Paywalls V2 `web_view` component to your app. @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) -public struct PaywallWebViewMessage: Sendable, Equatable { +struct PaywallWebViewMessage: Sendable, Equatable { /// The identifier of the `web_view` component that produced this message. - public let componentID: String + let componentID: String /// The message type, e.g. `"rc:step-complete"`. - public let type: String + let type: String /// The responses collected by the web flow. Only populated for `"rc:step-complete"` messages. - public let responses: [String: PaywallWebViewValue]? + let responses: [String: PaywallWebViewValue]? /// The error reported by the web content. Only populated for `"rc:error"` messages. - public let error: String? + let error: String? - public init( + init( componentID: String, type: String, responses: [String: PaywallWebViewValue]? = nil, @@ -36,7 +35,7 @@ public struct PaywallWebViewMessage: Sendable, Equatable { /// A JSON-compatible value exchanged between a Paywalls V2 `web_view` component and your app. @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) -public struct PaywallWebViewValue: Sendable, Equatable, Hashable, Codable { +struct PaywallWebViewValue: Sendable, Equatable, Hashable, Codable { private indirect enum Storage: Sendable, Equatable, Hashable { case string(String) @@ -54,54 +53,54 @@ public struct PaywallWebViewValue: Sendable, Equatable, Hashable, Codable { } /// Creates a string value. - public static func string(_ value: String) -> Self { Self(.string(value)) } + static func string(_ value: String) -> Self { Self(.string(value)) } /// Creates a numeric value. Non-finite values normalize to ``null`` because JSON cannot encode them. - public static func number(_ value: Double) -> Self { value.isFinite ? Self(.number(value)) : .null } + static func number(_ value: Double) -> Self { value.isFinite ? Self(.number(value)) : .null } /// Creates a boolean value. - public static func bool(_ value: Bool) -> Self { Self(.bool(value)) } + static func bool(_ value: Bool) -> Self { Self(.bool(value)) } /// Creates an array value. - public static func array(_ value: [PaywallWebViewValue]) -> Self { Self(.array(value)) } + static func array(_ value: [PaywallWebViewValue]) -> Self { Self(.array(value)) } /// Creates an object (dictionary) value. - public static func object(_ value: [String: PaywallWebViewValue]) -> Self { Self(.object(value)) } + static func object(_ value: [String: PaywallWebViewValue]) -> Self { Self(.object(value)) } /// A null value. - public static var null: Self { Self(.null) } + static var null: Self { Self(.null) } - public var stringValue: String? { + var stringValue: String? { if case .string(let value) = self.storage { return value } return nil } - public var numberValue: Double? { + var numberValue: Double? { if case .number(let value) = self.storage { return value } return nil } - public var boolValue: Bool? { + var boolValue: Bool? { if case .bool(let value) = self.storage { return value } return nil } - public var arrayValue: [PaywallWebViewValue]? { + var arrayValue: [PaywallWebViewValue]? { if case .array(let value) = self.storage { return value } return nil } - public var objectValue: [String: PaywallWebViewValue]? { + var objectValue: [String: PaywallWebViewValue]? { if case .object(let value) = self.storage { return value } return nil } - public var isNull: Bool { + var isNull: Bool { if case .null = self.storage { return true } return false } - public init(from decoder: Decoder) throws { + init(from decoder: Decoder) throws { let container = try decoder.singleValueContainer() if container.decodeNil() { @@ -119,7 +118,7 @@ public struct PaywallWebViewValue: Sendable, Equatable, Hashable, Codable { } } - public func encode(to encoder: Encoder) throws { + func encode(to encoder: Encoder) throws { var container = encoder.singleValueContainer() switch self.storage { @@ -142,34 +141,34 @@ public struct PaywallWebViewValue: Sendable, Equatable, Hashable, Codable { @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) @MainActor -public struct PaywallWebViewController { +struct PaywallWebViewController { init() {} - public func postVariables(componentID: String, variables: [String: PaywallWebViewValue]) { - // Session wiring lands in a later PR; public API is inert until then. + func postVariables(componentID: String, variables: [String: PaywallWebViewValue]) { + // Session wiring lands in a later PR. } - public func postMessage(componentID: String, type: String, variables: [String: PaywallWebViewValue]) { - // Session wiring lands in a later PR; public API is inert until then. + func postMessage(componentID: String, type: String, variables: [String: PaywallWebViewValue]) { + // Session wiring lands in a later PR. } } /// A wrapper for the Paywalls V2 `web_view` message handler. @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) -public struct PaywallWebViewMessageAction { +struct PaywallWebViewMessageAction { private let action: @MainActor (PaywallWebViewMessage, PaywallWebViewController) -> Void - public init( + init( _ action: @escaping @MainActor (PaywallWebViewMessage, PaywallWebViewController) -> Void ) { self.action = action } @MainActor - public func callAsFunction(_ message: PaywallWebViewMessage, _ controller: PaywallWebViewController) { + func callAsFunction(_ message: PaywallWebViewMessage, _ controller: PaywallWebViewController) { self.action(message, controller) } @@ -194,7 +193,7 @@ extension EnvironmentValues { extension View { /// Invokes the given closure when a Paywalls V2 `web_view` component sends a message. - public func onPaywallWebViewMessage( + func onPaywallWebViewMessage( _ action: @escaping @MainActor (PaywallWebViewMessage, PaywallWebViewController) -> Void ) -> some View { self.environment(\.paywallWebViewMessageAction, PaywallWebViewMessageAction(action)) diff --git a/Tests/APITesters/AllAPITests/AllAPITests.xcodeproj/project.pbxproj b/Tests/APITesters/AllAPITests/AllAPITests.xcodeproj/project.pbxproj index 74a25fd257..9cc8116989 100644 --- a/Tests/APITesters/AllAPITests/AllAPITests.xcodeproj/project.pbxproj +++ b/Tests/APITesters/AllAPITests/AllAPITests.xcodeproj/project.pbxproj @@ -8,7 +8,6 @@ /* Begin PBXBuildFile section */ 1E8A60812CDCF29D0034ACF3 /* RedeemWebPurchasesAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E8A60802CDCF29D0034ACF3 /* RedeemWebPurchasesAPI.swift */; }; - B096F58208E0AA6582F34133 /* PaywallWebViewAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = B0DC08831A62751743D823FA /* PaywallWebViewAPI.swift */; }; 1EB697C22CD26218003000FC /* WebPurchaseRedemptionResultAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1EB697C12CD26218003000FC /* WebPurchaseRedemptionResultAPI.swift */; }; 2D4C60E12C5AD1BC00A29FD2 /* AllAPITests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 2D4C60D82C5AD1BC00A29FD2 /* AllAPITests.framework */; }; 2D4C60E62C5AD1BC00A29FD2 /* AllAPITestsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D4C60E52C5AD1BC00A29FD2 /* AllAPITestsTests.swift */; }; @@ -229,7 +228,6 @@ /* Begin PBXFileReference section */ 1E8A60802CDCF29D0034ACF3 /* RedeemWebPurchasesAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RedeemWebPurchasesAPI.swift; sourceTree = ""; }; - B0DC08831A62751743D823FA /* PaywallWebViewAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaywallWebViewAPI.swift; sourceTree = ""; }; 1EB697C12CD26218003000FC /* WebPurchaseRedemptionResultAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebPurchaseRedemptionResultAPI.swift; sourceTree = ""; }; 2D4C60D82C5AD1BC00A29FD2 /* AllAPITests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = AllAPITests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 2D4C60DB2C5AD1BC00A29FD2 /* AllAPITests.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = AllAPITests.h; sourceTree = ""; }; @@ -647,7 +645,6 @@ children = ( FD33CD722D035876000D13A4 /* CustomerCenterViewControllerAPI.swift */, 2D4C622D2C5ADA6700A29FD2 /* PaywallViewAPI.swift */, - B0DC08831A62751743D823FA /* PaywallWebViewAPI.swift */, 1E8A60802CDCF29D0034ACF3 /* RedeemWebPurchasesAPI.swift */, 2D4C622B2C5ADA6700A29FD2 /* PaywallViewControllerAPI.swift */, 2D4C622C2C5ADA6700A29FD2 /* main.swift */, @@ -1122,7 +1119,6 @@ FDCC983A2D0A0FD500F5854B /* CustomerCenterViewAPI.swift in Sources */, 2D4C62302C5ADA6700A29FD2 /* PaywallViewControllerAPI.swift in Sources */, 1E8A60812CDCF29D0034ACF3 /* RedeemWebPurchasesAPI.swift in Sources */, - B096F58208E0AA6582F34133 /* PaywallWebViewAPI.swift in Sources */, 2D4C622E2C5ADA6700A29FD2 /* PaywallViewAPI.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/Tests/APITesters/AllAPITests/RevenueCatUISwiftAPITester/PaywallWebViewAPI.swift b/Tests/APITesters/AllAPITests/RevenueCatUISwiftAPITester/PaywallWebViewAPI.swift deleted file mode 100644 index a8a2986ca9..0000000000 --- a/Tests/APITesters/AllAPITests/RevenueCatUISwiftAPITester/PaywallWebViewAPI.swift +++ /dev/null @@ -1,77 +0,0 @@ -// -// PaywallWebViewAPI.swift -// RevenueCatUISwiftAPITester -// -// Compile-time checks for the Paywalls V2 web_view public API surface. -// - -import RevenueCat -import RevenueCatUI -import SwiftUI - -#if !os(tvOS) // For Paywalls V2 - -@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) -struct PaywallWebViewApp: View { - - var body: some View { - Text("") - .onPaywallWebViewMessage { (message: PaywallWebViewMessage, controller: PaywallWebViewController) in - let componentID: String = message.componentID - let type: String = message.type - let responses: [String: PaywallWebViewValue]? = message.responses - let error: String? = message.error - - controller.postVariables( - componentID: componentID, - variables: ["custom": .object(["plan": .string("annual")])] - ) - controller.postMessage(componentID: componentID, type: type, variables: [:]) - - _ = (responses, error) - } - } - -} - -@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) -func checkPaywallWebViewMessageAPI() { - let message = PaywallWebViewMessage( - componentID: "", - type: "rc:step-complete", - responses: ["selected_plan": .string("annual")], - error: nil - ) - let sameMessage: Bool = message == message - _ = sameMessage -} - -@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) -func checkPaywallWebViewValueAPI() { - let string: PaywallWebViewValue = .string("s") - let number: PaywallWebViewValue = .number(1.5) - let bool: PaywallWebViewValue = .bool(true) - let array: PaywallWebViewValue = .array([string, number]) - let object: PaywallWebViewValue = .object(["key": bool]) - let null: PaywallWebViewValue = .null - - let _: String? = string.stringValue - let _: Double? = number.numberValue - let _: Bool? = bool.boolValue - let _: [PaywallWebViewValue]? = array.arrayValue - let _: [String: PaywallWebViewValue]? = object.objectValue - let _: Bool = null.isNull - let _: Set = [string, number] -} - -@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) -@MainActor -func checkPaywallWebViewMessageActionAPI( - message: PaywallWebViewMessage, - controller: PaywallWebViewController -) { - let action = PaywallWebViewMessageAction { (_: PaywallWebViewMessage, _: PaywallWebViewController) in } - action(message, controller) -} - -#endif diff --git a/api/revenuecatui-api-ios-simulator.swiftinterface b/api/revenuecatui-api-ios-simulator.swiftinterface index 428cfba3b0..30bbb7549c 100644 --- a/api/revenuecatui-api-ios-simulator.swiftinterface +++ b/api/revenuecatui-api-ios-simulator.swiftinterface @@ -309,66 +309,6 @@ extension RevenueCat.Purchases { @available(iOS 15.0, macOS 12.0, *) @_Concurrency.MainActor @objc final public func presentPaywall(from url: Foundation.URL) -> Swift.Bool } -@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) -public struct PaywallWebViewMessage : Swift.Sendable, Swift.Equatable { - public let componentID: Swift.String - public let type: Swift.String - public let responses: [Swift.String : RevenueCatUI.PaywallWebViewValue]? - public let error: Swift.String? - public init(componentID: Swift.String, type: Swift.String, responses: [Swift.String : RevenueCatUI.PaywallWebViewValue]? = nil, error: Swift.String? = nil) - public static func == (a: RevenueCatUI.PaywallWebViewMessage, b: RevenueCatUI.PaywallWebViewMessage) -> Swift.Bool -} -@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) -public struct PaywallWebViewValue : Swift.Sendable, Swift.Equatable, Swift.Hashable, Swift.Codable { - public static func string(_ value: Swift.String) -> RevenueCatUI.PaywallWebViewValue - public static func number(_ value: Swift.Double) -> RevenueCatUI.PaywallWebViewValue - public static func bool(_ value: Swift.Bool) -> RevenueCatUI.PaywallWebViewValue - public static func array(_ value: [RevenueCatUI.PaywallWebViewValue]) -> RevenueCatUI.PaywallWebViewValue - public static func object(_ value: [Swift.String : RevenueCatUI.PaywallWebViewValue]) -> RevenueCatUI.PaywallWebViewValue - public static var null: RevenueCatUI.PaywallWebViewValue { - get - } - public var stringValue: Swift.String? { - get - } - public var numberValue: Swift.Double? { - get - } - public var boolValue: Swift.Bool? { - get - } - public var arrayValue: [RevenueCatUI.PaywallWebViewValue]? { - get - } - public var objectValue: [Swift.String : RevenueCatUI.PaywallWebViewValue]? { - get - } - public var isNull: Swift.Bool { - get - } - public init(from decoder: any Swift.Decoder) throws - public func encode(to encoder: any Swift.Encoder) throws - public static func == (a: RevenueCatUI.PaywallWebViewValue, b: RevenueCatUI.PaywallWebViewValue) -> Swift.Bool - public func hash(into hasher: inout Swift.Hasher) - public var hashValue: Swift.Int { - get - } -} -@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) -@_Concurrency.MainActor public struct PaywallWebViewController { - @_Concurrency.MainActor public func postVariables(componentID: Swift.String, variables: [Swift.String : RevenueCatUI.PaywallWebViewValue]) - @_Concurrency.MainActor public func postMessage(componentID: Swift.String, type: Swift.String, variables: [Swift.String : RevenueCatUI.PaywallWebViewValue]) -} -@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) -public struct PaywallWebViewMessageAction { - public init(_ action: @escaping @_Concurrency.MainActor (RevenueCatUI.PaywallWebViewMessage, RevenueCatUI.PaywallWebViewController) -> Swift.Void) - @_Concurrency.MainActor public func callAsFunction(_ message: RevenueCatUI.PaywallWebViewMessage, _ controller: RevenueCatUI.PaywallWebViewController) -} -@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) -extension SwiftUICore.View { - @_Concurrency.MainActor @preconcurrency public func onPaywallWebViewMessage(_ action: @escaping @_Concurrency.MainActor (RevenueCatUI.PaywallWebViewMessage, RevenueCatUI.PaywallWebViewController) -> Swift.Void) -> some SwiftUICore.View - -} @_inheritsConvenienceInitializers @_hasMissingDesignatedInitializers @available(iOS 15.0, macOS 12.0, tvOS 15.0, *) @objc(RCPaywallFooterViewController) @_Concurrency.MainActor @preconcurrency final public class PaywallFooterViewController : RevenueCatUI.PaywallViewController { @_Concurrency.MainActor @preconcurrency public init(offering: RevenueCat.Offering? = nil, performPurchase: @escaping RevenueCatUI.PerformPurchase, performRestore: @escaping RevenueCatUI.PerformRestore, dismissRequestedHandler: ((_ controller: RevenueCatUI.PaywallViewController) -> Swift.Void)? = nil) @@ -606,7 +546,5 @@ extension RevenueCatUI.CustomerCenterNavigationLink : Swift.Sendable {} @available(iOS 15.0, macOS 12.0, watchOS 8.0, *) @available(tvOS, unavailable, message: "RevenueCatUI does not support tvOS yet") extension RevenueCatUI.PaywallView : Swift.Sendable {} -@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) -extension RevenueCatUI.PaywallWebViewController : Swift.Sendable {} extension RevenueCatUI.PaywallPresentationMode : Swift.Equatable {} extension RevenueCatUI.PaywallPresentationMode : Swift.Hashable {} diff --git a/api/revenuecatui-api-ios.swiftinterface b/api/revenuecatui-api-ios.swiftinterface index 9514729189..5106fe465c 100644 --- a/api/revenuecatui-api-ios.swiftinterface +++ b/api/revenuecatui-api-ios.swiftinterface @@ -309,66 +309,6 @@ extension RevenueCat.Purchases { @available(iOS 15.0, macOS 12.0, *) @_Concurrency.MainActor @objc final public func presentPaywall(from url: Foundation.URL) -> Swift.Bool } -@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) -public struct PaywallWebViewMessage : Swift.Sendable, Swift.Equatable { - public let componentID: Swift.String - public let type: Swift.String - public let responses: [Swift.String : RevenueCatUI.PaywallWebViewValue]? - public let error: Swift.String? - public init(componentID: Swift.String, type: Swift.String, responses: [Swift.String : RevenueCatUI.PaywallWebViewValue]? = nil, error: Swift.String? = nil) - public static func == (a: RevenueCatUI.PaywallWebViewMessage, b: RevenueCatUI.PaywallWebViewMessage) -> Swift.Bool -} -@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) -public struct PaywallWebViewValue : Swift.Sendable, Swift.Equatable, Swift.Hashable, Swift.Codable { - public static func string(_ value: Swift.String) -> RevenueCatUI.PaywallWebViewValue - public static func number(_ value: Swift.Double) -> RevenueCatUI.PaywallWebViewValue - public static func bool(_ value: Swift.Bool) -> RevenueCatUI.PaywallWebViewValue - public static func array(_ value: [RevenueCatUI.PaywallWebViewValue]) -> RevenueCatUI.PaywallWebViewValue - public static func object(_ value: [Swift.String : RevenueCatUI.PaywallWebViewValue]) -> RevenueCatUI.PaywallWebViewValue - public static var null: RevenueCatUI.PaywallWebViewValue { - get - } - public var stringValue: Swift.String? { - get - } - public var numberValue: Swift.Double? { - get - } - public var boolValue: Swift.Bool? { - get - } - public var arrayValue: [RevenueCatUI.PaywallWebViewValue]? { - get - } - public var objectValue: [Swift.String : RevenueCatUI.PaywallWebViewValue]? { - get - } - public var isNull: Swift.Bool { - get - } - public init(from decoder: any Swift.Decoder) throws - public func encode(to encoder: any Swift.Encoder) throws - public static func == (a: RevenueCatUI.PaywallWebViewValue, b: RevenueCatUI.PaywallWebViewValue) -> Swift.Bool - public func hash(into hasher: inout Swift.Hasher) - public var hashValue: Swift.Int { - get - } -} -@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) -@_Concurrency.MainActor public struct PaywallWebViewController { - @_Concurrency.MainActor public func postVariables(componentID: Swift.String, variables: [Swift.String : RevenueCatUI.PaywallWebViewValue]) - @_Concurrency.MainActor public func postMessage(componentID: Swift.String, type: Swift.String, variables: [Swift.String : RevenueCatUI.PaywallWebViewValue]) -} -@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) -public struct PaywallWebViewMessageAction { - public init(_ action: @escaping @_Concurrency.MainActor (RevenueCatUI.PaywallWebViewMessage, RevenueCatUI.PaywallWebViewController) -> Swift.Void) - @_Concurrency.MainActor public func callAsFunction(_ message: RevenueCatUI.PaywallWebViewMessage, _ controller: RevenueCatUI.PaywallWebViewController) -} -@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) -extension SwiftUICore.View { - @_Concurrency.MainActor @preconcurrency public func onPaywallWebViewMessage(_ action: @escaping @_Concurrency.MainActor (RevenueCatUI.PaywallWebViewMessage, RevenueCatUI.PaywallWebViewController) -> Swift.Void) -> some SwiftUICore.View - -} @_inheritsConvenienceInitializers @_hasMissingDesignatedInitializers @available(iOS 15.0, macOS 12.0, tvOS 15.0, *) @objc(RCPaywallFooterViewController) @_Concurrency.MainActor @preconcurrency final public class PaywallFooterViewController : RevenueCatUI.PaywallViewController { @_Concurrency.MainActor @preconcurrency public init(offering: RevenueCat.Offering? = nil, performPurchase: @escaping RevenueCatUI.PerformPurchase, performRestore: @escaping RevenueCatUI.PerformRestore, dismissRequestedHandler: ((_ controller: RevenueCatUI.PaywallViewController) -> Swift.Void)? = nil) @@ -606,7 +546,5 @@ extension RevenueCatUI.CustomerCenterNavigationLink : Swift.Sendable {} @available(iOS 15.0, macOS 12.0, watchOS 8.0, *) @available(tvOS, unavailable, message: "RevenueCatUI does not support tvOS yet") extension RevenueCatUI.PaywallView : Swift.Sendable {} -@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) -extension RevenueCatUI.PaywallWebViewController : Swift.Sendable {} extension RevenueCatUI.PaywallPresentationMode : Swift.Equatable {} extension RevenueCatUI.PaywallPresentationMode : Swift.Hashable {} diff --git a/api/revenuecatui-api-macos.swiftinterface b/api/revenuecatui-api-macos.swiftinterface index d7553c3e37..e64db71c52 100644 --- a/api/revenuecatui-api-macos.swiftinterface +++ b/api/revenuecatui-api-macos.swiftinterface @@ -168,66 +168,6 @@ open class CustomPaywallFontProvider : RevenueCatUI.PaywallFontProvider { @available(tvOS, unavailable, introduced: 15.0, message: "RevenueCatUI does not support tvOS yet") public typealias Body = @_opaqueReturnTypeOf("$s12RevenueCatUI11PaywallViewV4bodyQrvp", 0) __ } -@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) -public struct PaywallWebViewMessage : Swift.Sendable, Swift.Equatable { - public let componentID: Swift.String - public let type: Swift.String - public let responses: [Swift.String : RevenueCatUI.PaywallWebViewValue]? - public let error: Swift.String? - public init(componentID: Swift.String, type: Swift.String, responses: [Swift.String : RevenueCatUI.PaywallWebViewValue]? = nil, error: Swift.String? = nil) - public static func == (a: RevenueCatUI.PaywallWebViewMessage, b: RevenueCatUI.PaywallWebViewMessage) -> Swift.Bool -} -@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) -public struct PaywallWebViewValue : Swift.Sendable, Swift.Equatable, Swift.Hashable, Swift.Codable { - public static func string(_ value: Swift.String) -> RevenueCatUI.PaywallWebViewValue - public static func number(_ value: Swift.Double) -> RevenueCatUI.PaywallWebViewValue - public static func bool(_ value: Swift.Bool) -> RevenueCatUI.PaywallWebViewValue - public static func array(_ value: [RevenueCatUI.PaywallWebViewValue]) -> RevenueCatUI.PaywallWebViewValue - public static func object(_ value: [Swift.String : RevenueCatUI.PaywallWebViewValue]) -> RevenueCatUI.PaywallWebViewValue - public static var null: RevenueCatUI.PaywallWebViewValue { - get - } - public var stringValue: Swift.String? { - get - } - public var numberValue: Swift.Double? { - get - } - public var boolValue: Swift.Bool? { - get - } - public var arrayValue: [RevenueCatUI.PaywallWebViewValue]? { - get - } - public var objectValue: [Swift.String : RevenueCatUI.PaywallWebViewValue]? { - get - } - public var isNull: Swift.Bool { - get - } - public init(from decoder: any Swift.Decoder) throws - public func encode(to encoder: any Swift.Encoder) throws - public static func == (a: RevenueCatUI.PaywallWebViewValue, b: RevenueCatUI.PaywallWebViewValue) -> Swift.Bool - public func hash(into hasher: inout Swift.Hasher) - public var hashValue: Swift.Int { - get - } -} -@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) -@_Concurrency.MainActor public struct PaywallWebViewController { - @_Concurrency.MainActor public func postVariables(componentID: Swift.String, variables: [Swift.String : RevenueCatUI.PaywallWebViewValue]) - @_Concurrency.MainActor public func postMessage(componentID: Swift.String, type: Swift.String, variables: [Swift.String : RevenueCatUI.PaywallWebViewValue]) -} -@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) -public struct PaywallWebViewMessageAction { - public init(_ action: @escaping @_Concurrency.MainActor (RevenueCatUI.PaywallWebViewMessage, RevenueCatUI.PaywallWebViewController) -> Swift.Void) - @_Concurrency.MainActor public func callAsFunction(_ message: RevenueCatUI.PaywallWebViewMessage, _ controller: RevenueCatUI.PaywallWebViewController) -} -@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) -extension SwiftUICore.View { - @_Concurrency.MainActor @preconcurrency public func onPaywallWebViewMessage(_ action: @escaping @_Concurrency.MainActor (RevenueCatUI.PaywallWebViewMessage, RevenueCatUI.PaywallWebViewController) -> Swift.Void) -> some SwiftUICore.View - -} @available(iOS 14.0, macOS 11.0, tvOS 14.0, watchOS 7.0, *) extension SwiftUICore.View { @_Concurrency.MainActor @preconcurrency public func onWebPurchaseRedemptionAttempt(perform completion: @escaping @Sendable (RevenueCat.WebPurchaseRedemptionResult) -> Swift.Void) -> some SwiftUICore.View @@ -384,7 +324,5 @@ extension SwiftUICore.View { @available(iOS 15.0, macOS 12.0, watchOS 8.0, *) @available(tvOS, unavailable, message: "RevenueCatUI does not support tvOS yet") extension RevenueCatUI.PaywallView : Swift.Sendable {} -@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) -extension RevenueCatUI.PaywallWebViewController : Swift.Sendable {} extension RevenueCatUI.PaywallPresentationMode : Swift.Equatable {} extension RevenueCatUI.PaywallPresentationMode : Swift.Hashable {} diff --git a/api/revenuecatui-api-visionos-simulator.swiftinterface b/api/revenuecatui-api-visionos-simulator.swiftinterface index 7369428ec5..b87d2b82d9 100644 --- a/api/revenuecatui-api-visionos-simulator.swiftinterface +++ b/api/revenuecatui-api-visionos-simulator.swiftinterface @@ -179,66 +179,6 @@ extension RevenueCat.Purchases { @available(iOS 15.0, macOS 12.0, *) @_Concurrency.MainActor @objc final public func presentPaywall(from url: Foundation.URL) -> Swift.Bool } -@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) -public struct PaywallWebViewMessage : Swift.Sendable, Swift.Equatable { - public let componentID: Swift.String - public let type: Swift.String - public let responses: [Swift.String : RevenueCatUI.PaywallWebViewValue]? - public let error: Swift.String? - public init(componentID: Swift.String, type: Swift.String, responses: [Swift.String : RevenueCatUI.PaywallWebViewValue]? = nil, error: Swift.String? = nil) - public static func == (a: RevenueCatUI.PaywallWebViewMessage, b: RevenueCatUI.PaywallWebViewMessage) -> Swift.Bool -} -@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) -public struct PaywallWebViewValue : Swift.Sendable, Swift.Equatable, Swift.Hashable, Swift.Codable { - public static func string(_ value: Swift.String) -> RevenueCatUI.PaywallWebViewValue - public static func number(_ value: Swift.Double) -> RevenueCatUI.PaywallWebViewValue - public static func bool(_ value: Swift.Bool) -> RevenueCatUI.PaywallWebViewValue - public static func array(_ value: [RevenueCatUI.PaywallWebViewValue]) -> RevenueCatUI.PaywallWebViewValue - public static func object(_ value: [Swift.String : RevenueCatUI.PaywallWebViewValue]) -> RevenueCatUI.PaywallWebViewValue - public static var null: RevenueCatUI.PaywallWebViewValue { - get - } - public var stringValue: Swift.String? { - get - } - public var numberValue: Swift.Double? { - get - } - public var boolValue: Swift.Bool? { - get - } - public var arrayValue: [RevenueCatUI.PaywallWebViewValue]? { - get - } - public var objectValue: [Swift.String : RevenueCatUI.PaywallWebViewValue]? { - get - } - public var isNull: Swift.Bool { - get - } - public init(from decoder: any Swift.Decoder) throws - public func encode(to encoder: any Swift.Encoder) throws - public static func == (a: RevenueCatUI.PaywallWebViewValue, b: RevenueCatUI.PaywallWebViewValue) -> Swift.Bool - public func hash(into hasher: inout Swift.Hasher) - public var hashValue: Swift.Int { - get - } -} -@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) -@_Concurrency.MainActor public struct PaywallWebViewController { - @_Concurrency.MainActor public func postVariables(componentID: Swift.String, variables: [Swift.String : RevenueCatUI.PaywallWebViewValue]) - @_Concurrency.MainActor public func postMessage(componentID: Swift.String, type: Swift.String, variables: [Swift.String : RevenueCatUI.PaywallWebViewValue]) -} -@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) -public struct PaywallWebViewMessageAction { - public init(_ action: @escaping @_Concurrency.MainActor (RevenueCatUI.PaywallWebViewMessage, RevenueCatUI.PaywallWebViewController) -> Swift.Void) - @_Concurrency.MainActor public func callAsFunction(_ message: RevenueCatUI.PaywallWebViewMessage, _ controller: RevenueCatUI.PaywallWebViewController) -} -@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) -extension SwiftUICore.View { - @_Concurrency.MainActor @preconcurrency public func onPaywallWebViewMessage(_ action: @escaping @_Concurrency.MainActor (RevenueCatUI.PaywallWebViewMessage, RevenueCatUI.PaywallWebViewController) -> Swift.Void) -> some SwiftUICore.View - -} @_inheritsConvenienceInitializers @_hasMissingDesignatedInitializers @available(iOS 15.0, macOS 12.0, tvOS 15.0, *) @objc(RCPaywallFooterViewController) @_Concurrency.MainActor @preconcurrency final public class PaywallFooterViewController : RevenueCatUI.PaywallViewController { @_Concurrency.MainActor @preconcurrency public init(offering: RevenueCat.Offering? = nil, performPurchase: @escaping RevenueCatUI.PerformPurchase, performRestore: @escaping RevenueCatUI.PerformRestore, dismissRequestedHandler: ((_ controller: RevenueCatUI.PaywallViewController) -> Swift.Void)? = nil) @@ -466,7 +406,5 @@ extension SwiftUICore.View { @available(iOS 15.0, macOS 12.0, watchOS 8.0, *) @available(tvOS, unavailable, message: "RevenueCatUI does not support tvOS yet") extension RevenueCatUI.PaywallView : Swift.Sendable {} -@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) -extension RevenueCatUI.PaywallWebViewController : Swift.Sendable {} extension RevenueCatUI.PaywallPresentationMode : Swift.Equatable {} extension RevenueCatUI.PaywallPresentationMode : Swift.Hashable {} diff --git a/api/revenuecatui-api-visionos.swiftinterface b/api/revenuecatui-api-visionos.swiftinterface index cccba66c64..ec8d35ca1f 100644 --- a/api/revenuecatui-api-visionos.swiftinterface +++ b/api/revenuecatui-api-visionos.swiftinterface @@ -179,66 +179,6 @@ extension RevenueCat.Purchases { @available(iOS 15.0, macOS 12.0, *) @_Concurrency.MainActor @objc final public func presentPaywall(from url: Foundation.URL) -> Swift.Bool } -@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) -public struct PaywallWebViewMessage : Swift.Sendable, Swift.Equatable { - public let componentID: Swift.String - public let type: Swift.String - public let responses: [Swift.String : RevenueCatUI.PaywallWebViewValue]? - public let error: Swift.String? - public init(componentID: Swift.String, type: Swift.String, responses: [Swift.String : RevenueCatUI.PaywallWebViewValue]? = nil, error: Swift.String? = nil) - public static func == (a: RevenueCatUI.PaywallWebViewMessage, b: RevenueCatUI.PaywallWebViewMessage) -> Swift.Bool -} -@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) -public struct PaywallWebViewValue : Swift.Sendable, Swift.Equatable, Swift.Hashable, Swift.Codable { - public static func string(_ value: Swift.String) -> RevenueCatUI.PaywallWebViewValue - public static func number(_ value: Swift.Double) -> RevenueCatUI.PaywallWebViewValue - public static func bool(_ value: Swift.Bool) -> RevenueCatUI.PaywallWebViewValue - public static func array(_ value: [RevenueCatUI.PaywallWebViewValue]) -> RevenueCatUI.PaywallWebViewValue - public static func object(_ value: [Swift.String : RevenueCatUI.PaywallWebViewValue]) -> RevenueCatUI.PaywallWebViewValue - public static var null: RevenueCatUI.PaywallWebViewValue { - get - } - public var stringValue: Swift.String? { - get - } - public var numberValue: Swift.Double? { - get - } - public var boolValue: Swift.Bool? { - get - } - public var arrayValue: [RevenueCatUI.PaywallWebViewValue]? { - get - } - public var objectValue: [Swift.String : RevenueCatUI.PaywallWebViewValue]? { - get - } - public var isNull: Swift.Bool { - get - } - public init(from decoder: any Swift.Decoder) throws - public func encode(to encoder: any Swift.Encoder) throws - public static func == (a: RevenueCatUI.PaywallWebViewValue, b: RevenueCatUI.PaywallWebViewValue) -> Swift.Bool - public func hash(into hasher: inout Swift.Hasher) - public var hashValue: Swift.Int { - get - } -} -@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) -@_Concurrency.MainActor public struct PaywallWebViewController { - @_Concurrency.MainActor public func postVariables(componentID: Swift.String, variables: [Swift.String : RevenueCatUI.PaywallWebViewValue]) - @_Concurrency.MainActor public func postMessage(componentID: Swift.String, type: Swift.String, variables: [Swift.String : RevenueCatUI.PaywallWebViewValue]) -} -@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) -public struct PaywallWebViewMessageAction { - public init(_ action: @escaping @_Concurrency.MainActor (RevenueCatUI.PaywallWebViewMessage, RevenueCatUI.PaywallWebViewController) -> Swift.Void) - @_Concurrency.MainActor public func callAsFunction(_ message: RevenueCatUI.PaywallWebViewMessage, _ controller: RevenueCatUI.PaywallWebViewController) -} -@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) -extension SwiftUICore.View { - @_Concurrency.MainActor @preconcurrency public func onPaywallWebViewMessage(_ action: @escaping @_Concurrency.MainActor (RevenueCatUI.PaywallWebViewMessage, RevenueCatUI.PaywallWebViewController) -> Swift.Void) -> some SwiftUICore.View - -} @_inheritsConvenienceInitializers @_hasMissingDesignatedInitializers @available(iOS 15.0, macOS 12.0, tvOS 15.0, *) @objc(RCPaywallFooterViewController) @_Concurrency.MainActor @preconcurrency final public class PaywallFooterViewController : RevenueCatUI.PaywallViewController { @_Concurrency.MainActor @preconcurrency public init(offering: RevenueCat.Offering? = nil, performPurchase: @escaping RevenueCatUI.PerformPurchase, performRestore: @escaping RevenueCatUI.PerformRestore, dismissRequestedHandler: ((_ controller: RevenueCatUI.PaywallViewController) -> Swift.Void)? = nil) @@ -466,7 +406,5 @@ extension SwiftUICore.View { @available(iOS 15.0, macOS 12.0, watchOS 8.0, *) @available(tvOS, unavailable, message: "RevenueCatUI does not support tvOS yet") extension RevenueCatUI.PaywallView : Swift.Sendable {} -@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) -extension RevenueCatUI.PaywallWebViewController : Swift.Sendable {} extension RevenueCatUI.PaywallPresentationMode : Swift.Equatable {} extension RevenueCatUI.PaywallPresentationMode : Swift.Hashable {} diff --git a/api/revenuecatui-api-watchos-simulator.swiftinterface b/api/revenuecatui-api-watchos-simulator.swiftinterface index 7038282c5d..03d128c761 100644 --- a/api/revenuecatui-api-watchos-simulator.swiftinterface +++ b/api/revenuecatui-api-watchos-simulator.swiftinterface @@ -166,66 +166,6 @@ open class CustomPaywallFontProvider : RevenueCatUI.PaywallFontProvider { @available(tvOS, unavailable, introduced: 15.0, message: "RevenueCatUI does not support tvOS yet") public typealias Body = @_opaqueReturnTypeOf("$s12RevenueCatUI11PaywallViewV4bodyQrvp", 0) __ } -@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) -public struct PaywallWebViewMessage : Swift.Sendable, Swift.Equatable { - public let componentID: Swift.String - public let type: Swift.String - public let responses: [Swift.String : RevenueCatUI.PaywallWebViewValue]? - public let error: Swift.String? - public init(componentID: Swift.String, type: Swift.String, responses: [Swift.String : RevenueCatUI.PaywallWebViewValue]? = nil, error: Swift.String? = nil) - public static func == (a: RevenueCatUI.PaywallWebViewMessage, b: RevenueCatUI.PaywallWebViewMessage) -> Swift.Bool -} -@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) -public struct PaywallWebViewValue : Swift.Sendable, Swift.Equatable, Swift.Hashable, Swift.Codable { - public static func string(_ value: Swift.String) -> RevenueCatUI.PaywallWebViewValue - public static func number(_ value: Swift.Double) -> RevenueCatUI.PaywallWebViewValue - public static func bool(_ value: Swift.Bool) -> RevenueCatUI.PaywallWebViewValue - public static func array(_ value: [RevenueCatUI.PaywallWebViewValue]) -> RevenueCatUI.PaywallWebViewValue - public static func object(_ value: [Swift.String : RevenueCatUI.PaywallWebViewValue]) -> RevenueCatUI.PaywallWebViewValue - public static var null: RevenueCatUI.PaywallWebViewValue { - get - } - public var stringValue: Swift.String? { - get - } - public var numberValue: Swift.Double? { - get - } - public var boolValue: Swift.Bool? { - get - } - public var arrayValue: [RevenueCatUI.PaywallWebViewValue]? { - get - } - public var objectValue: [Swift.String : RevenueCatUI.PaywallWebViewValue]? { - get - } - public var isNull: Swift.Bool { - get - } - public init(from decoder: any Swift.Decoder) throws - public func encode(to encoder: any Swift.Encoder) throws - public static func == (a: RevenueCatUI.PaywallWebViewValue, b: RevenueCatUI.PaywallWebViewValue) -> Swift.Bool - public func hash(into hasher: inout Swift.Hasher) - public var hashValue: Swift.Int { - get - } -} -@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) -@_Concurrency.MainActor public struct PaywallWebViewController { - @_Concurrency.MainActor public func postVariables(componentID: Swift.String, variables: [Swift.String : RevenueCatUI.PaywallWebViewValue]) - @_Concurrency.MainActor public func postMessage(componentID: Swift.String, type: Swift.String, variables: [Swift.String : RevenueCatUI.PaywallWebViewValue]) -} -@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) -public struct PaywallWebViewMessageAction { - public init(_ action: @escaping @_Concurrency.MainActor (RevenueCatUI.PaywallWebViewMessage, RevenueCatUI.PaywallWebViewController) -> Swift.Void) - @_Concurrency.MainActor public func callAsFunction(_ message: RevenueCatUI.PaywallWebViewMessage, _ controller: RevenueCatUI.PaywallWebViewController) -} -@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) -extension SwiftUICore.View { - @_Concurrency.MainActor @preconcurrency public func onPaywallWebViewMessage(_ action: @escaping @_Concurrency.MainActor (RevenueCatUI.PaywallWebViewMessage, RevenueCatUI.PaywallWebViewController) -> Swift.Void) -> some SwiftUICore.View - -} @available(iOS 14.0, macOS 11.0, tvOS 14.0, watchOS 7.0, *) extension SwiftUICore.View { @_Concurrency.MainActor @preconcurrency public func onWebPurchaseRedemptionAttempt(perform completion: @escaping @Sendable (RevenueCat.WebPurchaseRedemptionResult) -> Swift.Void) -> some SwiftUICore.View @@ -339,7 +279,5 @@ extension SwiftUICore.View { @available(iOS 15.0, macOS 12.0, watchOS 8.0, *) @available(tvOS, unavailable, message: "RevenueCatUI does not support tvOS yet") extension RevenueCatUI.PaywallView : Swift.Sendable {} -@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) -extension RevenueCatUI.PaywallWebViewController : Swift.Sendable {} extension RevenueCatUI.PaywallPresentationMode : Swift.Equatable {} extension RevenueCatUI.PaywallPresentationMode : Swift.Hashable {} diff --git a/api/revenuecatui-api-watchos.swiftinterface b/api/revenuecatui-api-watchos.swiftinterface index 0490cf9e59..39b2ed3084 100644 --- a/api/revenuecatui-api-watchos.swiftinterface +++ b/api/revenuecatui-api-watchos.swiftinterface @@ -166,66 +166,6 @@ open class CustomPaywallFontProvider : RevenueCatUI.PaywallFontProvider { @available(tvOS, unavailable, introduced: 15.0, message: "RevenueCatUI does not support tvOS yet") public typealias Body = @_opaqueReturnTypeOf("$s12RevenueCatUI11PaywallViewV4bodyQrvp", 0) __ } -@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) -public struct PaywallWebViewMessage : Swift.Sendable, Swift.Equatable { - public let componentID: Swift.String - public let type: Swift.String - public let responses: [Swift.String : RevenueCatUI.PaywallWebViewValue]? - public let error: Swift.String? - public init(componentID: Swift.String, type: Swift.String, responses: [Swift.String : RevenueCatUI.PaywallWebViewValue]? = nil, error: Swift.String? = nil) - public static func == (a: RevenueCatUI.PaywallWebViewMessage, b: RevenueCatUI.PaywallWebViewMessage) -> Swift.Bool -} -@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) -public struct PaywallWebViewValue : Swift.Sendable, Swift.Equatable, Swift.Hashable, Swift.Codable { - public static func string(_ value: Swift.String) -> RevenueCatUI.PaywallWebViewValue - public static func number(_ value: Swift.Double) -> RevenueCatUI.PaywallWebViewValue - public static func bool(_ value: Swift.Bool) -> RevenueCatUI.PaywallWebViewValue - public static func array(_ value: [RevenueCatUI.PaywallWebViewValue]) -> RevenueCatUI.PaywallWebViewValue - public static func object(_ value: [Swift.String : RevenueCatUI.PaywallWebViewValue]) -> RevenueCatUI.PaywallWebViewValue - public static var null: RevenueCatUI.PaywallWebViewValue { - get - } - public var stringValue: Swift.String? { - get - } - public var numberValue: Swift.Double? { - get - } - public var boolValue: Swift.Bool? { - get - } - public var arrayValue: [RevenueCatUI.PaywallWebViewValue]? { - get - } - public var objectValue: [Swift.String : RevenueCatUI.PaywallWebViewValue]? { - get - } - public var isNull: Swift.Bool { - get - } - public init(from decoder: any Swift.Decoder) throws - public func encode(to encoder: any Swift.Encoder) throws - public static func == (a: RevenueCatUI.PaywallWebViewValue, b: RevenueCatUI.PaywallWebViewValue) -> Swift.Bool - public func hash(into hasher: inout Swift.Hasher) - public var hashValue: Swift.Int { - get - } -} -@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) -@_Concurrency.MainActor public struct PaywallWebViewController { - @_Concurrency.MainActor public func postVariables(componentID: Swift.String, variables: [Swift.String : RevenueCatUI.PaywallWebViewValue]) - @_Concurrency.MainActor public func postMessage(componentID: Swift.String, type: Swift.String, variables: [Swift.String : RevenueCatUI.PaywallWebViewValue]) -} -@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) -public struct PaywallWebViewMessageAction { - public init(_ action: @escaping @_Concurrency.MainActor (RevenueCatUI.PaywallWebViewMessage, RevenueCatUI.PaywallWebViewController) -> Swift.Void) - @_Concurrency.MainActor public func callAsFunction(_ message: RevenueCatUI.PaywallWebViewMessage, _ controller: RevenueCatUI.PaywallWebViewController) -} -@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) -extension SwiftUICore.View { - @_Concurrency.MainActor @preconcurrency public func onPaywallWebViewMessage(_ action: @escaping @_Concurrency.MainActor (RevenueCatUI.PaywallWebViewMessage, RevenueCatUI.PaywallWebViewController) -> Swift.Void) -> some SwiftUICore.View - -} @available(iOS 14.0, macOS 11.0, tvOS 14.0, watchOS 7.0, *) extension SwiftUICore.View { @_Concurrency.MainActor @preconcurrency public func onWebPurchaseRedemptionAttempt(perform completion: @escaping @Sendable (RevenueCat.WebPurchaseRedemptionResult) -> Swift.Void) -> some SwiftUICore.View @@ -339,7 +279,5 @@ extension SwiftUICore.View { @available(iOS 15.0, macOS 12.0, watchOS 8.0, *) @available(tvOS, unavailable, message: "RevenueCatUI does not support tvOS yet") extension RevenueCatUI.PaywallView : Swift.Sendable {} -@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) -extension RevenueCatUI.PaywallWebViewController : Swift.Sendable {} extension RevenueCatUI.PaywallPresentationMode : Swift.Equatable {} extension RevenueCatUI.PaywallPresentationMode : Swift.Hashable {} From 41401ace9f3bd3224dd6d5f96526e658abe5ca0f Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 16 Jul 2026 20:01:56 +0000 Subject: [PATCH 12/35] refactor(paywalls): keep web view bridge internal Co-authored-by: Jacob Rakidzich --- .../WebView/PaywallWebViewAPI.swift | 61 ++++++++------- .../AllAPITests.xcodeproj/project.pbxproj | 4 - .../PaywallWebViewAPI.swift | 77 ------------------- ...enuecatui-api-ios-simulator.swiftinterface | 62 --------------- api/revenuecatui-api-ios.swiftinterface | 62 --------------- api/revenuecatui-api-macos.swiftinterface | 62 --------------- ...atui-api-visionos-simulator.swiftinterface | 62 --------------- api/revenuecatui-api-visionos.swiftinterface | 62 --------------- ...catui-api-watchos-simulator.swiftinterface | 62 --------------- api/revenuecatui-api-watchos.swiftinterface | 62 --------------- 10 files changed, 30 insertions(+), 546 deletions(-) delete mode 100644 Tests/APITesters/AllAPITests/RevenueCatUISwiftAPITester/PaywallWebViewAPI.swift diff --git a/RevenueCatUI/Templates/V2/Components/WebView/PaywallWebViewAPI.swift b/RevenueCatUI/Templates/V2/Components/WebView/PaywallWebViewAPI.swift index 5ba2a3fe3b..4aed083814 100644 --- a/RevenueCatUI/Templates/V2/Components/WebView/PaywallWebViewAPI.swift +++ b/RevenueCatUI/Templates/V2/Components/WebView/PaywallWebViewAPI.swift @@ -1,26 +1,25 @@ import Foundation import SwiftUI -// swiftlint:disable missing_docs #if !os(tvOS) // For Paywalls V2 /// A validated message sent from a Paywalls V2 `web_view` component to your app. @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) -public struct PaywallWebViewMessage: Sendable, Equatable { +struct PaywallWebViewMessage: Sendable, Equatable { /// The identifier of the `web_view` component that produced this message. - public let componentID: String + let componentID: String /// The message type, e.g. `"rc:step-complete"`. - public let type: String + let type: String /// The responses collected by the web flow. Only populated for `"rc:step-complete"` messages. - public let responses: [String: PaywallWebViewValue]? + let responses: [String: PaywallWebViewValue]? /// The error reported by the web content. Only populated for `"rc:error"` messages. - public let error: String? + let error: String? - public init( + init( componentID: String, type: String, responses: [String: PaywallWebViewValue]? = nil, @@ -36,7 +35,7 @@ public struct PaywallWebViewMessage: Sendable, Equatable { /// A JSON-compatible value exchanged between a Paywalls V2 `web_view` component and your app. @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) -public struct PaywallWebViewValue: Sendable, Equatable, Hashable, Codable { +struct PaywallWebViewValue: Sendable, Equatable, Hashable, Codable { private indirect enum Storage: Sendable, Equatable, Hashable { case string(String) @@ -54,54 +53,54 @@ public struct PaywallWebViewValue: Sendable, Equatable, Hashable, Codable { } /// Creates a string value. - public static func string(_ value: String) -> Self { Self(.string(value)) } + static func string(_ value: String) -> Self { Self(.string(value)) } /// Creates a numeric value. Non-finite values normalize to ``null`` because JSON cannot encode them. - public static func number(_ value: Double) -> Self { value.isFinite ? Self(.number(value)) : .null } + static func number(_ value: Double) -> Self { value.isFinite ? Self(.number(value)) : .null } /// Creates a boolean value. - public static func bool(_ value: Bool) -> Self { Self(.bool(value)) } + static func bool(_ value: Bool) -> Self { Self(.bool(value)) } /// Creates an array value. - public static func array(_ value: [PaywallWebViewValue]) -> Self { Self(.array(value)) } + static func array(_ value: [PaywallWebViewValue]) -> Self { Self(.array(value)) } /// Creates an object (dictionary) value. - public static func object(_ value: [String: PaywallWebViewValue]) -> Self { Self(.object(value)) } + static func object(_ value: [String: PaywallWebViewValue]) -> Self { Self(.object(value)) } /// A null value. - public static var null: Self { Self(.null) } + static var null: Self { Self(.null) } - public var stringValue: String? { + var stringValue: String? { if case .string(let value) = self.storage { return value } return nil } - public var numberValue: Double? { + var numberValue: Double? { if case .number(let value) = self.storage { return value } return nil } - public var boolValue: Bool? { + var boolValue: Bool? { if case .bool(let value) = self.storage { return value } return nil } - public var arrayValue: [PaywallWebViewValue]? { + var arrayValue: [PaywallWebViewValue]? { if case .array(let value) = self.storage { return value } return nil } - public var objectValue: [String: PaywallWebViewValue]? { + var objectValue: [String: PaywallWebViewValue]? { if case .object(let value) = self.storage { return value } return nil } - public var isNull: Bool { + var isNull: Bool { if case .null = self.storage { return true } return false } - public init(from decoder: Decoder) throws { + init(from decoder: Decoder) throws { let container = try decoder.singleValueContainer() if container.decodeNil() { @@ -119,7 +118,7 @@ public struct PaywallWebViewValue: Sendable, Equatable, Hashable, Codable { } } - public func encode(to encoder: Encoder) throws { + func encode(to encoder: Encoder) throws { var container = encoder.singleValueContainer() switch self.storage { @@ -142,34 +141,34 @@ public struct PaywallWebViewValue: Sendable, Equatable, Hashable, Codable { @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) @MainActor -public struct PaywallWebViewController { +struct PaywallWebViewController { init() {} - public func postVariables(componentID: String, variables: [String: PaywallWebViewValue]) { - // Session wiring lands in a later PR; public API is inert until then. + func postVariables(componentID: String, variables: [String: PaywallWebViewValue]) { + // Session wiring lands in a later PR. } - public func postMessage(componentID: String, type: String, variables: [String: PaywallWebViewValue]) { - // Session wiring lands in a later PR; public API is inert until then. + func postMessage(componentID: String, type: String, variables: [String: PaywallWebViewValue]) { + // Session wiring lands in a later PR. } } /// A wrapper for the Paywalls V2 `web_view` message handler. @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) -public struct PaywallWebViewMessageAction { +struct PaywallWebViewMessageAction { private let action: @MainActor (PaywallWebViewMessage, PaywallWebViewController) -> Void - public init( + init( _ action: @escaping @MainActor (PaywallWebViewMessage, PaywallWebViewController) -> Void ) { self.action = action } @MainActor - public func callAsFunction(_ message: PaywallWebViewMessage, _ controller: PaywallWebViewController) { + func callAsFunction(_ message: PaywallWebViewMessage, _ controller: PaywallWebViewController) { self.action(message, controller) } @@ -194,7 +193,7 @@ extension EnvironmentValues { extension View { /// Invokes the given closure when a Paywalls V2 `web_view` component sends a message. - public func onPaywallWebViewMessage( + func onPaywallWebViewMessage( _ action: @escaping @MainActor (PaywallWebViewMessage, PaywallWebViewController) -> Void ) -> some View { self.environment(\.paywallWebViewMessageAction, PaywallWebViewMessageAction(action)) diff --git a/Tests/APITesters/AllAPITests/AllAPITests.xcodeproj/project.pbxproj b/Tests/APITesters/AllAPITests/AllAPITests.xcodeproj/project.pbxproj index 74a25fd257..9cc8116989 100644 --- a/Tests/APITesters/AllAPITests/AllAPITests.xcodeproj/project.pbxproj +++ b/Tests/APITesters/AllAPITests/AllAPITests.xcodeproj/project.pbxproj @@ -8,7 +8,6 @@ /* Begin PBXBuildFile section */ 1E8A60812CDCF29D0034ACF3 /* RedeemWebPurchasesAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E8A60802CDCF29D0034ACF3 /* RedeemWebPurchasesAPI.swift */; }; - B096F58208E0AA6582F34133 /* PaywallWebViewAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = B0DC08831A62751743D823FA /* PaywallWebViewAPI.swift */; }; 1EB697C22CD26218003000FC /* WebPurchaseRedemptionResultAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1EB697C12CD26218003000FC /* WebPurchaseRedemptionResultAPI.swift */; }; 2D4C60E12C5AD1BC00A29FD2 /* AllAPITests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 2D4C60D82C5AD1BC00A29FD2 /* AllAPITests.framework */; }; 2D4C60E62C5AD1BC00A29FD2 /* AllAPITestsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D4C60E52C5AD1BC00A29FD2 /* AllAPITestsTests.swift */; }; @@ -229,7 +228,6 @@ /* Begin PBXFileReference section */ 1E8A60802CDCF29D0034ACF3 /* RedeemWebPurchasesAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RedeemWebPurchasesAPI.swift; sourceTree = ""; }; - B0DC08831A62751743D823FA /* PaywallWebViewAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaywallWebViewAPI.swift; sourceTree = ""; }; 1EB697C12CD26218003000FC /* WebPurchaseRedemptionResultAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebPurchaseRedemptionResultAPI.swift; sourceTree = ""; }; 2D4C60D82C5AD1BC00A29FD2 /* AllAPITests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = AllAPITests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 2D4C60DB2C5AD1BC00A29FD2 /* AllAPITests.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = AllAPITests.h; sourceTree = ""; }; @@ -647,7 +645,6 @@ children = ( FD33CD722D035876000D13A4 /* CustomerCenterViewControllerAPI.swift */, 2D4C622D2C5ADA6700A29FD2 /* PaywallViewAPI.swift */, - B0DC08831A62751743D823FA /* PaywallWebViewAPI.swift */, 1E8A60802CDCF29D0034ACF3 /* RedeemWebPurchasesAPI.swift */, 2D4C622B2C5ADA6700A29FD2 /* PaywallViewControllerAPI.swift */, 2D4C622C2C5ADA6700A29FD2 /* main.swift */, @@ -1122,7 +1119,6 @@ FDCC983A2D0A0FD500F5854B /* CustomerCenterViewAPI.swift in Sources */, 2D4C62302C5ADA6700A29FD2 /* PaywallViewControllerAPI.swift in Sources */, 1E8A60812CDCF29D0034ACF3 /* RedeemWebPurchasesAPI.swift in Sources */, - B096F58208E0AA6582F34133 /* PaywallWebViewAPI.swift in Sources */, 2D4C622E2C5ADA6700A29FD2 /* PaywallViewAPI.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/Tests/APITesters/AllAPITests/RevenueCatUISwiftAPITester/PaywallWebViewAPI.swift b/Tests/APITesters/AllAPITests/RevenueCatUISwiftAPITester/PaywallWebViewAPI.swift deleted file mode 100644 index a8a2986ca9..0000000000 --- a/Tests/APITesters/AllAPITests/RevenueCatUISwiftAPITester/PaywallWebViewAPI.swift +++ /dev/null @@ -1,77 +0,0 @@ -// -// PaywallWebViewAPI.swift -// RevenueCatUISwiftAPITester -// -// Compile-time checks for the Paywalls V2 web_view public API surface. -// - -import RevenueCat -import RevenueCatUI -import SwiftUI - -#if !os(tvOS) // For Paywalls V2 - -@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) -struct PaywallWebViewApp: View { - - var body: some View { - Text("") - .onPaywallWebViewMessage { (message: PaywallWebViewMessage, controller: PaywallWebViewController) in - let componentID: String = message.componentID - let type: String = message.type - let responses: [String: PaywallWebViewValue]? = message.responses - let error: String? = message.error - - controller.postVariables( - componentID: componentID, - variables: ["custom": .object(["plan": .string("annual")])] - ) - controller.postMessage(componentID: componentID, type: type, variables: [:]) - - _ = (responses, error) - } - } - -} - -@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) -func checkPaywallWebViewMessageAPI() { - let message = PaywallWebViewMessage( - componentID: "", - type: "rc:step-complete", - responses: ["selected_plan": .string("annual")], - error: nil - ) - let sameMessage: Bool = message == message - _ = sameMessage -} - -@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) -func checkPaywallWebViewValueAPI() { - let string: PaywallWebViewValue = .string("s") - let number: PaywallWebViewValue = .number(1.5) - let bool: PaywallWebViewValue = .bool(true) - let array: PaywallWebViewValue = .array([string, number]) - let object: PaywallWebViewValue = .object(["key": bool]) - let null: PaywallWebViewValue = .null - - let _: String? = string.stringValue - let _: Double? = number.numberValue - let _: Bool? = bool.boolValue - let _: [PaywallWebViewValue]? = array.arrayValue - let _: [String: PaywallWebViewValue]? = object.objectValue - let _: Bool = null.isNull - let _: Set = [string, number] -} - -@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) -@MainActor -func checkPaywallWebViewMessageActionAPI( - message: PaywallWebViewMessage, - controller: PaywallWebViewController -) { - let action = PaywallWebViewMessageAction { (_: PaywallWebViewMessage, _: PaywallWebViewController) in } - action(message, controller) -} - -#endif diff --git a/api/revenuecatui-api-ios-simulator.swiftinterface b/api/revenuecatui-api-ios-simulator.swiftinterface index 428cfba3b0..30bbb7549c 100644 --- a/api/revenuecatui-api-ios-simulator.swiftinterface +++ b/api/revenuecatui-api-ios-simulator.swiftinterface @@ -309,66 +309,6 @@ extension RevenueCat.Purchases { @available(iOS 15.0, macOS 12.0, *) @_Concurrency.MainActor @objc final public func presentPaywall(from url: Foundation.URL) -> Swift.Bool } -@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) -public struct PaywallWebViewMessage : Swift.Sendable, Swift.Equatable { - public let componentID: Swift.String - public let type: Swift.String - public let responses: [Swift.String : RevenueCatUI.PaywallWebViewValue]? - public let error: Swift.String? - public init(componentID: Swift.String, type: Swift.String, responses: [Swift.String : RevenueCatUI.PaywallWebViewValue]? = nil, error: Swift.String? = nil) - public static func == (a: RevenueCatUI.PaywallWebViewMessage, b: RevenueCatUI.PaywallWebViewMessage) -> Swift.Bool -} -@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) -public struct PaywallWebViewValue : Swift.Sendable, Swift.Equatable, Swift.Hashable, Swift.Codable { - public static func string(_ value: Swift.String) -> RevenueCatUI.PaywallWebViewValue - public static func number(_ value: Swift.Double) -> RevenueCatUI.PaywallWebViewValue - public static func bool(_ value: Swift.Bool) -> RevenueCatUI.PaywallWebViewValue - public static func array(_ value: [RevenueCatUI.PaywallWebViewValue]) -> RevenueCatUI.PaywallWebViewValue - public static func object(_ value: [Swift.String : RevenueCatUI.PaywallWebViewValue]) -> RevenueCatUI.PaywallWebViewValue - public static var null: RevenueCatUI.PaywallWebViewValue { - get - } - public var stringValue: Swift.String? { - get - } - public var numberValue: Swift.Double? { - get - } - public var boolValue: Swift.Bool? { - get - } - public var arrayValue: [RevenueCatUI.PaywallWebViewValue]? { - get - } - public var objectValue: [Swift.String : RevenueCatUI.PaywallWebViewValue]? { - get - } - public var isNull: Swift.Bool { - get - } - public init(from decoder: any Swift.Decoder) throws - public func encode(to encoder: any Swift.Encoder) throws - public static func == (a: RevenueCatUI.PaywallWebViewValue, b: RevenueCatUI.PaywallWebViewValue) -> Swift.Bool - public func hash(into hasher: inout Swift.Hasher) - public var hashValue: Swift.Int { - get - } -} -@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) -@_Concurrency.MainActor public struct PaywallWebViewController { - @_Concurrency.MainActor public func postVariables(componentID: Swift.String, variables: [Swift.String : RevenueCatUI.PaywallWebViewValue]) - @_Concurrency.MainActor public func postMessage(componentID: Swift.String, type: Swift.String, variables: [Swift.String : RevenueCatUI.PaywallWebViewValue]) -} -@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) -public struct PaywallWebViewMessageAction { - public init(_ action: @escaping @_Concurrency.MainActor (RevenueCatUI.PaywallWebViewMessage, RevenueCatUI.PaywallWebViewController) -> Swift.Void) - @_Concurrency.MainActor public func callAsFunction(_ message: RevenueCatUI.PaywallWebViewMessage, _ controller: RevenueCatUI.PaywallWebViewController) -} -@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) -extension SwiftUICore.View { - @_Concurrency.MainActor @preconcurrency public func onPaywallWebViewMessage(_ action: @escaping @_Concurrency.MainActor (RevenueCatUI.PaywallWebViewMessage, RevenueCatUI.PaywallWebViewController) -> Swift.Void) -> some SwiftUICore.View - -} @_inheritsConvenienceInitializers @_hasMissingDesignatedInitializers @available(iOS 15.0, macOS 12.0, tvOS 15.0, *) @objc(RCPaywallFooterViewController) @_Concurrency.MainActor @preconcurrency final public class PaywallFooterViewController : RevenueCatUI.PaywallViewController { @_Concurrency.MainActor @preconcurrency public init(offering: RevenueCat.Offering? = nil, performPurchase: @escaping RevenueCatUI.PerformPurchase, performRestore: @escaping RevenueCatUI.PerformRestore, dismissRequestedHandler: ((_ controller: RevenueCatUI.PaywallViewController) -> Swift.Void)? = nil) @@ -606,7 +546,5 @@ extension RevenueCatUI.CustomerCenterNavigationLink : Swift.Sendable {} @available(iOS 15.0, macOS 12.0, watchOS 8.0, *) @available(tvOS, unavailable, message: "RevenueCatUI does not support tvOS yet") extension RevenueCatUI.PaywallView : Swift.Sendable {} -@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) -extension RevenueCatUI.PaywallWebViewController : Swift.Sendable {} extension RevenueCatUI.PaywallPresentationMode : Swift.Equatable {} extension RevenueCatUI.PaywallPresentationMode : Swift.Hashable {} diff --git a/api/revenuecatui-api-ios.swiftinterface b/api/revenuecatui-api-ios.swiftinterface index 9514729189..5106fe465c 100644 --- a/api/revenuecatui-api-ios.swiftinterface +++ b/api/revenuecatui-api-ios.swiftinterface @@ -309,66 +309,6 @@ extension RevenueCat.Purchases { @available(iOS 15.0, macOS 12.0, *) @_Concurrency.MainActor @objc final public func presentPaywall(from url: Foundation.URL) -> Swift.Bool } -@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) -public struct PaywallWebViewMessage : Swift.Sendable, Swift.Equatable { - public let componentID: Swift.String - public let type: Swift.String - public let responses: [Swift.String : RevenueCatUI.PaywallWebViewValue]? - public let error: Swift.String? - public init(componentID: Swift.String, type: Swift.String, responses: [Swift.String : RevenueCatUI.PaywallWebViewValue]? = nil, error: Swift.String? = nil) - public static func == (a: RevenueCatUI.PaywallWebViewMessage, b: RevenueCatUI.PaywallWebViewMessage) -> Swift.Bool -} -@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) -public struct PaywallWebViewValue : Swift.Sendable, Swift.Equatable, Swift.Hashable, Swift.Codable { - public static func string(_ value: Swift.String) -> RevenueCatUI.PaywallWebViewValue - public static func number(_ value: Swift.Double) -> RevenueCatUI.PaywallWebViewValue - public static func bool(_ value: Swift.Bool) -> RevenueCatUI.PaywallWebViewValue - public static func array(_ value: [RevenueCatUI.PaywallWebViewValue]) -> RevenueCatUI.PaywallWebViewValue - public static func object(_ value: [Swift.String : RevenueCatUI.PaywallWebViewValue]) -> RevenueCatUI.PaywallWebViewValue - public static var null: RevenueCatUI.PaywallWebViewValue { - get - } - public var stringValue: Swift.String? { - get - } - public var numberValue: Swift.Double? { - get - } - public var boolValue: Swift.Bool? { - get - } - public var arrayValue: [RevenueCatUI.PaywallWebViewValue]? { - get - } - public var objectValue: [Swift.String : RevenueCatUI.PaywallWebViewValue]? { - get - } - public var isNull: Swift.Bool { - get - } - public init(from decoder: any Swift.Decoder) throws - public func encode(to encoder: any Swift.Encoder) throws - public static func == (a: RevenueCatUI.PaywallWebViewValue, b: RevenueCatUI.PaywallWebViewValue) -> Swift.Bool - public func hash(into hasher: inout Swift.Hasher) - public var hashValue: Swift.Int { - get - } -} -@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) -@_Concurrency.MainActor public struct PaywallWebViewController { - @_Concurrency.MainActor public func postVariables(componentID: Swift.String, variables: [Swift.String : RevenueCatUI.PaywallWebViewValue]) - @_Concurrency.MainActor public func postMessage(componentID: Swift.String, type: Swift.String, variables: [Swift.String : RevenueCatUI.PaywallWebViewValue]) -} -@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) -public struct PaywallWebViewMessageAction { - public init(_ action: @escaping @_Concurrency.MainActor (RevenueCatUI.PaywallWebViewMessage, RevenueCatUI.PaywallWebViewController) -> Swift.Void) - @_Concurrency.MainActor public func callAsFunction(_ message: RevenueCatUI.PaywallWebViewMessage, _ controller: RevenueCatUI.PaywallWebViewController) -} -@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) -extension SwiftUICore.View { - @_Concurrency.MainActor @preconcurrency public func onPaywallWebViewMessage(_ action: @escaping @_Concurrency.MainActor (RevenueCatUI.PaywallWebViewMessage, RevenueCatUI.PaywallWebViewController) -> Swift.Void) -> some SwiftUICore.View - -} @_inheritsConvenienceInitializers @_hasMissingDesignatedInitializers @available(iOS 15.0, macOS 12.0, tvOS 15.0, *) @objc(RCPaywallFooterViewController) @_Concurrency.MainActor @preconcurrency final public class PaywallFooterViewController : RevenueCatUI.PaywallViewController { @_Concurrency.MainActor @preconcurrency public init(offering: RevenueCat.Offering? = nil, performPurchase: @escaping RevenueCatUI.PerformPurchase, performRestore: @escaping RevenueCatUI.PerformRestore, dismissRequestedHandler: ((_ controller: RevenueCatUI.PaywallViewController) -> Swift.Void)? = nil) @@ -606,7 +546,5 @@ extension RevenueCatUI.CustomerCenterNavigationLink : Swift.Sendable {} @available(iOS 15.0, macOS 12.0, watchOS 8.0, *) @available(tvOS, unavailable, message: "RevenueCatUI does not support tvOS yet") extension RevenueCatUI.PaywallView : Swift.Sendable {} -@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) -extension RevenueCatUI.PaywallWebViewController : Swift.Sendable {} extension RevenueCatUI.PaywallPresentationMode : Swift.Equatable {} extension RevenueCatUI.PaywallPresentationMode : Swift.Hashable {} diff --git a/api/revenuecatui-api-macos.swiftinterface b/api/revenuecatui-api-macos.swiftinterface index d7553c3e37..e64db71c52 100644 --- a/api/revenuecatui-api-macos.swiftinterface +++ b/api/revenuecatui-api-macos.swiftinterface @@ -168,66 +168,6 @@ open class CustomPaywallFontProvider : RevenueCatUI.PaywallFontProvider { @available(tvOS, unavailable, introduced: 15.0, message: "RevenueCatUI does not support tvOS yet") public typealias Body = @_opaqueReturnTypeOf("$s12RevenueCatUI11PaywallViewV4bodyQrvp", 0) __ } -@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) -public struct PaywallWebViewMessage : Swift.Sendable, Swift.Equatable { - public let componentID: Swift.String - public let type: Swift.String - public let responses: [Swift.String : RevenueCatUI.PaywallWebViewValue]? - public let error: Swift.String? - public init(componentID: Swift.String, type: Swift.String, responses: [Swift.String : RevenueCatUI.PaywallWebViewValue]? = nil, error: Swift.String? = nil) - public static func == (a: RevenueCatUI.PaywallWebViewMessage, b: RevenueCatUI.PaywallWebViewMessage) -> Swift.Bool -} -@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) -public struct PaywallWebViewValue : Swift.Sendable, Swift.Equatable, Swift.Hashable, Swift.Codable { - public static func string(_ value: Swift.String) -> RevenueCatUI.PaywallWebViewValue - public static func number(_ value: Swift.Double) -> RevenueCatUI.PaywallWebViewValue - public static func bool(_ value: Swift.Bool) -> RevenueCatUI.PaywallWebViewValue - public static func array(_ value: [RevenueCatUI.PaywallWebViewValue]) -> RevenueCatUI.PaywallWebViewValue - public static func object(_ value: [Swift.String : RevenueCatUI.PaywallWebViewValue]) -> RevenueCatUI.PaywallWebViewValue - public static var null: RevenueCatUI.PaywallWebViewValue { - get - } - public var stringValue: Swift.String? { - get - } - public var numberValue: Swift.Double? { - get - } - public var boolValue: Swift.Bool? { - get - } - public var arrayValue: [RevenueCatUI.PaywallWebViewValue]? { - get - } - public var objectValue: [Swift.String : RevenueCatUI.PaywallWebViewValue]? { - get - } - public var isNull: Swift.Bool { - get - } - public init(from decoder: any Swift.Decoder) throws - public func encode(to encoder: any Swift.Encoder) throws - public static func == (a: RevenueCatUI.PaywallWebViewValue, b: RevenueCatUI.PaywallWebViewValue) -> Swift.Bool - public func hash(into hasher: inout Swift.Hasher) - public var hashValue: Swift.Int { - get - } -} -@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) -@_Concurrency.MainActor public struct PaywallWebViewController { - @_Concurrency.MainActor public func postVariables(componentID: Swift.String, variables: [Swift.String : RevenueCatUI.PaywallWebViewValue]) - @_Concurrency.MainActor public func postMessage(componentID: Swift.String, type: Swift.String, variables: [Swift.String : RevenueCatUI.PaywallWebViewValue]) -} -@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) -public struct PaywallWebViewMessageAction { - public init(_ action: @escaping @_Concurrency.MainActor (RevenueCatUI.PaywallWebViewMessage, RevenueCatUI.PaywallWebViewController) -> Swift.Void) - @_Concurrency.MainActor public func callAsFunction(_ message: RevenueCatUI.PaywallWebViewMessage, _ controller: RevenueCatUI.PaywallWebViewController) -} -@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) -extension SwiftUICore.View { - @_Concurrency.MainActor @preconcurrency public func onPaywallWebViewMessage(_ action: @escaping @_Concurrency.MainActor (RevenueCatUI.PaywallWebViewMessage, RevenueCatUI.PaywallWebViewController) -> Swift.Void) -> some SwiftUICore.View - -} @available(iOS 14.0, macOS 11.0, tvOS 14.0, watchOS 7.0, *) extension SwiftUICore.View { @_Concurrency.MainActor @preconcurrency public func onWebPurchaseRedemptionAttempt(perform completion: @escaping @Sendable (RevenueCat.WebPurchaseRedemptionResult) -> Swift.Void) -> some SwiftUICore.View @@ -384,7 +324,5 @@ extension SwiftUICore.View { @available(iOS 15.0, macOS 12.0, watchOS 8.0, *) @available(tvOS, unavailable, message: "RevenueCatUI does not support tvOS yet") extension RevenueCatUI.PaywallView : Swift.Sendable {} -@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) -extension RevenueCatUI.PaywallWebViewController : Swift.Sendable {} extension RevenueCatUI.PaywallPresentationMode : Swift.Equatable {} extension RevenueCatUI.PaywallPresentationMode : Swift.Hashable {} diff --git a/api/revenuecatui-api-visionos-simulator.swiftinterface b/api/revenuecatui-api-visionos-simulator.swiftinterface index 7369428ec5..b87d2b82d9 100644 --- a/api/revenuecatui-api-visionos-simulator.swiftinterface +++ b/api/revenuecatui-api-visionos-simulator.swiftinterface @@ -179,66 +179,6 @@ extension RevenueCat.Purchases { @available(iOS 15.0, macOS 12.0, *) @_Concurrency.MainActor @objc final public func presentPaywall(from url: Foundation.URL) -> Swift.Bool } -@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) -public struct PaywallWebViewMessage : Swift.Sendable, Swift.Equatable { - public let componentID: Swift.String - public let type: Swift.String - public let responses: [Swift.String : RevenueCatUI.PaywallWebViewValue]? - public let error: Swift.String? - public init(componentID: Swift.String, type: Swift.String, responses: [Swift.String : RevenueCatUI.PaywallWebViewValue]? = nil, error: Swift.String? = nil) - public static func == (a: RevenueCatUI.PaywallWebViewMessage, b: RevenueCatUI.PaywallWebViewMessage) -> Swift.Bool -} -@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) -public struct PaywallWebViewValue : Swift.Sendable, Swift.Equatable, Swift.Hashable, Swift.Codable { - public static func string(_ value: Swift.String) -> RevenueCatUI.PaywallWebViewValue - public static func number(_ value: Swift.Double) -> RevenueCatUI.PaywallWebViewValue - public static func bool(_ value: Swift.Bool) -> RevenueCatUI.PaywallWebViewValue - public static func array(_ value: [RevenueCatUI.PaywallWebViewValue]) -> RevenueCatUI.PaywallWebViewValue - public static func object(_ value: [Swift.String : RevenueCatUI.PaywallWebViewValue]) -> RevenueCatUI.PaywallWebViewValue - public static var null: RevenueCatUI.PaywallWebViewValue { - get - } - public var stringValue: Swift.String? { - get - } - public var numberValue: Swift.Double? { - get - } - public var boolValue: Swift.Bool? { - get - } - public var arrayValue: [RevenueCatUI.PaywallWebViewValue]? { - get - } - public var objectValue: [Swift.String : RevenueCatUI.PaywallWebViewValue]? { - get - } - public var isNull: Swift.Bool { - get - } - public init(from decoder: any Swift.Decoder) throws - public func encode(to encoder: any Swift.Encoder) throws - public static func == (a: RevenueCatUI.PaywallWebViewValue, b: RevenueCatUI.PaywallWebViewValue) -> Swift.Bool - public func hash(into hasher: inout Swift.Hasher) - public var hashValue: Swift.Int { - get - } -} -@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) -@_Concurrency.MainActor public struct PaywallWebViewController { - @_Concurrency.MainActor public func postVariables(componentID: Swift.String, variables: [Swift.String : RevenueCatUI.PaywallWebViewValue]) - @_Concurrency.MainActor public func postMessage(componentID: Swift.String, type: Swift.String, variables: [Swift.String : RevenueCatUI.PaywallWebViewValue]) -} -@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) -public struct PaywallWebViewMessageAction { - public init(_ action: @escaping @_Concurrency.MainActor (RevenueCatUI.PaywallWebViewMessage, RevenueCatUI.PaywallWebViewController) -> Swift.Void) - @_Concurrency.MainActor public func callAsFunction(_ message: RevenueCatUI.PaywallWebViewMessage, _ controller: RevenueCatUI.PaywallWebViewController) -} -@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) -extension SwiftUICore.View { - @_Concurrency.MainActor @preconcurrency public func onPaywallWebViewMessage(_ action: @escaping @_Concurrency.MainActor (RevenueCatUI.PaywallWebViewMessage, RevenueCatUI.PaywallWebViewController) -> Swift.Void) -> some SwiftUICore.View - -} @_inheritsConvenienceInitializers @_hasMissingDesignatedInitializers @available(iOS 15.0, macOS 12.0, tvOS 15.0, *) @objc(RCPaywallFooterViewController) @_Concurrency.MainActor @preconcurrency final public class PaywallFooterViewController : RevenueCatUI.PaywallViewController { @_Concurrency.MainActor @preconcurrency public init(offering: RevenueCat.Offering? = nil, performPurchase: @escaping RevenueCatUI.PerformPurchase, performRestore: @escaping RevenueCatUI.PerformRestore, dismissRequestedHandler: ((_ controller: RevenueCatUI.PaywallViewController) -> Swift.Void)? = nil) @@ -466,7 +406,5 @@ extension SwiftUICore.View { @available(iOS 15.0, macOS 12.0, watchOS 8.0, *) @available(tvOS, unavailable, message: "RevenueCatUI does not support tvOS yet") extension RevenueCatUI.PaywallView : Swift.Sendable {} -@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) -extension RevenueCatUI.PaywallWebViewController : Swift.Sendable {} extension RevenueCatUI.PaywallPresentationMode : Swift.Equatable {} extension RevenueCatUI.PaywallPresentationMode : Swift.Hashable {} diff --git a/api/revenuecatui-api-visionos.swiftinterface b/api/revenuecatui-api-visionos.swiftinterface index cccba66c64..ec8d35ca1f 100644 --- a/api/revenuecatui-api-visionos.swiftinterface +++ b/api/revenuecatui-api-visionos.swiftinterface @@ -179,66 +179,6 @@ extension RevenueCat.Purchases { @available(iOS 15.0, macOS 12.0, *) @_Concurrency.MainActor @objc final public func presentPaywall(from url: Foundation.URL) -> Swift.Bool } -@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) -public struct PaywallWebViewMessage : Swift.Sendable, Swift.Equatable { - public let componentID: Swift.String - public let type: Swift.String - public let responses: [Swift.String : RevenueCatUI.PaywallWebViewValue]? - public let error: Swift.String? - public init(componentID: Swift.String, type: Swift.String, responses: [Swift.String : RevenueCatUI.PaywallWebViewValue]? = nil, error: Swift.String? = nil) - public static func == (a: RevenueCatUI.PaywallWebViewMessage, b: RevenueCatUI.PaywallWebViewMessage) -> Swift.Bool -} -@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) -public struct PaywallWebViewValue : Swift.Sendable, Swift.Equatable, Swift.Hashable, Swift.Codable { - public static func string(_ value: Swift.String) -> RevenueCatUI.PaywallWebViewValue - public static func number(_ value: Swift.Double) -> RevenueCatUI.PaywallWebViewValue - public static func bool(_ value: Swift.Bool) -> RevenueCatUI.PaywallWebViewValue - public static func array(_ value: [RevenueCatUI.PaywallWebViewValue]) -> RevenueCatUI.PaywallWebViewValue - public static func object(_ value: [Swift.String : RevenueCatUI.PaywallWebViewValue]) -> RevenueCatUI.PaywallWebViewValue - public static var null: RevenueCatUI.PaywallWebViewValue { - get - } - public var stringValue: Swift.String? { - get - } - public var numberValue: Swift.Double? { - get - } - public var boolValue: Swift.Bool? { - get - } - public var arrayValue: [RevenueCatUI.PaywallWebViewValue]? { - get - } - public var objectValue: [Swift.String : RevenueCatUI.PaywallWebViewValue]? { - get - } - public var isNull: Swift.Bool { - get - } - public init(from decoder: any Swift.Decoder) throws - public func encode(to encoder: any Swift.Encoder) throws - public static func == (a: RevenueCatUI.PaywallWebViewValue, b: RevenueCatUI.PaywallWebViewValue) -> Swift.Bool - public func hash(into hasher: inout Swift.Hasher) - public var hashValue: Swift.Int { - get - } -} -@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) -@_Concurrency.MainActor public struct PaywallWebViewController { - @_Concurrency.MainActor public func postVariables(componentID: Swift.String, variables: [Swift.String : RevenueCatUI.PaywallWebViewValue]) - @_Concurrency.MainActor public func postMessage(componentID: Swift.String, type: Swift.String, variables: [Swift.String : RevenueCatUI.PaywallWebViewValue]) -} -@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) -public struct PaywallWebViewMessageAction { - public init(_ action: @escaping @_Concurrency.MainActor (RevenueCatUI.PaywallWebViewMessage, RevenueCatUI.PaywallWebViewController) -> Swift.Void) - @_Concurrency.MainActor public func callAsFunction(_ message: RevenueCatUI.PaywallWebViewMessage, _ controller: RevenueCatUI.PaywallWebViewController) -} -@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) -extension SwiftUICore.View { - @_Concurrency.MainActor @preconcurrency public func onPaywallWebViewMessage(_ action: @escaping @_Concurrency.MainActor (RevenueCatUI.PaywallWebViewMessage, RevenueCatUI.PaywallWebViewController) -> Swift.Void) -> some SwiftUICore.View - -} @_inheritsConvenienceInitializers @_hasMissingDesignatedInitializers @available(iOS 15.0, macOS 12.0, tvOS 15.0, *) @objc(RCPaywallFooterViewController) @_Concurrency.MainActor @preconcurrency final public class PaywallFooterViewController : RevenueCatUI.PaywallViewController { @_Concurrency.MainActor @preconcurrency public init(offering: RevenueCat.Offering? = nil, performPurchase: @escaping RevenueCatUI.PerformPurchase, performRestore: @escaping RevenueCatUI.PerformRestore, dismissRequestedHandler: ((_ controller: RevenueCatUI.PaywallViewController) -> Swift.Void)? = nil) @@ -466,7 +406,5 @@ extension SwiftUICore.View { @available(iOS 15.0, macOS 12.0, watchOS 8.0, *) @available(tvOS, unavailable, message: "RevenueCatUI does not support tvOS yet") extension RevenueCatUI.PaywallView : Swift.Sendable {} -@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) -extension RevenueCatUI.PaywallWebViewController : Swift.Sendable {} extension RevenueCatUI.PaywallPresentationMode : Swift.Equatable {} extension RevenueCatUI.PaywallPresentationMode : Swift.Hashable {} diff --git a/api/revenuecatui-api-watchos-simulator.swiftinterface b/api/revenuecatui-api-watchos-simulator.swiftinterface index 7038282c5d..03d128c761 100644 --- a/api/revenuecatui-api-watchos-simulator.swiftinterface +++ b/api/revenuecatui-api-watchos-simulator.swiftinterface @@ -166,66 +166,6 @@ open class CustomPaywallFontProvider : RevenueCatUI.PaywallFontProvider { @available(tvOS, unavailable, introduced: 15.0, message: "RevenueCatUI does not support tvOS yet") public typealias Body = @_opaqueReturnTypeOf("$s12RevenueCatUI11PaywallViewV4bodyQrvp", 0) __ } -@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) -public struct PaywallWebViewMessage : Swift.Sendable, Swift.Equatable { - public let componentID: Swift.String - public let type: Swift.String - public let responses: [Swift.String : RevenueCatUI.PaywallWebViewValue]? - public let error: Swift.String? - public init(componentID: Swift.String, type: Swift.String, responses: [Swift.String : RevenueCatUI.PaywallWebViewValue]? = nil, error: Swift.String? = nil) - public static func == (a: RevenueCatUI.PaywallWebViewMessage, b: RevenueCatUI.PaywallWebViewMessage) -> Swift.Bool -} -@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) -public struct PaywallWebViewValue : Swift.Sendable, Swift.Equatable, Swift.Hashable, Swift.Codable { - public static func string(_ value: Swift.String) -> RevenueCatUI.PaywallWebViewValue - public static func number(_ value: Swift.Double) -> RevenueCatUI.PaywallWebViewValue - public static func bool(_ value: Swift.Bool) -> RevenueCatUI.PaywallWebViewValue - public static func array(_ value: [RevenueCatUI.PaywallWebViewValue]) -> RevenueCatUI.PaywallWebViewValue - public static func object(_ value: [Swift.String : RevenueCatUI.PaywallWebViewValue]) -> RevenueCatUI.PaywallWebViewValue - public static var null: RevenueCatUI.PaywallWebViewValue { - get - } - public var stringValue: Swift.String? { - get - } - public var numberValue: Swift.Double? { - get - } - public var boolValue: Swift.Bool? { - get - } - public var arrayValue: [RevenueCatUI.PaywallWebViewValue]? { - get - } - public var objectValue: [Swift.String : RevenueCatUI.PaywallWebViewValue]? { - get - } - public var isNull: Swift.Bool { - get - } - public init(from decoder: any Swift.Decoder) throws - public func encode(to encoder: any Swift.Encoder) throws - public static func == (a: RevenueCatUI.PaywallWebViewValue, b: RevenueCatUI.PaywallWebViewValue) -> Swift.Bool - public func hash(into hasher: inout Swift.Hasher) - public var hashValue: Swift.Int { - get - } -} -@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) -@_Concurrency.MainActor public struct PaywallWebViewController { - @_Concurrency.MainActor public func postVariables(componentID: Swift.String, variables: [Swift.String : RevenueCatUI.PaywallWebViewValue]) - @_Concurrency.MainActor public func postMessage(componentID: Swift.String, type: Swift.String, variables: [Swift.String : RevenueCatUI.PaywallWebViewValue]) -} -@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) -public struct PaywallWebViewMessageAction { - public init(_ action: @escaping @_Concurrency.MainActor (RevenueCatUI.PaywallWebViewMessage, RevenueCatUI.PaywallWebViewController) -> Swift.Void) - @_Concurrency.MainActor public func callAsFunction(_ message: RevenueCatUI.PaywallWebViewMessage, _ controller: RevenueCatUI.PaywallWebViewController) -} -@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) -extension SwiftUICore.View { - @_Concurrency.MainActor @preconcurrency public func onPaywallWebViewMessage(_ action: @escaping @_Concurrency.MainActor (RevenueCatUI.PaywallWebViewMessage, RevenueCatUI.PaywallWebViewController) -> Swift.Void) -> some SwiftUICore.View - -} @available(iOS 14.0, macOS 11.0, tvOS 14.0, watchOS 7.0, *) extension SwiftUICore.View { @_Concurrency.MainActor @preconcurrency public func onWebPurchaseRedemptionAttempt(perform completion: @escaping @Sendable (RevenueCat.WebPurchaseRedemptionResult) -> Swift.Void) -> some SwiftUICore.View @@ -339,7 +279,5 @@ extension SwiftUICore.View { @available(iOS 15.0, macOS 12.0, watchOS 8.0, *) @available(tvOS, unavailable, message: "RevenueCatUI does not support tvOS yet") extension RevenueCatUI.PaywallView : Swift.Sendable {} -@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) -extension RevenueCatUI.PaywallWebViewController : Swift.Sendable {} extension RevenueCatUI.PaywallPresentationMode : Swift.Equatable {} extension RevenueCatUI.PaywallPresentationMode : Swift.Hashable {} diff --git a/api/revenuecatui-api-watchos.swiftinterface b/api/revenuecatui-api-watchos.swiftinterface index 0490cf9e59..39b2ed3084 100644 --- a/api/revenuecatui-api-watchos.swiftinterface +++ b/api/revenuecatui-api-watchos.swiftinterface @@ -166,66 +166,6 @@ open class CustomPaywallFontProvider : RevenueCatUI.PaywallFontProvider { @available(tvOS, unavailable, introduced: 15.0, message: "RevenueCatUI does not support tvOS yet") public typealias Body = @_opaqueReturnTypeOf("$s12RevenueCatUI11PaywallViewV4bodyQrvp", 0) __ } -@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) -public struct PaywallWebViewMessage : Swift.Sendable, Swift.Equatable { - public let componentID: Swift.String - public let type: Swift.String - public let responses: [Swift.String : RevenueCatUI.PaywallWebViewValue]? - public let error: Swift.String? - public init(componentID: Swift.String, type: Swift.String, responses: [Swift.String : RevenueCatUI.PaywallWebViewValue]? = nil, error: Swift.String? = nil) - public static func == (a: RevenueCatUI.PaywallWebViewMessage, b: RevenueCatUI.PaywallWebViewMessage) -> Swift.Bool -} -@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) -public struct PaywallWebViewValue : Swift.Sendable, Swift.Equatable, Swift.Hashable, Swift.Codable { - public static func string(_ value: Swift.String) -> RevenueCatUI.PaywallWebViewValue - public static func number(_ value: Swift.Double) -> RevenueCatUI.PaywallWebViewValue - public static func bool(_ value: Swift.Bool) -> RevenueCatUI.PaywallWebViewValue - public static func array(_ value: [RevenueCatUI.PaywallWebViewValue]) -> RevenueCatUI.PaywallWebViewValue - public static func object(_ value: [Swift.String : RevenueCatUI.PaywallWebViewValue]) -> RevenueCatUI.PaywallWebViewValue - public static var null: RevenueCatUI.PaywallWebViewValue { - get - } - public var stringValue: Swift.String? { - get - } - public var numberValue: Swift.Double? { - get - } - public var boolValue: Swift.Bool? { - get - } - public var arrayValue: [RevenueCatUI.PaywallWebViewValue]? { - get - } - public var objectValue: [Swift.String : RevenueCatUI.PaywallWebViewValue]? { - get - } - public var isNull: Swift.Bool { - get - } - public init(from decoder: any Swift.Decoder) throws - public func encode(to encoder: any Swift.Encoder) throws - public static func == (a: RevenueCatUI.PaywallWebViewValue, b: RevenueCatUI.PaywallWebViewValue) -> Swift.Bool - public func hash(into hasher: inout Swift.Hasher) - public var hashValue: Swift.Int { - get - } -} -@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) -@_Concurrency.MainActor public struct PaywallWebViewController { - @_Concurrency.MainActor public func postVariables(componentID: Swift.String, variables: [Swift.String : RevenueCatUI.PaywallWebViewValue]) - @_Concurrency.MainActor public func postMessage(componentID: Swift.String, type: Swift.String, variables: [Swift.String : RevenueCatUI.PaywallWebViewValue]) -} -@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) -public struct PaywallWebViewMessageAction { - public init(_ action: @escaping @_Concurrency.MainActor (RevenueCatUI.PaywallWebViewMessage, RevenueCatUI.PaywallWebViewController) -> Swift.Void) - @_Concurrency.MainActor public func callAsFunction(_ message: RevenueCatUI.PaywallWebViewMessage, _ controller: RevenueCatUI.PaywallWebViewController) -} -@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) -extension SwiftUICore.View { - @_Concurrency.MainActor @preconcurrency public func onPaywallWebViewMessage(_ action: @escaping @_Concurrency.MainActor (RevenueCatUI.PaywallWebViewMessage, RevenueCatUI.PaywallWebViewController) -> Swift.Void) -> some SwiftUICore.View - -} @available(iOS 14.0, macOS 11.0, tvOS 14.0, watchOS 7.0, *) extension SwiftUICore.View { @_Concurrency.MainActor @preconcurrency public func onWebPurchaseRedemptionAttempt(perform completion: @escaping @Sendable (RevenueCat.WebPurchaseRedemptionResult) -> Swift.Void) -> some SwiftUICore.View @@ -339,7 +279,5 @@ extension SwiftUICore.View { @available(iOS 15.0, macOS 12.0, watchOS 8.0, *) @available(tvOS, unavailable, message: "RevenueCatUI does not support tvOS yet") extension RevenueCatUI.PaywallView : Swift.Sendable {} -@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) -extension RevenueCatUI.PaywallWebViewController : Swift.Sendable {} extension RevenueCatUI.PaywallPresentationMode : Swift.Equatable {} extension RevenueCatUI.PaywallPresentationMode : Swift.Hashable {} From b5caeda251faab8958357a571661a2c0474dfb19 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 16 Jul 2026 20:01:56 +0000 Subject: [PATCH 13/35] refactor(paywalls): keep web view bridge internal Co-authored-by: Jacob Rakidzich --- .../WebView/PaywallWebViewAPI.swift | 57 +++++++------- .../AllAPITests.xcodeproj/project.pbxproj | 4 - .../PaywallWebViewAPI.swift | 77 ------------------- ...enuecatui-api-ios-simulator.swiftinterface | 62 --------------- api/revenuecatui-api-ios.swiftinterface | 62 --------------- api/revenuecatui-api-macos.swiftinterface | 62 --------------- ...atui-api-visionos-simulator.swiftinterface | 62 --------------- api/revenuecatui-api-visionos.swiftinterface | 62 --------------- ...catui-api-watchos-simulator.swiftinterface | 62 --------------- api/revenuecatui-api-watchos.swiftinterface | 62 --------------- 10 files changed, 28 insertions(+), 544 deletions(-) delete mode 100644 Tests/APITesters/AllAPITests/RevenueCatUISwiftAPITester/PaywallWebViewAPI.swift diff --git a/RevenueCatUI/Templates/V2/Components/WebView/PaywallWebViewAPI.swift b/RevenueCatUI/Templates/V2/Components/WebView/PaywallWebViewAPI.swift index 94265d2bb1..01a717ab1b 100644 --- a/RevenueCatUI/Templates/V2/Components/WebView/PaywallWebViewAPI.swift +++ b/RevenueCatUI/Templates/V2/Components/WebView/PaywallWebViewAPI.swift @@ -1,26 +1,25 @@ import Foundation import SwiftUI -// swiftlint:disable missing_docs #if !os(tvOS) // For Paywalls V2 /// A validated message sent from a Paywalls V2 `web_view` component to your app. @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) -public struct PaywallWebViewMessage: Sendable, Equatable { +struct PaywallWebViewMessage: Sendable, Equatable { /// The identifier of the `web_view` component that produced this message. - public let componentID: String + let componentID: String /// The message type, e.g. `"rc:step-complete"`. - public let type: String + let type: String /// The responses collected by the web flow. Only populated for `"rc:step-complete"` messages. - public let responses: [String: PaywallWebViewValue]? + let responses: [String: PaywallWebViewValue]? /// The error reported by the web content. Only populated for `"rc:error"` messages. - public let error: String? + let error: String? - public init( + init( componentID: String, type: String, responses: [String: PaywallWebViewValue]? = nil, @@ -36,7 +35,7 @@ public struct PaywallWebViewMessage: Sendable, Equatable { /// A JSON-compatible value exchanged between a Paywalls V2 `web_view` component and your app. @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) -public struct PaywallWebViewValue: Sendable, Equatable, Hashable, Codable { +struct PaywallWebViewValue: Sendable, Equatable, Hashable, Codable { private indirect enum Storage: Sendable, Equatable, Hashable { case string(String) @@ -54,54 +53,54 @@ public struct PaywallWebViewValue: Sendable, Equatable, Hashable, Codable { } /// Creates a string value. - public static func string(_ value: String) -> Self { Self(.string(value)) } + static func string(_ value: String) -> Self { Self(.string(value)) } /// Creates a numeric value. Non-finite values normalize to ``null`` because JSON cannot encode them. - public static func number(_ value: Double) -> Self { value.isFinite ? Self(.number(value)) : .null } + static func number(_ value: Double) -> Self { value.isFinite ? Self(.number(value)) : .null } /// Creates a boolean value. - public static func bool(_ value: Bool) -> Self { Self(.bool(value)) } + static func bool(_ value: Bool) -> Self { Self(.bool(value)) } /// Creates an array value. - public static func array(_ value: [PaywallWebViewValue]) -> Self { Self(.array(value)) } + static func array(_ value: [PaywallWebViewValue]) -> Self { Self(.array(value)) } /// Creates an object (dictionary) value. - public static func object(_ value: [String: PaywallWebViewValue]) -> Self { Self(.object(value)) } + static func object(_ value: [String: PaywallWebViewValue]) -> Self { Self(.object(value)) } /// A null value. - public static var null: Self { Self(.null) } + static var null: Self { Self(.null) } - public var stringValue: String? { + var stringValue: String? { if case .string(let value) = self.storage { return value } return nil } - public var numberValue: Double? { + var numberValue: Double? { if case .number(let value) = self.storage { return value } return nil } - public var boolValue: Bool? { + var boolValue: Bool? { if case .bool(let value) = self.storage { return value } return nil } - public var arrayValue: [PaywallWebViewValue]? { + var arrayValue: [PaywallWebViewValue]? { if case .array(let value) = self.storage { return value } return nil } - public var objectValue: [String: PaywallWebViewValue]? { + var objectValue: [String: PaywallWebViewValue]? { if case .object(let value) = self.storage { return value } return nil } - public var isNull: Bool { + var isNull: Bool { if case .null = self.storage { return true } return false } - public init(from decoder: Decoder) throws { + init(from decoder: Decoder) throws { let container = try decoder.singleValueContainer() if container.decodeNil() { @@ -119,7 +118,7 @@ public struct PaywallWebViewValue: Sendable, Equatable, Hashable, Codable { } } - public func encode(to encoder: Encoder) throws { + func encode(to encoder: Encoder) throws { var container = encoder.singleValueContainer() switch self.storage { @@ -142,7 +141,7 @@ public struct PaywallWebViewValue: Sendable, Equatable, Hashable, Codable { @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) @MainActor -public struct PaywallWebViewController { +struct PaywallWebViewController { #if canImport(WebKit) weak var session: WebViewSession? @@ -156,13 +155,13 @@ public struct PaywallWebViewController { } #endif - public func postVariables(componentID: String, variables: [String: PaywallWebViewValue]) { + func postVariables(componentID: String, variables: [String: PaywallWebViewValue]) { #if canImport(WebKit) self.session?.postVariables(componentID: componentID, variables: variables) #endif } - public func postMessage(componentID: String, type: String, variables: [String: PaywallWebViewValue]) { + func postMessage(componentID: String, type: String, variables: [String: PaywallWebViewValue]) { #if canImport(WebKit) self.session?.post(componentID: componentID, type: type, variables: variables) #endif @@ -172,18 +171,18 @@ public struct PaywallWebViewController { /// A wrapper for the Paywalls V2 `web_view` message handler. @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) -public struct PaywallWebViewMessageAction { +struct PaywallWebViewMessageAction { private let action: @MainActor (PaywallWebViewMessage, PaywallWebViewController) -> Void - public init( + init( _ action: @escaping @MainActor (PaywallWebViewMessage, PaywallWebViewController) -> Void ) { self.action = action } @MainActor - public func callAsFunction(_ message: PaywallWebViewMessage, _ controller: PaywallWebViewController) { + func callAsFunction(_ message: PaywallWebViewMessage, _ controller: PaywallWebViewController) { self.action(message, controller) } @@ -208,7 +207,7 @@ extension EnvironmentValues { extension View { /// Invokes the given closure when a Paywalls V2 `web_view` component sends a message. - public func onPaywallWebViewMessage( + func onPaywallWebViewMessage( _ action: @escaping @MainActor (PaywallWebViewMessage, PaywallWebViewController) -> Void ) -> some View { self.environment(\.paywallWebViewMessageAction, PaywallWebViewMessageAction(action)) diff --git a/Tests/APITesters/AllAPITests/AllAPITests.xcodeproj/project.pbxproj b/Tests/APITesters/AllAPITests/AllAPITests.xcodeproj/project.pbxproj index 74a25fd257..9cc8116989 100644 --- a/Tests/APITesters/AllAPITests/AllAPITests.xcodeproj/project.pbxproj +++ b/Tests/APITesters/AllAPITests/AllAPITests.xcodeproj/project.pbxproj @@ -8,7 +8,6 @@ /* Begin PBXBuildFile section */ 1E8A60812CDCF29D0034ACF3 /* RedeemWebPurchasesAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E8A60802CDCF29D0034ACF3 /* RedeemWebPurchasesAPI.swift */; }; - B096F58208E0AA6582F34133 /* PaywallWebViewAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = B0DC08831A62751743D823FA /* PaywallWebViewAPI.swift */; }; 1EB697C22CD26218003000FC /* WebPurchaseRedemptionResultAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1EB697C12CD26218003000FC /* WebPurchaseRedemptionResultAPI.swift */; }; 2D4C60E12C5AD1BC00A29FD2 /* AllAPITests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 2D4C60D82C5AD1BC00A29FD2 /* AllAPITests.framework */; }; 2D4C60E62C5AD1BC00A29FD2 /* AllAPITestsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D4C60E52C5AD1BC00A29FD2 /* AllAPITestsTests.swift */; }; @@ -229,7 +228,6 @@ /* Begin PBXFileReference section */ 1E8A60802CDCF29D0034ACF3 /* RedeemWebPurchasesAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RedeemWebPurchasesAPI.swift; sourceTree = ""; }; - B0DC08831A62751743D823FA /* PaywallWebViewAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaywallWebViewAPI.swift; sourceTree = ""; }; 1EB697C12CD26218003000FC /* WebPurchaseRedemptionResultAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebPurchaseRedemptionResultAPI.swift; sourceTree = ""; }; 2D4C60D82C5AD1BC00A29FD2 /* AllAPITests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = AllAPITests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 2D4C60DB2C5AD1BC00A29FD2 /* AllAPITests.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = AllAPITests.h; sourceTree = ""; }; @@ -647,7 +645,6 @@ children = ( FD33CD722D035876000D13A4 /* CustomerCenterViewControllerAPI.swift */, 2D4C622D2C5ADA6700A29FD2 /* PaywallViewAPI.swift */, - B0DC08831A62751743D823FA /* PaywallWebViewAPI.swift */, 1E8A60802CDCF29D0034ACF3 /* RedeemWebPurchasesAPI.swift */, 2D4C622B2C5ADA6700A29FD2 /* PaywallViewControllerAPI.swift */, 2D4C622C2C5ADA6700A29FD2 /* main.swift */, @@ -1122,7 +1119,6 @@ FDCC983A2D0A0FD500F5854B /* CustomerCenterViewAPI.swift in Sources */, 2D4C62302C5ADA6700A29FD2 /* PaywallViewControllerAPI.swift in Sources */, 1E8A60812CDCF29D0034ACF3 /* RedeemWebPurchasesAPI.swift in Sources */, - B096F58208E0AA6582F34133 /* PaywallWebViewAPI.swift in Sources */, 2D4C622E2C5ADA6700A29FD2 /* PaywallViewAPI.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/Tests/APITesters/AllAPITests/RevenueCatUISwiftAPITester/PaywallWebViewAPI.swift b/Tests/APITesters/AllAPITests/RevenueCatUISwiftAPITester/PaywallWebViewAPI.swift deleted file mode 100644 index a8a2986ca9..0000000000 --- a/Tests/APITesters/AllAPITests/RevenueCatUISwiftAPITester/PaywallWebViewAPI.swift +++ /dev/null @@ -1,77 +0,0 @@ -// -// PaywallWebViewAPI.swift -// RevenueCatUISwiftAPITester -// -// Compile-time checks for the Paywalls V2 web_view public API surface. -// - -import RevenueCat -import RevenueCatUI -import SwiftUI - -#if !os(tvOS) // For Paywalls V2 - -@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) -struct PaywallWebViewApp: View { - - var body: some View { - Text("") - .onPaywallWebViewMessage { (message: PaywallWebViewMessage, controller: PaywallWebViewController) in - let componentID: String = message.componentID - let type: String = message.type - let responses: [String: PaywallWebViewValue]? = message.responses - let error: String? = message.error - - controller.postVariables( - componentID: componentID, - variables: ["custom": .object(["plan": .string("annual")])] - ) - controller.postMessage(componentID: componentID, type: type, variables: [:]) - - _ = (responses, error) - } - } - -} - -@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) -func checkPaywallWebViewMessageAPI() { - let message = PaywallWebViewMessage( - componentID: "", - type: "rc:step-complete", - responses: ["selected_plan": .string("annual")], - error: nil - ) - let sameMessage: Bool = message == message - _ = sameMessage -} - -@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) -func checkPaywallWebViewValueAPI() { - let string: PaywallWebViewValue = .string("s") - let number: PaywallWebViewValue = .number(1.5) - let bool: PaywallWebViewValue = .bool(true) - let array: PaywallWebViewValue = .array([string, number]) - let object: PaywallWebViewValue = .object(["key": bool]) - let null: PaywallWebViewValue = .null - - let _: String? = string.stringValue - let _: Double? = number.numberValue - let _: Bool? = bool.boolValue - let _: [PaywallWebViewValue]? = array.arrayValue - let _: [String: PaywallWebViewValue]? = object.objectValue - let _: Bool = null.isNull - let _: Set = [string, number] -} - -@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) -@MainActor -func checkPaywallWebViewMessageActionAPI( - message: PaywallWebViewMessage, - controller: PaywallWebViewController -) { - let action = PaywallWebViewMessageAction { (_: PaywallWebViewMessage, _: PaywallWebViewController) in } - action(message, controller) -} - -#endif diff --git a/api/revenuecatui-api-ios-simulator.swiftinterface b/api/revenuecatui-api-ios-simulator.swiftinterface index 428cfba3b0..30bbb7549c 100644 --- a/api/revenuecatui-api-ios-simulator.swiftinterface +++ b/api/revenuecatui-api-ios-simulator.swiftinterface @@ -309,66 +309,6 @@ extension RevenueCat.Purchases { @available(iOS 15.0, macOS 12.0, *) @_Concurrency.MainActor @objc final public func presentPaywall(from url: Foundation.URL) -> Swift.Bool } -@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) -public struct PaywallWebViewMessage : Swift.Sendable, Swift.Equatable { - public let componentID: Swift.String - public let type: Swift.String - public let responses: [Swift.String : RevenueCatUI.PaywallWebViewValue]? - public let error: Swift.String? - public init(componentID: Swift.String, type: Swift.String, responses: [Swift.String : RevenueCatUI.PaywallWebViewValue]? = nil, error: Swift.String? = nil) - public static func == (a: RevenueCatUI.PaywallWebViewMessage, b: RevenueCatUI.PaywallWebViewMessage) -> Swift.Bool -} -@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) -public struct PaywallWebViewValue : Swift.Sendable, Swift.Equatable, Swift.Hashable, Swift.Codable { - public static func string(_ value: Swift.String) -> RevenueCatUI.PaywallWebViewValue - public static func number(_ value: Swift.Double) -> RevenueCatUI.PaywallWebViewValue - public static func bool(_ value: Swift.Bool) -> RevenueCatUI.PaywallWebViewValue - public static func array(_ value: [RevenueCatUI.PaywallWebViewValue]) -> RevenueCatUI.PaywallWebViewValue - public static func object(_ value: [Swift.String : RevenueCatUI.PaywallWebViewValue]) -> RevenueCatUI.PaywallWebViewValue - public static var null: RevenueCatUI.PaywallWebViewValue { - get - } - public var stringValue: Swift.String? { - get - } - public var numberValue: Swift.Double? { - get - } - public var boolValue: Swift.Bool? { - get - } - public var arrayValue: [RevenueCatUI.PaywallWebViewValue]? { - get - } - public var objectValue: [Swift.String : RevenueCatUI.PaywallWebViewValue]? { - get - } - public var isNull: Swift.Bool { - get - } - public init(from decoder: any Swift.Decoder) throws - public func encode(to encoder: any Swift.Encoder) throws - public static func == (a: RevenueCatUI.PaywallWebViewValue, b: RevenueCatUI.PaywallWebViewValue) -> Swift.Bool - public func hash(into hasher: inout Swift.Hasher) - public var hashValue: Swift.Int { - get - } -} -@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) -@_Concurrency.MainActor public struct PaywallWebViewController { - @_Concurrency.MainActor public func postVariables(componentID: Swift.String, variables: [Swift.String : RevenueCatUI.PaywallWebViewValue]) - @_Concurrency.MainActor public func postMessage(componentID: Swift.String, type: Swift.String, variables: [Swift.String : RevenueCatUI.PaywallWebViewValue]) -} -@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) -public struct PaywallWebViewMessageAction { - public init(_ action: @escaping @_Concurrency.MainActor (RevenueCatUI.PaywallWebViewMessage, RevenueCatUI.PaywallWebViewController) -> Swift.Void) - @_Concurrency.MainActor public func callAsFunction(_ message: RevenueCatUI.PaywallWebViewMessage, _ controller: RevenueCatUI.PaywallWebViewController) -} -@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) -extension SwiftUICore.View { - @_Concurrency.MainActor @preconcurrency public func onPaywallWebViewMessage(_ action: @escaping @_Concurrency.MainActor (RevenueCatUI.PaywallWebViewMessage, RevenueCatUI.PaywallWebViewController) -> Swift.Void) -> some SwiftUICore.View - -} @_inheritsConvenienceInitializers @_hasMissingDesignatedInitializers @available(iOS 15.0, macOS 12.0, tvOS 15.0, *) @objc(RCPaywallFooterViewController) @_Concurrency.MainActor @preconcurrency final public class PaywallFooterViewController : RevenueCatUI.PaywallViewController { @_Concurrency.MainActor @preconcurrency public init(offering: RevenueCat.Offering? = nil, performPurchase: @escaping RevenueCatUI.PerformPurchase, performRestore: @escaping RevenueCatUI.PerformRestore, dismissRequestedHandler: ((_ controller: RevenueCatUI.PaywallViewController) -> Swift.Void)? = nil) @@ -606,7 +546,5 @@ extension RevenueCatUI.CustomerCenterNavigationLink : Swift.Sendable {} @available(iOS 15.0, macOS 12.0, watchOS 8.0, *) @available(tvOS, unavailable, message: "RevenueCatUI does not support tvOS yet") extension RevenueCatUI.PaywallView : Swift.Sendable {} -@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) -extension RevenueCatUI.PaywallWebViewController : Swift.Sendable {} extension RevenueCatUI.PaywallPresentationMode : Swift.Equatable {} extension RevenueCatUI.PaywallPresentationMode : Swift.Hashable {} diff --git a/api/revenuecatui-api-ios.swiftinterface b/api/revenuecatui-api-ios.swiftinterface index 9514729189..5106fe465c 100644 --- a/api/revenuecatui-api-ios.swiftinterface +++ b/api/revenuecatui-api-ios.swiftinterface @@ -309,66 +309,6 @@ extension RevenueCat.Purchases { @available(iOS 15.0, macOS 12.0, *) @_Concurrency.MainActor @objc final public func presentPaywall(from url: Foundation.URL) -> Swift.Bool } -@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) -public struct PaywallWebViewMessage : Swift.Sendable, Swift.Equatable { - public let componentID: Swift.String - public let type: Swift.String - public let responses: [Swift.String : RevenueCatUI.PaywallWebViewValue]? - public let error: Swift.String? - public init(componentID: Swift.String, type: Swift.String, responses: [Swift.String : RevenueCatUI.PaywallWebViewValue]? = nil, error: Swift.String? = nil) - public static func == (a: RevenueCatUI.PaywallWebViewMessage, b: RevenueCatUI.PaywallWebViewMessage) -> Swift.Bool -} -@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) -public struct PaywallWebViewValue : Swift.Sendable, Swift.Equatable, Swift.Hashable, Swift.Codable { - public static func string(_ value: Swift.String) -> RevenueCatUI.PaywallWebViewValue - public static func number(_ value: Swift.Double) -> RevenueCatUI.PaywallWebViewValue - public static func bool(_ value: Swift.Bool) -> RevenueCatUI.PaywallWebViewValue - public static func array(_ value: [RevenueCatUI.PaywallWebViewValue]) -> RevenueCatUI.PaywallWebViewValue - public static func object(_ value: [Swift.String : RevenueCatUI.PaywallWebViewValue]) -> RevenueCatUI.PaywallWebViewValue - public static var null: RevenueCatUI.PaywallWebViewValue { - get - } - public var stringValue: Swift.String? { - get - } - public var numberValue: Swift.Double? { - get - } - public var boolValue: Swift.Bool? { - get - } - public var arrayValue: [RevenueCatUI.PaywallWebViewValue]? { - get - } - public var objectValue: [Swift.String : RevenueCatUI.PaywallWebViewValue]? { - get - } - public var isNull: Swift.Bool { - get - } - public init(from decoder: any Swift.Decoder) throws - public func encode(to encoder: any Swift.Encoder) throws - public static func == (a: RevenueCatUI.PaywallWebViewValue, b: RevenueCatUI.PaywallWebViewValue) -> Swift.Bool - public func hash(into hasher: inout Swift.Hasher) - public var hashValue: Swift.Int { - get - } -} -@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) -@_Concurrency.MainActor public struct PaywallWebViewController { - @_Concurrency.MainActor public func postVariables(componentID: Swift.String, variables: [Swift.String : RevenueCatUI.PaywallWebViewValue]) - @_Concurrency.MainActor public func postMessage(componentID: Swift.String, type: Swift.String, variables: [Swift.String : RevenueCatUI.PaywallWebViewValue]) -} -@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) -public struct PaywallWebViewMessageAction { - public init(_ action: @escaping @_Concurrency.MainActor (RevenueCatUI.PaywallWebViewMessage, RevenueCatUI.PaywallWebViewController) -> Swift.Void) - @_Concurrency.MainActor public func callAsFunction(_ message: RevenueCatUI.PaywallWebViewMessage, _ controller: RevenueCatUI.PaywallWebViewController) -} -@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) -extension SwiftUICore.View { - @_Concurrency.MainActor @preconcurrency public func onPaywallWebViewMessage(_ action: @escaping @_Concurrency.MainActor (RevenueCatUI.PaywallWebViewMessage, RevenueCatUI.PaywallWebViewController) -> Swift.Void) -> some SwiftUICore.View - -} @_inheritsConvenienceInitializers @_hasMissingDesignatedInitializers @available(iOS 15.0, macOS 12.0, tvOS 15.0, *) @objc(RCPaywallFooterViewController) @_Concurrency.MainActor @preconcurrency final public class PaywallFooterViewController : RevenueCatUI.PaywallViewController { @_Concurrency.MainActor @preconcurrency public init(offering: RevenueCat.Offering? = nil, performPurchase: @escaping RevenueCatUI.PerformPurchase, performRestore: @escaping RevenueCatUI.PerformRestore, dismissRequestedHandler: ((_ controller: RevenueCatUI.PaywallViewController) -> Swift.Void)? = nil) @@ -606,7 +546,5 @@ extension RevenueCatUI.CustomerCenterNavigationLink : Swift.Sendable {} @available(iOS 15.0, macOS 12.0, watchOS 8.0, *) @available(tvOS, unavailable, message: "RevenueCatUI does not support tvOS yet") extension RevenueCatUI.PaywallView : Swift.Sendable {} -@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) -extension RevenueCatUI.PaywallWebViewController : Swift.Sendable {} extension RevenueCatUI.PaywallPresentationMode : Swift.Equatable {} extension RevenueCatUI.PaywallPresentationMode : Swift.Hashable {} diff --git a/api/revenuecatui-api-macos.swiftinterface b/api/revenuecatui-api-macos.swiftinterface index d7553c3e37..e64db71c52 100644 --- a/api/revenuecatui-api-macos.swiftinterface +++ b/api/revenuecatui-api-macos.swiftinterface @@ -168,66 +168,6 @@ open class CustomPaywallFontProvider : RevenueCatUI.PaywallFontProvider { @available(tvOS, unavailable, introduced: 15.0, message: "RevenueCatUI does not support tvOS yet") public typealias Body = @_opaqueReturnTypeOf("$s12RevenueCatUI11PaywallViewV4bodyQrvp", 0) __ } -@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) -public struct PaywallWebViewMessage : Swift.Sendable, Swift.Equatable { - public let componentID: Swift.String - public let type: Swift.String - public let responses: [Swift.String : RevenueCatUI.PaywallWebViewValue]? - public let error: Swift.String? - public init(componentID: Swift.String, type: Swift.String, responses: [Swift.String : RevenueCatUI.PaywallWebViewValue]? = nil, error: Swift.String? = nil) - public static func == (a: RevenueCatUI.PaywallWebViewMessage, b: RevenueCatUI.PaywallWebViewMessage) -> Swift.Bool -} -@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) -public struct PaywallWebViewValue : Swift.Sendable, Swift.Equatable, Swift.Hashable, Swift.Codable { - public static func string(_ value: Swift.String) -> RevenueCatUI.PaywallWebViewValue - public static func number(_ value: Swift.Double) -> RevenueCatUI.PaywallWebViewValue - public static func bool(_ value: Swift.Bool) -> RevenueCatUI.PaywallWebViewValue - public static func array(_ value: [RevenueCatUI.PaywallWebViewValue]) -> RevenueCatUI.PaywallWebViewValue - public static func object(_ value: [Swift.String : RevenueCatUI.PaywallWebViewValue]) -> RevenueCatUI.PaywallWebViewValue - public static var null: RevenueCatUI.PaywallWebViewValue { - get - } - public var stringValue: Swift.String? { - get - } - public var numberValue: Swift.Double? { - get - } - public var boolValue: Swift.Bool? { - get - } - public var arrayValue: [RevenueCatUI.PaywallWebViewValue]? { - get - } - public var objectValue: [Swift.String : RevenueCatUI.PaywallWebViewValue]? { - get - } - public var isNull: Swift.Bool { - get - } - public init(from decoder: any Swift.Decoder) throws - public func encode(to encoder: any Swift.Encoder) throws - public static func == (a: RevenueCatUI.PaywallWebViewValue, b: RevenueCatUI.PaywallWebViewValue) -> Swift.Bool - public func hash(into hasher: inout Swift.Hasher) - public var hashValue: Swift.Int { - get - } -} -@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) -@_Concurrency.MainActor public struct PaywallWebViewController { - @_Concurrency.MainActor public func postVariables(componentID: Swift.String, variables: [Swift.String : RevenueCatUI.PaywallWebViewValue]) - @_Concurrency.MainActor public func postMessage(componentID: Swift.String, type: Swift.String, variables: [Swift.String : RevenueCatUI.PaywallWebViewValue]) -} -@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) -public struct PaywallWebViewMessageAction { - public init(_ action: @escaping @_Concurrency.MainActor (RevenueCatUI.PaywallWebViewMessage, RevenueCatUI.PaywallWebViewController) -> Swift.Void) - @_Concurrency.MainActor public func callAsFunction(_ message: RevenueCatUI.PaywallWebViewMessage, _ controller: RevenueCatUI.PaywallWebViewController) -} -@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) -extension SwiftUICore.View { - @_Concurrency.MainActor @preconcurrency public func onPaywallWebViewMessage(_ action: @escaping @_Concurrency.MainActor (RevenueCatUI.PaywallWebViewMessage, RevenueCatUI.PaywallWebViewController) -> Swift.Void) -> some SwiftUICore.View - -} @available(iOS 14.0, macOS 11.0, tvOS 14.0, watchOS 7.0, *) extension SwiftUICore.View { @_Concurrency.MainActor @preconcurrency public func onWebPurchaseRedemptionAttempt(perform completion: @escaping @Sendable (RevenueCat.WebPurchaseRedemptionResult) -> Swift.Void) -> some SwiftUICore.View @@ -384,7 +324,5 @@ extension SwiftUICore.View { @available(iOS 15.0, macOS 12.0, watchOS 8.0, *) @available(tvOS, unavailable, message: "RevenueCatUI does not support tvOS yet") extension RevenueCatUI.PaywallView : Swift.Sendable {} -@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) -extension RevenueCatUI.PaywallWebViewController : Swift.Sendable {} extension RevenueCatUI.PaywallPresentationMode : Swift.Equatable {} extension RevenueCatUI.PaywallPresentationMode : Swift.Hashable {} diff --git a/api/revenuecatui-api-visionos-simulator.swiftinterface b/api/revenuecatui-api-visionos-simulator.swiftinterface index 7369428ec5..b87d2b82d9 100644 --- a/api/revenuecatui-api-visionos-simulator.swiftinterface +++ b/api/revenuecatui-api-visionos-simulator.swiftinterface @@ -179,66 +179,6 @@ extension RevenueCat.Purchases { @available(iOS 15.0, macOS 12.0, *) @_Concurrency.MainActor @objc final public func presentPaywall(from url: Foundation.URL) -> Swift.Bool } -@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) -public struct PaywallWebViewMessage : Swift.Sendable, Swift.Equatable { - public let componentID: Swift.String - public let type: Swift.String - public let responses: [Swift.String : RevenueCatUI.PaywallWebViewValue]? - public let error: Swift.String? - public init(componentID: Swift.String, type: Swift.String, responses: [Swift.String : RevenueCatUI.PaywallWebViewValue]? = nil, error: Swift.String? = nil) - public static func == (a: RevenueCatUI.PaywallWebViewMessage, b: RevenueCatUI.PaywallWebViewMessage) -> Swift.Bool -} -@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) -public struct PaywallWebViewValue : Swift.Sendable, Swift.Equatable, Swift.Hashable, Swift.Codable { - public static func string(_ value: Swift.String) -> RevenueCatUI.PaywallWebViewValue - public static func number(_ value: Swift.Double) -> RevenueCatUI.PaywallWebViewValue - public static func bool(_ value: Swift.Bool) -> RevenueCatUI.PaywallWebViewValue - public static func array(_ value: [RevenueCatUI.PaywallWebViewValue]) -> RevenueCatUI.PaywallWebViewValue - public static func object(_ value: [Swift.String : RevenueCatUI.PaywallWebViewValue]) -> RevenueCatUI.PaywallWebViewValue - public static var null: RevenueCatUI.PaywallWebViewValue { - get - } - public var stringValue: Swift.String? { - get - } - public var numberValue: Swift.Double? { - get - } - public var boolValue: Swift.Bool? { - get - } - public var arrayValue: [RevenueCatUI.PaywallWebViewValue]? { - get - } - public var objectValue: [Swift.String : RevenueCatUI.PaywallWebViewValue]? { - get - } - public var isNull: Swift.Bool { - get - } - public init(from decoder: any Swift.Decoder) throws - public func encode(to encoder: any Swift.Encoder) throws - public static func == (a: RevenueCatUI.PaywallWebViewValue, b: RevenueCatUI.PaywallWebViewValue) -> Swift.Bool - public func hash(into hasher: inout Swift.Hasher) - public var hashValue: Swift.Int { - get - } -} -@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) -@_Concurrency.MainActor public struct PaywallWebViewController { - @_Concurrency.MainActor public func postVariables(componentID: Swift.String, variables: [Swift.String : RevenueCatUI.PaywallWebViewValue]) - @_Concurrency.MainActor public func postMessage(componentID: Swift.String, type: Swift.String, variables: [Swift.String : RevenueCatUI.PaywallWebViewValue]) -} -@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) -public struct PaywallWebViewMessageAction { - public init(_ action: @escaping @_Concurrency.MainActor (RevenueCatUI.PaywallWebViewMessage, RevenueCatUI.PaywallWebViewController) -> Swift.Void) - @_Concurrency.MainActor public func callAsFunction(_ message: RevenueCatUI.PaywallWebViewMessage, _ controller: RevenueCatUI.PaywallWebViewController) -} -@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) -extension SwiftUICore.View { - @_Concurrency.MainActor @preconcurrency public func onPaywallWebViewMessage(_ action: @escaping @_Concurrency.MainActor (RevenueCatUI.PaywallWebViewMessage, RevenueCatUI.PaywallWebViewController) -> Swift.Void) -> some SwiftUICore.View - -} @_inheritsConvenienceInitializers @_hasMissingDesignatedInitializers @available(iOS 15.0, macOS 12.0, tvOS 15.0, *) @objc(RCPaywallFooterViewController) @_Concurrency.MainActor @preconcurrency final public class PaywallFooterViewController : RevenueCatUI.PaywallViewController { @_Concurrency.MainActor @preconcurrency public init(offering: RevenueCat.Offering? = nil, performPurchase: @escaping RevenueCatUI.PerformPurchase, performRestore: @escaping RevenueCatUI.PerformRestore, dismissRequestedHandler: ((_ controller: RevenueCatUI.PaywallViewController) -> Swift.Void)? = nil) @@ -466,7 +406,5 @@ extension SwiftUICore.View { @available(iOS 15.0, macOS 12.0, watchOS 8.0, *) @available(tvOS, unavailable, message: "RevenueCatUI does not support tvOS yet") extension RevenueCatUI.PaywallView : Swift.Sendable {} -@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) -extension RevenueCatUI.PaywallWebViewController : Swift.Sendable {} extension RevenueCatUI.PaywallPresentationMode : Swift.Equatable {} extension RevenueCatUI.PaywallPresentationMode : Swift.Hashable {} diff --git a/api/revenuecatui-api-visionos.swiftinterface b/api/revenuecatui-api-visionos.swiftinterface index cccba66c64..ec8d35ca1f 100644 --- a/api/revenuecatui-api-visionos.swiftinterface +++ b/api/revenuecatui-api-visionos.swiftinterface @@ -179,66 +179,6 @@ extension RevenueCat.Purchases { @available(iOS 15.0, macOS 12.0, *) @_Concurrency.MainActor @objc final public func presentPaywall(from url: Foundation.URL) -> Swift.Bool } -@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) -public struct PaywallWebViewMessage : Swift.Sendable, Swift.Equatable { - public let componentID: Swift.String - public let type: Swift.String - public let responses: [Swift.String : RevenueCatUI.PaywallWebViewValue]? - public let error: Swift.String? - public init(componentID: Swift.String, type: Swift.String, responses: [Swift.String : RevenueCatUI.PaywallWebViewValue]? = nil, error: Swift.String? = nil) - public static func == (a: RevenueCatUI.PaywallWebViewMessage, b: RevenueCatUI.PaywallWebViewMessage) -> Swift.Bool -} -@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) -public struct PaywallWebViewValue : Swift.Sendable, Swift.Equatable, Swift.Hashable, Swift.Codable { - public static func string(_ value: Swift.String) -> RevenueCatUI.PaywallWebViewValue - public static func number(_ value: Swift.Double) -> RevenueCatUI.PaywallWebViewValue - public static func bool(_ value: Swift.Bool) -> RevenueCatUI.PaywallWebViewValue - public static func array(_ value: [RevenueCatUI.PaywallWebViewValue]) -> RevenueCatUI.PaywallWebViewValue - public static func object(_ value: [Swift.String : RevenueCatUI.PaywallWebViewValue]) -> RevenueCatUI.PaywallWebViewValue - public static var null: RevenueCatUI.PaywallWebViewValue { - get - } - public var stringValue: Swift.String? { - get - } - public var numberValue: Swift.Double? { - get - } - public var boolValue: Swift.Bool? { - get - } - public var arrayValue: [RevenueCatUI.PaywallWebViewValue]? { - get - } - public var objectValue: [Swift.String : RevenueCatUI.PaywallWebViewValue]? { - get - } - public var isNull: Swift.Bool { - get - } - public init(from decoder: any Swift.Decoder) throws - public func encode(to encoder: any Swift.Encoder) throws - public static func == (a: RevenueCatUI.PaywallWebViewValue, b: RevenueCatUI.PaywallWebViewValue) -> Swift.Bool - public func hash(into hasher: inout Swift.Hasher) - public var hashValue: Swift.Int { - get - } -} -@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) -@_Concurrency.MainActor public struct PaywallWebViewController { - @_Concurrency.MainActor public func postVariables(componentID: Swift.String, variables: [Swift.String : RevenueCatUI.PaywallWebViewValue]) - @_Concurrency.MainActor public func postMessage(componentID: Swift.String, type: Swift.String, variables: [Swift.String : RevenueCatUI.PaywallWebViewValue]) -} -@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) -public struct PaywallWebViewMessageAction { - public init(_ action: @escaping @_Concurrency.MainActor (RevenueCatUI.PaywallWebViewMessage, RevenueCatUI.PaywallWebViewController) -> Swift.Void) - @_Concurrency.MainActor public func callAsFunction(_ message: RevenueCatUI.PaywallWebViewMessage, _ controller: RevenueCatUI.PaywallWebViewController) -} -@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) -extension SwiftUICore.View { - @_Concurrency.MainActor @preconcurrency public func onPaywallWebViewMessage(_ action: @escaping @_Concurrency.MainActor (RevenueCatUI.PaywallWebViewMessage, RevenueCatUI.PaywallWebViewController) -> Swift.Void) -> some SwiftUICore.View - -} @_inheritsConvenienceInitializers @_hasMissingDesignatedInitializers @available(iOS 15.0, macOS 12.0, tvOS 15.0, *) @objc(RCPaywallFooterViewController) @_Concurrency.MainActor @preconcurrency final public class PaywallFooterViewController : RevenueCatUI.PaywallViewController { @_Concurrency.MainActor @preconcurrency public init(offering: RevenueCat.Offering? = nil, performPurchase: @escaping RevenueCatUI.PerformPurchase, performRestore: @escaping RevenueCatUI.PerformRestore, dismissRequestedHandler: ((_ controller: RevenueCatUI.PaywallViewController) -> Swift.Void)? = nil) @@ -466,7 +406,5 @@ extension SwiftUICore.View { @available(iOS 15.0, macOS 12.0, watchOS 8.0, *) @available(tvOS, unavailable, message: "RevenueCatUI does not support tvOS yet") extension RevenueCatUI.PaywallView : Swift.Sendable {} -@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) -extension RevenueCatUI.PaywallWebViewController : Swift.Sendable {} extension RevenueCatUI.PaywallPresentationMode : Swift.Equatable {} extension RevenueCatUI.PaywallPresentationMode : Swift.Hashable {} diff --git a/api/revenuecatui-api-watchos-simulator.swiftinterface b/api/revenuecatui-api-watchos-simulator.swiftinterface index 7038282c5d..03d128c761 100644 --- a/api/revenuecatui-api-watchos-simulator.swiftinterface +++ b/api/revenuecatui-api-watchos-simulator.swiftinterface @@ -166,66 +166,6 @@ open class CustomPaywallFontProvider : RevenueCatUI.PaywallFontProvider { @available(tvOS, unavailable, introduced: 15.0, message: "RevenueCatUI does not support tvOS yet") public typealias Body = @_opaqueReturnTypeOf("$s12RevenueCatUI11PaywallViewV4bodyQrvp", 0) __ } -@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) -public struct PaywallWebViewMessage : Swift.Sendable, Swift.Equatable { - public let componentID: Swift.String - public let type: Swift.String - public let responses: [Swift.String : RevenueCatUI.PaywallWebViewValue]? - public let error: Swift.String? - public init(componentID: Swift.String, type: Swift.String, responses: [Swift.String : RevenueCatUI.PaywallWebViewValue]? = nil, error: Swift.String? = nil) - public static func == (a: RevenueCatUI.PaywallWebViewMessage, b: RevenueCatUI.PaywallWebViewMessage) -> Swift.Bool -} -@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) -public struct PaywallWebViewValue : Swift.Sendable, Swift.Equatable, Swift.Hashable, Swift.Codable { - public static func string(_ value: Swift.String) -> RevenueCatUI.PaywallWebViewValue - public static func number(_ value: Swift.Double) -> RevenueCatUI.PaywallWebViewValue - public static func bool(_ value: Swift.Bool) -> RevenueCatUI.PaywallWebViewValue - public static func array(_ value: [RevenueCatUI.PaywallWebViewValue]) -> RevenueCatUI.PaywallWebViewValue - public static func object(_ value: [Swift.String : RevenueCatUI.PaywallWebViewValue]) -> RevenueCatUI.PaywallWebViewValue - public static var null: RevenueCatUI.PaywallWebViewValue { - get - } - public var stringValue: Swift.String? { - get - } - public var numberValue: Swift.Double? { - get - } - public var boolValue: Swift.Bool? { - get - } - public var arrayValue: [RevenueCatUI.PaywallWebViewValue]? { - get - } - public var objectValue: [Swift.String : RevenueCatUI.PaywallWebViewValue]? { - get - } - public var isNull: Swift.Bool { - get - } - public init(from decoder: any Swift.Decoder) throws - public func encode(to encoder: any Swift.Encoder) throws - public static func == (a: RevenueCatUI.PaywallWebViewValue, b: RevenueCatUI.PaywallWebViewValue) -> Swift.Bool - public func hash(into hasher: inout Swift.Hasher) - public var hashValue: Swift.Int { - get - } -} -@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) -@_Concurrency.MainActor public struct PaywallWebViewController { - @_Concurrency.MainActor public func postVariables(componentID: Swift.String, variables: [Swift.String : RevenueCatUI.PaywallWebViewValue]) - @_Concurrency.MainActor public func postMessage(componentID: Swift.String, type: Swift.String, variables: [Swift.String : RevenueCatUI.PaywallWebViewValue]) -} -@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) -public struct PaywallWebViewMessageAction { - public init(_ action: @escaping @_Concurrency.MainActor (RevenueCatUI.PaywallWebViewMessage, RevenueCatUI.PaywallWebViewController) -> Swift.Void) - @_Concurrency.MainActor public func callAsFunction(_ message: RevenueCatUI.PaywallWebViewMessage, _ controller: RevenueCatUI.PaywallWebViewController) -} -@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) -extension SwiftUICore.View { - @_Concurrency.MainActor @preconcurrency public func onPaywallWebViewMessage(_ action: @escaping @_Concurrency.MainActor (RevenueCatUI.PaywallWebViewMessage, RevenueCatUI.PaywallWebViewController) -> Swift.Void) -> some SwiftUICore.View - -} @available(iOS 14.0, macOS 11.0, tvOS 14.0, watchOS 7.0, *) extension SwiftUICore.View { @_Concurrency.MainActor @preconcurrency public func onWebPurchaseRedemptionAttempt(perform completion: @escaping @Sendable (RevenueCat.WebPurchaseRedemptionResult) -> Swift.Void) -> some SwiftUICore.View @@ -339,7 +279,5 @@ extension SwiftUICore.View { @available(iOS 15.0, macOS 12.0, watchOS 8.0, *) @available(tvOS, unavailable, message: "RevenueCatUI does not support tvOS yet") extension RevenueCatUI.PaywallView : Swift.Sendable {} -@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) -extension RevenueCatUI.PaywallWebViewController : Swift.Sendable {} extension RevenueCatUI.PaywallPresentationMode : Swift.Equatable {} extension RevenueCatUI.PaywallPresentationMode : Swift.Hashable {} diff --git a/api/revenuecatui-api-watchos.swiftinterface b/api/revenuecatui-api-watchos.swiftinterface index 0490cf9e59..39b2ed3084 100644 --- a/api/revenuecatui-api-watchos.swiftinterface +++ b/api/revenuecatui-api-watchos.swiftinterface @@ -166,66 +166,6 @@ open class CustomPaywallFontProvider : RevenueCatUI.PaywallFontProvider { @available(tvOS, unavailable, introduced: 15.0, message: "RevenueCatUI does not support tvOS yet") public typealias Body = @_opaqueReturnTypeOf("$s12RevenueCatUI11PaywallViewV4bodyQrvp", 0) __ } -@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) -public struct PaywallWebViewMessage : Swift.Sendable, Swift.Equatable { - public let componentID: Swift.String - public let type: Swift.String - public let responses: [Swift.String : RevenueCatUI.PaywallWebViewValue]? - public let error: Swift.String? - public init(componentID: Swift.String, type: Swift.String, responses: [Swift.String : RevenueCatUI.PaywallWebViewValue]? = nil, error: Swift.String? = nil) - public static func == (a: RevenueCatUI.PaywallWebViewMessage, b: RevenueCatUI.PaywallWebViewMessage) -> Swift.Bool -} -@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) -public struct PaywallWebViewValue : Swift.Sendable, Swift.Equatable, Swift.Hashable, Swift.Codable { - public static func string(_ value: Swift.String) -> RevenueCatUI.PaywallWebViewValue - public static func number(_ value: Swift.Double) -> RevenueCatUI.PaywallWebViewValue - public static func bool(_ value: Swift.Bool) -> RevenueCatUI.PaywallWebViewValue - public static func array(_ value: [RevenueCatUI.PaywallWebViewValue]) -> RevenueCatUI.PaywallWebViewValue - public static func object(_ value: [Swift.String : RevenueCatUI.PaywallWebViewValue]) -> RevenueCatUI.PaywallWebViewValue - public static var null: RevenueCatUI.PaywallWebViewValue { - get - } - public var stringValue: Swift.String? { - get - } - public var numberValue: Swift.Double? { - get - } - public var boolValue: Swift.Bool? { - get - } - public var arrayValue: [RevenueCatUI.PaywallWebViewValue]? { - get - } - public var objectValue: [Swift.String : RevenueCatUI.PaywallWebViewValue]? { - get - } - public var isNull: Swift.Bool { - get - } - public init(from decoder: any Swift.Decoder) throws - public func encode(to encoder: any Swift.Encoder) throws - public static func == (a: RevenueCatUI.PaywallWebViewValue, b: RevenueCatUI.PaywallWebViewValue) -> Swift.Bool - public func hash(into hasher: inout Swift.Hasher) - public var hashValue: Swift.Int { - get - } -} -@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) -@_Concurrency.MainActor public struct PaywallWebViewController { - @_Concurrency.MainActor public func postVariables(componentID: Swift.String, variables: [Swift.String : RevenueCatUI.PaywallWebViewValue]) - @_Concurrency.MainActor public func postMessage(componentID: Swift.String, type: Swift.String, variables: [Swift.String : RevenueCatUI.PaywallWebViewValue]) -} -@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) -public struct PaywallWebViewMessageAction { - public init(_ action: @escaping @_Concurrency.MainActor (RevenueCatUI.PaywallWebViewMessage, RevenueCatUI.PaywallWebViewController) -> Swift.Void) - @_Concurrency.MainActor public func callAsFunction(_ message: RevenueCatUI.PaywallWebViewMessage, _ controller: RevenueCatUI.PaywallWebViewController) -} -@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) -extension SwiftUICore.View { - @_Concurrency.MainActor @preconcurrency public func onPaywallWebViewMessage(_ action: @escaping @_Concurrency.MainActor (RevenueCatUI.PaywallWebViewMessage, RevenueCatUI.PaywallWebViewController) -> Swift.Void) -> some SwiftUICore.View - -} @available(iOS 14.0, macOS 11.0, tvOS 14.0, watchOS 7.0, *) extension SwiftUICore.View { @_Concurrency.MainActor @preconcurrency public func onWebPurchaseRedemptionAttempt(perform completion: @escaping @Sendable (RevenueCat.WebPurchaseRedemptionResult) -> Swift.Void) -> some SwiftUICore.View @@ -339,7 +279,5 @@ extension SwiftUICore.View { @available(iOS 15.0, macOS 12.0, watchOS 8.0, *) @available(tvOS, unavailable, message: "RevenueCatUI does not support tvOS yet") extension RevenueCatUI.PaywallView : Swift.Sendable {} -@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) -extension RevenueCatUI.PaywallWebViewController : Swift.Sendable {} extension RevenueCatUI.PaywallPresentationMode : Swift.Equatable {} extension RevenueCatUI.PaywallPresentationMode : Swift.Hashable {} From ad3541fa438f363328d25caa7cccbc7357213a14 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 16 Jul 2026 20:01:56 +0000 Subject: [PATCH 14/35] refactor(paywalls): keep web view bridge internal Co-authored-by: Jacob Rakidzich --- .../WebView/PaywallWebViewAPI.swift | 57 +++++++------- .../AllAPITests.xcodeproj/project.pbxproj | 4 - .../PaywallWebViewAPI.swift | 77 ------------------- ...enuecatui-api-ios-simulator.swiftinterface | 62 --------------- api/revenuecatui-api-ios.swiftinterface | 62 --------------- api/revenuecatui-api-macos.swiftinterface | 62 --------------- ...atui-api-visionos-simulator.swiftinterface | 62 --------------- api/revenuecatui-api-visionos.swiftinterface | 62 --------------- ...catui-api-watchos-simulator.swiftinterface | 62 --------------- api/revenuecatui-api-watchos.swiftinterface | 62 --------------- 10 files changed, 28 insertions(+), 544 deletions(-) delete mode 100644 Tests/APITesters/AllAPITests/RevenueCatUISwiftAPITester/PaywallWebViewAPI.swift diff --git a/RevenueCatUI/Templates/V2/Components/WebView/PaywallWebViewAPI.swift b/RevenueCatUI/Templates/V2/Components/WebView/PaywallWebViewAPI.swift index 94265d2bb1..01a717ab1b 100644 --- a/RevenueCatUI/Templates/V2/Components/WebView/PaywallWebViewAPI.swift +++ b/RevenueCatUI/Templates/V2/Components/WebView/PaywallWebViewAPI.swift @@ -1,26 +1,25 @@ import Foundation import SwiftUI -// swiftlint:disable missing_docs #if !os(tvOS) // For Paywalls V2 /// A validated message sent from a Paywalls V2 `web_view` component to your app. @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) -public struct PaywallWebViewMessage: Sendable, Equatable { +struct PaywallWebViewMessage: Sendable, Equatable { /// The identifier of the `web_view` component that produced this message. - public let componentID: String + let componentID: String /// The message type, e.g. `"rc:step-complete"`. - public let type: String + let type: String /// The responses collected by the web flow. Only populated for `"rc:step-complete"` messages. - public let responses: [String: PaywallWebViewValue]? + let responses: [String: PaywallWebViewValue]? /// The error reported by the web content. Only populated for `"rc:error"` messages. - public let error: String? + let error: String? - public init( + init( componentID: String, type: String, responses: [String: PaywallWebViewValue]? = nil, @@ -36,7 +35,7 @@ public struct PaywallWebViewMessage: Sendable, Equatable { /// A JSON-compatible value exchanged between a Paywalls V2 `web_view` component and your app. @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) -public struct PaywallWebViewValue: Sendable, Equatable, Hashable, Codable { +struct PaywallWebViewValue: Sendable, Equatable, Hashable, Codable { private indirect enum Storage: Sendable, Equatable, Hashable { case string(String) @@ -54,54 +53,54 @@ public struct PaywallWebViewValue: Sendable, Equatable, Hashable, Codable { } /// Creates a string value. - public static func string(_ value: String) -> Self { Self(.string(value)) } + static func string(_ value: String) -> Self { Self(.string(value)) } /// Creates a numeric value. Non-finite values normalize to ``null`` because JSON cannot encode them. - public static func number(_ value: Double) -> Self { value.isFinite ? Self(.number(value)) : .null } + static func number(_ value: Double) -> Self { value.isFinite ? Self(.number(value)) : .null } /// Creates a boolean value. - public static func bool(_ value: Bool) -> Self { Self(.bool(value)) } + static func bool(_ value: Bool) -> Self { Self(.bool(value)) } /// Creates an array value. - public static func array(_ value: [PaywallWebViewValue]) -> Self { Self(.array(value)) } + static func array(_ value: [PaywallWebViewValue]) -> Self { Self(.array(value)) } /// Creates an object (dictionary) value. - public static func object(_ value: [String: PaywallWebViewValue]) -> Self { Self(.object(value)) } + static func object(_ value: [String: PaywallWebViewValue]) -> Self { Self(.object(value)) } /// A null value. - public static var null: Self { Self(.null) } + static var null: Self { Self(.null) } - public var stringValue: String? { + var stringValue: String? { if case .string(let value) = self.storage { return value } return nil } - public var numberValue: Double? { + var numberValue: Double? { if case .number(let value) = self.storage { return value } return nil } - public var boolValue: Bool? { + var boolValue: Bool? { if case .bool(let value) = self.storage { return value } return nil } - public var arrayValue: [PaywallWebViewValue]? { + var arrayValue: [PaywallWebViewValue]? { if case .array(let value) = self.storage { return value } return nil } - public var objectValue: [String: PaywallWebViewValue]? { + var objectValue: [String: PaywallWebViewValue]? { if case .object(let value) = self.storage { return value } return nil } - public var isNull: Bool { + var isNull: Bool { if case .null = self.storage { return true } return false } - public init(from decoder: Decoder) throws { + init(from decoder: Decoder) throws { let container = try decoder.singleValueContainer() if container.decodeNil() { @@ -119,7 +118,7 @@ public struct PaywallWebViewValue: Sendable, Equatable, Hashable, Codable { } } - public func encode(to encoder: Encoder) throws { + func encode(to encoder: Encoder) throws { var container = encoder.singleValueContainer() switch self.storage { @@ -142,7 +141,7 @@ public struct PaywallWebViewValue: Sendable, Equatable, Hashable, Codable { @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) @MainActor -public struct PaywallWebViewController { +struct PaywallWebViewController { #if canImport(WebKit) weak var session: WebViewSession? @@ -156,13 +155,13 @@ public struct PaywallWebViewController { } #endif - public func postVariables(componentID: String, variables: [String: PaywallWebViewValue]) { + func postVariables(componentID: String, variables: [String: PaywallWebViewValue]) { #if canImport(WebKit) self.session?.postVariables(componentID: componentID, variables: variables) #endif } - public func postMessage(componentID: String, type: String, variables: [String: PaywallWebViewValue]) { + func postMessage(componentID: String, type: String, variables: [String: PaywallWebViewValue]) { #if canImport(WebKit) self.session?.post(componentID: componentID, type: type, variables: variables) #endif @@ -172,18 +171,18 @@ public struct PaywallWebViewController { /// A wrapper for the Paywalls V2 `web_view` message handler. @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) -public struct PaywallWebViewMessageAction { +struct PaywallWebViewMessageAction { private let action: @MainActor (PaywallWebViewMessage, PaywallWebViewController) -> Void - public init( + init( _ action: @escaping @MainActor (PaywallWebViewMessage, PaywallWebViewController) -> Void ) { self.action = action } @MainActor - public func callAsFunction(_ message: PaywallWebViewMessage, _ controller: PaywallWebViewController) { + func callAsFunction(_ message: PaywallWebViewMessage, _ controller: PaywallWebViewController) { self.action(message, controller) } @@ -208,7 +207,7 @@ extension EnvironmentValues { extension View { /// Invokes the given closure when a Paywalls V2 `web_view` component sends a message. - public func onPaywallWebViewMessage( + func onPaywallWebViewMessage( _ action: @escaping @MainActor (PaywallWebViewMessage, PaywallWebViewController) -> Void ) -> some View { self.environment(\.paywallWebViewMessageAction, PaywallWebViewMessageAction(action)) diff --git a/Tests/APITesters/AllAPITests/AllAPITests.xcodeproj/project.pbxproj b/Tests/APITesters/AllAPITests/AllAPITests.xcodeproj/project.pbxproj index 74a25fd257..9cc8116989 100644 --- a/Tests/APITesters/AllAPITests/AllAPITests.xcodeproj/project.pbxproj +++ b/Tests/APITesters/AllAPITests/AllAPITests.xcodeproj/project.pbxproj @@ -8,7 +8,6 @@ /* Begin PBXBuildFile section */ 1E8A60812CDCF29D0034ACF3 /* RedeemWebPurchasesAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E8A60802CDCF29D0034ACF3 /* RedeemWebPurchasesAPI.swift */; }; - B096F58208E0AA6582F34133 /* PaywallWebViewAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = B0DC08831A62751743D823FA /* PaywallWebViewAPI.swift */; }; 1EB697C22CD26218003000FC /* WebPurchaseRedemptionResultAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1EB697C12CD26218003000FC /* WebPurchaseRedemptionResultAPI.swift */; }; 2D4C60E12C5AD1BC00A29FD2 /* AllAPITests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 2D4C60D82C5AD1BC00A29FD2 /* AllAPITests.framework */; }; 2D4C60E62C5AD1BC00A29FD2 /* AllAPITestsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D4C60E52C5AD1BC00A29FD2 /* AllAPITestsTests.swift */; }; @@ -229,7 +228,6 @@ /* Begin PBXFileReference section */ 1E8A60802CDCF29D0034ACF3 /* RedeemWebPurchasesAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RedeemWebPurchasesAPI.swift; sourceTree = ""; }; - B0DC08831A62751743D823FA /* PaywallWebViewAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaywallWebViewAPI.swift; sourceTree = ""; }; 1EB697C12CD26218003000FC /* WebPurchaseRedemptionResultAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebPurchaseRedemptionResultAPI.swift; sourceTree = ""; }; 2D4C60D82C5AD1BC00A29FD2 /* AllAPITests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = AllAPITests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 2D4C60DB2C5AD1BC00A29FD2 /* AllAPITests.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = AllAPITests.h; sourceTree = ""; }; @@ -647,7 +645,6 @@ children = ( FD33CD722D035876000D13A4 /* CustomerCenterViewControllerAPI.swift */, 2D4C622D2C5ADA6700A29FD2 /* PaywallViewAPI.swift */, - B0DC08831A62751743D823FA /* PaywallWebViewAPI.swift */, 1E8A60802CDCF29D0034ACF3 /* RedeemWebPurchasesAPI.swift */, 2D4C622B2C5ADA6700A29FD2 /* PaywallViewControllerAPI.swift */, 2D4C622C2C5ADA6700A29FD2 /* main.swift */, @@ -1122,7 +1119,6 @@ FDCC983A2D0A0FD500F5854B /* CustomerCenterViewAPI.swift in Sources */, 2D4C62302C5ADA6700A29FD2 /* PaywallViewControllerAPI.swift in Sources */, 1E8A60812CDCF29D0034ACF3 /* RedeemWebPurchasesAPI.swift in Sources */, - B096F58208E0AA6582F34133 /* PaywallWebViewAPI.swift in Sources */, 2D4C622E2C5ADA6700A29FD2 /* PaywallViewAPI.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/Tests/APITesters/AllAPITests/RevenueCatUISwiftAPITester/PaywallWebViewAPI.swift b/Tests/APITesters/AllAPITests/RevenueCatUISwiftAPITester/PaywallWebViewAPI.swift deleted file mode 100644 index a8a2986ca9..0000000000 --- a/Tests/APITesters/AllAPITests/RevenueCatUISwiftAPITester/PaywallWebViewAPI.swift +++ /dev/null @@ -1,77 +0,0 @@ -// -// PaywallWebViewAPI.swift -// RevenueCatUISwiftAPITester -// -// Compile-time checks for the Paywalls V2 web_view public API surface. -// - -import RevenueCat -import RevenueCatUI -import SwiftUI - -#if !os(tvOS) // For Paywalls V2 - -@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) -struct PaywallWebViewApp: View { - - var body: some View { - Text("") - .onPaywallWebViewMessage { (message: PaywallWebViewMessage, controller: PaywallWebViewController) in - let componentID: String = message.componentID - let type: String = message.type - let responses: [String: PaywallWebViewValue]? = message.responses - let error: String? = message.error - - controller.postVariables( - componentID: componentID, - variables: ["custom": .object(["plan": .string("annual")])] - ) - controller.postMessage(componentID: componentID, type: type, variables: [:]) - - _ = (responses, error) - } - } - -} - -@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) -func checkPaywallWebViewMessageAPI() { - let message = PaywallWebViewMessage( - componentID: "", - type: "rc:step-complete", - responses: ["selected_plan": .string("annual")], - error: nil - ) - let sameMessage: Bool = message == message - _ = sameMessage -} - -@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) -func checkPaywallWebViewValueAPI() { - let string: PaywallWebViewValue = .string("s") - let number: PaywallWebViewValue = .number(1.5) - let bool: PaywallWebViewValue = .bool(true) - let array: PaywallWebViewValue = .array([string, number]) - let object: PaywallWebViewValue = .object(["key": bool]) - let null: PaywallWebViewValue = .null - - let _: String? = string.stringValue - let _: Double? = number.numberValue - let _: Bool? = bool.boolValue - let _: [PaywallWebViewValue]? = array.arrayValue - let _: [String: PaywallWebViewValue]? = object.objectValue - let _: Bool = null.isNull - let _: Set = [string, number] -} - -@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) -@MainActor -func checkPaywallWebViewMessageActionAPI( - message: PaywallWebViewMessage, - controller: PaywallWebViewController -) { - let action = PaywallWebViewMessageAction { (_: PaywallWebViewMessage, _: PaywallWebViewController) in } - action(message, controller) -} - -#endif diff --git a/api/revenuecatui-api-ios-simulator.swiftinterface b/api/revenuecatui-api-ios-simulator.swiftinterface index 428cfba3b0..30bbb7549c 100644 --- a/api/revenuecatui-api-ios-simulator.swiftinterface +++ b/api/revenuecatui-api-ios-simulator.swiftinterface @@ -309,66 +309,6 @@ extension RevenueCat.Purchases { @available(iOS 15.0, macOS 12.0, *) @_Concurrency.MainActor @objc final public func presentPaywall(from url: Foundation.URL) -> Swift.Bool } -@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) -public struct PaywallWebViewMessage : Swift.Sendable, Swift.Equatable { - public let componentID: Swift.String - public let type: Swift.String - public let responses: [Swift.String : RevenueCatUI.PaywallWebViewValue]? - public let error: Swift.String? - public init(componentID: Swift.String, type: Swift.String, responses: [Swift.String : RevenueCatUI.PaywallWebViewValue]? = nil, error: Swift.String? = nil) - public static func == (a: RevenueCatUI.PaywallWebViewMessage, b: RevenueCatUI.PaywallWebViewMessage) -> Swift.Bool -} -@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) -public struct PaywallWebViewValue : Swift.Sendable, Swift.Equatable, Swift.Hashable, Swift.Codable { - public static func string(_ value: Swift.String) -> RevenueCatUI.PaywallWebViewValue - public static func number(_ value: Swift.Double) -> RevenueCatUI.PaywallWebViewValue - public static func bool(_ value: Swift.Bool) -> RevenueCatUI.PaywallWebViewValue - public static func array(_ value: [RevenueCatUI.PaywallWebViewValue]) -> RevenueCatUI.PaywallWebViewValue - public static func object(_ value: [Swift.String : RevenueCatUI.PaywallWebViewValue]) -> RevenueCatUI.PaywallWebViewValue - public static var null: RevenueCatUI.PaywallWebViewValue { - get - } - public var stringValue: Swift.String? { - get - } - public var numberValue: Swift.Double? { - get - } - public var boolValue: Swift.Bool? { - get - } - public var arrayValue: [RevenueCatUI.PaywallWebViewValue]? { - get - } - public var objectValue: [Swift.String : RevenueCatUI.PaywallWebViewValue]? { - get - } - public var isNull: Swift.Bool { - get - } - public init(from decoder: any Swift.Decoder) throws - public func encode(to encoder: any Swift.Encoder) throws - public static func == (a: RevenueCatUI.PaywallWebViewValue, b: RevenueCatUI.PaywallWebViewValue) -> Swift.Bool - public func hash(into hasher: inout Swift.Hasher) - public var hashValue: Swift.Int { - get - } -} -@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) -@_Concurrency.MainActor public struct PaywallWebViewController { - @_Concurrency.MainActor public func postVariables(componentID: Swift.String, variables: [Swift.String : RevenueCatUI.PaywallWebViewValue]) - @_Concurrency.MainActor public func postMessage(componentID: Swift.String, type: Swift.String, variables: [Swift.String : RevenueCatUI.PaywallWebViewValue]) -} -@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) -public struct PaywallWebViewMessageAction { - public init(_ action: @escaping @_Concurrency.MainActor (RevenueCatUI.PaywallWebViewMessage, RevenueCatUI.PaywallWebViewController) -> Swift.Void) - @_Concurrency.MainActor public func callAsFunction(_ message: RevenueCatUI.PaywallWebViewMessage, _ controller: RevenueCatUI.PaywallWebViewController) -} -@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) -extension SwiftUICore.View { - @_Concurrency.MainActor @preconcurrency public func onPaywallWebViewMessage(_ action: @escaping @_Concurrency.MainActor (RevenueCatUI.PaywallWebViewMessage, RevenueCatUI.PaywallWebViewController) -> Swift.Void) -> some SwiftUICore.View - -} @_inheritsConvenienceInitializers @_hasMissingDesignatedInitializers @available(iOS 15.0, macOS 12.0, tvOS 15.0, *) @objc(RCPaywallFooterViewController) @_Concurrency.MainActor @preconcurrency final public class PaywallFooterViewController : RevenueCatUI.PaywallViewController { @_Concurrency.MainActor @preconcurrency public init(offering: RevenueCat.Offering? = nil, performPurchase: @escaping RevenueCatUI.PerformPurchase, performRestore: @escaping RevenueCatUI.PerformRestore, dismissRequestedHandler: ((_ controller: RevenueCatUI.PaywallViewController) -> Swift.Void)? = nil) @@ -606,7 +546,5 @@ extension RevenueCatUI.CustomerCenterNavigationLink : Swift.Sendable {} @available(iOS 15.0, macOS 12.0, watchOS 8.0, *) @available(tvOS, unavailable, message: "RevenueCatUI does not support tvOS yet") extension RevenueCatUI.PaywallView : Swift.Sendable {} -@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) -extension RevenueCatUI.PaywallWebViewController : Swift.Sendable {} extension RevenueCatUI.PaywallPresentationMode : Swift.Equatable {} extension RevenueCatUI.PaywallPresentationMode : Swift.Hashable {} diff --git a/api/revenuecatui-api-ios.swiftinterface b/api/revenuecatui-api-ios.swiftinterface index 9514729189..5106fe465c 100644 --- a/api/revenuecatui-api-ios.swiftinterface +++ b/api/revenuecatui-api-ios.swiftinterface @@ -309,66 +309,6 @@ extension RevenueCat.Purchases { @available(iOS 15.0, macOS 12.0, *) @_Concurrency.MainActor @objc final public func presentPaywall(from url: Foundation.URL) -> Swift.Bool } -@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) -public struct PaywallWebViewMessage : Swift.Sendable, Swift.Equatable { - public let componentID: Swift.String - public let type: Swift.String - public let responses: [Swift.String : RevenueCatUI.PaywallWebViewValue]? - public let error: Swift.String? - public init(componentID: Swift.String, type: Swift.String, responses: [Swift.String : RevenueCatUI.PaywallWebViewValue]? = nil, error: Swift.String? = nil) - public static func == (a: RevenueCatUI.PaywallWebViewMessage, b: RevenueCatUI.PaywallWebViewMessage) -> Swift.Bool -} -@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) -public struct PaywallWebViewValue : Swift.Sendable, Swift.Equatable, Swift.Hashable, Swift.Codable { - public static func string(_ value: Swift.String) -> RevenueCatUI.PaywallWebViewValue - public static func number(_ value: Swift.Double) -> RevenueCatUI.PaywallWebViewValue - public static func bool(_ value: Swift.Bool) -> RevenueCatUI.PaywallWebViewValue - public static func array(_ value: [RevenueCatUI.PaywallWebViewValue]) -> RevenueCatUI.PaywallWebViewValue - public static func object(_ value: [Swift.String : RevenueCatUI.PaywallWebViewValue]) -> RevenueCatUI.PaywallWebViewValue - public static var null: RevenueCatUI.PaywallWebViewValue { - get - } - public var stringValue: Swift.String? { - get - } - public var numberValue: Swift.Double? { - get - } - public var boolValue: Swift.Bool? { - get - } - public var arrayValue: [RevenueCatUI.PaywallWebViewValue]? { - get - } - public var objectValue: [Swift.String : RevenueCatUI.PaywallWebViewValue]? { - get - } - public var isNull: Swift.Bool { - get - } - public init(from decoder: any Swift.Decoder) throws - public func encode(to encoder: any Swift.Encoder) throws - public static func == (a: RevenueCatUI.PaywallWebViewValue, b: RevenueCatUI.PaywallWebViewValue) -> Swift.Bool - public func hash(into hasher: inout Swift.Hasher) - public var hashValue: Swift.Int { - get - } -} -@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) -@_Concurrency.MainActor public struct PaywallWebViewController { - @_Concurrency.MainActor public func postVariables(componentID: Swift.String, variables: [Swift.String : RevenueCatUI.PaywallWebViewValue]) - @_Concurrency.MainActor public func postMessage(componentID: Swift.String, type: Swift.String, variables: [Swift.String : RevenueCatUI.PaywallWebViewValue]) -} -@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) -public struct PaywallWebViewMessageAction { - public init(_ action: @escaping @_Concurrency.MainActor (RevenueCatUI.PaywallWebViewMessage, RevenueCatUI.PaywallWebViewController) -> Swift.Void) - @_Concurrency.MainActor public func callAsFunction(_ message: RevenueCatUI.PaywallWebViewMessage, _ controller: RevenueCatUI.PaywallWebViewController) -} -@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) -extension SwiftUICore.View { - @_Concurrency.MainActor @preconcurrency public func onPaywallWebViewMessage(_ action: @escaping @_Concurrency.MainActor (RevenueCatUI.PaywallWebViewMessage, RevenueCatUI.PaywallWebViewController) -> Swift.Void) -> some SwiftUICore.View - -} @_inheritsConvenienceInitializers @_hasMissingDesignatedInitializers @available(iOS 15.0, macOS 12.0, tvOS 15.0, *) @objc(RCPaywallFooterViewController) @_Concurrency.MainActor @preconcurrency final public class PaywallFooterViewController : RevenueCatUI.PaywallViewController { @_Concurrency.MainActor @preconcurrency public init(offering: RevenueCat.Offering? = nil, performPurchase: @escaping RevenueCatUI.PerformPurchase, performRestore: @escaping RevenueCatUI.PerformRestore, dismissRequestedHandler: ((_ controller: RevenueCatUI.PaywallViewController) -> Swift.Void)? = nil) @@ -606,7 +546,5 @@ extension RevenueCatUI.CustomerCenterNavigationLink : Swift.Sendable {} @available(iOS 15.0, macOS 12.0, watchOS 8.0, *) @available(tvOS, unavailable, message: "RevenueCatUI does not support tvOS yet") extension RevenueCatUI.PaywallView : Swift.Sendable {} -@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) -extension RevenueCatUI.PaywallWebViewController : Swift.Sendable {} extension RevenueCatUI.PaywallPresentationMode : Swift.Equatable {} extension RevenueCatUI.PaywallPresentationMode : Swift.Hashable {} diff --git a/api/revenuecatui-api-macos.swiftinterface b/api/revenuecatui-api-macos.swiftinterface index d7553c3e37..e64db71c52 100644 --- a/api/revenuecatui-api-macos.swiftinterface +++ b/api/revenuecatui-api-macos.swiftinterface @@ -168,66 +168,6 @@ open class CustomPaywallFontProvider : RevenueCatUI.PaywallFontProvider { @available(tvOS, unavailable, introduced: 15.0, message: "RevenueCatUI does not support tvOS yet") public typealias Body = @_opaqueReturnTypeOf("$s12RevenueCatUI11PaywallViewV4bodyQrvp", 0) __ } -@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) -public struct PaywallWebViewMessage : Swift.Sendable, Swift.Equatable { - public let componentID: Swift.String - public let type: Swift.String - public let responses: [Swift.String : RevenueCatUI.PaywallWebViewValue]? - public let error: Swift.String? - public init(componentID: Swift.String, type: Swift.String, responses: [Swift.String : RevenueCatUI.PaywallWebViewValue]? = nil, error: Swift.String? = nil) - public static func == (a: RevenueCatUI.PaywallWebViewMessage, b: RevenueCatUI.PaywallWebViewMessage) -> Swift.Bool -} -@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) -public struct PaywallWebViewValue : Swift.Sendable, Swift.Equatable, Swift.Hashable, Swift.Codable { - public static func string(_ value: Swift.String) -> RevenueCatUI.PaywallWebViewValue - public static func number(_ value: Swift.Double) -> RevenueCatUI.PaywallWebViewValue - public static func bool(_ value: Swift.Bool) -> RevenueCatUI.PaywallWebViewValue - public static func array(_ value: [RevenueCatUI.PaywallWebViewValue]) -> RevenueCatUI.PaywallWebViewValue - public static func object(_ value: [Swift.String : RevenueCatUI.PaywallWebViewValue]) -> RevenueCatUI.PaywallWebViewValue - public static var null: RevenueCatUI.PaywallWebViewValue { - get - } - public var stringValue: Swift.String? { - get - } - public var numberValue: Swift.Double? { - get - } - public var boolValue: Swift.Bool? { - get - } - public var arrayValue: [RevenueCatUI.PaywallWebViewValue]? { - get - } - public var objectValue: [Swift.String : RevenueCatUI.PaywallWebViewValue]? { - get - } - public var isNull: Swift.Bool { - get - } - public init(from decoder: any Swift.Decoder) throws - public func encode(to encoder: any Swift.Encoder) throws - public static func == (a: RevenueCatUI.PaywallWebViewValue, b: RevenueCatUI.PaywallWebViewValue) -> Swift.Bool - public func hash(into hasher: inout Swift.Hasher) - public var hashValue: Swift.Int { - get - } -} -@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) -@_Concurrency.MainActor public struct PaywallWebViewController { - @_Concurrency.MainActor public func postVariables(componentID: Swift.String, variables: [Swift.String : RevenueCatUI.PaywallWebViewValue]) - @_Concurrency.MainActor public func postMessage(componentID: Swift.String, type: Swift.String, variables: [Swift.String : RevenueCatUI.PaywallWebViewValue]) -} -@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) -public struct PaywallWebViewMessageAction { - public init(_ action: @escaping @_Concurrency.MainActor (RevenueCatUI.PaywallWebViewMessage, RevenueCatUI.PaywallWebViewController) -> Swift.Void) - @_Concurrency.MainActor public func callAsFunction(_ message: RevenueCatUI.PaywallWebViewMessage, _ controller: RevenueCatUI.PaywallWebViewController) -} -@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) -extension SwiftUICore.View { - @_Concurrency.MainActor @preconcurrency public func onPaywallWebViewMessage(_ action: @escaping @_Concurrency.MainActor (RevenueCatUI.PaywallWebViewMessage, RevenueCatUI.PaywallWebViewController) -> Swift.Void) -> some SwiftUICore.View - -} @available(iOS 14.0, macOS 11.0, tvOS 14.0, watchOS 7.0, *) extension SwiftUICore.View { @_Concurrency.MainActor @preconcurrency public func onWebPurchaseRedemptionAttempt(perform completion: @escaping @Sendable (RevenueCat.WebPurchaseRedemptionResult) -> Swift.Void) -> some SwiftUICore.View @@ -384,7 +324,5 @@ extension SwiftUICore.View { @available(iOS 15.0, macOS 12.0, watchOS 8.0, *) @available(tvOS, unavailable, message: "RevenueCatUI does not support tvOS yet") extension RevenueCatUI.PaywallView : Swift.Sendable {} -@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) -extension RevenueCatUI.PaywallWebViewController : Swift.Sendable {} extension RevenueCatUI.PaywallPresentationMode : Swift.Equatable {} extension RevenueCatUI.PaywallPresentationMode : Swift.Hashable {} diff --git a/api/revenuecatui-api-visionos-simulator.swiftinterface b/api/revenuecatui-api-visionos-simulator.swiftinterface index 7369428ec5..b87d2b82d9 100644 --- a/api/revenuecatui-api-visionos-simulator.swiftinterface +++ b/api/revenuecatui-api-visionos-simulator.swiftinterface @@ -179,66 +179,6 @@ extension RevenueCat.Purchases { @available(iOS 15.0, macOS 12.0, *) @_Concurrency.MainActor @objc final public func presentPaywall(from url: Foundation.URL) -> Swift.Bool } -@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) -public struct PaywallWebViewMessage : Swift.Sendable, Swift.Equatable { - public let componentID: Swift.String - public let type: Swift.String - public let responses: [Swift.String : RevenueCatUI.PaywallWebViewValue]? - public let error: Swift.String? - public init(componentID: Swift.String, type: Swift.String, responses: [Swift.String : RevenueCatUI.PaywallWebViewValue]? = nil, error: Swift.String? = nil) - public static func == (a: RevenueCatUI.PaywallWebViewMessage, b: RevenueCatUI.PaywallWebViewMessage) -> Swift.Bool -} -@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) -public struct PaywallWebViewValue : Swift.Sendable, Swift.Equatable, Swift.Hashable, Swift.Codable { - public static func string(_ value: Swift.String) -> RevenueCatUI.PaywallWebViewValue - public static func number(_ value: Swift.Double) -> RevenueCatUI.PaywallWebViewValue - public static func bool(_ value: Swift.Bool) -> RevenueCatUI.PaywallWebViewValue - public static func array(_ value: [RevenueCatUI.PaywallWebViewValue]) -> RevenueCatUI.PaywallWebViewValue - public static func object(_ value: [Swift.String : RevenueCatUI.PaywallWebViewValue]) -> RevenueCatUI.PaywallWebViewValue - public static var null: RevenueCatUI.PaywallWebViewValue { - get - } - public var stringValue: Swift.String? { - get - } - public var numberValue: Swift.Double? { - get - } - public var boolValue: Swift.Bool? { - get - } - public var arrayValue: [RevenueCatUI.PaywallWebViewValue]? { - get - } - public var objectValue: [Swift.String : RevenueCatUI.PaywallWebViewValue]? { - get - } - public var isNull: Swift.Bool { - get - } - public init(from decoder: any Swift.Decoder) throws - public func encode(to encoder: any Swift.Encoder) throws - public static func == (a: RevenueCatUI.PaywallWebViewValue, b: RevenueCatUI.PaywallWebViewValue) -> Swift.Bool - public func hash(into hasher: inout Swift.Hasher) - public var hashValue: Swift.Int { - get - } -} -@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) -@_Concurrency.MainActor public struct PaywallWebViewController { - @_Concurrency.MainActor public func postVariables(componentID: Swift.String, variables: [Swift.String : RevenueCatUI.PaywallWebViewValue]) - @_Concurrency.MainActor public func postMessage(componentID: Swift.String, type: Swift.String, variables: [Swift.String : RevenueCatUI.PaywallWebViewValue]) -} -@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) -public struct PaywallWebViewMessageAction { - public init(_ action: @escaping @_Concurrency.MainActor (RevenueCatUI.PaywallWebViewMessage, RevenueCatUI.PaywallWebViewController) -> Swift.Void) - @_Concurrency.MainActor public func callAsFunction(_ message: RevenueCatUI.PaywallWebViewMessage, _ controller: RevenueCatUI.PaywallWebViewController) -} -@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) -extension SwiftUICore.View { - @_Concurrency.MainActor @preconcurrency public func onPaywallWebViewMessage(_ action: @escaping @_Concurrency.MainActor (RevenueCatUI.PaywallWebViewMessage, RevenueCatUI.PaywallWebViewController) -> Swift.Void) -> some SwiftUICore.View - -} @_inheritsConvenienceInitializers @_hasMissingDesignatedInitializers @available(iOS 15.0, macOS 12.0, tvOS 15.0, *) @objc(RCPaywallFooterViewController) @_Concurrency.MainActor @preconcurrency final public class PaywallFooterViewController : RevenueCatUI.PaywallViewController { @_Concurrency.MainActor @preconcurrency public init(offering: RevenueCat.Offering? = nil, performPurchase: @escaping RevenueCatUI.PerformPurchase, performRestore: @escaping RevenueCatUI.PerformRestore, dismissRequestedHandler: ((_ controller: RevenueCatUI.PaywallViewController) -> Swift.Void)? = nil) @@ -466,7 +406,5 @@ extension SwiftUICore.View { @available(iOS 15.0, macOS 12.0, watchOS 8.0, *) @available(tvOS, unavailable, message: "RevenueCatUI does not support tvOS yet") extension RevenueCatUI.PaywallView : Swift.Sendable {} -@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) -extension RevenueCatUI.PaywallWebViewController : Swift.Sendable {} extension RevenueCatUI.PaywallPresentationMode : Swift.Equatable {} extension RevenueCatUI.PaywallPresentationMode : Swift.Hashable {} diff --git a/api/revenuecatui-api-visionos.swiftinterface b/api/revenuecatui-api-visionos.swiftinterface index cccba66c64..ec8d35ca1f 100644 --- a/api/revenuecatui-api-visionos.swiftinterface +++ b/api/revenuecatui-api-visionos.swiftinterface @@ -179,66 +179,6 @@ extension RevenueCat.Purchases { @available(iOS 15.0, macOS 12.0, *) @_Concurrency.MainActor @objc final public func presentPaywall(from url: Foundation.URL) -> Swift.Bool } -@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) -public struct PaywallWebViewMessage : Swift.Sendable, Swift.Equatable { - public let componentID: Swift.String - public let type: Swift.String - public let responses: [Swift.String : RevenueCatUI.PaywallWebViewValue]? - public let error: Swift.String? - public init(componentID: Swift.String, type: Swift.String, responses: [Swift.String : RevenueCatUI.PaywallWebViewValue]? = nil, error: Swift.String? = nil) - public static func == (a: RevenueCatUI.PaywallWebViewMessage, b: RevenueCatUI.PaywallWebViewMessage) -> Swift.Bool -} -@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) -public struct PaywallWebViewValue : Swift.Sendable, Swift.Equatable, Swift.Hashable, Swift.Codable { - public static func string(_ value: Swift.String) -> RevenueCatUI.PaywallWebViewValue - public static func number(_ value: Swift.Double) -> RevenueCatUI.PaywallWebViewValue - public static func bool(_ value: Swift.Bool) -> RevenueCatUI.PaywallWebViewValue - public static func array(_ value: [RevenueCatUI.PaywallWebViewValue]) -> RevenueCatUI.PaywallWebViewValue - public static func object(_ value: [Swift.String : RevenueCatUI.PaywallWebViewValue]) -> RevenueCatUI.PaywallWebViewValue - public static var null: RevenueCatUI.PaywallWebViewValue { - get - } - public var stringValue: Swift.String? { - get - } - public var numberValue: Swift.Double? { - get - } - public var boolValue: Swift.Bool? { - get - } - public var arrayValue: [RevenueCatUI.PaywallWebViewValue]? { - get - } - public var objectValue: [Swift.String : RevenueCatUI.PaywallWebViewValue]? { - get - } - public var isNull: Swift.Bool { - get - } - public init(from decoder: any Swift.Decoder) throws - public func encode(to encoder: any Swift.Encoder) throws - public static func == (a: RevenueCatUI.PaywallWebViewValue, b: RevenueCatUI.PaywallWebViewValue) -> Swift.Bool - public func hash(into hasher: inout Swift.Hasher) - public var hashValue: Swift.Int { - get - } -} -@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) -@_Concurrency.MainActor public struct PaywallWebViewController { - @_Concurrency.MainActor public func postVariables(componentID: Swift.String, variables: [Swift.String : RevenueCatUI.PaywallWebViewValue]) - @_Concurrency.MainActor public func postMessage(componentID: Swift.String, type: Swift.String, variables: [Swift.String : RevenueCatUI.PaywallWebViewValue]) -} -@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) -public struct PaywallWebViewMessageAction { - public init(_ action: @escaping @_Concurrency.MainActor (RevenueCatUI.PaywallWebViewMessage, RevenueCatUI.PaywallWebViewController) -> Swift.Void) - @_Concurrency.MainActor public func callAsFunction(_ message: RevenueCatUI.PaywallWebViewMessage, _ controller: RevenueCatUI.PaywallWebViewController) -} -@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) -extension SwiftUICore.View { - @_Concurrency.MainActor @preconcurrency public func onPaywallWebViewMessage(_ action: @escaping @_Concurrency.MainActor (RevenueCatUI.PaywallWebViewMessage, RevenueCatUI.PaywallWebViewController) -> Swift.Void) -> some SwiftUICore.View - -} @_inheritsConvenienceInitializers @_hasMissingDesignatedInitializers @available(iOS 15.0, macOS 12.0, tvOS 15.0, *) @objc(RCPaywallFooterViewController) @_Concurrency.MainActor @preconcurrency final public class PaywallFooterViewController : RevenueCatUI.PaywallViewController { @_Concurrency.MainActor @preconcurrency public init(offering: RevenueCat.Offering? = nil, performPurchase: @escaping RevenueCatUI.PerformPurchase, performRestore: @escaping RevenueCatUI.PerformRestore, dismissRequestedHandler: ((_ controller: RevenueCatUI.PaywallViewController) -> Swift.Void)? = nil) @@ -466,7 +406,5 @@ extension SwiftUICore.View { @available(iOS 15.0, macOS 12.0, watchOS 8.0, *) @available(tvOS, unavailable, message: "RevenueCatUI does not support tvOS yet") extension RevenueCatUI.PaywallView : Swift.Sendable {} -@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) -extension RevenueCatUI.PaywallWebViewController : Swift.Sendable {} extension RevenueCatUI.PaywallPresentationMode : Swift.Equatable {} extension RevenueCatUI.PaywallPresentationMode : Swift.Hashable {} diff --git a/api/revenuecatui-api-watchos-simulator.swiftinterface b/api/revenuecatui-api-watchos-simulator.swiftinterface index 7038282c5d..03d128c761 100644 --- a/api/revenuecatui-api-watchos-simulator.swiftinterface +++ b/api/revenuecatui-api-watchos-simulator.swiftinterface @@ -166,66 +166,6 @@ open class CustomPaywallFontProvider : RevenueCatUI.PaywallFontProvider { @available(tvOS, unavailable, introduced: 15.0, message: "RevenueCatUI does not support tvOS yet") public typealias Body = @_opaqueReturnTypeOf("$s12RevenueCatUI11PaywallViewV4bodyQrvp", 0) __ } -@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) -public struct PaywallWebViewMessage : Swift.Sendable, Swift.Equatable { - public let componentID: Swift.String - public let type: Swift.String - public let responses: [Swift.String : RevenueCatUI.PaywallWebViewValue]? - public let error: Swift.String? - public init(componentID: Swift.String, type: Swift.String, responses: [Swift.String : RevenueCatUI.PaywallWebViewValue]? = nil, error: Swift.String? = nil) - public static func == (a: RevenueCatUI.PaywallWebViewMessage, b: RevenueCatUI.PaywallWebViewMessage) -> Swift.Bool -} -@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) -public struct PaywallWebViewValue : Swift.Sendable, Swift.Equatable, Swift.Hashable, Swift.Codable { - public static func string(_ value: Swift.String) -> RevenueCatUI.PaywallWebViewValue - public static func number(_ value: Swift.Double) -> RevenueCatUI.PaywallWebViewValue - public static func bool(_ value: Swift.Bool) -> RevenueCatUI.PaywallWebViewValue - public static func array(_ value: [RevenueCatUI.PaywallWebViewValue]) -> RevenueCatUI.PaywallWebViewValue - public static func object(_ value: [Swift.String : RevenueCatUI.PaywallWebViewValue]) -> RevenueCatUI.PaywallWebViewValue - public static var null: RevenueCatUI.PaywallWebViewValue { - get - } - public var stringValue: Swift.String? { - get - } - public var numberValue: Swift.Double? { - get - } - public var boolValue: Swift.Bool? { - get - } - public var arrayValue: [RevenueCatUI.PaywallWebViewValue]? { - get - } - public var objectValue: [Swift.String : RevenueCatUI.PaywallWebViewValue]? { - get - } - public var isNull: Swift.Bool { - get - } - public init(from decoder: any Swift.Decoder) throws - public func encode(to encoder: any Swift.Encoder) throws - public static func == (a: RevenueCatUI.PaywallWebViewValue, b: RevenueCatUI.PaywallWebViewValue) -> Swift.Bool - public func hash(into hasher: inout Swift.Hasher) - public var hashValue: Swift.Int { - get - } -} -@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) -@_Concurrency.MainActor public struct PaywallWebViewController { - @_Concurrency.MainActor public func postVariables(componentID: Swift.String, variables: [Swift.String : RevenueCatUI.PaywallWebViewValue]) - @_Concurrency.MainActor public func postMessage(componentID: Swift.String, type: Swift.String, variables: [Swift.String : RevenueCatUI.PaywallWebViewValue]) -} -@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) -public struct PaywallWebViewMessageAction { - public init(_ action: @escaping @_Concurrency.MainActor (RevenueCatUI.PaywallWebViewMessage, RevenueCatUI.PaywallWebViewController) -> Swift.Void) - @_Concurrency.MainActor public func callAsFunction(_ message: RevenueCatUI.PaywallWebViewMessage, _ controller: RevenueCatUI.PaywallWebViewController) -} -@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) -extension SwiftUICore.View { - @_Concurrency.MainActor @preconcurrency public func onPaywallWebViewMessage(_ action: @escaping @_Concurrency.MainActor (RevenueCatUI.PaywallWebViewMessage, RevenueCatUI.PaywallWebViewController) -> Swift.Void) -> some SwiftUICore.View - -} @available(iOS 14.0, macOS 11.0, tvOS 14.0, watchOS 7.0, *) extension SwiftUICore.View { @_Concurrency.MainActor @preconcurrency public func onWebPurchaseRedemptionAttempt(perform completion: @escaping @Sendable (RevenueCat.WebPurchaseRedemptionResult) -> Swift.Void) -> some SwiftUICore.View @@ -339,7 +279,5 @@ extension SwiftUICore.View { @available(iOS 15.0, macOS 12.0, watchOS 8.0, *) @available(tvOS, unavailable, message: "RevenueCatUI does not support tvOS yet") extension RevenueCatUI.PaywallView : Swift.Sendable {} -@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) -extension RevenueCatUI.PaywallWebViewController : Swift.Sendable {} extension RevenueCatUI.PaywallPresentationMode : Swift.Equatable {} extension RevenueCatUI.PaywallPresentationMode : Swift.Hashable {} diff --git a/api/revenuecatui-api-watchos.swiftinterface b/api/revenuecatui-api-watchos.swiftinterface index 0490cf9e59..39b2ed3084 100644 --- a/api/revenuecatui-api-watchos.swiftinterface +++ b/api/revenuecatui-api-watchos.swiftinterface @@ -166,66 +166,6 @@ open class CustomPaywallFontProvider : RevenueCatUI.PaywallFontProvider { @available(tvOS, unavailable, introduced: 15.0, message: "RevenueCatUI does not support tvOS yet") public typealias Body = @_opaqueReturnTypeOf("$s12RevenueCatUI11PaywallViewV4bodyQrvp", 0) __ } -@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) -public struct PaywallWebViewMessage : Swift.Sendable, Swift.Equatable { - public let componentID: Swift.String - public let type: Swift.String - public let responses: [Swift.String : RevenueCatUI.PaywallWebViewValue]? - public let error: Swift.String? - public init(componentID: Swift.String, type: Swift.String, responses: [Swift.String : RevenueCatUI.PaywallWebViewValue]? = nil, error: Swift.String? = nil) - public static func == (a: RevenueCatUI.PaywallWebViewMessage, b: RevenueCatUI.PaywallWebViewMessage) -> Swift.Bool -} -@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) -public struct PaywallWebViewValue : Swift.Sendable, Swift.Equatable, Swift.Hashable, Swift.Codable { - public static func string(_ value: Swift.String) -> RevenueCatUI.PaywallWebViewValue - public static func number(_ value: Swift.Double) -> RevenueCatUI.PaywallWebViewValue - public static func bool(_ value: Swift.Bool) -> RevenueCatUI.PaywallWebViewValue - public static func array(_ value: [RevenueCatUI.PaywallWebViewValue]) -> RevenueCatUI.PaywallWebViewValue - public static func object(_ value: [Swift.String : RevenueCatUI.PaywallWebViewValue]) -> RevenueCatUI.PaywallWebViewValue - public static var null: RevenueCatUI.PaywallWebViewValue { - get - } - public var stringValue: Swift.String? { - get - } - public var numberValue: Swift.Double? { - get - } - public var boolValue: Swift.Bool? { - get - } - public var arrayValue: [RevenueCatUI.PaywallWebViewValue]? { - get - } - public var objectValue: [Swift.String : RevenueCatUI.PaywallWebViewValue]? { - get - } - public var isNull: Swift.Bool { - get - } - public init(from decoder: any Swift.Decoder) throws - public func encode(to encoder: any Swift.Encoder) throws - public static func == (a: RevenueCatUI.PaywallWebViewValue, b: RevenueCatUI.PaywallWebViewValue) -> Swift.Bool - public func hash(into hasher: inout Swift.Hasher) - public var hashValue: Swift.Int { - get - } -} -@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) -@_Concurrency.MainActor public struct PaywallWebViewController { - @_Concurrency.MainActor public func postVariables(componentID: Swift.String, variables: [Swift.String : RevenueCatUI.PaywallWebViewValue]) - @_Concurrency.MainActor public func postMessage(componentID: Swift.String, type: Swift.String, variables: [Swift.String : RevenueCatUI.PaywallWebViewValue]) -} -@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) -public struct PaywallWebViewMessageAction { - public init(_ action: @escaping @_Concurrency.MainActor (RevenueCatUI.PaywallWebViewMessage, RevenueCatUI.PaywallWebViewController) -> Swift.Void) - @_Concurrency.MainActor public func callAsFunction(_ message: RevenueCatUI.PaywallWebViewMessage, _ controller: RevenueCatUI.PaywallWebViewController) -} -@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) -extension SwiftUICore.View { - @_Concurrency.MainActor @preconcurrency public func onPaywallWebViewMessage(_ action: @escaping @_Concurrency.MainActor (RevenueCatUI.PaywallWebViewMessage, RevenueCatUI.PaywallWebViewController) -> Swift.Void) -> some SwiftUICore.View - -} @available(iOS 14.0, macOS 11.0, tvOS 14.0, watchOS 7.0, *) extension SwiftUICore.View { @_Concurrency.MainActor @preconcurrency public func onWebPurchaseRedemptionAttempt(perform completion: @escaping @Sendable (RevenueCat.WebPurchaseRedemptionResult) -> Swift.Void) -> some SwiftUICore.View @@ -339,7 +279,5 @@ extension SwiftUICore.View { @available(iOS 15.0, macOS 12.0, watchOS 8.0, *) @available(tvOS, unavailable, message: "RevenueCatUI does not support tvOS yet") extension RevenueCatUI.PaywallView : Swift.Sendable {} -@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) -extension RevenueCatUI.PaywallWebViewController : Swift.Sendable {} extension RevenueCatUI.PaywallPresentationMode : Swift.Equatable {} extension RevenueCatUI.PaywallPresentationMode : Swift.Hashable {} From bba700f5c38dec18c8aca4a7b141e62c6104361f Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 17 Jul 2026 14:01:18 +0000 Subject: [PATCH 15/35] feat(paywalls): apply fit defaults to web views Co-authored-by: Jacob Rakidzich --- .../WebView/WebViewComponentView.swift | 51 ++++++++++++++++--- .../Components/PaywallWebViewComponent.swift | 4 +- .../PaywallsV2/WebViewComponentTests.swift | 35 ++++++++++++- 3 files changed, 79 insertions(+), 11 deletions(-) diff --git a/RevenueCatUI/Templates/V2/Components/WebView/WebViewComponentView.swift b/RevenueCatUI/Templates/V2/Components/WebView/WebViewComponentView.swift index adcf79f918..c61898d458 100644 --- a/RevenueCatUI/Templates/V2/Components/WebView/WebViewComponentView.swift +++ b/RevenueCatUI/Templates/V2/Components/WebView/WebViewComponentView.swift @@ -29,7 +29,7 @@ struct WebViewComponentView: View { ) .id( "\(viewModel.urlString)-\(componentID)-" + - "\(viewModel.size.width == .fit)-\(viewModel.size.height == .fit)" + "\(viewModel.size.width.isFit)-\(viewModel.size.height.isFit)" ) } else { RenderOnlyWebViewComponentView(viewModel: viewModel, url: url) @@ -44,6 +44,31 @@ struct WebViewComponentView: View { } +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) +enum WebViewSizing { + + static func resolvedDimension( + measured: CGFloat?, + defaultSize: UInt?, + fallback: CGFloat + ) -> CGFloat { + measured ?? defaultSize.map { CGFloat($0) } ?? fallback + } + +} + +private extension PaywallComponent.SizeConstraint { + + var isFit: Bool { + if case .fit = self { + return true + } + + return false + } + +} + #if canImport(WebKit) && !os(watchOS) @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) @@ -85,8 +110,8 @@ private struct BridgedWebViewComponentView: View { expectedOrigin: origin, localeIdentifier: viewModel.locale.identifier, fitAxes: ( - width: viewModel.size.width == .fit, - height: viewModel.size.height == .fit + width: viewModel.size.width.isFit, + height: viewModel.size.height.isFit ) ) ) @@ -358,8 +383,14 @@ private extension View { measuredWidth: CGFloat? ) -> some View { switch constraint { - case .fit: - self.frame(width: measuredWidth ?? WebViewEnvelope.fallbackFitWidth) + case .fit(let defaultSize): + self.frame( + width: WebViewSizing.resolvedDimension( + measured: measuredWidth, + defaultSize: defaultSize, + fallback: WebViewEnvelope.fallbackFitWidth + ) + ) case .fill: self.frame(maxWidth: .infinity) case .fixed(let value): @@ -375,8 +406,14 @@ private extension View { measuredHeight: CGFloat? ) -> some View { switch constraint { - case .fit: - self.frame(height: measuredHeight ?? WebViewEnvelope.fallbackFitHeight) + case .fit(let defaultSize): + self.frame( + height: WebViewSizing.resolvedDimension( + measured: measuredHeight, + defaultSize: defaultSize, + fallback: WebViewEnvelope.fallbackFitHeight + ) + ) case .fill: self.frame(maxHeight: .infinity) case .fixed(let value): diff --git a/Sources/Paywalls/Components/PaywallWebViewComponent.swift b/Sources/Paywalls/Components/PaywallWebViewComponent.swift index 26b2ba87e2..a0fe2fadbe 100644 --- a/Sources/Paywalls/Components/PaywallWebViewComponent.swift +++ b/Sources/Paywalls/Components/PaywallWebViewComponent.swift @@ -38,7 +38,7 @@ import Foundation visible: Bool? = nil, protocolVersion: Int = 1, url: String, - size: Size = .init(width: .fill, height: .fit) + size: Size = .init(width: .fill, height: .fit(nil)) ) { self.type = "web_view" self.id = id @@ -77,7 +77,7 @@ import Foundation self.protocolVersion = try container.decodeIfPresent(Int.self, forKey: .protocolVersion) ?? 1 self.url = try container.decode(String.self, forKey: .url) self.size = try container.decodeIfPresent(Size.self, forKey: .size) - ?? .init(width: .fill, height: .fit) + ?? .init(width: .fill, height: .fit(nil)) } public func encode(to encoder: Encoder) throws { diff --git a/Tests/RevenueCatUITests/PaywallsV2/WebViewComponentTests.swift b/Tests/RevenueCatUITests/PaywallsV2/WebViewComponentTests.swift index f2ecc96560..53023fefd6 100644 --- a/Tests/RevenueCatUITests/PaywallsV2/WebViewComponentTests.swift +++ b/Tests/RevenueCatUITests/PaywallsV2/WebViewComponentTests.swift @@ -21,7 +21,7 @@ final class WebViewComponentTests: TestCase { XCTAssertNil(minimal.visible) XCTAssertEqual(minimal.protocolVersion, 1) XCTAssertEqual(minimal.size.width, .fill) - XCTAssertEqual(minimal.size.height, .fit) + XCTAssertEqual(minimal.size.height, .fit(nil)) XCTAssertEqual(minimal.url, "https://example.com") XCTAssertEqual(minimal.type, "web_view") } @@ -46,12 +46,43 @@ final class WebViewComponentTests: TestCase { XCTAssertEqual(component.url, "https://example.com/index.html") } + func testDecodesFitLoadingDefaults() throws { + let component = try JSONDecoder.default.decode(PaywallComponent.WebViewComponent.self, from: Data(""" + { + "type": "web_view", + "url": "https://example.com", + "size": { + "width": { "type": "fit", "default": 320 }, + "height": { "type": "fit", "default": 180 } + } + } + """.utf8)) + + XCTAssertEqual(component.size.width, .fit(320)) + XCTAssertEqual(component.size.height, .fit(180)) + } + + func testResolvedFitDimensionPrecedence() { + XCTAssertEqual( + WebViewSizing.resolvedDimension(measured: 420, defaultSize: 240, fallback: 300), + 420 + ) + XCTAssertEqual( + WebViewSizing.resolvedDimension(measured: nil, defaultSize: 240, fallback: 300), + 240 + ) + XCTAssertEqual( + WebViewSizing.resolvedDimension(measured: nil, defaultSize: nil, fallback: 300), + 300 + ) + } + func testViewModelURLValidationHashingAndLocale() { let component = PaywallComponent.WebViewComponent( id: "web", protocolVersion: 2, url: "https://example.com/path", - size: .init(width: .fill, height: .fit) + size: .init(width: .fill, height: .fit(nil)) ) let viewModel = WebViewComponentViewModel( component: component, From 94982da16f8286703e8b3b77a3e6cddf54e6a592 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 17 Jul 2026 14:09:44 +0000 Subject: [PATCH 16/35] fix(paywalls): remove client-side web view payload limits Co-authored-by: Jacob Rakidzich --- .../Components/WebView/WebViewEnvelope.swift | 55 +------------------ .../PaywallsV2/WebViewEnvelopeTests.swift | 33 +++-------- 2 files changed, 11 insertions(+), 77 deletions(-) diff --git a/RevenueCatUI/Templates/V2/Components/WebView/WebViewEnvelope.swift b/RevenueCatUI/Templates/V2/Components/WebView/WebViewEnvelope.swift index 7905029df5..b336c868b2 100644 --- a/RevenueCatUI/Templates/V2/Components/WebView/WebViewEnvelope.swift +++ b/RevenueCatUI/Templates/V2/Components/WebView/WebViewEnvelope.swift @@ -19,11 +19,6 @@ enum WebViewEnvelope { static let messageTypeResize = "resize" static let messageTypeFit = "fit" - static let maxInboundFrameBytes = 65_536 - static let maxJSONDepth = 16 - /// Structural depth budget for a whole inbound frame: the envelope object itself (1 level) - /// plus a payload tree of at most `maxJSONDepth` levels. - static let maxFrameDepth = maxJSONDepth + 1 static let maxResizePoints: CGFloat = 10_000 static let resizeThreshold: CGFloat = 1 static let fallbackFitHeight: CGFloat = 100 @@ -83,15 +78,13 @@ enum WebViewEnvelope { static func decode(rawMessage: Any) -> Envelope? { let data: Data if let string = rawMessage as? String { - guard string.utf8.count <= Self.maxInboundFrameBytes, - let stringData = string.data(using: .utf8) else { + guard let stringData = string.data(using: .utf8) else { return nil } data = stringData } else if let dictionary = rawMessage as? [String: Any] { guard JSONSerialization.isValidJSONObject(dictionary), - let serialized = try? JSONSerialization.data(withJSONObject: dictionary), - serialized.count <= Self.maxInboundFrameBytes else { + let serialized = try? JSONSerialization.data(withJSONObject: dictionary) else { return nil } data = serialized @@ -99,13 +92,6 @@ enum WebViewEnvelope { return nil } - // Enforce the nesting limit BEFORE decoding: `PaywallWebViewValue.init(from:)` recurses - // per nesting level, so a hostile deeply-nested frame (tens of thousands of levels fit in - // 64 KiB) could otherwise overflow the stack before any post-decode check runs. - guard !Self.exceedsMaxDepth(data) else { - return nil - } - guard let envelope = try? JSONDecoder().decode(Envelope.self, from: data), envelope.channel == Self.channel else { return nil @@ -114,43 +100,6 @@ enum WebViewEnvelope { return envelope } - /// Non-recursive scan of the raw JSON bytes, tracking `{`/`[` nesting outside string - /// literals (honoring `\` escapes). Returns `true` when the depth exceeds ``maxFrameDepth``. - static func exceedsMaxDepth(_ data: Data) -> Bool { - var depth = 0 - var inString = false - var escaped = false - - for byte in data { - if inString { - if escaped { - escaped = false - } else if byte == UInt8(ascii: "\\") { - escaped = true - } else if byte == UInt8(ascii: "\"") { - inString = false - } - continue - } - - switch byte { - case UInt8(ascii: "\""): - inString = true - case UInt8(ascii: "{"), UInt8(ascii: "["): - depth += 1 - if depth > Self.maxFrameDepth { - return true - } - case UInt8(ascii: "}"), UInt8(ascii: "]"): - depth -= 1 - default: - break - } - } - - return false - } - static func receiveScript(for envelope: Envelope) -> String? { let encoder = JSONEncoder() guard let data = try? encoder.encode(envelope), diff --git a/Tests/RevenueCatUITests/PaywallsV2/WebViewEnvelopeTests.swift b/Tests/RevenueCatUITests/PaywallsV2/WebViewEnvelopeTests.swift index 509b97964e..a5e12969eb 100644 --- a/Tests/RevenueCatUITests/PaywallsV2/WebViewEnvelopeTests.swift +++ b/Tests/RevenueCatUITests/PaywallsV2/WebViewEnvelopeTests.swift @@ -52,33 +52,18 @@ final class WebViewEnvelopeTests: TestCase { ])) } - func testByteLimit() { - let underLimitPayload = String(repeating: "x", count: WebViewEnvelope.maxInboundFrameBytes - 250) - XCTAssertNotNil(WebViewEnvelope.decode(rawMessage: Self.json([ - "channel": WebViewEnvelope.channel, - "protocol_version": 1, - "kind": "message", - "component_id": "web", - "type": "rc:step-loaded", - "payload": ["value": underLimitPayload] - ]))) + func testDoesNotEnforceClientSideByteLimit() { + let payload = String(repeating: "x", count: 65_537) + let frame = Self.frame(payload: ["value": payload]) - let overLimitPayload = String(repeating: "x", count: WebViewEnvelope.maxInboundFrameBytes + 1) - XCTAssertNil(WebViewEnvelope.decode(rawMessage: Self.json([ - "channel": WebViewEnvelope.channel, - "protocol_version": 1, - "kind": "message", - "component_id": "web", - "type": "rc:step-loaded", - "payload": ["value": overLimitPayload] - ]))) + XCTAssertNotNil(WebViewEnvelope.decode(rawMessage: frame)) + XCTAssertNotNil(WebViewEnvelope.decode(rawMessage: Self.json(frame))) } - func testDepthLimit() { - XCTAssertNotNil(WebViewEnvelope.decode( - rawMessage: Self.json(Self.frame(payload: Self.nestedObject(depth: 15))) - )) - XCTAssertNil(WebViewEnvelope.decode(rawMessage: Self.json(Self.frame(payload: Self.nestedObject(depth: 16))))) + func testDoesNotEnforceClientSideDepthLimit() { + XCTAssertNotNil( + WebViewEnvelope.decode(rawMessage: Self.json(Self.frame(payload: Self.nestedObject(depth: 16)))) + ) } func testEncodeDecodeRoundTripAndEscaping() throws { From ea6d24bf4933450bd663b6a937654703fcd54297 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 17 Jul 2026 14:20:57 +0000 Subject: [PATCH 17/35] test(paywalls): address web view envelope review Co-authored-by: Jacob Rakidzich --- .../Components/WebView/WebViewEnvelope.swift | 19 +++------- .../PaywallsV2/WebViewEnvelopeTests.swift | 37 +++++++++++++++---- 2 files changed, 34 insertions(+), 22 deletions(-) diff --git a/RevenueCatUI/Templates/V2/Components/WebView/WebViewEnvelope.swift b/RevenueCatUI/Templates/V2/Components/WebView/WebViewEnvelope.swift index b336c868b2..671aeb2fe9 100644 --- a/RevenueCatUI/Templates/V2/Components/WebView/WebViewEnvelope.swift +++ b/RevenueCatUI/Templates/V2/Components/WebView/WebViewEnvelope.swift @@ -1,5 +1,4 @@ import Foundation -// swiftlint:disable nesting #if !os(tvOS) // For Paywalls V2 @@ -24,6 +23,7 @@ enum WebViewEnvelope { static let fallbackFitHeight: CGFloat = 100 static let fallbackFitWidth: CGFloat = 300 + // swiftlint:disable nesting enum Kind: String, Codable { case connect case `init` @@ -74,21 +74,12 @@ enum WebViewEnvelope { case error } } + // swiftlint:enable nesting static func decode(rawMessage: Any) -> Envelope? { - let data: Data - if let string = rawMessage as? String { - guard let stringData = string.data(using: .utf8) else { - return nil - } - data = stringData - } else if let dictionary = rawMessage as? [String: Any] { - guard JSONSerialization.isValidJSONObject(dictionary), - let serialized = try? JSONSerialization.data(withJSONObject: dictionary) else { - return nil - } - data = serialized - } else { + // `workflow-web-components-sdk` sends frames with `postMessage(JSON.stringify(frame))`. + guard let string = rawMessage as? String, + let data = string.data(using: .utf8) else { return nil } diff --git a/Tests/RevenueCatUITests/PaywallsV2/WebViewEnvelopeTests.swift b/Tests/RevenueCatUITests/PaywallsV2/WebViewEnvelopeTests.swift index a5e12969eb..58aa3bf426 100644 --- a/Tests/RevenueCatUITests/PaywallsV2/WebViewEnvelopeTests.swift +++ b/Tests/RevenueCatUITests/PaywallsV2/WebViewEnvelopeTests.swift @@ -43,20 +43,17 @@ final class WebViewEnvelopeTests: TestCase { "kind": "message" ]))) XCTAssertNil(WebViewEnvelope.decode(rawMessage: "{not json")) - XCTAssertNil(WebViewEnvelope.decode(rawMessage: [ - "channel": WebViewEnvelope.channel, - "protocol_version": 1, - "kind": "message", - "component_id": "web", - "payload": ["nested": ["bad": Date()]] - ])) + } + + func testRejectsNonStringFrames() { + XCTAssertNil(WebViewEnvelope.decode(rawMessage: Self.frame(payload: ["value": "valid JSON"]))) + XCTAssertNil(WebViewEnvelope.decode(rawMessage: 1)) } func testDoesNotEnforceClientSideByteLimit() { let payload = String(repeating: "x", count: 65_537) let frame = Self.frame(payload: ["value": payload]) - XCTAssertNotNil(WebViewEnvelope.decode(rawMessage: frame)) XCTAssertNotNil(WebViewEnvelope.decode(rawMessage: Self.json(frame))) } @@ -84,6 +81,30 @@ final class WebViewEnvelopeTests: TestCase { XCTAssertEqual(decoded.payload?["value"]?.stringValue, hostile) } + func testKindCodableRoundTrip() throws { + let kinds: [WebViewEnvelope.Kind] = [ + .connect, + .`init`, + .reject, + .message, + .request, + .response, + .error + ] + + for kind in kinds { + let data = try JSONEncoder().encode(kind) + XCTAssertEqual(try JSONDecoder().decode(WebViewEnvelope.Kind.self, from: data), kind) + } + } + + func testReservedPayloadKeysMatchWireProtocol() { + XCTAssertEqual( + WebViewEnvelope.reservedPayloadKeys, + ["channel", "protocol_version", "kind", "type", "component_id", "id", "error", "variables"] + ) + } + private static func frame(payload: [String: Any]) -> [String: Any] { [ "channel": WebViewEnvelope.channel, From 06125950309f465a64383feeb5b73fab4ba3703a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 17 Jul 2026 14:45:14 +0000 Subject: [PATCH 18/35] refactor(paywalls): remove unused web view handler API Co-authored-by: Jacob Rakidzich --- .../WebView/PaywallWebViewAPI.swift | 96 +------------------ 1 file changed, 2 insertions(+), 94 deletions(-) diff --git a/RevenueCatUI/Templates/V2/Components/WebView/PaywallWebViewAPI.swift b/RevenueCatUI/Templates/V2/Components/WebView/PaywallWebViewAPI.swift index 4aed083814..25e0d16a6c 100644 --- a/RevenueCatUI/Templates/V2/Components/WebView/PaywallWebViewAPI.swift +++ b/RevenueCatUI/Templates/V2/Components/WebView/PaywallWebViewAPI.swift @@ -1,44 +1,14 @@ import Foundation -import SwiftUI #if !os(tvOS) // For Paywalls V2 -/// A validated message sent from a Paywalls V2 `web_view` component to your app. -@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) -struct PaywallWebViewMessage: Sendable, Equatable { - - /// The identifier of the `web_view` component that produced this message. - let componentID: String - - /// The message type, e.g. `"rc:step-complete"`. - let type: String - - /// The responses collected by the web flow. Only populated for `"rc:step-complete"` messages. - let responses: [String: PaywallWebViewValue]? - - /// The error reported by the web content. Only populated for `"rc:error"` messages. - let error: String? - - init( - componentID: String, - type: String, - responses: [String: PaywallWebViewValue]? = nil, - error: String? = nil - ) { - self.componentID = componentID - self.type = type - self.responses = responses - self.error = error - } - -} - /// A JSON-compatible value exchanged between a Paywalls V2 `web_view` component and your app. @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) struct PaywallWebViewValue: Sendable, Equatable, Hashable, Codable { - private indirect enum Storage: Sendable, Equatable, Hashable { + private enum Storage: Sendable, Equatable, Hashable { case string(String) + // The web peer uses JavaScript `Number` values, so there is no integer precision to preserve. case number(Double) case bool(Bool) case array([PaywallWebViewValue]) @@ -139,66 +109,4 @@ struct PaywallWebViewValue: Sendable, Equatable, Hashable, Codable { } -@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) -@MainActor -struct PaywallWebViewController { - - init() {} - - func postVariables(componentID: String, variables: [String: PaywallWebViewValue]) { - // Session wiring lands in a later PR. - } - - func postMessage(componentID: String, type: String, variables: [String: PaywallWebViewValue]) { - // Session wiring lands in a later PR. - } - -} - -/// A wrapper for the Paywalls V2 `web_view` message handler. -@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) -struct PaywallWebViewMessageAction { - - private let action: @MainActor (PaywallWebViewMessage, PaywallWebViewController) -> Void - - init( - _ action: @escaping @MainActor (PaywallWebViewMessage, PaywallWebViewController) -> Void - ) { - self.action = action - } - - @MainActor - func callAsFunction(_ message: PaywallWebViewMessage, _ controller: PaywallWebViewController) { - self.action(message, controller) - } - -} - -@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) -struct PaywallWebViewMessageActionKey: EnvironmentKey { - static let defaultValue: PaywallWebViewMessageAction? = nil -} - -@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) -extension EnvironmentValues { - - var paywallWebViewMessageAction: PaywallWebViewMessageAction? { - get { self[PaywallWebViewMessageActionKey.self] } - set { self[PaywallWebViewMessageActionKey.self] = newValue } - } - -} - -@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) -extension View { - - /// Invokes the given closure when a Paywalls V2 `web_view` component sends a message. - func onPaywallWebViewMessage( - _ action: @escaping @MainActor (PaywallWebViewMessage, PaywallWebViewController) -> Void - ) -> some View { - self.environment(\.paywallWebViewMessageAction, PaywallWebViewMessageAction(action)) - } - -} - #endif From d14d63ca685f5932e3b031f700a11ccde72cef16 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 17 Jul 2026 14:46:22 +0000 Subject: [PATCH 19/35] fix(paywalls): retain internal bridge dependencies Co-authored-by: Jacob Rakidzich --- .../WebView/PaywallWebViewAPI.swift | 81 +++++++++++++++++++ 1 file changed, 81 insertions(+) diff --git a/RevenueCatUI/Templates/V2/Components/WebView/PaywallWebViewAPI.swift b/RevenueCatUI/Templates/V2/Components/WebView/PaywallWebViewAPI.swift index 25e0d16a6c..145b578a94 100644 --- a/RevenueCatUI/Templates/V2/Components/WebView/PaywallWebViewAPI.swift +++ b/RevenueCatUI/Templates/V2/Components/WebView/PaywallWebViewAPI.swift @@ -1,7 +1,38 @@ import Foundation +import SwiftUI #if !os(tvOS) // For Paywalls V2 +/// A validated message sent from a Paywalls V2 `web_view` component to your app. +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) +struct PaywallWebViewMessage: Sendable, Equatable { + + /// The identifier of the `web_view` component that produced this message. + let componentID: String + + /// The message type, e.g. `"rc:step-complete"`. + let type: String + + /// The responses collected by the web flow. Only populated for `"rc:step-complete"` messages. + let responses: [String: PaywallWebViewValue]? + + /// The error reported by the web content. Only populated for `"rc:error"` messages. + let error: String? + + init( + componentID: String, + type: String, + responses: [String: PaywallWebViewValue]? = nil, + error: String? = nil + ) { + self.componentID = componentID + self.type = type + self.responses = responses + self.error = error + } + +} + /// A JSON-compatible value exchanged between a Paywalls V2 `web_view` component and your app. @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) struct PaywallWebViewValue: Sendable, Equatable, Hashable, Codable { @@ -109,4 +140,54 @@ struct PaywallWebViewValue: Sendable, Equatable, Hashable, Codable { } +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) +@MainActor +struct PaywallWebViewController { + + init() {} + + func postVariables(componentID: String, variables: [String: PaywallWebViewValue]) { + // Session wiring lands in a later PR. + } + + func postMessage(componentID: String, type: String, variables: [String: PaywallWebViewValue]) { + // Session wiring lands in a later PR. + } + +} + +/// A wrapper for the Paywalls V2 `web_view` message handler. +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) +struct PaywallWebViewMessageAction { + + private let action: @MainActor (PaywallWebViewMessage, PaywallWebViewController) -> Void + + init( + _ action: @escaping @MainActor (PaywallWebViewMessage, PaywallWebViewController) -> Void + ) { + self.action = action + } + + @MainActor + func callAsFunction(_ message: PaywallWebViewMessage, _ controller: PaywallWebViewController) { + self.action(message, controller) + } + +} + +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) +struct PaywallWebViewMessageActionKey: EnvironmentKey { + static let defaultValue: PaywallWebViewMessageAction? = nil +} + +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) +extension EnvironmentValues { + + var paywallWebViewMessageAction: PaywallWebViewMessageAction? { + get { self[PaywallWebViewMessageActionKey.self] } + set { self[PaywallWebViewMessageActionKey.self] = newValue } + } + +} + #endif From 19cbfe67e290fc2c676da40fb99c58e53320ac1a Mon Sep 17 00:00:00 2001 From: Jacob Rakidzich Date: Fri, 17 Jul 2026 11:09:28 -0500 Subject: [PATCH 20/35] refactor(paywalls): remove web view message handler concept The bridge no longer forwards messages to an app-provided handler, so the message, action, controller, and environment types are removed. Only the JSON value type remains. Co-authored-by: Cursor --- .../WebView/PaywallWebViewAPI.swift | 81 ------------------- 1 file changed, 81 deletions(-) diff --git a/RevenueCatUI/Templates/V2/Components/WebView/PaywallWebViewAPI.swift b/RevenueCatUI/Templates/V2/Components/WebView/PaywallWebViewAPI.swift index 145b578a94..25e0d16a6c 100644 --- a/RevenueCatUI/Templates/V2/Components/WebView/PaywallWebViewAPI.swift +++ b/RevenueCatUI/Templates/V2/Components/WebView/PaywallWebViewAPI.swift @@ -1,38 +1,7 @@ import Foundation -import SwiftUI #if !os(tvOS) // For Paywalls V2 -/// A validated message sent from a Paywalls V2 `web_view` component to your app. -@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) -struct PaywallWebViewMessage: Sendable, Equatable { - - /// The identifier of the `web_view` component that produced this message. - let componentID: String - - /// The message type, e.g. `"rc:step-complete"`. - let type: String - - /// The responses collected by the web flow. Only populated for `"rc:step-complete"` messages. - let responses: [String: PaywallWebViewValue]? - - /// The error reported by the web content. Only populated for `"rc:error"` messages. - let error: String? - - init( - componentID: String, - type: String, - responses: [String: PaywallWebViewValue]? = nil, - error: String? = nil - ) { - self.componentID = componentID - self.type = type - self.responses = responses - self.error = error - } - -} - /// A JSON-compatible value exchanged between a Paywalls V2 `web_view` component and your app. @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) struct PaywallWebViewValue: Sendable, Equatable, Hashable, Codable { @@ -140,54 +109,4 @@ struct PaywallWebViewValue: Sendable, Equatable, Hashable, Codable { } -@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) -@MainActor -struct PaywallWebViewController { - - init() {} - - func postVariables(componentID: String, variables: [String: PaywallWebViewValue]) { - // Session wiring lands in a later PR. - } - - func postMessage(componentID: String, type: String, variables: [String: PaywallWebViewValue]) { - // Session wiring lands in a later PR. - } - -} - -/// A wrapper for the Paywalls V2 `web_view` message handler. -@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) -struct PaywallWebViewMessageAction { - - private let action: @MainActor (PaywallWebViewMessage, PaywallWebViewController) -> Void - - init( - _ action: @escaping @MainActor (PaywallWebViewMessage, PaywallWebViewController) -> Void - ) { - self.action = action - } - - @MainActor - func callAsFunction(_ message: PaywallWebViewMessage, _ controller: PaywallWebViewController) { - self.action(message, controller) - } - -} - -@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) -struct PaywallWebViewMessageActionKey: EnvironmentKey { - static let defaultValue: PaywallWebViewMessageAction? = nil -} - -@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) -extension EnvironmentValues { - - var paywallWebViewMessageAction: PaywallWebViewMessageAction? { - get { self[PaywallWebViewMessageActionKey.self] } - set { self[PaywallWebViewMessageActionKey.self] = newValue } - } - -} - #endif From eb53f301b9eb598867921a3ac2d44dd4acc748db Mon Sep 17 00:00:00 2001 From: Antonio Pallares Date: Mon, 20 Jul 2026 20:17:15 +0200 Subject: [PATCH 21/35] Adapt web view bridge to trimmed envelope API Match Part 2: drop the unused `rc:*` message types (step-loaded, step-complete, request-variables, variables, error) and their bridge handlers, keeping only `resize`/`fit`. This also removes the now-dead SDK-variables/locale plumbing and `reservedPayloadKeys`. Move `receiveScript` (and its `receiveFunction` constant) from the envelope data model into WebViewSession, where the sole consumer lives, with tests. Co-authored-by: Cursor --- .../Components/WebView/WebViewEnvelope.swift | 26 ---- .../Components/WebView/WebViewSession.swift | 96 +++---------- .../PaywallsV2/WebViewEnvelopeTests.swift | 23 +-- .../PaywallsV2/WebViewSessionTests.swift | 132 +++++------------- 4 files changed, 56 insertions(+), 221 deletions(-) diff --git a/RevenueCatUI/Templates/V2/Components/WebView/WebViewEnvelope.swift b/RevenueCatUI/Templates/V2/Components/WebView/WebViewEnvelope.swift index 671aeb2fe9..de572acf4c 100644 --- a/RevenueCatUI/Templates/V2/Components/WebView/WebViewEnvelope.swift +++ b/RevenueCatUI/Templates/V2/Components/WebView/WebViewEnvelope.swift @@ -7,14 +7,8 @@ enum WebViewEnvelope { static let channel = "rc-web-components" static let messageHandlerName = "rcWebComponents" - static let receiveFunction = "__rcWebComponentsReceive" static let defaultProtocolVersion = 1 - static let messageTypeStepLoaded = "rc:step-loaded" - static let messageTypeStepComplete = "rc:step-complete" - static let messageTypeRequestVariables = "rc:request-variables" - static let messageTypeVariables = "rc:variables" - static let messageTypeError = "rc:error" static let messageTypeResize = "resize" static let messageTypeFit = "fit" @@ -91,26 +85,6 @@ enum WebViewEnvelope { return envelope } - static func receiveScript(for envelope: Envelope) -> String? { - let encoder = JSONEncoder() - guard let data = try? encoder.encode(envelope), - let json = String(data: data, encoding: .utf8) else { - return nil - } - let escaped = json - .replacingOccurrences(of: "\u{2028}", with: "\\u2028") - .replacingOccurrences(of: "\u{2029}", with: "\\u2029") - - return """ - (function(){var m=\(escaped);if(typeof window.\(Self.receiveFunction)==='function'){\ - window.\(Self.receiveFunction)(m);}})(); - """ - } - - static let reservedPayloadKeys: Set = [ - "channel", "protocol_version", "kind", "type", "component_id", "id", "error", "variables" - ] - } #endif diff --git a/RevenueCatUI/Templates/V2/Components/WebView/WebViewSession.swift b/RevenueCatUI/Templates/V2/Components/WebView/WebViewSession.swift index 061646daf0..e15fdfba0e 100644 --- a/RevenueCatUI/Templates/V2/Components/WebView/WebViewSession.swift +++ b/RevenueCatUI/Templates/V2/Components/WebView/WebViewSession.swift @@ -24,7 +24,6 @@ final class WebViewSession: NSObject, ObservableObject, WKScriptMessageHandler { let fitAxes: (width: Bool, height: Bool) - private let localeIdentifier: String private var lastAppliedWidth: CGFloat? private var lastAppliedHeight: CGFloat? @@ -32,7 +31,6 @@ final class WebViewSession: NSObject, ObservableObject, WKScriptMessageHandler { componentID: String, protocolVersion: Int, expectedOrigin: String, - localeIdentifier: String, fitAxes: (width: Bool, height: Bool), evaluateJavaScript: @escaping (String) -> Void = { _ in }, currentURL: @escaping () -> URL? = { nil } @@ -40,7 +38,6 @@ final class WebViewSession: NSObject, ObservableObject, WKScriptMessageHandler { self.componentID = componentID self.protocolVersion = protocolVersion self.expectedOrigin = expectedOrigin - self.localeIdentifier = localeIdentifier self.fitAxes = fitAxes self.evaluateJavaScript = evaluateJavaScript self.currentURL = currentURL @@ -92,15 +89,6 @@ final class WebViewSession: NSObject, ObservableObject, WKScriptMessageHandler { switch type { case WebViewEnvelope.messageTypeResize: self.handleResize(envelope.payload) - case WebViewEnvelope.messageTypeStepLoaded: - // Recognized protocol frame; the SDK takes no action on it. - break - case WebViewEnvelope.messageTypeStepComplete: - self.handleStepComplete(envelope) - case WebViewEnvelope.messageTypeRequestVariables: - self.handleRequestVariables(envelope) - case WebViewEnvelope.messageTypeError: - self.handleError(envelope) default: self.logRejected("unknown-message-type") } @@ -160,63 +148,6 @@ final class WebViewSession: NSObject, ObservableObject, WKScriptMessageHandler { } } - /// Validates the frame shape so malformed frames are still logged and rejected; - /// the SDK takes no further action on well-formed step-complete frames. - private func handleStepComplete(_ envelope: WebViewEnvelope.Envelope) { - if let value = envelope.payload?["responses"] { - guard value.objectValue != nil else { - self.logRejected("malformed-responses") - return - } - } else if let payload = envelope.payload { - guard WebViewEnvelope.reservedPayloadKeys.isDisjoint(with: payload.keys) else { - self.logRejected("reserved-response-key") - return - } - } - } - - private func handleRequestVariables(_ envelope: WebViewEnvelope.Envelope) { - switch envelope.kind { - case .request: - guard let id = envelope.id else { - self.logRejected("request-without-id") - return - } - self.send( - .init( - kind: .response, - componentID: self.componentID, - type: WebViewEnvelope.messageTypeRequestVariables, - id: id, - payload: self.sdkVariables - ), - allowBeforeNavigation: false - ) - case .message: - self.send( - .init( - kind: .message, - componentID: self.componentID, - type: WebViewEnvelope.messageTypeVariables, - payload: self.sdkVariables - ), - allowBeforeNavigation: false - ) - default: - self.logRejected("unsupported-request-variables-kind") - } - } - - /// Validates the frame shape so malformed frames are still logged and rejected; - /// the SDK takes no further action on well-formed error frames. - private func handleError(_ envelope: WebViewEnvelope.Envelope) { - let error = envelope.payload?["error"]?.stringValue ?? envelope.error - if error == nil { - self.logRejected("missing-error") - } - } - private func handleResize(_ payload: [String: PaywallWebViewValue]?) { guard let payload else { return @@ -263,7 +194,7 @@ final class WebViewSession: NSObject, ObservableObject, WKScriptMessageHandler { Logger.debug(Strings.paywall_web_view_post_message_skipped) return } - guard let script = WebViewEnvelope.receiveScript(for: envelope) else { + guard let script = Self.receiveScript(for: envelope) else { Logger.debug(Strings.paywall_web_view_post_message_failed("encoding failed")) return } @@ -271,16 +202,25 @@ final class WebViewSession: NSObject, ObservableObject, WKScriptMessageHandler { self.evaluateJavaScript(script) } - private var sdkVariables: [String: PaywallWebViewValue] { - ["locale": .string(Self.bcp47Tag(fromLocaleIdentifier: self.localeIdentifier))] - } + /// Name of the JS function injected into the web view that receives host-to-content frames. + private static let receiveFunction = "__rcWebComponentsReceive" - nonisolated static func bcp47Tag(fromLocaleIdentifier identifier: String) -> String { - if #available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *) { - return Locale(identifier: identifier).identifier(.bcp47) - } else { - return identifier.replacingOccurrences(of: "_", with: "-") + /// Serializes `envelope` into the JS snippet injected into the web view to deliver a + /// host-to-content frame. This is bridge/transport behavior, so it lives here rather than + /// on the envelope data model. + nonisolated static func receiveScript(for envelope: WebViewEnvelope.Envelope) -> String? { + guard let data = try? JSONEncoder().encode(envelope), + let json = String(data: data, encoding: .utf8) else { + return nil } + let escaped = json + .replacingOccurrences(of: "\u{2028}", with: "\\u2028") + .replacingOccurrences(of: "\u{2029}", with: "\\u2029") + + return """ + (function(){var m=\(escaped);if(typeof window.\(Self.receiveFunction)==='function'){\ + window.\(Self.receiveFunction)(m);}})(); + """ } private func validResizeValue(_ value: Double?, axisIsFit: Bool) -> CGFloat? { diff --git a/Tests/RevenueCatUITests/PaywallsV2/WebViewEnvelopeTests.swift b/Tests/RevenueCatUITests/PaywallsV2/WebViewEnvelopeTests.swift index 58aa3bf426..429aeff963 100644 --- a/Tests/RevenueCatUITests/PaywallsV2/WebViewEnvelopeTests.swift +++ b/Tests/RevenueCatUITests/PaywallsV2/WebViewEnvelopeTests.swift @@ -63,7 +63,7 @@ final class WebViewEnvelopeTests: TestCase { ) } - func testEncodeDecodeRoundTripAndEscaping() throws { + func testEncodeDecodeRoundTrip() throws { let hostile = "annual\" }); alert('xss'); //\n\\ end\u{2028}\u{2029}" let envelope = WebViewEnvelope.Envelope( kind: .message, @@ -72,11 +72,10 @@ final class WebViewEnvelopeTests: TestCase { payload: ["value": .string(hostile)] ) - let script = try XCTUnwrap(WebViewEnvelope.receiveScript(for: envelope)) - XCTAssertFalse(script.contains("\u{2028}")) - XCTAssertFalse(script.contains("\u{2029}")) + let data = try JSONEncoder().encode(envelope) + let json = try XCTUnwrap(String(data: data, encoding: .utf8)) - let decoded = try Self.decodeEnvelope(fromScript: script) + let decoded = try XCTUnwrap(WebViewEnvelope.decode(rawMessage: json)) XCTAssertEqual(decoded, envelope) XCTAssertEqual(decoded.payload?["value"]?.stringValue, hostile) } @@ -98,13 +97,6 @@ final class WebViewEnvelopeTests: TestCase { } } - func testReservedPayloadKeysMatchWireProtocol() { - XCTAssertEqual( - WebViewEnvelope.reservedPayloadKeys, - ["channel", "protocol_version", "kind", "type", "component_id", "id", "error", "variables"] - ) - } - private static func frame(payload: [String: Any]) -> [String: Any] { [ "channel": WebViewEnvelope.channel, @@ -129,13 +121,6 @@ final class WebViewEnvelopeTests: TestCase { return String(data: data, encoding: .utf8)! } - static func decodeEnvelope(fromScript script: String) throws -> WebViewEnvelope.Envelope { - let start = try XCTUnwrap(script.range(of: "var m=")?.upperBound) - let end = try XCTUnwrap(script.range(of: ";if", range: start..\\ end\u{2028}\u{2029}" + let envelope = WebViewEnvelope.Envelope( + kind: .message, + componentID: "web", + type: "rc:variables", + payload: ["value": .string(hostile)] + ) + + let script = try XCTUnwrap(WebViewSession.receiveScript(for: envelope)) + XCTAssertFalse(script.contains("\u{2028}")) + XCTAssertFalse(script.contains("\u{2029}")) + + let decoded = try Self.decodeEnvelope(fromScript: script) + XCTAssertEqual(decoded, envelope) + XCTAssertEqual(decoded.payload?["value"]?.stringValue, hostile) + } + + static func decodeEnvelope(fromScript script: String) throws -> WebViewEnvelope.Envelope { + let start = try XCTUnwrap(script.range(of: "var m=")?.upperBound) + let end = try XCTUnwrap(script.range(of: ";if", range: start.. [WebViewEnvelope.Envelope] { try self.capturedScripts.map { - try WebViewEnvelopeTests.decodeEnvelope(fromScript: $0) + try WebViewSessionTests.decodeEnvelope(fromScript: $0) } } From 86d9c0b010cec2b943bf44d7d18075c7db1710c9 Mon Sep 17 00:00:00 2001 From: Antonio Pallares Date: Tue, 21 Jul 2026 11:57:24 +0200 Subject: [PATCH 22/35] refactor(paywalls): harden web_view session origin gating Validate inbound frames against the sender frame's securityOrigin instead of the WebView's top-level URL (parity with Android's sourceOrigin), keeping the top-level URL only as an outbound defense-in-depth check. Normalize expectedOrigin in init, drop the dead protocolVersion parameter in favor of the fixed supported version, move receiveFunction onto WebViewEnvelope, and log dropped frames at warning level. Adds origin-gating tests. Co-authored-by: Cursor --- .../Components/WebView/WebViewEnvelope.swift | 2 + .../WebView/WebViewOriginPolicy.swift | 20 ++++ .../Components/WebView/WebViewSession.swift | 90 ++++++++-------- .../PaywallsV2/WebViewSessionTests.swift | 100 ++++++++++++++++-- 4 files changed, 161 insertions(+), 51 deletions(-) diff --git a/RevenueCatUI/Templates/V2/Components/WebView/WebViewEnvelope.swift b/RevenueCatUI/Templates/V2/Components/WebView/WebViewEnvelope.swift index 58d972b9ee..f858401fac 100644 --- a/RevenueCatUI/Templates/V2/Components/WebView/WebViewEnvelope.swift +++ b/RevenueCatUI/Templates/V2/Components/WebView/WebViewEnvelope.swift @@ -7,6 +7,8 @@ enum WebViewEnvelope { static let channel = "rc-web-components" static let messageHandlerName = "rcWebComponents" + /// Name of the JS function injected into the web view that receives host-to-content frames. + static let receiveFunction = "__rcWebComponentsReceive" static let defaultProtocolVersion = 1 static let messageTypeResize = "resize" diff --git a/RevenueCatUI/Templates/V2/Components/WebView/WebViewOriginPolicy.swift b/RevenueCatUI/Templates/V2/Components/WebView/WebViewOriginPolicy.swift index 9612cd9eca..a04750515f 100644 --- a/RevenueCatUI/Templates/V2/Components/WebView/WebViewOriginPolicy.swift +++ b/RevenueCatUI/Templates/V2/Components/WebView/WebViewOriginPolicy.swift @@ -26,6 +26,26 @@ enum WebViewOrigin { return "\(scheme)://\(host)\(suffix)" } + /// Canonical origin of the frame that posted a script message. Uses the frame's security origin + /// (the authoritative sender) rather than the WebView's top-level URL. + nonisolated static func origin(of securityOrigin: WKSecurityOrigin) -> String? { + let scheme = securityOrigin.`protocol`.lowercased() + let host = securityOrigin.host.lowercased() + guard !scheme.isEmpty, !host.isEmpty else { + return nil + } + + let port = securityOrigin.port + let suffix: String + // `WKSecurityOrigin` reports `0` for the scheme's default port. + if port != 0, !Self.isDefaultPort(port, scheme: scheme) { + suffix = ":\(port)" + } else { + suffix = "" + } + return "\(scheme)://\(host)\(suffix)" + } + nonisolated private static func isDefaultPort(_ port: Int, scheme: String) -> Bool { (scheme == "https" && port == 443) || (scheme == "http" && port == 80) } diff --git a/RevenueCatUI/Templates/V2/Components/WebView/WebViewSession.swift b/RevenueCatUI/Templates/V2/Components/WebView/WebViewSession.swift index e15fdfba0e..f2d8c23011 100644 --- a/RevenueCatUI/Templates/V2/Components/WebView/WebViewSession.swift +++ b/RevenueCatUI/Templates/V2/Components/WebView/WebViewSession.swift @@ -12,7 +12,6 @@ import WebKit final class WebViewSession: NSObject, ObservableObject, WKScriptMessageHandler { let componentID: String - let protocolVersion: Int let expectedOrigin: String var onContentResize: (@MainActor (CGFloat?, CGFloat?) -> Void)? /// Invoked from ``resetForNewDocument()`` so the SwiftUI host can clear measured fit sizes. @@ -24,20 +23,25 @@ final class WebViewSession: NSObject, ObservableObject, WKScriptMessageHandler { let fitAxes: (width: Bool, height: Bool) + /// The single protocol version this SDK build implements. Deliberately not the schema's + /// `protocol_version`: the host must never accept a handshake for a version it cannot service, + /// even if a future schema declares one. + private let protocolVersion = WebViewEnvelope.defaultProtocolVersion + private var lastAppliedWidth: CGFloat? private var lastAppliedHeight: CGFloat? init( componentID: String, - protocolVersion: Int, expectedOrigin: String, fitAxes: (width: Bool, height: Bool), - evaluateJavaScript: @escaping (String) -> Void = { _ in }, - currentURL: @escaping () -> URL? = { nil } + evaluateJavaScript: @escaping (String) -> Void, + currentURL: @escaping () -> URL? ) { self.componentID = componentID - self.protocolVersion = protocolVersion - self.expectedOrigin = expectedOrigin + // Normalize to a canonical origin so comparisons match the navigation policy (and Android), + // whether the caller passes a bare origin or a full URL. + self.expectedOrigin = URL(string: expectedOrigin).flatMap(WebViewOrigin.origin(of:)) ?? expectedOrigin self.fitAxes = fitAxes self.evaluateJavaScript = evaluateJavaScript self.currentURL = currentURL @@ -58,18 +62,19 @@ final class WebViewSession: NSObject, ObservableObject, WKScriptMessageHandler { _ userContentController: WKUserContentController, didReceive message: WKScriptMessage ) { + // Validate against the origin of the frame that actually posted the message, not the + // WebView's top-level URL (which can lag behind navigations). + let sourceOrigin = WebViewOrigin.origin(of: message.frameInfo.securityOrigin) + let isMainFrame = message.frameInfo.isMainFrame + let body = message.body MainActor.assumeIsolated { - self.handle( - rawMessage: message.body, - isMainFrame: message.frameInfo.isMainFrame, - currentURL: message.webView?.url - ) + self.handle(rawMessage: body, isMainFrame: isMainFrame, sourceOrigin: sourceOrigin) } } - func handle(rawMessage: Any, isMainFrame: Bool, currentURL: URL?) { - guard isMainFrame else { - self.logRejected("non-main-frame") + func handle(rawMessage: Any, isMainFrame: Bool, sourceOrigin: String?) { + guard self.isSourceTrusted(sourceOrigin: sourceOrigin, isMainFrame: isMainFrame) else { + self.logRejected("untrusted-source") return } guard let envelope = WebViewEnvelope.decode(rawMessage: rawMessage) else { @@ -78,11 +83,11 @@ final class WebViewSession: NSObject, ObservableObject, WKScriptMessageHandler { } if envelope.kind == .connect { - self.handleConnect(envelope, currentURL: currentURL) + self.handleConnect(envelope) return } - guard let type = self.validatedAppFrameType(envelope, currentURL: currentURL) else { + guard let type = self.validatedAppFrameType(envelope) else { return } @@ -96,10 +101,7 @@ final class WebViewSession: NSObject, ObservableObject, WKScriptMessageHandler { /// Validates a post-handshake frame and returns its app message type, or `nil` (logged) when /// the frame must be dropped. - private func validatedAppFrameType( - _ envelope: WebViewEnvelope.Envelope, - currentURL: URL? - ) -> String? { + private func validatedAppFrameType(_ envelope: WebViewEnvelope.Envelope) -> String? { guard self.channelOpen else { self.logRejected("channel-closed") return nil @@ -118,10 +120,6 @@ final class WebViewSession: NSObject, ObservableObject, WKScriptMessageHandler { self.logRejected("component-id-mismatch") return nil } - guard self.originMatches(currentURL: currentURL, allowBeforeNavigation: false) else { - self.logRejected("origin-mismatch") - return nil - } guard let type = envelope.type else { self.logRejected("missing-type") return nil @@ -129,21 +127,18 @@ final class WebViewSession: NSObject, ObservableObject, WKScriptMessageHandler { return type } - private func handleConnect(_ envelope: WebViewEnvelope.Envelope, currentURL: URL?) { - guard self.originMatches(currentURL: currentURL, allowBeforeNavigation: true) else { - self.logRejected("origin-mismatch") - return - } + private func handleConnect(_ envelope: WebViewEnvelope.Envelope) { guard !self.channelOpen else { return } - if envelope.protocolVersion == WebViewEnvelope.defaultProtocolVersion { + if envelope.protocolVersion == self.protocolVersion { self.channelOpen = true self.send(.init(kind: .`init`, componentID: self.componentID), allowBeforeNavigation: true) self.sendFitMessageIfNeeded() } else { - let error = "Unsupported protocol_version \(envelope.protocolVersion); native host supports 1" + let error = "Unsupported protocol_version \(envelope.protocolVersion); " + + "native host supports \(self.protocolVersion)" self.send(.init(kind: .reject, componentID: "", error: error), allowBeforeNavigation: true) } } @@ -187,11 +182,12 @@ final class WebViewSession: NSObject, ObservableObject, WKScriptMessageHandler { private func send(_ envelope: WebViewEnvelope.Envelope, allowBeforeNavigation: Bool) { guard self.channelOpen || envelope.kind == .reject else { - Logger.debug(Strings.paywall_web_view_post_message_skipped) + Logger.warning(Strings.paywall_web_view_post_message_skipped) return } - guard self.originMatches(currentURL: self.currentURL(), allowBeforeNavigation: allowBeforeNavigation) else { - Logger.debug(Strings.paywall_web_view_post_message_skipped) + // Defense in depth: drop outbound frames if the top-level URL left the expected origin. + guard self.isCurrentURLTrusted(allowBeforeNavigation: allowBeforeNavigation) else { + Logger.warning(Strings.paywall_web_view_post_message_skipped) return } guard let script = Self.receiveScript(for: envelope) else { @@ -202,9 +198,6 @@ final class WebViewSession: NSObject, ObservableObject, WKScriptMessageHandler { self.evaluateJavaScript(script) } - /// Name of the JS function injected into the web view that receives host-to-content frames. - private static let receiveFunction = "__rcWebComponentsReceive" - /// Serializes `envelope` into the JS snippet injected into the web view to deliver a /// host-to-content frame. This is bridge/transport behavior, so it lives here rather than /// on the envelope data model. @@ -217,9 +210,10 @@ final class WebViewSession: NSObject, ObservableObject, WKScriptMessageHandler { .replacingOccurrences(of: "\u{2028}", with: "\\u2028") .replacingOccurrences(of: "\u{2029}", with: "\\u2029") + let receiveFunction = WebViewEnvelope.receiveFunction return """ - (function(){var m=\(escaped);if(typeof window.\(Self.receiveFunction)==='function'){\ - window.\(Self.receiveFunction)(m);}})(); + (function(){var m=\(escaped);if(typeof window.\(receiveFunction)==='function'){\ + window.\(receiveFunction)(m);}})(); """ } @@ -245,15 +239,27 @@ final class WebViewSession: NSObject, ObservableObject, WKScriptMessageHandler { return value } - private func originMatches(currentURL: URL?, allowBeforeNavigation: Bool) -> Bool { - guard let currentURL else { + /// Whether the message came from the expected origin on the main frame. Uses the sender frame's + /// origin (the authoritative source); subframe messages are always rejected — isolation for + /// those is expected from the server CSP. + private func isSourceTrusted(sourceOrigin: String?, isMainFrame: Bool) -> Bool { + guard isMainFrame, let sourceOrigin else { + return false + } + return sourceOrigin == self.expectedOrigin + } + + /// Whether the WebView's current top-level URL still has the expected origin. Used only as an + /// outbound defense-in-depth check; inbound traffic is gated by ``isSourceTrusted(sourceOrigin:isMainFrame:)``. + private func isCurrentURLTrusted(allowBeforeNavigation: Bool) -> Bool { + guard let currentURL = self.currentURL() else { return allowBeforeNavigation } return WebViewOrigin.origin(of: currentURL) == self.expectedOrigin } private func logRejected(_ reason: String) { - Logger.debug(Strings.paywall_web_view_message_rejected(reason: reason)) + Logger.warning(Strings.paywall_web_view_message_rejected(reason: reason)) } } diff --git a/Tests/RevenueCatUITests/PaywallsV2/WebViewSessionTests.swift b/Tests/RevenueCatUITests/PaywallsV2/WebViewSessionTests.swift index 71ffc78b0d..cf8e7586bc 100644 --- a/Tests/RevenueCatUITests/PaywallsV2/WebViewSessionTests.swift +++ b/Tests/RevenueCatUITests/PaywallsV2/WebViewSessionTests.swift @@ -211,15 +211,89 @@ final class WebViewSessionTests: TestCase { XCTAssertFalse(harness.session.channelOpen) } + // MARK: - Origin gating + + func testDropsInboundFromUntrustedOrigin() { + let harness = Harness() + harness.connect() + + harness.handle( + .init(kind: .message, componentID: "web", type: WebViewEnvelope.messageTypeResize), + sourceOrigin: "https://evil.example.org" + ) + + XCTAssertTrue(harness.capturedScripts.isEmpty) + } + + func testDropsConnectFromUntrustedOrigin() { + let harness = Harness() + + harness.handle(.init(kind: .connect, componentID: ""), sourceOrigin: "https://evil.example.org") + + XCTAssertFalse(harness.session.channelOpen) + XCTAssertTrue(harness.capturedScripts.isEmpty) + } + + func testDropsInboundWithoutSourceOrigin() { + let harness = Harness() + + harness.handle(.init(kind: .connect, componentID: ""), sourceOrigin: nil) + + XCTAssertFalse(harness.session.channelOpen) + XCTAssertTrue(harness.capturedScripts.isEmpty) + } + + func testConnectBeforeNavigationURLIsAvailable() throws { + let harness = Harness() + harness.currentURL = nil + + harness.handle(.init(kind: .connect, componentID: "")) + + XCTAssertTrue(harness.session.channelOpen) + XCTAssertEqual(try harness.outboundEnvelopes().map(\.kind), [.`init`]) + } + + func testNormalizesExpectedOriginFromFullURL() { + // Caller passes a full URL (path, uppercase host, explicit default port). It must normalize + // to a canonical origin so trusted same-origin traffic still matches. + let harness = Harness(expectedOrigin: "https://Example.com:443/paywall/index.html") + + harness.handle(.init(kind: .connect, componentID: ""), sourceOrigin: "https://example.com") + + XCTAssertTrue(harness.session.channelOpen) + } + + func testDeliversOutboundOnSameOriginDifferentPath() throws { + let harness = Harness(size: (width: false, height: true)) + harness.currentURL = URL(string: "https://example.com/promo/step-two.html")! + + harness.handle(.init(kind: .connect, componentID: "")) + + XCTAssertEqual(try harness.outboundEnvelopes().map(\.kind), [.`init`, .message]) + } + + func testDropsOutboundAfterNavigationToUnexpectedOrigin() { + let harness = Harness() + // Inbound source is still the trusted origin, but the top-level URL has left it: every + // outbound frame (even `init`) must be dropped. + harness.currentURL = URL(string: "https://evil.example.org/phish.html")! + + harness.handle(.init(kind: .connect, componentID: "")) + + XCTAssertTrue(harness.session.channelOpen) + XCTAssertTrue(harness.capturedScripts.isEmpty) + } + // MARK: - Round trip through a real WKWebView func testRoundTripThroughRealWebView() throws { let expectedURL = URL(string: "https://example.com/index.html")! let session = WebViewSession( componentID: "web", - protocolVersion: 1, expectedOrigin: "https://example.com", - fitAxes: (width: false, height: false) + fitAxes: (width: false, height: false), + evaluateJavaScript: { _ in }, + currentURL: { nil } ) let configuration = WKWebViewConfiguration() @@ -363,18 +437,22 @@ private final class RoundTripNavigationDelegate: NSObject, WKNavigationDelegate @MainActor private final class Harness { + static let expectedOrigin = "https://example.com" + let session: WebViewSession var capturedScripts: [String] = [] - var currentURL = URL(string: "https://example.com/path")! + var currentURL: URL? = URL(string: "https://example.com/path")! init( - size: (width: Bool, height: Bool) = (false, false) + size: (width: Bool, height: Bool) = (false, false), + expectedOrigin: String = Harness.expectedOrigin ) { self.session = WebViewSession( componentID: "web", - protocolVersion: 99, - expectedOrigin: "https://example.com", - fitAxes: size + expectedOrigin: expectedOrigin, + fitAxes: size, + evaluateJavaScript: { _ in }, + currentURL: { nil } ) self.session.evaluateJavaScript = { [weak self] script in self?.capturedScripts.append(script) @@ -389,12 +467,16 @@ private final class Harness { self.capturedScripts.removeAll() } - func handle(_ envelope: WebViewEnvelope.Envelope, isMainFrame: Bool = true) { + func handle( + _ envelope: WebViewEnvelope.Envelope, + isMainFrame: Bool = true, + sourceOrigin: String? = Harness.expectedOrigin + ) { let data = try! JSONEncoder().encode(envelope) self.session.handle( rawMessage: String(data: data, encoding: .utf8)!, isMainFrame: isMainFrame, - currentURL: self.currentURL + sourceOrigin: sourceOrigin ) } From 7940b96f9c5187ed1e9c043a88e26ef3b7926ad1 Mon Sep 17 00:00:00 2001 From: Antonio Pallares Date: Tue, 21 Jul 2026 12:12:13 +0200 Subject: [PATCH 23/35] refactor(paywalls): expose web_view origin as URL/WKSecurityOrigin extensions Replace WebViewOrigin.origin(of:) static helpers with a webViewOrigin computed property on URL and WKSecurityOrigin, sharing a single private canonicalization helper. Also drop the redundant nonisolated/assumeIsolated on the script message handler since WebViewSession is already @MainActor. Co-authored-by: Cursor --- .../WebView/WebViewOriginPolicy.swift | 46 ++++++++++--------- .../Components/WebView/WebViewSession.swift | 12 ++--- .../WebViewNavigationPolicyTests.swift | 8 ++-- 3 files changed, 33 insertions(+), 33 deletions(-) diff --git a/RevenueCatUI/Templates/V2/Components/WebView/WebViewOriginPolicy.swift b/RevenueCatUI/Templates/V2/Components/WebView/WebViewOriginPolicy.swift index a04750515f..2d1a520364 100644 --- a/RevenueCatUI/Templates/V2/Components/WebView/WebViewOriginPolicy.swift +++ b/RevenueCatUI/Templates/V2/Components/WebView/WebViewOriginPolicy.swift @@ -6,39 +6,41 @@ import WebKit #if !os(tvOS) && canImport(WebKit) // For Paywalls V2 -@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) -enum WebViewOrigin { +extension URL { - nonisolated static func origin(of url: URL) -> String? { - guard let scheme = url.scheme?.lowercased(), - let host = url.host?.lowercased(), + /// Canonical origin (`scheme://host[:port]`) of the URL, or `nil` if it has no scheme or host. + nonisolated var webViewOrigin: String? { + guard let scheme = self.scheme?.lowercased(), + let host = self.host?.lowercased(), !host.isEmpty else { return nil } - - let port = url.port - let suffix: String - if let port, !Self.isDefaultPort(port, scheme: scheme) { - suffix = ":\(port)" - } else { - suffix = "" - } - return "\(scheme)://\(host)\(suffix)" + return WebViewOrigin.canonicalOrigin(scheme: scheme, host: host, port: self.port) } +} + +extension WKSecurityOrigin { + /// Canonical origin of the frame that posted a script message. Uses the frame's security origin /// (the authoritative sender) rather than the WebView's top-level URL. - nonisolated static func origin(of securityOrigin: WKSecurityOrigin) -> String? { - let scheme = securityOrigin.`protocol`.lowercased() - let host = securityOrigin.host.lowercased() + var webViewOrigin: String? { + let scheme = self.`protocol`.lowercased() + let host = self.host.lowercased() guard !scheme.isEmpty, !host.isEmpty else { return nil } + // `WKSecurityOrigin` reports `0` for the scheme's default port. + return WebViewOrigin.canonicalOrigin(scheme: scheme, host: host, port: self.port == 0 ? nil : self.port) + } - let port = securityOrigin.port +} + +private enum WebViewOrigin { + + nonisolated static func canonicalOrigin(scheme: String, host: String, port: Int?) -> String { let suffix: String - // `WKSecurityOrigin` reports `0` for the scheme's default port. - if port != 0, !Self.isDefaultPort(port, scheme: scheme) { + if let port, !isDefaultPort(port, scheme: scheme) { suffix = ":\(port)" } else { suffix = "" @@ -62,14 +64,14 @@ enum WebViewNavigationPolicy { // to `default-src 'self'`). static func policy(for url: URL?, isMainFrame: Bool, expectedOrigin: String) -> WKNavigationActionPolicy { guard let url, - let origin = WebViewOrigin.origin(of: url), + let origin = url.webViewOrigin, origin.hasPrefix("https://") else { return .cancel } guard isMainFrame else { return .allow } - let expected = URL(string: expectedOrigin).flatMap(WebViewOrigin.origin(of:)) + let expected = URL(string: expectedOrigin)?.webViewOrigin return origin == expected ? .allow : .cancel } diff --git a/RevenueCatUI/Templates/V2/Components/WebView/WebViewSession.swift b/RevenueCatUI/Templates/V2/Components/WebView/WebViewSession.swift index f2d8c23011..45e6690891 100644 --- a/RevenueCatUI/Templates/V2/Components/WebView/WebViewSession.swift +++ b/RevenueCatUI/Templates/V2/Components/WebView/WebViewSession.swift @@ -41,7 +41,7 @@ final class WebViewSession: NSObject, ObservableObject, WKScriptMessageHandler { self.componentID = componentID // Normalize to a canonical origin so comparisons match the navigation policy (and Android), // whether the caller passes a bare origin or a full URL. - self.expectedOrigin = URL(string: expectedOrigin).flatMap(WebViewOrigin.origin(of:)) ?? expectedOrigin + self.expectedOrigin = URL(string: expectedOrigin)?.webViewOrigin ?? expectedOrigin self.fitAxes = fitAxes self.evaluateJavaScript = evaluateJavaScript self.currentURL = currentURL @@ -58,18 +58,16 @@ final class WebViewSession: NSObject, ObservableObject, WKScriptMessageHandler { self.onDocumentReset?() } - nonisolated func userContentController( + func userContentController( _ userContentController: WKUserContentController, didReceive message: WKScriptMessage ) { // Validate against the origin of the frame that actually posted the message, not the // WebView's top-level URL (which can lag behind navigations). - let sourceOrigin = WebViewOrigin.origin(of: message.frameInfo.securityOrigin) + let sourceOrigin = message.frameInfo.securityOrigin.webViewOrigin let isMainFrame = message.frameInfo.isMainFrame let body = message.body - MainActor.assumeIsolated { - self.handle(rawMessage: body, isMainFrame: isMainFrame, sourceOrigin: sourceOrigin) - } + self.handle(rawMessage: body, isMainFrame: isMainFrame, sourceOrigin: sourceOrigin) } func handle(rawMessage: Any, isMainFrame: Bool, sourceOrigin: String?) { @@ -255,7 +253,7 @@ final class WebViewSession: NSObject, ObservableObject, WKScriptMessageHandler { guard let currentURL = self.currentURL() else { return allowBeforeNavigation } - return WebViewOrigin.origin(of: currentURL) == self.expectedOrigin + return currentURL.webViewOrigin == self.expectedOrigin } private func logRejected(_ reason: String) { diff --git a/Tests/RevenueCatUITests/PaywallsV2/WebViewNavigationPolicyTests.swift b/Tests/RevenueCatUITests/PaywallsV2/WebViewNavigationPolicyTests.swift index 355732c2c4..7574dc355d 100644 --- a/Tests/RevenueCatUITests/PaywallsV2/WebViewNavigationPolicyTests.swift +++ b/Tests/RevenueCatUITests/PaywallsV2/WebViewNavigationPolicyTests.swift @@ -110,18 +110,18 @@ final class WebViewNavigationPolicyTests: TestCase { func testOriginStripsDefaultPortKeepsNonDefaultAndNormalizesCase() { XCTAssertEqual( - WebViewOrigin.origin(of: URL(string: "https://Example.COM:443/path")!), + URL(string: "https://Example.COM:443/path")!.webViewOrigin, "https://example.com" ) XCTAssertEqual( - WebViewOrigin.origin(of: URL(string: "http://Example.COM:80/path")!), + URL(string: "http://Example.COM:80/path")!.webViewOrigin, "http://example.com" ) XCTAssertEqual( - WebViewOrigin.origin(of: URL(string: "HTTPS://Example.COM:8443/path")!), + URL(string: "HTTPS://Example.COM:8443/path")!.webViewOrigin, "https://example.com:8443" ) - XCTAssertNil(WebViewOrigin.origin(of: URL(string: "https:///no-host")!)) + XCTAssertNil(URL(string: "https:///no-host")!.webViewOrigin) } } From 7280b5dcc0e6acdae4d0e7e0baeeb1042dbd1e7f Mon Sep 17 00:00:00 2001 From: Antonio Pallares Date: Tue, 21 Jul 2026 12:14:19 +0200 Subject: [PATCH 24/35] refactor(paywalls): consolidate web_view imports under single platform guard Co-authored-by: Cursor --- .../V2/Components/WebView/WebViewOriginPolicy.swift | 7 ++----- .../Templates/V2/Components/WebView/WebViewSession.swift | 9 +++------ 2 files changed, 5 insertions(+), 11 deletions(-) diff --git a/RevenueCatUI/Templates/V2/Components/WebView/WebViewOriginPolicy.swift b/RevenueCatUI/Templates/V2/Components/WebView/WebViewOriginPolicy.swift index 2d1a520364..846e0623b5 100644 --- a/RevenueCatUI/Templates/V2/Components/WebView/WebViewOriginPolicy.swift +++ b/RevenueCatUI/Templates/V2/Components/WebView/WebViewOriginPolicy.swift @@ -1,10 +1,7 @@ -import Foundation +#if !os(tvOS) && canImport(WebKit) // For Paywalls V2 -#if canImport(WebKit) +import Foundation import WebKit -#endif - -#if !os(tvOS) && canImport(WebKit) // For Paywalls V2 extension URL { diff --git a/RevenueCatUI/Templates/V2/Components/WebView/WebViewSession.swift b/RevenueCatUI/Templates/V2/Components/WebView/WebViewSession.swift index 45e6690891..6ea9daf660 100644 --- a/RevenueCatUI/Templates/V2/Components/WebView/WebViewSession.swift +++ b/RevenueCatUI/Templates/V2/Components/WebView/WebViewSession.swift @@ -1,11 +1,8 @@ -import Foundation -@_spi(Internal) import RevenueCat +#if !os(tvOS) && canImport(WebKit) // For Paywalls V2 -#if canImport(WebKit) +import Foundation import WebKit -#endif - -#if !os(tvOS) && canImport(WebKit) // For Paywalls V2 +@_spi(Internal) import RevenueCat @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) @MainActor From 0a866f8ebc2c72c874948c7874aaf65adedc4816 Mon Sep 17 00:00:00 2001 From: Antonio Pallares Date: Tue, 21 Jul 2026 12:19:30 +0200 Subject: [PATCH 25/35] refactor(paywalls): add copyright header to web_view files and fix import order Co-authored-by: Cursor --- .../Templates/V2/Components/WebView/WebViewEnvelope.swift | 4 ++++ .../V2/Components/WebView/WebViewOriginPolicy.swift | 4 ++++ .../Templates/V2/Components/WebView/WebViewSession.swift | 6 +++++- 3 files changed, 13 insertions(+), 1 deletion(-) diff --git a/RevenueCatUI/Templates/V2/Components/WebView/WebViewEnvelope.swift b/RevenueCatUI/Templates/V2/Components/WebView/WebViewEnvelope.swift index f858401fac..504cec1a2d 100644 --- a/RevenueCatUI/Templates/V2/Components/WebView/WebViewEnvelope.swift +++ b/RevenueCatUI/Templates/V2/Components/WebView/WebViewEnvelope.swift @@ -1,3 +1,7 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// + import Foundation #if !os(tvOS) // For Paywalls V2 diff --git a/RevenueCatUI/Templates/V2/Components/WebView/WebViewOriginPolicy.swift b/RevenueCatUI/Templates/V2/Components/WebView/WebViewOriginPolicy.swift index 846e0623b5..7eb342ce85 100644 --- a/RevenueCatUI/Templates/V2/Components/WebView/WebViewOriginPolicy.swift +++ b/RevenueCatUI/Templates/V2/Components/WebView/WebViewOriginPolicy.swift @@ -1,3 +1,7 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// + #if !os(tvOS) && canImport(WebKit) // For Paywalls V2 import Foundation diff --git a/RevenueCatUI/Templates/V2/Components/WebView/WebViewSession.swift b/RevenueCatUI/Templates/V2/Components/WebView/WebViewSession.swift index 6ea9daf660..f3df509f11 100644 --- a/RevenueCatUI/Templates/V2/Components/WebView/WebViewSession.swift +++ b/RevenueCatUI/Templates/V2/Components/WebView/WebViewSession.swift @@ -1,8 +1,12 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// + #if !os(tvOS) && canImport(WebKit) // For Paywalls V2 import Foundation -import WebKit @_spi(Internal) import RevenueCat +import WebKit @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) @MainActor From b9078c44c0085a0e83c203efdaa694f842f9096e Mon Sep 17 00:00:00 2001 From: Antonio Pallares Date: Tue, 21 Jul 2026 12:24:49 +0200 Subject: [PATCH 26/35] fix(paywalls): adopt web_view origin extensions and add file headers Follow-up to merging the refactored base into webview/6-view: - Use the new `URL.webViewOrigin` extension instead of the now-private `WebViewOrigin.origin(of:)` in WebViewComponentView. - Add the copyright header to WebViewComponentView/ViewModel to match the sibling web_view files. --- .../V2/Components/WebView/WebViewComponentView.swift | 6 +++++- .../V2/Components/WebView/WebViewComponentViewModel.swift | 4 ++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/RevenueCatUI/Templates/V2/Components/WebView/WebViewComponentView.swift b/RevenueCatUI/Templates/V2/Components/WebView/WebViewComponentView.swift index ebe1a60eee..93b231c7b9 100644 --- a/RevenueCatUI/Templates/V2/Components/WebView/WebViewComponentView.swift +++ b/RevenueCatUI/Templates/V2/Components/WebView/WebViewComponentView.swift @@ -1,3 +1,7 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// + @_spi(Internal) import RevenueCat import SwiftUI @@ -87,7 +91,7 @@ private struct BridgedWebViewComponentView: View { self.url = url self.componentID = componentID - let origin = WebViewOrigin.origin(of: url) ?? "" + let origin = url.webViewOrigin ?? "" // `evaluateJavaScript`/`currentURL` are rebound to the live web view in the representable's // `configureSession(for:)`; the no-op defaults only cover the window before it is created. self._session = StateObject( diff --git a/RevenueCatUI/Templates/V2/Components/WebView/WebViewComponentViewModel.swift b/RevenueCatUI/Templates/V2/Components/WebView/WebViewComponentViewModel.swift index 9df49e5ea7..5cb733db87 100644 --- a/RevenueCatUI/Templates/V2/Components/WebView/WebViewComponentViewModel.swift +++ b/RevenueCatUI/Templates/V2/Components/WebView/WebViewComponentViewModel.swift @@ -1,3 +1,7 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// + import Foundation @_spi(Internal) import RevenueCat #if !os(tvOS) // For Paywalls V2 From 07ecb4cc17a6fa3f7a575d111d53c9edc434ad90 Mon Sep 17 00:00:00 2001 From: Antonio Pallares Date: Tue, 21 Jul 2026 13:09:52 +0200 Subject: [PATCH 27/35] fix(paywalls): treat unresolvable web_view origin as inert bridge Normalize expectedOrigin to an optional canonical origin; when it cannot be resolved, keep it nil and log a warning instead of silently trusting the raw string, so the bridge rejects all inbound/outbound traffic and the misconfiguration is diagnosable. Mirrors purchases-android. Also splits the outbound skip log into distinct reasons (channel-not-open vs untrusted-current-url), documents why handshake replies allow sending before the top-level URL settles, and fixes a stale render-only test comment. Co-authored-by: Cursor --- RevenueCatUI/Data/Strings.swift | 10 ++++-- .../Components/WebView/WebViewSession.swift | 33 ++++++++++++++----- .../PaywallsV2/WebViewSessionTests.swift | 15 +++++++-- 3 files changed, 45 insertions(+), 13 deletions(-) diff --git a/RevenueCatUI/Data/Strings.swift b/RevenueCatUI/Data/Strings.swift index 2391d503f1..5a04c0d1fc 100644 --- a/RevenueCatUI/Data/Strings.swift +++ b/RevenueCatUI/Data/Strings.swift @@ -112,7 +112,8 @@ enum Strings { // WebView case paywall_web_view_message_rejected(reason: String) case paywall_web_view_post_message_failed(String) - case paywall_web_view_post_message_skipped + case paywall_web_view_post_message_skipped(reason: String) + case paywall_web_view_invalid_expected_origin(String) // Exit Offers case errorFetchingOfferings(Error) @@ -373,8 +374,11 @@ extension Strings: CustomStringConvertible { return "Paywalls V2 web_view message rejected: \(reason)" case .paywall_web_view_post_message_failed(let error): return "Paywalls V2 web_view postMessage failed: \(error)" - case .paywall_web_view_post_message_skipped: - return "Paywalls V2 web_view postMessage skipped because the channel is not ready or origin changed." + case .paywall_web_view_post_message_skipped(let reason): + return "Paywalls V2 web_view postMessage skipped: \(reason)" + case .paywall_web_view_invalid_expected_origin(let origin): + return "Paywalls V2 web_view expected origin '\(origin)' is not a valid origin; " + + "the bridge cannot verify message provenance and will reject all messages." case .errorFetchingOfferings(let error): return "Error fetching offerings: \(error)" diff --git a/RevenueCatUI/Templates/V2/Components/WebView/WebViewSession.swift b/RevenueCatUI/Templates/V2/Components/WebView/WebViewSession.swift index f3df509f11..1d25a301d8 100644 --- a/RevenueCatUI/Templates/V2/Components/WebView/WebViewSession.swift +++ b/RevenueCatUI/Templates/V2/Components/WebView/WebViewSession.swift @@ -13,7 +13,10 @@ import WebKit final class WebViewSession: NSObject, ObservableObject, WKScriptMessageHandler { let componentID: String - let expectedOrigin: String + /// The canonical origin every message is gated against, or `nil` when the caller's origin + /// could not be normalized. A `nil` origin makes the bridge inert (all traffic is rejected), + /// mirroring purchases-android. + let expectedOrigin: String? var onContentResize: (@MainActor (CGFloat?, CGFloat?) -> Void)? /// Invoked from ``resetForNewDocument()`` so the SwiftUI host can clear measured fit sizes. var onDocumentReset: (@MainActor () -> Void)? @@ -41,8 +44,14 @@ final class WebViewSession: NSObject, ObservableObject, WKScriptMessageHandler { ) { self.componentID = componentID // Normalize to a canonical origin so comparisons match the navigation policy (and Android), - // whether the caller passes a bare origin or a full URL. - self.expectedOrigin = URL(string: expectedOrigin)?.webViewOrigin ?? expectedOrigin + // whether the caller passes a bare origin or a full URL. A value that cannot be normalized + // is kept `nil` (rather than silently trusting the raw string) so the bridge stays inert and + // the misconfiguration is diagnosable, like purchases-android. + let normalizedOrigin = URL(string: expectedOrigin)?.webViewOrigin + if normalizedOrigin == nil { + Logger.warning(Strings.paywall_web_view_invalid_expected_origin(expectedOrigin)) + } + self.expectedOrigin = normalizedOrigin self.fitAxes = fitAxes self.evaluateJavaScript = evaluateJavaScript self.currentURL = currentURL @@ -133,6 +142,11 @@ final class WebViewSession: NSObject, ObservableObject, WKScriptMessageHandler { if envelope.protocolVersion == self.protocolVersion { self.channelOpen = true + // Handshake replies (`init` and the follow-up fit message) use `allowBeforeNavigation: + // true`: the `connect` that triggered them was already gated against the authoritative + // sender-frame origin, but the WebView's top-level `url` may not be populated yet this + // early, and dropping these would leave the bridge half-open. The stricter current-URL + // check still applies to every later send. self.send(.init(kind: .`init`, componentID: self.componentID), allowBeforeNavigation: true) self.sendFitMessageIfNeeded() } else { @@ -181,12 +195,12 @@ final class WebViewSession: NSObject, ObservableObject, WKScriptMessageHandler { private func send(_ envelope: WebViewEnvelope.Envelope, allowBeforeNavigation: Bool) { guard self.channelOpen || envelope.kind == .reject else { - Logger.warning(Strings.paywall_web_view_post_message_skipped) + Logger.warning(Strings.paywall_web_view_post_message_skipped(reason: "channel-not-open")) return } // Defense in depth: drop outbound frames if the top-level URL left the expected origin. guard self.isCurrentURLTrusted(allowBeforeNavigation: allowBeforeNavigation) else { - Logger.warning(Strings.paywall_web_view_post_message_skipped) + Logger.warning(Strings.paywall_web_view_post_message_skipped(reason: "untrusted-current-url")) return } guard let script = Self.receiveScript(for: envelope) else { @@ -242,19 +256,22 @@ final class WebViewSession: NSObject, ObservableObject, WKScriptMessageHandler { /// origin (the authoritative source); subframe messages are always rejected — isolation for /// those is expected from the server CSP. private func isSourceTrusted(sourceOrigin: String?, isMainFrame: Bool) -> Bool { - guard isMainFrame, let sourceOrigin else { + guard isMainFrame, let expectedOrigin = self.expectedOrigin, let sourceOrigin else { return false } - return sourceOrigin == self.expectedOrigin + return sourceOrigin == expectedOrigin } /// Whether the WebView's current top-level URL still has the expected origin. Used only as an /// outbound defense-in-depth check; inbound traffic is gated by ``isSourceTrusted(sourceOrigin:isMainFrame:)``. private func isCurrentURLTrusted(allowBeforeNavigation: Bool) -> Bool { + guard let expectedOrigin = self.expectedOrigin else { + return false + } guard let currentURL = self.currentURL() else { return allowBeforeNavigation } - return currentURL.webViewOrigin == self.expectedOrigin + return currentURL.webViewOrigin == expectedOrigin } private func logRejected(_ reason: String) { diff --git a/Tests/RevenueCatUITests/PaywallsV2/WebViewSessionTests.swift b/Tests/RevenueCatUITests/PaywallsV2/WebViewSessionTests.swift index cf8e7586bc..eef976e6e3 100644 --- a/Tests/RevenueCatUITests/PaywallsV2/WebViewSessionTests.swift +++ b/Tests/RevenueCatUITests/PaywallsV2/WebViewSessionTests.swift @@ -272,6 +272,17 @@ final class WebViewSessionTests: TestCase { XCTAssertEqual(try harness.outboundEnvelopes().map(\.kind), [.`init`, .message]) } + func testInvalidExpectedOriginRejectsAllTraffic() { + // A caller origin that cannot be normalized leaves the bridge inert: even a `connect` that + // looks like it comes from the configured origin is rejected, so the channel never opens. + let harness = Harness(expectedOrigin: "not a valid origin") + + harness.handle(.init(kind: .connect, componentID: ""), sourceOrigin: "not a valid origin") + + XCTAssertFalse(harness.session.channelOpen) + XCTAssertTrue(harness.capturedScripts.isEmpty) + } + func testDropsOutboundAfterNavigationToUnexpectedOrigin() { let harness = Harness() // Inbound source is still the trusted origin, but the top-level URL has left it: every @@ -342,8 +353,8 @@ final class WebViewSessionTests: TestCase { } func testRenderOnlyWebViewExposesNoBridgeSurface() throws { - // Render-only mode (`componentID == nil`) registers NO script message handler, so page - // JavaScript must see no native bridge surface at all. + // A web view configured without the bridge (no script message handler registered) must + // expose no native bridge surface to page JavaScript at all. let webView = WKWebView(frame: .zero, configuration: WKWebViewConfiguration()) let loaded = self.expectation(description: "web view finished loading") From 2eb13086682175f1ca9ccb23587521959858eb31 Mon Sep 17 00:00:00 2001 From: Antonio Pallares Date: Tue, 21 Jul 2026 13:44:53 +0200 Subject: [PATCH 28/35] refactor(paywalls): tidy web_view session comments and narrow handleConnect Pass only the protocol version into handleConnect (matching handleResize), annotate the JS line-terminator escaping, and trim comment wording. Co-authored-by: Cursor --- .../Components/WebView/WebViewSession.swift | 23 +++++++++---------- 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/RevenueCatUI/Templates/V2/Components/WebView/WebViewSession.swift b/RevenueCatUI/Templates/V2/Components/WebView/WebViewSession.swift index 1d25a301d8..eee5590a2f 100644 --- a/RevenueCatUI/Templates/V2/Components/WebView/WebViewSession.swift +++ b/RevenueCatUI/Templates/V2/Components/WebView/WebViewSession.swift @@ -14,8 +14,7 @@ final class WebViewSession: NSObject, ObservableObject, WKScriptMessageHandler { let componentID: String /// The canonical origin every message is gated against, or `nil` when the caller's origin - /// could not be normalized. A `nil` origin makes the bridge inert (all traffic is rejected), - /// mirroring purchases-android. + /// could not be normalized. A `nil` origin makes the bridge inert (all traffic is rejected). let expectedOrigin: String? var onContentResize: (@MainActor (CGFloat?, CGFloat?) -> Void)? /// Invoked from ``resetForNewDocument()`` so the SwiftUI host can clear measured fit sizes. @@ -43,10 +42,9 @@ final class WebViewSession: NSObject, ObservableObject, WKScriptMessageHandler { currentURL: @escaping () -> URL? ) { self.componentID = componentID - // Normalize to a canonical origin so comparisons match the navigation policy (and Android), - // whether the caller passes a bare origin or a full URL. A value that cannot be normalized - // is kept `nil` (rather than silently trusting the raw string) so the bridge stays inert and - // the misconfiguration is diagnosable, like purchases-android. + // Normalize to a canonical origin so comparisons match the navigation policy, whether the + // caller passes a bare origin or a full URL. A value that cannot be normalized is kept `nil` + // so the bridge stays inert and the misconfiguration is diagnosable. let normalizedOrigin = URL(string: expectedOrigin)?.webViewOrigin if normalizedOrigin == nil { Logger.warning(Strings.paywall_web_view_invalid_expected_origin(expectedOrigin)) @@ -91,7 +89,7 @@ final class WebViewSession: NSObject, ObservableObject, WKScriptMessageHandler { } if envelope.kind == .connect { - self.handleConnect(envelope) + self.handleConnect(protocolVersion: envelope.protocolVersion) return } @@ -135,12 +133,12 @@ final class WebViewSession: NSObject, ObservableObject, WKScriptMessageHandler { return type } - private func handleConnect(_ envelope: WebViewEnvelope.Envelope) { + private func handleConnect(protocolVersion: Int) { guard !self.channelOpen else { return } - if envelope.protocolVersion == self.protocolVersion { + if protocolVersion == self.protocolVersion { self.channelOpen = true // Handshake replies (`init` and the follow-up fit message) use `allowBeforeNavigation: // true`: the `connect` that triggered them was already gated against the authoritative @@ -150,7 +148,7 @@ final class WebViewSession: NSObject, ObservableObject, WKScriptMessageHandler { self.send(.init(kind: .`init`, componentID: self.componentID), allowBeforeNavigation: true) self.sendFitMessageIfNeeded() } else { - let error = "Unsupported protocol_version \(envelope.protocolVersion); " + + let error = "Unsupported protocol_version \(protocolVersion); " + "native host supports \(self.protocolVersion)" self.send(.init(kind: .reject, componentID: "", error: error), allowBeforeNavigation: true) } @@ -219,9 +217,10 @@ final class WebViewSession: NSObject, ObservableObject, WKScriptMessageHandler { let json = String(data: data, encoding: .utf8) else { return nil } + // Escape JS line terminators that are legal in JSON but not in JS string literals. let escaped = json - .replacingOccurrences(of: "\u{2028}", with: "\\u2028") - .replacingOccurrences(of: "\u{2029}", with: "\\u2029") + .replacingOccurrences(of: "\u{2028}", with: "\\u2028") // line separator + .replacingOccurrences(of: "\u{2029}", with: "\\u2029") // paragraph separator let receiveFunction = WebViewEnvelope.receiveFunction return """ From 53fde7ea947d6b9951eef86b01725d7df6dace36 Mon Sep 17 00:00:00 2001 From: Antonio Pallares Date: Tue, 21 Jul 2026 13:50:52 +0200 Subject: [PATCH 29/35] test(paywalls): add missing web_view drop-branch coverage and harden inbound-drop assertions Add coverage for component-id mismatch, unknown message type, missing type, and the wrong-channel decode filter. Rework the existing inbound-drop tests to assert against an onContentResize sink with a real payload instead of captured outbound scripts, so a resize that should be dropped is distinguishable from a no-op and the tests fail if their gate is removed. Co-authored-by: Cursor --- .../PaywallsV2/WebViewSessionTests.swift | 117 +++++++++++++++--- 1 file changed, 103 insertions(+), 14 deletions(-) diff --git a/Tests/RevenueCatUITests/PaywallsV2/WebViewSessionTests.swift b/Tests/RevenueCatUITests/PaywallsV2/WebViewSessionTests.swift index eef976e6e3..4ee3ff7f99 100644 --- a/Tests/RevenueCatUITests/PaywallsV2/WebViewSessionTests.swift +++ b/Tests/RevenueCatUITests/PaywallsV2/WebViewSessionTests.swift @@ -43,21 +43,34 @@ final class WebViewSessionTests: TestCase { } func testDropsBeforeConnectDuplicateConnectAndNonMainFrame() throws { - let harness = Harness() + let harness = Harness(size: (width: false, height: true)) + var resizes: [(CGFloat?, CGFloat?)] = [] + harness.session.onContentResize = { resizes.append(($0, $1)) } - harness.handle(.init(kind: .message, componentID: "web", type: WebViewEnvelope.messageTypeResize)) - XCTAssertTrue(harness.capturedScripts.isEmpty) + // A resize before the handshake is dropped (channel closed). + harness.handle(.init( + kind: .message, + componentID: "web", + type: WebViewEnvelope.messageTypeResize, + payload: ["height": .number(200)] + )) + XCTAssertTrue(resizes.isEmpty) harness.handle(.init(kind: .connect, componentID: "")) harness.handle(.init(kind: .connect, componentID: "")) XCTAssertEqual(try harness.outboundEnvelopes().filter { $0.kind == .`init` }.count, 1) - harness.capturedScripts.removeAll() + // A resize from a subframe is dropped even after the channel is open. harness.handle( - .init(kind: .message, componentID: "web", type: WebViewEnvelope.messageTypeResize), + .init( + kind: .message, + componentID: "web", + type: WebViewEnvelope.messageTypeResize, + payload: ["height": .number(200)] + ), isMainFrame: false ) - XCTAssertTrue(harness.capturedScripts.isEmpty) + XCTAssertTrue(resizes.isEmpty) } func testFitMessageDeclaresExactlyTheFitAxes() throws { @@ -81,27 +94,96 @@ final class WebViewSessionTests: TestCase { } func testDropsAppFramesWithNonAppKinds() { - let harness = Harness() + let harness = Harness(size: (width: false, height: true)) + var resizes: [(CGFloat?, CGFloat?)] = [] + harness.session.onContentResize = { resizes.append(($0, $1)) } harness.connect() + // Each carries a payload that would apply if the kind gate let it through. for kind: WebViewEnvelope.Kind in [.`init`, .reject, .response, .error] { harness.handle(.init( kind: kind, componentID: "web", type: WebViewEnvelope.messageTypeResize, - id: "id-1" + id: "id-1", + payload: ["height": .number(200)] )) } - XCTAssertTrue(harness.capturedScripts.isEmpty) + XCTAssertTrue(resizes.isEmpty) } func testDropsAnyRequestWithoutID() { - let harness = Harness() + let harness = Harness(size: (width: false, height: true)) + var resizes: [(CGFloat?, CGFloat?)] = [] + harness.session.onContentResize = { resizes.append(($0, $1)) } harness.connect() - harness.handle(.init(kind: .request, componentID: "web", type: WebViewEnvelope.messageTypeResize)) + harness.handle(.init( + kind: .request, + componentID: "web", + type: WebViewEnvelope.messageTypeResize, + payload: ["height": .number(200)] + )) + + XCTAssertTrue(resizes.isEmpty) + } + + func testDropsAppFrameFromDifferentComponent() { + let harness = Harness(size: (width: false, height: true)) + var resizes: [(CGFloat?, CGFloat?)] = [] + harness.session.onContentResize = { resizes.append(($0, $1)) } + harness.connect() + // Same shape as an applied resize, but addressed to another component on the same page. + harness.handle(.init( + kind: .message, + componentID: "other", + type: WebViewEnvelope.messageTypeResize, + payload: ["height": .number(200)] + )) + + XCTAssertTrue(resizes.isEmpty) + } + + func testDropsUnknownMessageType() { + let harness = Harness(size: (width: false, height: true)) + var resizes: [(CGFloat?, CGFloat?)] = [] + harness.session.onContentResize = { resizes.append(($0, $1)) } + harness.connect() + + harness.handle(.init( + kind: .message, + componentID: "web", + type: "rc:not-a-real-type", + payload: ["height": .number(200)] + )) + + XCTAssertTrue(resizes.isEmpty) + } + + func testDropsMessageWithoutType() { + let harness = Harness(size: (width: false, height: true)) + var resizes: [(CGFloat?, CGFloat?)] = [] + harness.session.onContentResize = { resizes.append(($0, $1)) } + harness.connect() + + harness.handle(.init(kind: .message, componentID: "web", payload: ["height": .number(200)])) + + XCTAssertTrue(resizes.isEmpty) + } + + func testDropsEnvelopeOnUnexpectedChannel() { + let harness = Harness() + + // Well-formed connect frame, but riding another SDK's channel: it must never open ours. + harness.session.handle( + rawMessage: #"{"channel":"someone-elses-channel","protocol_version":1,"kind":"connect","component_id":""}"#, + isMainFrame: true, + sourceOrigin: Harness.expectedOrigin + ) + + XCTAssertFalse(harness.session.channelOpen) XCTAssertTrue(harness.capturedScripts.isEmpty) } @@ -214,15 +296,22 @@ final class WebViewSessionTests: TestCase { // MARK: - Origin gating func testDropsInboundFromUntrustedOrigin() { - let harness = Harness() + let harness = Harness(size: (width: false, height: true)) + var resizes: [(CGFloat?, CGFloat?)] = [] + harness.session.onContentResize = { resizes.append(($0, $1)) } harness.connect() harness.handle( - .init(kind: .message, componentID: "web", type: WebViewEnvelope.messageTypeResize), + .init( + kind: .message, + componentID: "web", + type: WebViewEnvelope.messageTypeResize, + payload: ["height": .number(200)] + ), sourceOrigin: "https://evil.example.org" ) - XCTAssertTrue(harness.capturedScripts.isEmpty) + XCTAssertTrue(resizes.isEmpty) } func testDropsConnectFromUntrustedOrigin() { From 07c6a3160e4ae0513b527710f1ad6f86304b2f40 Mon Sep 17 00:00:00 2001 From: Antonio Pallares Date: Tue, 21 Jul 2026 14:31:09 +0200 Subject: [PATCH 30/35] refactor(paywalls): address web_view view review feedback - Drop unused `localizationProvider` from WebViewComponentViewModel.init - Include `size` and `visible` in the view model's equality/hash so rendered-state differences aren't treated as equal - Cache `url` validation via a lazy var - Drop the redundant BridgedWebViewComponentView.componentID property - Isolate the navigation-delegate Coordinator to @MainActor and drop the per-method MainActor.assumeIsolated wrappers - Add view-model equality/visible tests and an empty-origin navigation policy case Co-authored-by: Cursor --- .../WebView/WebViewComponentView.swift | 16 ++--- .../WebView/WebViewComponentViewModel.swift | 29 +++++--- .../PaywallsV2/WebViewComponentTests.swift | 69 ++++++++++++++----- .../WebViewNavigationPolicyTests.swift | 14 ++++ 4 files changed, 91 insertions(+), 37 deletions(-) diff --git a/RevenueCatUI/Templates/V2/Components/WebView/WebViewComponentView.swift b/RevenueCatUI/Templates/V2/Components/WebView/WebViewComponentView.swift index 170455015a..15dd551829 100644 --- a/RevenueCatUI/Templates/V2/Components/WebView/WebViewComponentView.swift +++ b/RevenueCatUI/Templates/V2/Components/WebView/WebViewComponentView.swift @@ -68,7 +68,6 @@ private struct BridgedWebViewComponentView: View { let viewModel: WebViewComponentViewModel let url: URL - let componentID: String @StateObject private var session: WebViewSession @@ -89,7 +88,6 @@ private struct BridgedWebViewComponentView: View { ) { self.viewModel = viewModel self.url = url - self.componentID = componentID let origin = url.webViewOrigin ?? "" // `evaluateJavaScript`/`currentURL` are rebound to the live web view in the representable's @@ -281,6 +279,10 @@ struct WebViewRepresentable: PlatformViewRepresentable { #endif } + // `WKNavigationDelegate` is `@MainActor`-annotated in the SDK (its `WK_SWIFT_UI_ACTOR` attribute + // resolves to `@MainActor`), so isolating the coordinator to the main actor lets the delegate + // methods touch main-actor state directly instead of wrapping each body in `assumeIsolated`. + @MainActor final class Coordinator: NSObject, WKNavigationDelegate { let expectedOrigin: String @@ -305,16 +307,12 @@ struct WebViewRepresentable: PlatformViewRepresentable { } func webView(_ webView: WKWebView, didCommit navigation: WKNavigation!) { - MainActor.assumeIsolated { - self.session?.resetForNewDocument() - } + self.session?.resetForNewDocument() } func webViewWebContentProcessDidTerminate(_ webView: WKWebView) { - MainActor.assumeIsolated { - Logger.debug(Strings.paywall_web_view_content_process_terminated) - self.onProcessTerminated?() - } + Logger.debug(Strings.paywall_web_view_content_process_terminated) + self.onProcessTerminated?() } } diff --git a/RevenueCatUI/Templates/V2/Components/WebView/WebViewComponentViewModel.swift b/RevenueCatUI/Templates/V2/Components/WebView/WebViewComponentViewModel.swift index 5cb733db87..a18fb33aa5 100644 --- a/RevenueCatUI/Templates/V2/Components/WebView/WebViewComponentViewModel.swift +++ b/RevenueCatUI/Templates/V2/Components/WebView/WebViewComponentViewModel.swift @@ -14,30 +14,37 @@ final class WebViewComponentViewModel: Hashable { let visible: Bool let componentID: String - var url: URL? { - guard !self.urlString.contains("{{"), - let url = URL(string: self.urlString), - url.scheme?.lowercased() == "https", - url.host?.isEmpty == false else { - return nil - } - return url - } + lazy var url: URL? = Self.validatedHTTPSURL(from: self.urlString) - init(component: PaywallComponent.WebViewComponent, localizationProvider: LocalizationProvider) { + init(component: PaywallComponent.WebViewComponent) { self.urlString = component.url self.size = component.size self.visible = component.visible ?? true self.componentID = component.id } + private static func validatedHTTPSURL(from urlString: String) -> URL? { + guard !urlString.contains("{{"), + let url = URL(string: urlString), + url.scheme?.lowercased() == "https", + url.host?.isEmpty == false else { + return nil + } + return url + } + func hash(into hasher: inout Hasher) { hasher.combine(self.urlString) hasher.combine(self.componentID) + hasher.combine(self.size) + hasher.combine(self.visible) } static func == (lhs: WebViewComponentViewModel, rhs: WebViewComponentViewModel) -> Bool { - lhs.urlString == rhs.urlString && lhs.componentID == rhs.componentID + lhs.urlString == rhs.urlString && + lhs.componentID == rhs.componentID && + lhs.size == rhs.size && + lhs.visible == rhs.visible } } diff --git a/Tests/RevenueCatUITests/PaywallsV2/WebViewComponentTests.swift b/Tests/RevenueCatUITests/PaywallsV2/WebViewComponentTests.swift index d6e7164465..7644447fe5 100644 --- a/Tests/RevenueCatUITests/PaywallsV2/WebViewComponentTests.swift +++ b/Tests/RevenueCatUITests/PaywallsV2/WebViewComponentTests.swift @@ -230,27 +230,19 @@ final class WebViewComponentTests: TestCase { // MARK: - View model - func testViewModelURLValidationAndHashing() { - let component = PaywallComponent.WebViewComponent( - id: "web", - protocolVersion: 2, - url: "https://example.com/path", - size: .init(width: .fill, height: .fit(nil)) - ) + func testViewModelURLValidation() { let viewModel = WebViewComponentViewModel( - component: component, - localizationProvider: .init(locale: Locale(identifier: "en_US"), localizedStrings: [:]) + component: .init( + id: "web", + protocolVersion: 2, + url: "https://example.com/path", + size: .init(width: .fill, height: .fit(nil)) + ) ) XCTAssertEqual(viewModel.url?.absoluteString, "https://example.com/path") XCTAssertEqual(viewModel.componentID, "web") - let differentID = WebViewComponentViewModel( - component: .init(id: "other", protocolVersion: 1, url: "https://example.com/path"), - localizationProvider: .init(locale: Locale(identifier: "en_US"), localizedStrings: [:]) - ) - XCTAssertNotEqual(viewModel, differentID) - for invalidURL in [ "http://example.com", "file:///tmp/index.html", @@ -259,13 +251,50 @@ final class WebViewComponentTests: TestCase { "https://example.com/{{ custom.url }}" ] { let invalid = WebViewComponentViewModel( - component: .init(id: "web", protocolVersion: 1, url: invalidURL), - localizationProvider: .init(locale: Locale(identifier: "en_US"), localizedStrings: [:]) + component: .init(id: "web", protocolVersion: 1, url: invalidURL) ) XCTAssertNil(invalid.url) } } + func testViewModelDefaultsToVisible() { + XCTAssertTrue( + WebViewComponentViewModel(component: .init(id: "web", protocolVersion: 1, url: "https://example.com")) + .visible + ) + XCTAssertFalse( + WebViewComponentViewModel( + component: .init(id: "web", visible: false, protocolVersion: 1, url: "https://example.com") + ).visible + ) + } + + func testViewModelEqualityAndHashingConsidersRenderedState() { + func makeViewModel( + id: String = "web", + url: String = "https://example.com/path", + visible: Bool? = nil, + size: PaywallComponent.Size = .init(width: .fill, height: .fit(nil)) + ) -> WebViewComponentViewModel { + WebViewComponentViewModel( + component: .init(id: id, visible: visible, protocolVersion: 1, url: url, size: size) + ) + } + + let base = makeViewModel() + + // Equal inputs produce equal (and identically hashing) view models. + let same = makeViewModel() + XCTAssertEqual(base, same) + XCTAssertEqual(base.hashValue, same.hashValue) + + // Any rendered-state difference breaks equality. + XCTAssertNotEqual(base, makeViewModel(id: "other")) + XCTAssertNotEqual(base, makeViewModel(url: "https://example.com/other")) + XCTAssertNotEqual(base, makeViewModel(visible: false)) + XCTAssertNotEqual(base, makeViewModel(size: .init(width: .fixed(320), height: .fit(nil)))) + } + private static let fallbackStackJSON = """ { "type": "stack", @@ -321,6 +350,12 @@ final class WebViewCoordinatorLifecycleTests: TestCase { XCTAssertEqual(calls, 2) } + // Note: the coordinator's `decidePolicyFor` is a thin delegation to + // `WebViewNavigationPolicy.policy(for:isMainFrame:expectedOrigin:)`, which is exhaustively + // covered (allow/cancel, origin normalization, nil/empty origin) in WebViewNavigationPolicyTests. + // `WKNavigationAction`/`WKFrameInfo` cannot be constructed or subclassed for a unit test, so the + // delegation itself is not re-tested here. + } #endif diff --git a/Tests/RevenueCatUITests/PaywallsV2/WebViewNavigationPolicyTests.swift b/Tests/RevenueCatUITests/PaywallsV2/WebViewNavigationPolicyTests.swift index 7574dc355d..2ca34969b5 100644 --- a/Tests/RevenueCatUITests/PaywallsV2/WebViewNavigationPolicyTests.swift +++ b/Tests/RevenueCatUITests/PaywallsV2/WebViewNavigationPolicyTests.swift @@ -86,6 +86,20 @@ final class WebViewNavigationPolicyTests: TestCase { ) } + func testEmptyExpectedOriginMainFrameIsCancelled() { + // The view coerces an unresolvable origin to "" (see BridgedWebViewComponentView); that must + // cancel main-frame navigation too, keeping the whole web view inert rather than just muting + // the message bridge. + XCTAssertEqual( + WebViewNavigationPolicy.policy( + for: URL(string: "https://example.com/next")!, + isMainFrame: true, + expectedOrigin: "" + ), + .cancel + ) + } + func testHostlessExpectedOriginMainFrameIsCancelled() { XCTAssertEqual( WebViewNavigationPolicy.policy( From 2f1d7ca12f8f992d3ad63f1267e96cfbc17e014d Mon Sep 17 00:00:00 2001 From: Antonio Pallares Date: Tue, 21 Jul 2026 16:40:23 +0200 Subject: [PATCH 31/35] fix(paywalls): drop WebViewComponentTests from xcodeproj UnitTests target The consolidated WebViewComponentTests now imports RevenueCatUI, but the xcodeproj UnitTests target only links RevenueCat, causing "Unable to find module dependency: 'RevenueCatUI'". Remove it from that target's Sources so it runs only via the SPM RevenueCatUITests target, matching its RevenueCatUI-importing siblings (WebViewSessionTests, WebViewEnvelopeTests). Co-authored-by: Cursor --- RevenueCat.xcodeproj/project.pbxproj | 2 -- 1 file changed, 2 deletions(-) diff --git a/RevenueCat.xcodeproj/project.pbxproj b/RevenueCat.xcodeproj/project.pbxproj index 9e29cff50c..4822f740d5 100644 --- a/RevenueCat.xcodeproj/project.pbxproj +++ b/RevenueCat.xcodeproj/project.pbxproj @@ -110,7 +110,6 @@ 16DA8EDF2E4F6A6600283940 /* PaywallVideoComponent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 16DA8EDE2E4F6A6600283940 /* PaywallVideoComponent.swift */; }; 66EAEED2B259D54A9E789F4A /* PaywallWebViewComponent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 14C1ACED1916C08B0E8E2776 /* PaywallWebViewComponent.swift */; }; 16DA8EF42E4F7A2500283940 /* VideoComponentTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 16DA8EF32E4F7A2500283940 /* VideoComponentTests.swift */; }; - E7EB64E9D44783DD16A28B06 /* WebViewComponentTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E7EB64E9D44783DD16A28B05 /* WebViewComponentTests.swift */; }; FC5AE63E8F81C3EC2FFDA408 /* WebViewOriginPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 28661C6CE3F64078A0138080 /* WebViewOriginPolicy.swift */; }; FA4A2DF41FC434A9401F08CC /* PaywallWebViewValue.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9D0C57ECDF0E7F1A5B4418FF /* PaywallWebViewValue.swift */; }; 11CC2EA83C56933E40D8E5D8 /* WebViewEnvelope.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89DD761C9E00E61032AAEB44 /* WebViewEnvelope.swift */; }; @@ -7918,7 +7917,6 @@ 351B51BA26D450E800BD2BD7 /* ProductRequestDataExtensions.swift in Sources */, 57E6C27E29723F9E001AFE98 /* SigningTests.swift in Sources */, 16DA8EF42E4F7A2500283940 /* VideoComponentTests.swift in Sources */, - E7EB64E9D44783DD16A28B06 /* WebViewComponentTests.swift in Sources */, 574A2F3F282D75E300150D40 /* OfferingsDecodingTests.swift in Sources */, 3AC9CEA8FC6D49A3EB7D7393 /* PublishedWorkflowCodableTests.swift in Sources */, 35E840CE2710E2EB00899AE2 /* MockManageSubscriptionsHelper.swift in Sources */, From a32d71716f6f862d8dcfb36e0f76d966557382f1 Mon Sep 17 00:00:00 2001 From: Antonio Pallares Date: Tue, 21 Jul 2026 17:03:36 +0200 Subject: [PATCH 32/35] refactor(paywalls): split web_view schema and view tests by target WebViewComponentTests mixed RevenueCat schema (Codable) tests with RevenueCatUI view-model/coordinator tests in a single file. Split them to match the Video component convention: - Tests/UnitTests/Paywalls/Components/WebViewComponentTests.swift: schema decoding tests (RevenueCat only), wired into the xcodeproj UnitTests target so they run under `fastlane scan` again. - Tests/RevenueCatUITests/PaywallsV2/WebViewComponentViewTests.swift: view-model/sizing/coordinator tests (RevenueCatUI), SPM only. Co-authored-by: Cursor --- RevenueCat.xcodeproj/project.pbxproj | 8 +- .../WebViewComponentViewTests.swift | 162 ++++++++++++++++++ .../Components}/WebViewComponentTests.swift | 150 ++-------------- 3 files changed, 178 insertions(+), 142 deletions(-) create mode 100644 Tests/RevenueCatUITests/PaywallsV2/WebViewComponentViewTests.swift rename Tests/{RevenueCatUITests/PaywallsV2 => UnitTests/Paywalls/Components}/WebViewComponentTests.swift (61%) diff --git a/RevenueCat.xcodeproj/project.pbxproj b/RevenueCat.xcodeproj/project.pbxproj index 4822f740d5..a9be82b3b2 100644 --- a/RevenueCat.xcodeproj/project.pbxproj +++ b/RevenueCat.xcodeproj/project.pbxproj @@ -110,6 +110,7 @@ 16DA8EDF2E4F6A6600283940 /* PaywallVideoComponent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 16DA8EDE2E4F6A6600283940 /* PaywallVideoComponent.swift */; }; 66EAEED2B259D54A9E789F4A /* PaywallWebViewComponent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 14C1ACED1916C08B0E8E2776 /* PaywallWebViewComponent.swift */; }; 16DA8EF42E4F7A2500283940 /* VideoComponentTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 16DA8EF32E4F7A2500283940 /* VideoComponentTests.swift */; }; + AC01FEB700000000000000B6 /* WebViewComponentTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AC01FEB700000000000000B5 /* WebViewComponentTests.swift */; }; FC5AE63E8F81C3EC2FFDA408 /* WebViewOriginPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 28661C6CE3F64078A0138080 /* WebViewOriginPolicy.swift */; }; FA4A2DF41FC434A9401F08CC /* PaywallWebViewValue.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9D0C57ECDF0E7F1A5B4418FF /* PaywallWebViewValue.swift */; }; 11CC2EA83C56933E40D8E5D8 /* WebViewEnvelope.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89DD761C9E00E61032AAEB44 /* WebViewEnvelope.swift */; }; @@ -1678,7 +1679,8 @@ 16DA8ECC2E4EE33700283940 /* ImageComponentTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageComponentTests.swift; sourceTree = ""; }; 16DA8EDE2E4F6A6600283940 /* PaywallVideoComponent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaywallVideoComponent.swift; sourceTree = ""; }; 14C1ACED1916C08B0E8E2776 /* PaywallWebViewComponent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaywallWebViewComponent.swift; sourceTree = ""; }; - E7EB64E9D44783DD16A28B05 /* WebViewComponentTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebViewComponentTests.swift; sourceTree = ""; }; + E7EB64E9D44783DD16A28B05 /* WebViewComponentViewTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebViewComponentViewTests.swift; sourceTree = ""; }; + AC01FEB700000000000000B5 /* WebViewComponentTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebViewComponentTests.swift; sourceTree = ""; }; 16DA8EF32E4F7A2500283940 /* VideoComponentTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoComponentTests.swift; sourceTree = ""; }; 28661C6CE3F64078A0138080 /* WebViewOriginPolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebViewOriginPolicy.swift; sourceTree = ""; }; 9E7AD021B4580265DFDF6A02 /* WebViewNavigationPolicyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebViewNavigationPolicyTests.swift; sourceTree = ""; }; @@ -3252,7 +3254,7 @@ 030890822D2B77DD0069677B /* PaywallsV2 */ = { isa = PBXGroup; children = ( - E7EB64E9D44783DD16A28B05 /* WebViewComponentTests.swift */, + E7EB64E9D44783DD16A28B05 /* WebViewComponentViewTests.swift */, 9E7AD021B4580265DFDF6A02 /* WebViewNavigationPolicyTests.swift */, 686562C08D953A27986DB276 /* PaywallWebViewValueTests.swift */, C1B939C7DAAFEA1DE2DE2674 /* WebViewEnvelopeTests.swift */, @@ -3584,6 +3586,7 @@ 2C8EC6DE2CCD27A100D6CCF8 /* PartialComponentTests.swift */, 16DA8ECC2E4EE33700283940 /* ImageComponentTests.swift */, 16DA8EF32E4F7A2500283940 /* VideoComponentTests.swift */, + AC01FEB700000000000000B5 /* WebViewComponentTests.swift */, 164933232E5808BE00CE43A9 /* PaywallTransitionTest.swift */, 80442DA12FDF0AFA0085069A /* PaywallComponentStateTests.swift */, ); @@ -7917,6 +7920,7 @@ 351B51BA26D450E800BD2BD7 /* ProductRequestDataExtensions.swift in Sources */, 57E6C27E29723F9E001AFE98 /* SigningTests.swift in Sources */, 16DA8EF42E4F7A2500283940 /* VideoComponentTests.swift in Sources */, + AC01FEB700000000000000B6 /* WebViewComponentTests.swift in Sources */, 574A2F3F282D75E300150D40 /* OfferingsDecodingTests.swift in Sources */, 3AC9CEA8FC6D49A3EB7D7393 /* PublishedWorkflowCodableTests.swift in Sources */, 35E840CE2710E2EB00899AE2 /* MockManageSubscriptionsHelper.swift in Sources */, diff --git a/Tests/RevenueCatUITests/PaywallsV2/WebViewComponentViewTests.swift b/Tests/RevenueCatUITests/PaywallsV2/WebViewComponentViewTests.swift new file mode 100644 index 0000000000..cd67bde7db --- /dev/null +++ b/Tests/RevenueCatUITests/PaywallsV2/WebViewComponentViewTests.swift @@ -0,0 +1,162 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// WebViewComponentViewTests.swift +// +// Created by Antonio Pallares on 7/21/26. + +@_spi(Internal) import RevenueCat +@testable import RevenueCatUI +import XCTest +// swiftlint:disable force_try + +#if !os(tvOS) + +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) +final class WebViewComponentViewTests: TestCase { + + // MARK: - Provisional fit sizing + + func testResolvedFitDimensionPrecedence() { + XCTAssertEqual( + WebViewSizing.resolvedDimension(measured: 420, defaultSize: 240, fallback: 300), + 420 + ) + XCTAssertEqual( + WebViewSizing.resolvedDimension(measured: nil, defaultSize: 240, fallback: 300), + 240 + ) + XCTAssertEqual( + WebViewSizing.resolvedDimension(measured: nil, defaultSize: nil, fallback: 300), + 300 + ) + } + + // MARK: - View model + + func testViewModelURLValidation() { + let viewModel = WebViewComponentViewModel( + component: .init( + id: "web", + protocolVersion: 2, + url: "https://example.com/path", + size: .init(width: .fill, height: .fit(nil)) + ) + ) + + XCTAssertEqual(viewModel.url?.absoluteString, "https://example.com/path") + XCTAssertEqual(viewModel.componentID, "web") + + for invalidURL in [ + "http://example.com", + "file:///tmp/index.html", + "custom://example.com", + "https:///missing-host", + "https://example.com/{{ custom.url }}" + ] { + let invalid = WebViewComponentViewModel( + component: .init(id: "web", protocolVersion: 1, url: invalidURL) + ) + XCTAssertNil(invalid.url) + } + } + + func testViewModelDefaultsToVisible() { + XCTAssertTrue( + WebViewComponentViewModel(component: .init(id: "web", protocolVersion: 1, url: "https://example.com")) + .visible + ) + XCTAssertFalse( + WebViewComponentViewModel( + component: .init(id: "web", visible: false, protocolVersion: 1, url: "https://example.com") + ).visible + ) + } + + func testViewModelEqualityAndHashingConsidersRenderedState() { + func makeViewModel( + id: String = "web", + url: String = "https://example.com/path", + visible: Bool? = nil, + size: PaywallComponent.Size = .init(width: .fill, height: .fit(nil)) + ) -> WebViewComponentViewModel { + WebViewComponentViewModel( + component: .init(id: id, visible: visible, protocolVersion: 1, url: url, size: size) + ) + } + + let base = makeViewModel() + + // Equal inputs produce equal (and identically hashing) view models. + let same = makeViewModel() + XCTAssertEqual(base, same) + XCTAssertEqual(base.hashValue, same.hashValue) + + // Any rendered-state difference breaks equality. + XCTAssertNotEqual(base, makeViewModel(id: "other")) + XCTAssertNotEqual(base, makeViewModel(url: "https://example.com/other")) + XCTAssertNotEqual(base, makeViewModel(visible: false)) + XCTAssertNotEqual(base, makeViewModel(size: .init(width: .fixed(320), height: .fit(nil)))) + } + +} + +#if canImport(WebKit) && !os(watchOS) +import WebKit + +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) +@MainActor +final class WebViewCoordinatorLifecycleTests: TestCase { + + func testDidCommitResetsConnectedSessionChannel() { + let session = WebViewSession( + componentID: "web", + expectedOrigin: "https://example.com", + fitAxes: (width: false, height: false), + evaluateJavaScript: { _ in }, + currentURL: { nil } + ) + let data = try! JSONEncoder().encode(WebViewEnvelope.Envelope(kind: .connect, componentID: "")) + session.handle( + rawMessage: String(data: data, encoding: .utf8)!, + isMainFrame: true, + sourceOrigin: "https://example.com" + ) + XCTAssertTrue(session.channelOpen) + + let coordinator = WebViewRepresentable.Coordinator(expectedOrigin: "https://example.com") + coordinator.session = session + coordinator.webView(WKWebView(frame: .zero), didCommit: nil) + + XCTAssertFalse(session.channelOpen) + } + + func testProcessTerminationInvokesCallbackOncePerCall() { + let coordinator = WebViewRepresentable.Coordinator(expectedOrigin: "https://example.com") + var calls = 0 + coordinator.onProcessTerminated = { calls += 1 } + + let webView = WKWebView(frame: .zero) + coordinator.webViewWebContentProcessDidTerminate(webView) + coordinator.webViewWebContentProcessDidTerminate(webView) + + XCTAssertEqual(calls, 2) + } + + // Note: the coordinator's `decidePolicyFor` is a thin delegation to + // `WebViewNavigationPolicy.policy(for:isMainFrame:expectedOrigin:)`, which is exhaustively + // covered (allow/cancel, origin normalization, nil/empty origin) in WebViewNavigationPolicyTests. + // `WKNavigationAction`/`WKFrameInfo` cannot be constructed or subclassed for a unit test, so the + // delegation itself is not re-tested here. + +} + +#endif + +#endif diff --git a/Tests/RevenueCatUITests/PaywallsV2/WebViewComponentTests.swift b/Tests/UnitTests/Paywalls/Components/WebViewComponentTests.swift similarity index 61% rename from Tests/RevenueCatUITests/PaywallsV2/WebViewComponentTests.swift rename to Tests/UnitTests/Paywalls/Components/WebViewComponentTests.swift index 7644447fe5..64725d96a3 100644 --- a/Tests/RevenueCatUITests/PaywallsV2/WebViewComponentTests.swift +++ b/Tests/UnitTests/Paywalls/Components/WebViewComponentTests.swift @@ -1,19 +1,25 @@ // // Copyright RevenueCat Inc. All Rights Reserved. // +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// WebViewComponentTests.swift +// +// Created by Antonio Pallares on 7/21/26. +import Foundation @_spi(Internal) @testable import RevenueCat -@testable import RevenueCatUI import XCTest -// swiftlint:disable force_try #if !os(tvOS) @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) final class WebViewComponentTests: TestCase { - // MARK: - Schema decoding - func testDecodesMinimalJSONAndIgnoresUnknownKeys() throws { let minimal = try JSONDecoder.default.decode(PaywallComponent.WebViewComponent.self, from: Data(""" { @@ -193,8 +199,6 @@ final class WebViewComponentTests: TestCase { ) } - // MARK: - Provisional fit sizing - func testDecodesFitLoadingDefaults() throws { let component = try JSONDecoder.default.decode(PaywallComponent.WebViewComponent.self, from: Data(""" { @@ -213,88 +217,6 @@ final class WebViewComponentTests: TestCase { XCTAssertEqual(component.size.height, .fit(180)) } - func testResolvedFitDimensionPrecedence() { - XCTAssertEqual( - WebViewSizing.resolvedDimension(measured: 420, defaultSize: 240, fallback: 300), - 420 - ) - XCTAssertEqual( - WebViewSizing.resolvedDimension(measured: nil, defaultSize: 240, fallback: 300), - 240 - ) - XCTAssertEqual( - WebViewSizing.resolvedDimension(measured: nil, defaultSize: nil, fallback: 300), - 300 - ) - } - - // MARK: - View model - - func testViewModelURLValidation() { - let viewModel = WebViewComponentViewModel( - component: .init( - id: "web", - protocolVersion: 2, - url: "https://example.com/path", - size: .init(width: .fill, height: .fit(nil)) - ) - ) - - XCTAssertEqual(viewModel.url?.absoluteString, "https://example.com/path") - XCTAssertEqual(viewModel.componentID, "web") - - for invalidURL in [ - "http://example.com", - "file:///tmp/index.html", - "custom://example.com", - "https:///missing-host", - "https://example.com/{{ custom.url }}" - ] { - let invalid = WebViewComponentViewModel( - component: .init(id: "web", protocolVersion: 1, url: invalidURL) - ) - XCTAssertNil(invalid.url) - } - } - - func testViewModelDefaultsToVisible() { - XCTAssertTrue( - WebViewComponentViewModel(component: .init(id: "web", protocolVersion: 1, url: "https://example.com")) - .visible - ) - XCTAssertFalse( - WebViewComponentViewModel( - component: .init(id: "web", visible: false, protocolVersion: 1, url: "https://example.com") - ).visible - ) - } - - func testViewModelEqualityAndHashingConsidersRenderedState() { - func makeViewModel( - id: String = "web", - url: String = "https://example.com/path", - visible: Bool? = nil, - size: PaywallComponent.Size = .init(width: .fill, height: .fit(nil)) - ) -> WebViewComponentViewModel { - WebViewComponentViewModel( - component: .init(id: id, visible: visible, protocolVersion: 1, url: url, size: size) - ) - } - - let base = makeViewModel() - - // Equal inputs produce equal (and identically hashing) view models. - let same = makeViewModel() - XCTAssertEqual(base, same) - XCTAssertEqual(base.hashValue, same.hashValue) - - // Any rendered-state difference breaks equality. - XCTAssertNotEqual(base, makeViewModel(id: "other")) - XCTAssertNotEqual(base, makeViewModel(url: "https://example.com/other")) - XCTAssertNotEqual(base, makeViewModel(visible: false)) - XCTAssertNotEqual(base, makeViewModel(size: .init(width: .fixed(320), height: .fit(nil)))) - } - private static let fallbackStackJSON = """ { "type": "stack", @@ -308,56 +230,4 @@ final class WebViewComponentTests: TestCase { } -#if canImport(WebKit) && !os(watchOS) -import WebKit - -@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) -@MainActor -final class WebViewCoordinatorLifecycleTests: TestCase { - - func testDidCommitResetsConnectedSessionChannel() { - let session = WebViewSession( - componentID: "web", - expectedOrigin: "https://example.com", - fitAxes: (width: false, height: false), - evaluateJavaScript: { _ in }, - currentURL: { nil } - ) - let data = try! JSONEncoder().encode(WebViewEnvelope.Envelope(kind: .connect, componentID: "")) - session.handle( - rawMessage: String(data: data, encoding: .utf8)!, - isMainFrame: true, - sourceOrigin: "https://example.com" - ) - XCTAssertTrue(session.channelOpen) - - let coordinator = WebViewRepresentable.Coordinator(expectedOrigin: "https://example.com") - coordinator.session = session - coordinator.webView(WKWebView(frame: .zero), didCommit: nil) - - XCTAssertFalse(session.channelOpen) - } - - func testProcessTerminationInvokesCallbackOncePerCall() { - let coordinator = WebViewRepresentable.Coordinator(expectedOrigin: "https://example.com") - var calls = 0 - coordinator.onProcessTerminated = { calls += 1 } - - let webView = WKWebView(frame: .zero) - coordinator.webViewWebContentProcessDidTerminate(webView) - coordinator.webViewWebContentProcessDidTerminate(webView) - - XCTAssertEqual(calls, 2) - } - - // Note: the coordinator's `decidePolicyFor` is a thin delegation to - // `WebViewNavigationPolicy.policy(for:isMainFrame:expectedOrigin:)`, which is exhaustively - // covered (allow/cancel, origin normalization, nil/empty origin) in WebViewNavigationPolicyTests. - // `WKNavigationAction`/`WKFrameInfo` cannot be constructed or subclassed for a unit test, so the - // delegation itself is not re-tested here. - -} - -#endif - #endif From 408192bc1b15ee7fbf5e7d10c850b4f6eed0bad9 Mon Sep 17 00:00:00 2001 From: Antonio Pallares Date: Tue, 21 Jul 2026 18:22:17 +0200 Subject: [PATCH 33/35] Fix main-actor isolation error in WebViewRepresentable on Xcode 15 The private makeWebView/update/configureSession helpers were non-isolated, so mutating the @MainActor WebViewSession properties from them failed to compile under Xcode 15 (Swift 5.9) in the SPM RevenueCatUI release build. Annotate the helpers @MainActor to keep the main-actor chain intact. Co-authored-by: Cursor --- .../Templates/V2/Components/WebView/WebViewComponentView.swift | 3 +++ 1 file changed, 3 insertions(+) diff --git a/RevenueCatUI/Templates/V2/Components/WebView/WebViewComponentView.swift b/RevenueCatUI/Templates/V2/Components/WebView/WebViewComponentView.swift index 15dd551829..c9b97ffb54 100644 --- a/RevenueCatUI/Templates/V2/Components/WebView/WebViewComponentView.swift +++ b/RevenueCatUI/Templates/V2/Components/WebView/WebViewComponentView.swift @@ -195,6 +195,7 @@ struct WebViewRepresentable: PlatformViewRepresentable { } #endif + @MainActor private func makeWebView(context: Context) -> PlatformWebView { let configuration = WKWebViewConfiguration() configuration.websiteDataStore = .nonPersistent() @@ -241,10 +242,12 @@ struct WebViewRepresentable: PlatformViewRepresentable { return webView } + @MainActor private func update(_ webView: PlatformWebView) { self.configureSession(for: webView) } + @MainActor private func configureSession(for webView: PlatformWebView) { self.session?.onContentResize = self.onContentResize self.session?.onDocumentReset = self.onDocumentReset From 1c52e67a6d9c4d2894dcef198e2fb96cb8263f41 Mon Sep 17 00:00:00 2001 From: Antonio Pallares Date: Wed, 22 Jul 2026 09:09:47 +0200 Subject: [PATCH 34/35] refactor(paywalls): tighten web_view platform typealias access --- .../V2/Components/WebView/WebViewComponentView.swift | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/RevenueCatUI/Templates/V2/Components/WebView/WebViewComponentView.swift b/RevenueCatUI/Templates/V2/Components/WebView/WebViewComponentView.swift index c9b97ffb54..d66eb21738 100644 --- a/RevenueCatUI/Templates/V2/Components/WebView/WebViewComponentView.swift +++ b/RevenueCatUI/Templates/V2/Components/WebView/WebViewComponentView.swift @@ -143,12 +143,11 @@ private struct BridgedWebViewComponentView: View { } #if os(macOS) -typealias PlatformViewRepresentable = NSViewRepresentable -typealias PlatformWebView = WKWebView +private typealias PlatformViewRepresentable = NSViewRepresentable #else -typealias PlatformViewRepresentable = UIViewRepresentable -typealias PlatformWebView = WKWebView +private typealias PlatformViewRepresentable = UIViewRepresentable #endif +typealias PlatformWebView = WKWebView @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) struct WebViewRepresentable: PlatformViewRepresentable { From fb5e58906ba3e1dc5c6d21375495d7fc39ae5c65 Mon Sep 17 00:00:00 2001 From: Antonio Pallares Date: Wed, 22 Jul 2026 09:31:21 +0200 Subject: [PATCH 35/35] refactor(paywalls): drop redundant macOS web_view data-store cleanup The web view uses a per-view .nonPersistent() data store, which keeps data in memory and releases it when the web view deallocates, so the macOS-only removeData call on teardown was redundant. Co-authored-by: Cursor --- .../V2/Components/WebView/WebViewComponentView.swift | 7 ------- 1 file changed, 7 deletions(-) diff --git a/RevenueCatUI/Templates/V2/Components/WebView/WebViewComponentView.swift b/RevenueCatUI/Templates/V2/Components/WebView/WebViewComponentView.swift index d66eb21738..c20a8d4bde 100644 --- a/RevenueCatUI/Templates/V2/Components/WebView/WebViewComponentView.swift +++ b/RevenueCatUI/Templates/V2/Components/WebView/WebViewComponentView.swift @@ -272,13 +272,6 @@ struct WebViewRepresentable: PlatformViewRepresentable { webView.configuration.userContentController.removeScriptMessageHandler( forName: WebViewEnvelope.messageHandlerName ) - - #if os(macOS) - webView.configuration.websiteDataStore.removeData( - ofTypes: WKWebsiteDataStore.allWebsiteDataTypes(), - modifiedSince: .distantPast - ) {} - #endif } // `WKNavigationDelegate` is `@MainActor`-annotated in the SDK (its `WK_SWIFT_UI_ACTOR` attribute