Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions RevenueCat.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 */; };
Expand Down Expand Up @@ -1636,6 +1637,8 @@
16DA8ECC2E4EE33700283940 /* ImageComponentTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageComponentTests.swift; sourceTree = "<group>"; };
16DA8EDE2E4F6A6600283940 /* PaywallVideoComponent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaywallVideoComponent.swift; sourceTree = "<group>"; };
16DA8EF32E4F7A2500283940 /* VideoComponentTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoComponentTests.swift; sourceTree = "<group>"; };
9D0C57ECDF0E7F1A5B4418FF /* PaywallWebViewAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaywallWebViewAPI.swift; sourceTree = "<group>"; };
686562C08D953A27986DB276 /* PaywallWebViewValueTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaywallWebViewValueTests.swift; sourceTree = "<group>"; };
16DA8F1B2E4FB6E200283940 /* ImageComponent.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = ImageComponent.json; sourceTree = "<group>"; };
16DA8F1C2E4FB6E200283940 /* VideoComponent.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = VideoComponent.json; sourceTree = "<group>"; };
16E146AB2E99F1E20089B609 /* TransactionNotifications.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransactionNotifications.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -3165,6 +3168,7 @@
030890822D2B77DD0069677B /* PaywallsV2 */ = {
isa = PBXGroup;
children = (
686562C08D953A27986DB276 /* PaywallWebViewValueTests.swift */,
802593842FE07057005AF6DF /* PaywallStateStoreTests.swift */,
16A9F7B62FAA22D0008E8A4D /* VideoComponentViewTests.swift */,
A9D45E4EAFAD4C0385BEDFB3 /* VideoPlayerViewTests.swift */,
Expand Down Expand Up @@ -3297,6 +3301,14 @@
path = Video;
sourceTree = "<group>";
};
EB23E44596DD9714E81840CF /* WebView */ = {
isa = PBXGroup;
children = (
9D0C57ECDF0E7F1A5B4418FF /* PaywallWebViewAPI.swift */,
);
path = WebView;
sourceTree = "<group>";
};
1699BD5C2EEFBEEC002001FB /* DefaultPaywall */ = {
isa = PBXGroup;
children = (
Expand Down Expand Up @@ -3387,6 +3399,7 @@
0387D4AD2EC58AA5008E4A6B /* Countdown */,
E1C0A00BBEEF0001CCDD0001 /* Header */,
164681CD2E6B577600854AA5 /* Video */,
EB23E44596DD9714E81840CF /* WebView */,
2C7457472CEA66AB004ACE52 /* ComponentsView.swift */,
7707A94A2CAD936A006E0313 /* Button */,
03C06FC82D479C6300600693 /* Carousel */,
Expand Down Expand Up @@ -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 */,
Expand Down
205 changes: 205 additions & 0 deletions RevenueCatUI/Templates/V2/Components/WebView/PaywallWebViewAPI.swift
Original file line number Diff line number Diff line change
@@ -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 {
Comment thread
JZDesign marked this conversation as resolved.
Outdated

/// 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 {
Comment thread
JZDesign marked this conversation as resolved.
Outdated
case string(String)
case number(Double)
Comment thread
JZDesign marked this conversation as resolved.
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
Original file line number Diff line number Diff line change
Expand Up @@ -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 */; };
Expand Down Expand Up @@ -228,6 +229,7 @@

/* Begin PBXFileReference section */
1E8A60802CDCF29D0034ACF3 /* RedeemWebPurchasesAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RedeemWebPurchasesAPI.swift; sourceTree = "<group>"; };
B0DC08831A62751743D823FA /* PaywallWebViewAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaywallWebViewAPI.swift; sourceTree = "<group>"; };
1EB697C12CD26218003000FC /* WebPurchaseRedemptionResultAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebPurchaseRedemptionResultAPI.swift; sourceTree = "<group>"; };
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 = "<group>"; };
Expand Down Expand Up @@ -645,6 +647,7 @@
children = (
FD33CD722D035876000D13A4 /* CustomerCenterViewControllerAPI.swift */,
2D4C622D2C5ADA6700A29FD2 /* PaywallViewAPI.swift */,
B0DC08831A62751743D823FA /* PaywallWebViewAPI.swift */,
1E8A60802CDCF29D0034ACF3 /* RedeemWebPurchasesAPI.swift */,
2D4C622B2C5ADA6700A29FD2 /* PaywallViewControllerAPI.swift */,
2D4C622C2C5ADA6700A29FD2 /* main.swift */,
Expand Down Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
@@ -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<PaywallWebViewValue> = [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
Loading
Loading