Files
self/packages/native-shell-ios/Sources/SelfNativeShell/WebView/SelfWebViewHost.swift
Justin Hernandez f29130587b Harden WebView bridge and asset serving across native shells (#1924)
* security fix

* more security fixes

* fixes

* pr feedback

* Restore remote URL loading in native-shell-ios and native-shell-android

Remove bundled-asset-only loading and SHA-256 integrity checks from both
native shell packages. WebViews now load directly from the remote URL
(default: https://self-app-alpha.vercel.app) over HTTPS, matching the
pattern already implemented in kmp-sdk and self-sdk-swift.

Also fixes ObjC selector mismatch in self-sdk-swift WebViewProviderImpl
for configureRemoteLoading.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Restore remote URL loading in kmp-sdk and self-sdk-swift

Remove bundled-asset-only loading from kmp-sdk AndroidWebViewHost and
self-sdk-swift WebViewProviderImpl. Both now load directly from the
remote URL (default: https://self-app-alpha.vercel.app) over HTTPS.

Adds remoteWebAppBaseUrl to SelfSdkConfig and pipes it through
IosWebViewHost via the new configureRemoteLoading protocol method.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* coderabbit comments

* lint

* coderabbit comments

---------

Co-authored-by: seshanthS <seshanth@protonmail.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-07 22:39:27 +05:30

193 lines
6.5 KiB
Swift

// SPDX-License-Identifier: BUSL-1.1
import Foundation
import UIKit
import WebKit
final class SelfWebViewHost: NSObject {
private static let defaultRemoteBaseURL = URL(string: "https://self-app-alpha.vercel.app")!
private var webView: WKWebView?
private let router: MessageRouter
private let isDebugMode: Bool
private let remoteWebAppBaseURL: URL
init(
router: MessageRouter,
isDebugMode: Bool = false,
remoteWebAppBaseURL: URL? = nil
) {
self.router = router
self.isDebugMode = isDebugMode
self.remoteWebAppBaseURL = remoteWebAppBaseURL ?? Self.defaultRemoteBaseURL
super.init()
}
func createWebView() -> WKWebView {
let config = WKWebViewConfiguration()
let contentController = WKUserContentController()
contentController.add(WeakScriptMessageProxy(handler: self), name: "SelfNativeIOS")
config.userContentController = contentController
config.preferences.javaScriptCanOpenWindowsAutomatically = false
config.allowsInlineMediaPlayback = true
config.mediaTypesRequiringUserActionForPlayback = []
let webView = WKWebView(frame: .zero, configuration: config)
webView.scrollView.bounces = false
webView.isOpaque = false
webView.backgroundColor = .clear
webView.navigationDelegate = self
if #available(iOS 16.4, *) {
webView.isInspectable = isDebugMode
}
self.webView = webView
return webView
}
func loadContent(queryParams: String) {
guard let webView = webView else { return }
if isDebugMode {
let debugBase = URL(string: "http://localhost:5173")
if let url = RemoteNavigationPolicy.makeEntryURL(baseURL: debugBase, queryParams: queryParams) {
webView.load(URLRequest(url: url))
}
return
}
guard remoteWebAppBaseURL.scheme == "https" else { return }
if let url = RemoteNavigationPolicy.makeEntryURL(baseURL: remoteWebAppBaseURL, queryParams: queryParams) {
webView.load(URLRequest(url: url))
}
}
func evaluateJs(_ js: String) {
DispatchQueue.main.async { [weak self] in
self?.webView?.evaluateJavaScript(js, completionHandler: nil)
}
}
private func isAllowedNavigation(url: URL) -> Bool {
RemoteNavigationPolicy.isAllowedMainFrameNavigation(
url: url,
remoteWebAppBaseURL: remoteWebAppBaseURL,
isDebugMode: isDebugMode
)
}
private func resolvedPort(for url: URL) -> Int {
RemoteNavigationPolicy.resolvedPort(for: url)
}
private func isAllowedSubframeNavigation(url: URL) -> Bool {
RemoteNavigationPolicy.isAllowedSubframeNavigation(
url: url,
remoteWebAppBaseURL: remoteWebAppBaseURL,
isDebugMode: isDebugMode
)
}
}
extension SelfWebViewHost: WKNavigationDelegate {
func webView(
_ webView: WKWebView,
decidePolicyFor navigationAction: WKNavigationAction,
decisionHandler: @escaping (WKNavigationActionPolicy) -> Void
) {
guard let url = navigationAction.request.url else {
decisionHandler(.cancel)
return
}
if let targetFrame = navigationAction.targetFrame, !targetFrame.isMainFrame {
decisionHandler(isAllowedSubframeNavigation(url: url) ? .allow : .cancel)
return
}
decisionHandler(isAllowedNavigation(url: url) ? .allow : .cancel)
}
}
extension SelfWebViewHost: WKScriptMessageHandler {
func userContentController(
_ userContentController: WKUserContentController,
didReceive message: WKScriptMessage
) {
guard message.name == "SelfNativeIOS",
message.frameInfo.isMainFrame,
isTrustedBridgeFrameInfo(message.frameInfo.securityOrigin),
let body = message.body as? String else {
return
}
router.onMessageReceived(rawJson: body, isTrustedSource: true)
}
}
private extension SelfWebViewHost {
func isTrustedBridgeOrigin(_ url: URL?) -> Bool {
guard let url else { return false }
if isDebugMode {
return url.scheme == "http" &&
url.host == "localhost" &&
resolvedPort(for: url) == 5173
}
return url.scheme == remoteWebAppBaseURL.scheme &&
url.host == remoteWebAppBaseURL.host &&
resolvedPort(for: url) == resolvedPort(for: remoteWebAppBaseURL)
}
}
extension SelfWebViewHost {
func initialContentURL(queryParams: String) -> URL? {
if isDebugMode {
let debugBase = URL(string: "http://localhost:5173")
return RemoteNavigationPolicy.makeEntryURL(baseURL: debugBase, queryParams: queryParams)
}
guard remoteWebAppBaseURL.scheme == "https" else { return nil }
return RemoteNavigationPolicy.makeEntryURL(baseURL: remoteWebAppBaseURL, queryParams: queryParams)
}
func isAllowedNavigationURL(_ url: URL?, host: String? = nil) -> Bool {
guard let url else { return false }
return isAllowedNavigation(url: url) || isAllowedSubframeNavigation(url: url)
}
func isTrustedBridgeURL(_ url: URL?) -> Bool {
isTrustedBridgeOrigin(url)
}
func isTrustedBridgeFrameInfo(_ origin: WKSecurityOrigin) -> Bool {
if isDebugMode {
return origin.protocol == "http" && origin.host == "localhost" && origin.port == 5173
}
return origin.protocol == remoteWebAppBaseURL.scheme &&
origin.host == remoteWebAppBaseURL.host &&
resolvedSecurityOriginPort(origin) == resolvedPort(for: remoteWebAppBaseURL)
}
private func resolvedSecurityOriginPort(_ origin: WKSecurityOrigin) -> Int {
if origin.port != 0 { return origin.port }
switch origin.protocol {
case "https": return 443
case "http": return 80
default: return 0
}
}
}
// Prevents WKWebView retain cycle with WKScriptMessageHandler
private final class WeakScriptMessageProxy: NSObject, WKScriptMessageHandler {
private weak var handler: WKScriptMessageHandler?
init(handler: WKScriptMessageHandler) {
self.handler = handler
}
func userContentController(
_ userContentController: WKUserContentController,
didReceive message: WKScriptMessage
) {
handler?.userContentController(userContentController, didReceive: message)
}
}