diff --git a/RevenueCat.xcodeproj/project.pbxproj b/RevenueCat.xcodeproj/project.pbxproj index 138ca96a87..109e057423 100644 --- a/RevenueCat.xcodeproj/project.pbxproj +++ b/RevenueCat.xcodeproj/project.pbxproj @@ -109,7 +109,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 */; }; - FA4A2DF41FC434A9401F08CC /* PaywallWebViewAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9D0C57ECDF0E7F1A5B4418FF /* PaywallWebViewAPI.swift */; }; + FA4A2DF41FC434A9401F08CC /* PaywallWebViewValue.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9D0C57ECDF0E7F1A5B4418FF /* PaywallWebViewValue.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 */; }; @@ -1672,7 +1673,9 @@ 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 = ""; }; + 9D0C57ECDF0E7F1A5B4418FF /* PaywallWebViewValue.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaywallWebViewValue.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 = ""; }; @@ -3237,6 +3240,7 @@ isa = PBXGroup; children = ( 686562C08D953A27986DB276 /* PaywallWebViewValueTests.swift */, + C1B939C7DAAFEA1DE2DE2674 /* WebViewEnvelopeTests.swift */, 802593842FE07057005AF6DF /* PaywallStateStoreTests.swift */, 16A9F7B62FAA22D0008E8A4D /* VideoComponentViewTests.swift */, A9D45E4EAFAD4C0385BEDFB3 /* VideoPlayerViewTests.swift */, @@ -3374,7 +3378,8 @@ EB23E44596DD9714E81840CF /* WebView */ = { isa = PBXGroup; children = ( - 9D0C57ECDF0E7F1A5B4418FF /* PaywallWebViewAPI.swift */, + 9D0C57ECDF0E7F1A5B4418FF /* PaywallWebViewValue.swift */, + 89DD761C9E00E61032AAEB44 /* WebViewEnvelope.swift */, ); path = WebView; sourceTree = ""; @@ -8326,7 +8331,8 @@ 164681D02E6B577600854AA5 /* VideoComponentView.swift in Sources */, 164681D12E6B577600854AA5 /* VideoPlayerViewUIView.swift in Sources */, 164681D62E6B577600854AA5 /* VideoPlayerLayerUIView.swift in Sources */, - FA4A2DF41FC434A9401F08CC /* PaywallWebViewAPI.swift in Sources */, + FA4A2DF41FC434A9401F08CC /* PaywallWebViewValue.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/PaywallWebViewAPI.swift b/RevenueCatUI/Templates/V2/Components/WebView/PaywallWebViewValue.swift similarity index 98% rename from RevenueCatUI/Templates/V2/Components/WebView/PaywallWebViewAPI.swift rename to RevenueCatUI/Templates/V2/Components/WebView/PaywallWebViewValue.swift index 25e0d16a6c..35610467d0 100644 --- a/RevenueCatUI/Templates/V2/Components/WebView/PaywallWebViewAPI.swift +++ b/RevenueCatUI/Templates/V2/Components/WebView/PaywallWebViewValue.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/WebViewEnvelope.swift b/RevenueCatUI/Templates/V2/Components/WebView/WebViewEnvelope.swift new file mode 100644 index 0000000000..58d972b9ee --- /dev/null +++ b/RevenueCatUI/Templates/V2/Components/WebView/WebViewEnvelope.swift @@ -0,0 +1,91 @@ +import Foundation + +#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 defaultProtocolVersion = 1 + + static let messageTypeResize = "resize" + static let messageTypeFit = "fit" + + static let maxResizePoints: CGFloat = 10_000 + static let resizeThreshold: CGFloat = 1 + static let fallbackFitHeight: CGFloat = 100 + static let fallbackFitWidth: CGFloat = 300 + + // swiftlint:disable nesting + enum Kind: String, Codable { + case connect + case `init` + case reject + case message + case request + case response + case error + } + + /// One JSON message exchanged with the `web_view` content. Fields used vary by ``kind``. + 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 + } + } + // swiftlint:enable nesting + + static func decode(rawMessage: Any) -> Envelope? { + // `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 + } + + guard let envelope = try? JSONDecoder().decode(Envelope.self, from: data), + envelope.channel == Self.channel else { + return nil + } + + return envelope + } + +} + +#endif diff --git a/Tests/RevenueCatUITests/PaywallsV2/PaywallWebViewValueTests.swift b/Tests/RevenueCatUITests/PaywallsV2/PaywallWebViewValueTests.swift index a593eeb9c5..b06a7a176a 100644 --- a/Tests/RevenueCatUITests/PaywallsV2/PaywallWebViewValueTests.swift +++ b/Tests/RevenueCatUITests/PaywallsV2/PaywallWebViewValueTests.swift @@ -30,6 +30,36 @@ final class PaywallWebViewValueTests: TestCase { XCTAssertEqual(decoded.objectValue?["number"]?.numberValue, 1.25) XCTAssertEqual(decoded.objectValue?["bool"]?.boolValue, true) XCTAssertTrue(decoded.objectValue?["null"]?.isNull == true) + + let array = try XCTUnwrap(decoded.objectValue?["array"]?.arrayValue) + XCTAssertEqual(array.count, 2) + XCTAssertTrue(array.first?.isNull == true) + XCTAssertEqual(array.last?.stringValue, "x") + + XCTAssertEqual(decoded.objectValue?["object"]?.objectValue?["nested"]?.boolValue, false) + } + + func testTopLevelScalarsAndEmptyContainersRoundTrip() throws { + let values: [PaywallWebViewValue] = [ + .string("hello"), + .number(1.25), + .bool(true), + .bool(false), + .null, + .array([]), + .object([:]) + ] + + for value in values { + let decoded = try JSONDecoder().decode( + PaywallWebViewValue.self, + from: try JSONEncoder().encode(value) + ) + XCTAssertEqual(decoded, value) + } + + XCTAssertEqual(PaywallWebViewValue.array([]).arrayValue, []) + XCTAssertEqual(PaywallWebViewValue.object([:]).objectValue, [:]) } func testNumberBoolDisambiguation() throws { diff --git a/Tests/RevenueCatUITests/PaywallsV2/WebViewEnvelopeTests.swift b/Tests/RevenueCatUITests/PaywallsV2/WebViewEnvelopeTests.swift new file mode 100644 index 0000000000..819e72f924 --- /dev/null +++ b/Tests/RevenueCatUITests/PaywallsV2/WebViewEnvelopeTests.swift @@ -0,0 +1,234 @@ +// +// 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 WebViewEnvelopeTests: TestCase { + + func testDecodesValidConnectFrame() throws { + let envelope = WebViewEnvelope.decode(rawMessage: try 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() throws { + XCTAssertNil(WebViewEnvelope.decode(rawMessage: try Self.json([ + "channel": "wrong", + "protocol_version": 1, + "kind": "connect", + "component_id": "" + ]))) + XCTAssertNil(WebViewEnvelope.decode(rawMessage: try Self.json([ + "channel": WebViewEnvelope.channel, + "protocol_version": 1, + "kind": "unknown", + "component_id": "" + ]))) + XCTAssertNil(WebViewEnvelope.decode(rawMessage: try Self.json([ + "channel": WebViewEnvelope.channel, + "protocol_version": 1, + "kind": "message" + ]))) + XCTAssertNil(WebViewEnvelope.decode(rawMessage: "{not json")) + } + + func testRejectsNonStringFrames() { + XCTAssertNil(WebViewEnvelope.decode(rawMessage: Self.frame(payload: ["value": "valid JSON"]))) + XCTAssertNil(WebViewEnvelope.decode(rawMessage: 1)) + } + + func testDoesNotEnforceClientSideByteLimit() throws { + let payload = String(repeating: "x", count: 65_537) + let frame = Self.frame(payload: ["value": payload]) + + XCTAssertNotNil(WebViewEnvelope.decode(rawMessage: try Self.json(frame))) + } + + func testDoesNotEnforceClientSideDepthLimit() throws { + XCTAssertNotNil( + WebViewEnvelope.decode(rawMessage: try Self.json(Self.frame(payload: Self.nestedObject(depth: 16)))) + ) + } + + func testEncodedFrameUsesSnakeCaseKeysAndOmitsNilFields() throws { + let envelope = WebViewEnvelope.Envelope(kind: .connect, componentID: "web") + + let data = try JSONEncoder().encode(envelope) + let object = try XCTUnwrap(try JSONSerialization.jsonObject(with: data) as? [String: Any]) + + XCTAssertEqual(Set(object.keys), ["channel", "protocol_version", "kind", "component_id"]) + XCTAssertEqual(object["channel"] as? String, WebViewEnvelope.channel) + XCTAssertEqual(object["protocol_version"] as? Int, 1) + XCTAssertEqual(object["kind"] as? String, "connect") + XCTAssertEqual(object["component_id"] as? String, "web") + } + + func testDecodesFullMessageFrameWithRichPayload() throws { + let envelope = try XCTUnwrap(WebViewEnvelope.decode(rawMessage: try Self.json([ + "channel": WebViewEnvelope.channel, + "protocol_version": 1, + "kind": "response", + "component_id": "web", + "type": "rc:variables", + "id": "req-1", + "payload": [ + "string": "value", + "number": 1.25, + "bool": true, + "nested": ["inner": false], + "list": [1, 2, 3], + "nothing": NSNull() + ] + ]))) + + XCTAssertEqual(envelope.kind, .response) + XCTAssertEqual(envelope.componentID, "web") + XCTAssertEqual(envelope.type, "rc:variables") + XCTAssertEqual(envelope.id, "req-1") + + let payload = try XCTUnwrap(envelope.payload) + XCTAssertEqual(payload["string"]?.stringValue, "value") + XCTAssertEqual(payload["number"]?.numberValue, 1.25) + XCTAssertEqual(payload["bool"]?.boolValue, true) + XCTAssertEqual(payload["nested"]?.objectValue?["inner"]?.boolValue, false) + XCTAssertEqual(payload["list"]?.arrayValue?.count, 3) + XCTAssertTrue(payload["nothing"]?.isNull == true) + } + + func testRejectsInvalidProtocolVersion() throws { + // Missing protocol_version. + XCTAssertNil(WebViewEnvelope.decode(rawMessage: try Self.json([ + "channel": WebViewEnvelope.channel, + "kind": "message", + "component_id": "web" + ]))) + // Fractional protocol_version (integral only). + XCTAssertNil(WebViewEnvelope.decode(rawMessage: try Self.json([ + "channel": WebViewEnvelope.channel, + "protocol_version": 1.5, + "kind": "message", + "component_id": "web" + ]))) + // Non-numeric protocol_version. + XCTAssertNil(WebViewEnvelope.decode(rawMessage: try Self.json([ + "channel": WebViewEnvelope.channel, + "protocol_version": "1", + "kind": "message", + "component_id": "web" + ]))) + } + + func testRejectsNonStringFieldsAndNonObjectPayload() throws { + // Non-string type. + XCTAssertNil(WebViewEnvelope.decode(rawMessage: try Self.json([ + "channel": WebViewEnvelope.channel, + "protocol_version": 1, + "kind": "message", + "component_id": "web", + "type": 123 + ]))) + // Non-string id. + XCTAssertNil(WebViewEnvelope.decode(rawMessage: try Self.json([ + "channel": WebViewEnvelope.channel, + "protocol_version": 1, + "kind": "response", + "component_id": "web", + "id": 123 + ]))) + // Non-string error. + XCTAssertNil(WebViewEnvelope.decode(rawMessage: try Self.json([ + "channel": WebViewEnvelope.channel, + "protocol_version": 1, + "kind": "error", + "component_id": "web", + "error": 123 + ]))) + // Non-object payload. + XCTAssertNil(WebViewEnvelope.decode(rawMessage: try Self.json([ + "channel": WebViewEnvelope.channel, + "protocol_version": 1, + "kind": "message", + "component_id": "web", + "payload": [1, 2, 3] + ]))) + } + + func testRejectsValidJSONThatIsNotAnObject() { + XCTAssertNil(WebViewEnvelope.decode(rawMessage: "[1,2,3]")) + XCTAssertNil(WebViewEnvelope.decode(rawMessage: "\"hello\"")) + XCTAssertNil(WebViewEnvelope.decode(rawMessage: "5")) + } + + func testEncodeDecodeRoundTrip() 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 data = try JSONEncoder().encode(envelope) + let json = try XCTUnwrap(String(data: data, encoding: .utf8)) + + let decoded = try XCTUnwrap(WebViewEnvelope.decode(rawMessage: json)) + XCTAssertEqual(decoded, envelope) + 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) + } + } + + 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 try XCTUnwrap(String(data: data, encoding: .utf8)) + } + +} + +#endif