diff --git a/.gitignore b/.gitignore index 330d167..f289fcd 100644 --- a/.gitignore +++ b/.gitignore @@ -88,3 +88,6 @@ fastlane/test_output # https://github.com/johnno1962/injectionforxcode iOSInjectionProject/ + +# macOS +.DS_Store diff --git a/CHANGELOG.md b/CHANGELOG.md index 3674d8a..96715ac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,12 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Features + +- Add support for passing custom headers to `WKWebView` (only for the openInWebView option). [RMET-4287](https://outsystemsrd.atlassian.net/browse/RMET-4287). + ## 2.0.1 ### Features diff --git a/OSInAppBrowserLib.zip b/OSInAppBrowserLib.zip index 144951e..6de6348 100644 Binary files a/OSInAppBrowserLib.zip and b/OSInAppBrowserLib.zip differ diff --git a/OSInAppBrowserLib/RouterAdapters/OSIABWebViewRouterAdapter.swift b/OSInAppBrowserLib/RouterAdapters/OSIABWebViewRouterAdapter.swift index 89d8276..0a5ab4b 100644 --- a/OSInAppBrowserLib/RouterAdapters/OSIABWebViewRouterAdapter.swift +++ b/OSInAppBrowserLib/RouterAdapters/OSIABWebViewRouterAdapter.swift @@ -7,6 +7,8 @@ public class OSIABWebViewRouterAdapter: NSObject, OSIABRouter { /// Object that contains the value to format the visual presentation. private let options: OSIABWebViewOptions + /// Custom headers to be used by the WebView. + private let customHeaders: [String: String]? /// Object that manages the browser's cache private let cacheManager: OSIABCacheManager /// Object that manages all the callbacks available for the WebView. @@ -15,14 +17,17 @@ public class OSIABWebViewRouterAdapter: NSObject, OSIABRouter { /// Constructor method. /// - Parameters: /// - options: Object that contains the value to format the visual presentation. + /// - customHeaders: Custom headers to be used by the WebView. `nil` is provided in case of no value. /// - cacheManager: Object that manages the browser's cache /// - callbackHandler: Object that manages all the callbacks available for the WebView. public init( - _ options: OSIABWebViewOptions, + options: OSIABWebViewOptions, + customHeaders: [String: String]? = nil, cacheManager: OSIABCacheManager, callbackHandler: OSIABWebViewCallbackHandler ) { self.options = options + self.customHeaders = customHeaders self.cacheManager = cacheManager self.callbackHandler = callbackHandler } @@ -36,12 +41,13 @@ public class OSIABWebViewRouterAdapter: NSObject, OSIABRouter { let viewModel = OSIABWebViewModel( url: url, - self.options.toConfigurationModel().toWebViewConfiguration(), - self.options.allowOverScroll, - self.options.customUserAgent, - self.options.allowsBackForwardNavigationGestures, - uiModel: self.options.toUIModel(), - callbackHandler: self.callbackHandler + customHeaders: customHeaders, + webViewConfiguration: options.toConfigurationModel().toWebViewConfiguration(), + scrollViewBounces: options.allowOverScroll, + customUserAgent: options.customUserAgent, + backForwardNavigationGestures: options.allowsBackForwardNavigationGestures, + uiModel: options.toUIModel(), + callbackHandler: callbackHandler ) let dismissCallback: () -> Void = { self.callbackHandler.onBrowserClosed(true) } @@ -52,8 +58,8 @@ public class OSIABWebViewRouterAdapter: NSObject, OSIABRouter { } else { hostingController = OSIABWebView13Controller(rootView: .init(viewModel), dismiss: dismissCallback) } - hostingController.modalPresentationStyle = self.options.modalPresentationStyle - hostingController.modalTransitionStyle = self.options.modalTransitionStyle + hostingController.modalPresentationStyle = options.modalPresentationStyle + hostingController.modalTransitionStyle = options.modalTransitionStyle hostingController.presentationController?.delegate = self completionHandler(hostingController) @@ -66,10 +72,10 @@ private extension OSIABWebViewOptions { /// - Returns: The `OSIABWebViewConfigurationModel` equivalent value. func toConfigurationModel() -> OSIABWebViewConfigurationModel { .init( - self.mediaTypesRequiringUserActionForPlayback, - self.enableViewportScale, - self.allowInLineMediaPlayback, - self.surpressIncrementalRendering + mediaTypesRequiringUserActionForPlayback, + enableViewportScale, + allowInLineMediaPlayback, + surpressIncrementalRendering ) } @@ -77,12 +83,12 @@ private extension OSIABWebViewOptions { /// - Returns: The `OSIABWebViewUIModel` equivalent value. func toUIModel() -> OSIABWebViewUIModel { .init( - showURL: self.showURL, - showToolbar: self.showToolbar, - toolbarPosition: self.toolbarPosition, - showNavigationButtons: self.showNavigationButtons, - leftToRight: self.leftToRight, - closeButtonText: self.closeButtonText + showURL: showURL, + showToolbar: showToolbar, + toolbarPosition: toolbarPosition, + showNavigationButtons: showNavigationButtons, + leftToRight: leftToRight, + closeButtonText: closeButtonText ) } } diff --git a/OSInAppBrowserLib/WebView/OSIABWebViewModel.swift b/OSInAppBrowserLib/WebView/OSIABWebViewModel.swift index 1e3e173..a4323bd 100644 --- a/OSInAppBrowserLib/WebView/OSIABWebViewModel.swift +++ b/OSInAppBrowserLib/WebView/OSIABWebViewModel.swift @@ -1,5 +1,5 @@ import Combine -import WebKit +@preconcurrency import WebKit /// View Model containing all the WebView's customisations. class OSIABWebViewModel: NSObject, ObservableObject { @@ -20,6 +20,9 @@ class OSIABWebViewModel: NSObject, ObservableObject { /// Indicates if first load is already done. This is important in order to trigger the `browserPageLoad` event. private var firstLoadDone: Bool = false + /// Custom headers to be used by the WebView. + private let customHeaders: [String: String]? + /// The current URL being displayed @Published private(set) var url: URL /// Indicates if the URL is being loaded into the screen. @@ -31,7 +34,7 @@ class OSIABWebViewModel: NSObject, ObservableObject { /// Indicates if the forward button is available for pressing. @Published private(set) var forwardButtonEnabled: Bool = true - /// The current adress label being displayed on the screen. Empty string indicates that the address will not be displayed. + /// The current address label being displayed on the screen. Empty string indicates that the address will not be displayed. @Published private(set) var addressLabel: String = "" private var cancellables = Set() @@ -39,35 +42,32 @@ class OSIABWebViewModel: NSObject, ObservableObject { /// Constructor method. /// - Parameters: /// - url: The current URL being displayed - /// - webViewConfiguration: Collection of properties with which to initialize the WebView. + /// - webView: The WebView to display and configure. /// - scrollViewBounces: Indicates if the WebView's bounce property should be enabled. Defaults to `true`. /// - customUserAgent: Sets a custom user agent for the WebView. /// - uiModel: Collection of properties to apply to the WebView's interface. /// - callbackHandler: Object that manages all the callbacks available for the WebView. init( url: URL, - _ webViewConfiguration: WKWebViewConfiguration, - _ scrollViewBounces: Bool = true, - _ customUserAgent: String? = nil, - _ backForwardNavigationGestures: Bool = true, + customHeaders: [String: String]? = nil, + webView: WKWebView, + scrollViewBounces: Bool = true, + customUserAgent: String? = nil, + backForwardNavigationGestures: Bool = true, uiModel: OSIABWebViewUIModel, callbackHandler: OSIABWebViewCallbackHandler ) { self.url = url - self.webView = .init(frame: .zero, configuration: webViewConfiguration) + self.customHeaders = customHeaders + self.webView = webView self.closeButtonText = uiModel.closeButtonText self.callbackHandler = callbackHandler - if uiModel.showToolbar { - self.toolbarPosition = uiModel.toolbarPosition - if uiModel.showURL { - self.addressLabel = url.absoluteString - } - } else { - self.toolbarPosition = nil + self.toolbarPosition = uiModel.showToolbar ? uiModel.toolbarPosition : nil + if uiModel.showToolbar && uiModel.showURL { + self.addressLabel = url.absoluteString } self.showNavigationButtons = uiModel.showNavigationButtons self.leftToRight = uiModel.leftToRight - super.init() self.webView.allowsBackForwardNavigationGestures = backForwardNavigationGestures self.webView.scrollView.bounces = scrollViewBounces @@ -77,53 +77,76 @@ class OSIABWebViewModel: NSObject, ObservableObject { self.setupBindings(uiModel.showURL, uiModel.showToolbar, uiModel.showNavigationButtons) } + /// Constructor method. + /// - Parameters: + /// - url: The current URL being displayed + /// - webViewConfiguration: Collection of properties with which to initialize the WebView. + /// - scrollViewBounces: Indicates if the WebView's bounce property should be enabled. Defaults to `true`. + /// - customUserAgent: Sets a custom user agent for the WebView. + /// - uiModel: Collection of properties to apply to the WebView's interface. + /// - callbackHandler: Object that manages all the callbacks available for the WebView. + convenience init( + url: URL, + customHeaders: [String: String]? = nil, + webViewConfiguration: WKWebViewConfiguration, + scrollViewBounces: Bool = true, + customUserAgent: String? = nil, + backForwardNavigationGestures: Bool = true, + uiModel: OSIABWebViewUIModel, + callbackHandler: OSIABWebViewCallbackHandler + ) { + self.init( + url: url, + customHeaders: customHeaders, + webView: WKWebView(frame: .zero, configuration: webViewConfiguration), + scrollViewBounces: scrollViewBounces, + customUserAgent: customUserAgent, + backForwardNavigationGestures: backForwardNavigationGestures, + uiModel: uiModel, + callbackHandler: callbackHandler + ) + } + /// Setups the combine bindings, so that the Published properties can be filled automatically and reactively. private func setupBindings(_ showURL: Bool, _ showToolbar: Bool, _ showNavigationButtons: Bool) { if #available(iOS 14.0, *) { - self.webView.publisher(for: \.isLoading) + webView.publisher(for: \.isLoading) .assign(to: &$isLoading) - - self.webView.publisher(for: \.url) + webView.publisher(for: \.url) .compactMap { $0 } .assign(to: &$url) - if showToolbar { if showNavigationButtons { - self.webView.publisher(for: \.canGoBack) + webView.publisher(for: \.canGoBack) .assign(to: &$backButtonEnabled) - self.webView.publisher(for: \.canGoForward) + webView.publisher(for: \.canGoForward) .assign(to: &$forwardButtonEnabled) } - if showURL { - self.$url.map(\.absoluteString) + $url.map(\.absoluteString) .assign(to: &$addressLabel) } } } else { - self.webView.publisher(for: \.isLoading) + webView.publisher(for: \.isLoading) .assign(to: \.isLoading, on: self) .store(in: &cancellables) - - self.webView.publisher(for: \.url) + webView.publisher(for: \.url) .compactMap { $0 } .assign(to: \.url, on: self) .store(in: &cancellables) - if showToolbar { if showNavigationButtons { - self.webView.publisher(for: \.canGoBack) + webView.publisher(for: \.canGoBack) .assign(to: \.backButtonEnabled, on: self) .store(in: &cancellables) - - self.webView.publisher(for: \.canGoForward) + webView.publisher(for: \.canGoForward) .assign(to: \.forwardButtonEnabled, on: self) .store(in: &cancellables) } - if showURL { - self.$url.map(\.absoluteString) + $url.map(\.absoluteString) .assign(to: \.addressLabel, on: self) .store(in: &cancellables) } @@ -133,70 +156,69 @@ class OSIABWebViewModel: NSObject, ObservableObject { /// Loads the URL within the WebView. Is the first operation to be performed when the view is displayed. func loadURL() { - self.webView.load(.init(url: self.url)) + var request = URLRequest(url: url) + customHeaders?.forEach { key, value in + request.setValue(value, forHTTPHeaderField: key) + } + webView.load(request) } /// Signals the WebView to move forward. This is performed as a reaction to a button click. func forwardButtonPressed() { - self.webView.goForward() + webView.goForward() } /// Signals the WebView to move backwards. This is performed as a reaction to a button click. func backButtonPressed() { - self.webView.goBack() + webView.goBack() } /// Signals the WebView to be closed, triggering the `browserClosed` event. This is performed as a reaction to a button click. func closeButtonPressed() { - self.callbackHandler.onBrowserClosed(false) + callbackHandler.onBrowserClosed(false) } } // MARK: - WKNavigationDelegate implementation extension OSIABWebViewModel: WKNavigationDelegate { func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) { - var shouldStart = true - guard let url = navigationAction.request.url, url == navigationAction.request.mainDocumentURL else { return decisionHandler(.cancel) } // if is an app store, tel, sms, mailto or geo link, let the system handle it, otherwise it fails to load it if ["itms-appss", "itms-apps", "tel", "sms", "mailto", "geo"].contains(url.scheme) { webView.stopLoading() - self.callbackHandler.onDelegateURL(url) - shouldStart = false + callbackHandler.onDelegateURL(url) + decisionHandler(.cancel) + return } - if shouldStart { - if navigationAction.targetFrame != nil { - decisionHandler(.allow) - } else { - webView.load(navigationAction.request) - decisionHandler(.cancel) - } + if navigationAction.targetFrame != nil { + decisionHandler(.allow) } else { + webView.load(navigationAction.request) decisionHandler(.cancel) } } func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { - if !self.firstLoadDone { - self.callbackHandler.onBrowserPageLoad() - self.firstLoadDone = true + if !firstLoadDone { + callbackHandler.onBrowserPageLoad() + firstLoadDone = true } else { - self.callbackHandler.onBrowserPageNavigationCompleted(url.absoluteString) + callbackHandler.onBrowserPageNavigationCompleted(url.absoluteString) } error = nil } func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) { - self.webView(webView, didFailedNavigation: "didFailNavigation", with: error) + handleWebViewNavigationError("didFailNavigation", error: error) } func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) { - self.webView(webView, didFailedNavigation: "didFailProvisionalNavigation", with: error) + handleWebViewNavigationError("didFailProvisionalNavigation", error: error) } - private func webView(_ webView: WKWebView, didFailedNavigation delegateName: String, with error: Error) { + private func handleWebViewNavigationError(_ delegateName: String, error: Error) { print("webView: \(delegateName) - \(error.localizedDescription)") if (error as NSError).code != NSURLErrorCancelled { self.error = error @@ -217,7 +239,6 @@ extension OSIABWebViewModel: WKUIDelegate { private func createAlertController(withBodyText message: String, okButtonHandler: @escaping ButtonHandler, cancelButtonHandler: ButtonHandler? = nil) -> UIAlertController { let title = Bundle.main.infoDictionary?[kCFBundleNameKey as String] as? String ?? "" let alert = UIAlertController(title: title, message: message, preferredStyle: .alert) - let okAction = UIAlertAction(title: "OK", style: .default) { _ in okButtonHandler(alert) } @@ -234,14 +255,14 @@ extension OSIABWebViewModel: WKUIDelegate { } func webView(_ webView: WKWebView, runJavaScriptAlertPanelWithMessage message: String, initiatedByFrame frame: WKFrameInfo, completionHandler: @escaping () -> Void) { - let result = self.createAlertController( + let result = createAlertController( withBodyText: message, okButtonHandler: { alert in completionHandler() alert.dismiss(animated: true) } ) - self.callbackHandler.onDelegateAlertController(result) + callbackHandler.onDelegateAlertController(result) } func webView(_ webView: WKWebView, runJavaScriptConfirmPanelWithMessage message: String, initiatedByFrame frame: WKFrameInfo, completionHandler: @escaping (Bool) -> Void) { @@ -249,13 +270,12 @@ extension OSIABWebViewModel: WKUIDelegate { completionHandler(input) alert.dismiss(animated: true) } - - let result = self.createAlertController( + let result = createAlertController( withBodyText: message, okButtonHandler: { handler($0, true) }, cancelButtonHandler: { handler($0, false) } ) - self.callbackHandler.onDelegateAlertController(result) + callbackHandler.onDelegateAlertController(result) } func webView(_ webView: WKWebView, runJavaScriptTextInputPanelWithPrompt prompt: String, defaultText: String?, initiatedByFrame frame: WKFrameInfo, completionHandler: @escaping (String?) -> Void) { @@ -263,13 +283,12 @@ extension OSIABWebViewModel: WKUIDelegate { completionHandler(returnTextField ? alert.textFields?.first?.text : nil) alert.dismiss(animated: true) } - - let result = self.createAlertController( + let result = createAlertController( withBodyText: prompt, okButtonHandler: { handler($0, true) }, cancelButtonHandler: { handler($0, false) } ) result.addTextField { $0.text = defaultText } - self.callbackHandler.onDelegateAlertController(result) + callbackHandler.onDelegateAlertController(result) } } diff --git a/OSInAppBrowserLib/WebView/Views/OSIABWebView.swift b/OSInAppBrowserLib/WebView/Views/OSIABWebView.swift index ffa56e9..161147d 100644 --- a/OSInAppBrowserLib/WebView/Views/OSIABWebView.swift +++ b/OSInAppBrowserLib/WebView/Views/OSIABWebView.swift @@ -83,7 +83,7 @@ private extension OSIABWebViewModel { let configurationModel = OSIABWebViewConfigurationModel() self.init( url: .init(string: url)!, - configurationModel.toWebViewConfiguration(), + webViewConfiguration: configurationModel.toWebViewConfiguration(), uiModel: .init( showURL: showURL, showToolbar: showToolbar, diff --git a/OSInAppBrowserLib/WebView/Views/OSIABWebView13WrapperView.swift b/OSInAppBrowserLib/WebView/Views/OSIABWebView13WrapperView.swift index fa512d3..fba1aa9 100644 --- a/OSInAppBrowserLib/WebView/Views/OSIABWebView13WrapperView.swift +++ b/OSInAppBrowserLib/WebView/Views/OSIABWebView13WrapperView.swift @@ -52,7 +52,7 @@ private extension OSIABWebViewModel { let configurationModel = OSIABWebViewConfigurationModel() self.init( url: .init(string: "https://outsystems.com")!, - configurationModel.toWebViewConfiguration(), + webViewConfiguration: configurationModel.toWebViewConfiguration(), uiModel: .init(toolbarPosition: toolbarPosition), callbackHandler: .init( onDelegateURL: { _ in }, @@ -68,7 +68,7 @@ private extension OSIABWebViewModel { let configurationModel = OSIABWebViewConfigurationModel() self.init( url: .init(string: url)!, - configurationModel.toWebViewConfiguration(), + webViewConfiguration: configurationModel.toWebViewConfiguration(), uiModel: .init(), callbackHandler: .init( onDelegateURL: { _ in }, diff --git a/OSInAppBrowserLib/WebView/Views/OSIABWebViewWrapperView.swift b/OSInAppBrowserLib/WebView/Views/OSIABWebViewWrapperView.swift index 1240315..40adc94 100644 --- a/OSInAppBrowserLib/WebView/Views/OSIABWebViewWrapperView.swift +++ b/OSInAppBrowserLib/WebView/Views/OSIABWebViewWrapperView.swift @@ -29,7 +29,7 @@ private extension OSIABWebViewModel { let configurationModel = OSIABWebViewConfigurationModel() self.init( url: .init(string: "https://outsystems.com")!, - configurationModel.toWebViewConfiguration(), + webViewConfiguration: configurationModel.toWebViewConfiguration(), uiModel: .init(toolbarPosition: toolbarPosition), callbackHandler: .init( onDelegateURL: { _ in }, @@ -45,7 +45,7 @@ private extension OSIABWebViewModel { let configurationModel = OSIABWebViewConfigurationModel() self.init( url: .init(string: url)!, - configurationModel.toWebViewConfiguration(), + webViewConfiguration: configurationModel.toWebViewConfiguration(), uiModel: .init(), callbackHandler: .init( onDelegateURL: { _ in }, diff --git a/OSInAppBrowserLibTests/OSIABViewModelTests.swift b/OSInAppBrowserLibTests/OSIABViewModelTests.swift index 15f0f12..05837a8 100644 --- a/OSInAppBrowserLibTests/OSIABViewModelTests.swift +++ b/OSInAppBrowserLibTests/OSIABViewModelTests.swift @@ -136,6 +136,15 @@ final class OSIABViewModelTests: XCTestCase { XCTAssertEqual(sut.webView.url, url) } + func test_loadURL_withCustomHeaders() { + let mockWebView = MockWKWebView(frame: .zero, configuration: WKWebViewConfiguration()) + let sut = makeSUT(url, webView: mockWebView, customHeaders: ["Custom-Header": "Value"]) + + sut.loadURL() + + XCTAssertEqual(mockWebView.lastRequest?.value(forHTTPHeaderField: "Custom-Header"), "Value") + } + // MARK: Close Button Pressed func test_closeButtonPressed_triggersTheBrowserClosedEvent() { @@ -275,6 +284,8 @@ final class OSIABViewModelTests: XCTestCase { private extension OSIABViewModelTests { func makeSUT( _ url: URL, + webView: WKWebView? = nil, + customHeaders: [String: String]? = nil, mediaTypesRequiringUserActionForPlayback: WKAudiovisualMediaTypes = [], ignoresViewportScaleLimits: Bool = false, allowsInlineMediaPlayback: Bool = false, @@ -298,7 +309,8 @@ private extension OSIABViewModelTests { return .init( url: url, - configurationModel.toWebViewConfiguration(), + customHeaders: customHeaders, + webView ?? WKWebView(frame: .zero, configuration: configurationModel.toWebViewConfiguration()), scrollViewBounds, customUserAgent, uiModel: .init( @@ -318,3 +330,11 @@ private extension OSIABViewModelTests { ) } } + +class MockWKWebView: WKWebView { + var lastRequest: URLRequest? + override func load(_ request: URLRequest) -> WKNavigation? { + lastRequest = request + return nil + } +}