Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 commits
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
16 changes: 14 additions & 2 deletions RevenueCat.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +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 */; };
1401E951ACE24C7B84F94B7F /* WebViewIsolation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 833251CB0C6FE0BA16C2AC41 /* WebViewIsolation.swift */; };
FC5AE63E8F81C3EC2FFDA408 /* WebViewOriginPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 28661C6CE3F64078A0138080 /* WebViewOriginPolicy.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 */; };
Expand Down Expand Up @@ -1672,6 +1674,10 @@
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>"; };
833251CB0C6FE0BA16C2AC41 /* WebViewIsolation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebViewIsolation.swift; sourceTree = "<group>"; };
28661C6CE3F64078A0138080 /* WebViewOriginPolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebViewOriginPolicy.swift; sourceTree = "<group>"; };
19B79CAD4C423F576E7DFBA9 /* WebViewIsolationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebViewIsolationTests.swift; sourceTree = "<group>"; };
9E7AD021B4580265DFDF6A02 /* WebViewNavigationPolicyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebViewNavigationPolicyTests.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>"; };
Expand Down Expand Up @@ -3236,6 +3242,8 @@
030890822D2B77DD0069677B /* PaywallsV2 */ = {
isa = PBXGroup;
children = (
19B79CAD4C423F576E7DFBA9 /* WebViewIsolationTests.swift */,
9E7AD021B4580265DFDF6A02 /* WebViewNavigationPolicyTests.swift */,
686562C08D953A27986DB276 /* PaywallWebViewValueTests.swift */,
802593842FE07057005AF6DF /* PaywallStateStoreTests.swift */,
16A9F7B62FAA22D0008E8A4D /* VideoComponentViewTests.swift */,
Expand Down Expand Up @@ -3371,9 +3379,11 @@
path = Video;
sourceTree = "<group>";
};
EB23E44596DD9714E81840CF /* WebView */ = {
2561352F00BE1ECE1BAC4E9A /* WebView */ = {
isa = PBXGroup;
children = (
833251CB0C6FE0BA16C2AC41 /* WebViewIsolation.swift */,
28661C6CE3F64078A0138080 /* WebViewOriginPolicy.swift */,
9D0C57ECDF0E7F1A5B4418FF /* PaywallWebViewAPI.swift */,
);
path = WebView;
Expand Down Expand Up @@ -3470,7 +3480,7 @@
0387D4AD2EC58AA5008E4A6B /* Countdown */,
E1C0A00BBEEF0001CCDD0001 /* Header */,
164681CD2E6B577600854AA5 /* Video */,
EB23E44596DD9714E81840CF /* WebView */,
2561352F00BE1ECE1BAC4E9A /* WebView */,
2C7457472CEA66AB004ACE52 /* ComponentsView.swift */,
7707A94A2CAD936A006E0313 /* Button */,
03C06FC82D479C6300600693 /* Carousel */,
Expand Down Expand Up @@ -8326,6 +8336,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 */,
FA4A2DF41FC434A9401F08CC /* PaywallWebViewAPI.swift in Sources */,
DBAA1FC22F8D4ED8000E8C81 /* HeaderNestedHeroZLayerSafeAreaPreview.swift in Sources */,
164681D22E6B577600854AA5 /* VideoComponentViewModel.swift in Sources */,
Expand Down
6 changes: 6 additions & 0 deletions RevenueCatUI/Data/Strings.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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):
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import Foundation
@_spi(Internal) import RevenueCat

#if !os(tvOS) && canImport(WebKit) // For Paywalls V2
import WebKit

@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *)
enum WebViewIsolation {

static let contentRuleListIdentifier = "rc-webview-v2-isolation"

static let contentBlockingRules = """
[
{
"trigger": {
"url-filter": ".*",
"resource-type": ["image", "script", "font", "raw", "style-sheet", "media", "document"],
"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.error(Strings.paywall_web_view_content_rules_failed(String(describing: error)))
}
Comment thread
cursor[bot] marked this conversation as resolved.
Outdated
continuation.resume(returning: ruleList)
}
}
}

@MainActor
private static var ruleListTask: Task<WKContentRuleList?, Never>?

@MainActor
static func ruleList() async -> WKContentRuleList? {
if let ruleListTask {
return await ruleListTask.value
}

let task = Task<WKContentRuleList?, Never> { @MainActor in
await Self.compileRuleList(Self.contentRuleListIdentifier, Self.contentBlockingRules)
}
self.ruleListTask = task
return await task.value
}

@MainActor
static func resetRuleListCacheForTests() {
self.ruleListTask = nil
}

}

#endif
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
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 {

// The origin check is enforced on every frame, including sub-frames, so cross-origin iframes
// cannot navigate freely even if a third-party document load slips past the content rules.
// `isMainFrame` is kept for call-site context and potential future differentiation.
// Both sides are canonicalized so a non-canonical `expectedOrigin` (mixed case, explicit
// default port) doesn't cause legitimate same-origin navigations to be cancelled.
static func policy(for url: URL?, isMainFrame: Bool, expectedOrigin: String) -> WKNavigationActionPolicy {
guard let url,
let origin = WebViewOrigin.origin(of: url),
let expected = URL(string: expectedOrigin).flatMap(WebViewOrigin.origin(of:)),
origin == expected else {
return .cancel
Comment thread
cursor[bot] marked this conversation as resolved.
}
return .allow
}

}

#endif
77 changes: 77 additions & 0 deletions Tests/RevenueCatUITests/PaywallsV2/WebViewIsolationTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
//
// 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, *)
@MainActor
final class WebViewIsolationTests: TestCase {

func testContentRulesShape() throws {
Comment thread
ajpallares marked this conversation as resolved.
Outdated
XCTAssertEqual(WebViewIsolation.contentRuleListIdentifier, "rc-webview-v2-isolation")
let rules = WebViewIsolation.contentBlockingRules
let data = try XCTUnwrap(rules.data(using: .utf8))
let object = try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [[String: Any]])

XCTAssertEqual(object.count, 1)
let trigger = try XCTUnwrap(object[0]["trigger"] as? [String: Any])
XCTAssertEqual(
trigger["resource-type"] as? [String],
["image", "script", "font", "raw", "style-sheet", "media", "document"]
)
XCTAssertEqual(trigger["load-type"] as? [String], ["third-party"])
let action = try XCTUnwrap(object[0]["action"] as? [String: Any])
XCTAssertEqual(action["type"] as? String, "block")
XCTAssertFalse(rules.contains(#""url-filter": "data:"#))
}

func testContentBlockingRulesCompileThroughWebKit() async throws {
let identifier = "\(WebViewIsolation.contentRuleListIdentifier)-integration-test"

let ruleList: WKContentRuleList? = await withCheckedContinuation { continuation in
WKContentRuleListStore.default().compileContentRuleList(
forIdentifier: identifier,
encodedContentRuleList: WebViewIsolation.contentBlockingRules
) { compiled, _ in
continuation.resume(returning: compiled)
}
}

XCTAssertNotNil(ruleList)

await withCheckedContinuation { (continuation: CheckedContinuation<Void, Never>) in
WKContentRuleListStore.default().removeContentRuleList(forIdentifier: identifier) { _ in
continuation.resume()
}
}
}

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
Loading
Loading