-
Notifications
You must be signed in to change notification settings - Fork 432
feat(paywalls): web_view bridge session with document-reset lifecycle (5/7) #7231
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from 24 commits
Commits
Show all changes
34 commits
Select commit
Hold shift + click to select a range
5cf1dfd
feat(paywalls): public API surface for web_view messages (1/7)
cursoragent f7f5d16
feat(paywalls): web_view wire envelope + JSON hardening (2/7)
cursoragent d331b34
feat(paywalls): web_view navigation policy + content-rule isolation (…
cursoragent 7664d35
Merge webview/3-isolation-navigation into webview/5-session base
cursoragent b033efa
feat(paywalls): web_view bridge session with document-reset lifecycle…
cursoragent 24f352c
fix: remove extra vertical whitespace for SwiftLint
cursoragent e671378
refactor(paywalls): keep web view bridge internal
cursoragent 41401ac
refactor(paywalls): keep web view bridge internal
cursoragent b5caeda
refactor(paywalls): keep web view bridge internal
cursoragent d941c01
Merge updated internal web view API base
cursoragent c9f7142
Merge updated internal web view API stack
cursoragent 111d7ea
Merge updated internal web view API dependencies
cursoragent ca80c8f
Merge branch 'main' into webview/1-public-api
ajpallares 94982da
fix(paywalls): remove client-side web view payload limits
cursoragent ea6d24b
test(paywalls): address web view envelope review
cursoragent 0612595
refactor(paywalls): remove unused web view handler API
cursoragent d14d63c
fix(paywalls): retain internal bridge dependencies
cursoragent 19cbfe6
refactor(paywalls): remove web view message handler concept
JZDesign c3d27a9
Merge branch 'webview/1-public-api' into webview/2-envelope
JZDesign e3ad994
Merge updated internal web view API stack (handler removal)
JZDesign 4671130
Merge updated internal web view API dependencies (handler removal)
JZDesign eb53f30
Adapt web view bridge to trimmed envelope API
ajpallares 66b3e33
Merge branch 'main' into webview/5-session
ajpallares f984e23
Merge branch 'main' into webview/5-session
ajpallares 86d9c0b
refactor(paywalls): harden web_view session origin gating
ajpallares ab69065
Merge remote-tracking branch 'origin/webview/5-session' into webview/…
ajpallares a48f936
Merge branch 'main' into webview/5-session
ajpallares 7940b96
refactor(paywalls): expose web_view origin as URL/WKSecurityOrigin ex…
ajpallares 7280b5d
refactor(paywalls): consolidate web_view imports under single platfor…
ajpallares 0a866f8
refactor(paywalls): add copyright header to web_view files and fix im…
ajpallares 8ceb7d4
Merge branch 'main' into webview/5-session
ajpallares 07ecb4c
fix(paywalls): treat unresolvable web_view origin as inert bridge
ajpallares 2eb1308
refactor(paywalls): tidy web_view session comments and narrow handleC…
ajpallares 53fde7e
test(paywalls): add missing web_view drop-branch coverage and harden …
ajpallares File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
276 changes: 276 additions & 0 deletions
276
RevenueCatUI/Templates/V2/Components/WebView/WebViewSession.swift
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,276 @@ | ||
| 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 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 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 } | ||
| ) { | ||
| self.componentID = componentID | ||
| self.protocolVersion = protocolVersion | ||
| self.expectedOrigin = expectedOrigin | ||
| self.fitAxes = fitAxes | ||
| 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: | ||
| self.handleResize(envelope.payload) | ||
| 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 be dropped. | ||
| 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 | ||
| } | ||
|
|
||
| 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 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 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 = Self.receiveScript(for: envelope) else { | ||
| Logger.debug(Strings.paywall_web_view_post_message_failed("encoding failed")) | ||
| return | ||
| } | ||
|
|
||
| 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. | ||
| 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? { | ||
| 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 | ||
|
cursor[bot] marked this conversation as resolved.
Outdated
|
||
| } | ||
|
|
||
| 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 | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.