diff --git a/.github/workflows/swift.yml b/.github/workflows/swift.yml index 454bf0f6..e832e28f 100644 --- a/.github/workflows/swift.yml +++ b/.github/workflows/swift.yml @@ -19,7 +19,7 @@ jobs: runs-on: macos-13 strategy: matrix: - xcode: ['15.2', '15.4', '16.0'] + xcode: ['14.3.1', '15.1'] config: ['debug', 'release'] steps: - uses: actions/checkout@v3 diff --git a/Package.swift b/Package.swift index be1bf6a4..57a18cce 100644 --- a/Package.swift +++ b/Package.swift @@ -60,7 +60,8 @@ let package = Package( .target( name: "KlaviyoUI", dependencies: ["KlaviyoSwift"], - path: "Sources/KlaviyoUI"), + path: "Sources/KlaviyoUI", + resources: [.process("KlaviyoWebView/Resources")]), .testTarget( name: "KlaviyoUITests", dependencies: [ diff --git a/Sources/KlaviyoUI/KlaviyoWebView/KlaviyoWebViewController.swift b/Sources/KlaviyoUI/KlaviyoWebView/KlaviyoWebViewController.swift new file mode 100644 index 00000000..0c3b4eae --- /dev/null +++ b/Sources/KlaviyoUI/KlaviyoWebView/KlaviyoWebViewController.swift @@ -0,0 +1,136 @@ +// +// KlaviyoWebViewController.swift +// klaviyo-swift-sdk +// +// Created by Andrew Balmer on 9/28/24. +// + +import Combine +import UIKit +import WebKit + +class KlaviyoWebViewController: UIViewController, WKUIDelegate { + var webView: WKWebView! + private let viewModel: KlaviyoWebViewModeling + private var cancellables = Set() + + // MARK: - Initializers + + init(viewModel: KlaviyoWebViewModeling) { + self.viewModel = viewModel + super.init(nibName: nil, bundle: nil) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - View loading + + override func loadView() { + super.loadView() + + let config = createWebViewConfiguration() + webView = createWebView(with: config) + webView.navigationDelegate = self + webView.uiDelegate = self + + view.addSubview(webView) + + configureLoadScripts() + configureScriptEvaluator() + configureSubviewConstraints() + } + + override func viewDidLoad() { + let request = URLRequest(url: viewModel.url) + webView.load(request) + } + + // MARK: - WKWebView configuration + + func createWebViewConfiguration() -> WKWebViewConfiguration { + let config = WKWebViewConfiguration() + // customize any WKWebViewConfiguration properties here + // ex: config.allowsInlineMediaPlayback = true + return config + } + + func createWebView(with config: WKWebViewConfiguration) -> WKWebView { + let webView = WKWebView(frame: .zero, configuration: config) + // customize any WKWebView behaviors here + // ex: webView.allowsBackForwardNavigationGestures = true + return webView + } + + // MARK: - Scripts + + /// Configures the scripts to be injected into the website when the website loads. + func configureLoadScripts() { + guard let scriptsDict = viewModel.loadScripts else { return } + + for (name, script) in scriptsDict { + webView.configuration.userContentController.addUserScript(script) + webView.configuration.userContentController.add(self, name: name) + } + } + + func configureScriptEvaluator() { + viewModel.scriptSubject.sink { [weak self] script, callback in + Task { [weak self] in + do { + let result = try await self?.webView.evaluateJavaScript(script) + callback?(.success(result)) + } catch { + callback?(.failure(error)) + } + } + }.store(in: &cancellables) + } + + // MARK: - Layout + + func configureSubviewConstraints() { + webView.translatesAutoresizingMaskIntoConstraints = false + webView.leadingAnchor.constraint(equalTo: view.leadingAnchor).isActive = true + webView.trailingAnchor.constraint(equalTo: view.trailingAnchor).isActive = true + webView.topAnchor.constraint(equalTo: view.topAnchor).isActive = true + webView.bottomAnchor.constraint(equalTo: view.bottomAnchor).isActive = true + } +} + +extension KlaviyoWebViewController: WKNavigationDelegate { + func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) { + viewModel.handleNavigationEvent(.didStartProvisionalNavigation) + } + + func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: any Error) { + viewModel.handleNavigationEvent(.didFailProvisionalNavigation) + } + + func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { + viewModel.handleNavigationEvent(.didFinishNavigation) + } + + func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: any Error) { + viewModel.handleNavigationEvent(.didFailNavigation) + } +} + +extension KlaviyoWebViewController: WKScriptMessageHandler { + func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) { + viewModel.handleScriptMessage(message) + } +} + +// MARK: - Previews + +#if swift(>=5.9) +@available(iOS 17.0, *) +#Preview("Klaviyo.com") { + let url = URL(string: "https://www.klaviyo.com")! + let viewModel = KlaviyoWebViewModel(url: url) + return KlaviyoWebViewController(viewModel: viewModel) +} +#endif diff --git a/Sources/KlaviyoUI/KlaviyoWebView/KlaviyoWebViewModel.swift b/Sources/KlaviyoUI/KlaviyoWebView/KlaviyoWebViewModel.swift new file mode 100644 index 00000000..7bab7e89 --- /dev/null +++ b/Sources/KlaviyoUI/KlaviyoWebView/KlaviyoWebViewModel.swift @@ -0,0 +1,41 @@ +// +// KlaviyoWebViewModel.swift +// klaviyo-swift-sdk +// +// Created by Andrew Balmer on 9/30/24. +// + +import Combine +import Foundation +import WebKit + +class KlaviyoWebViewModel: KlaviyoWebViewModeling { + let url: URL + let loadScripts: [String: WKUserScript]? + + /// Publishes scripts for the `WKWebView` to execute. + let scriptSubject = PassthroughSubject<(script: String, callback: ((Result) -> Void)?), Never>() + + init(url: URL) { + self.url = url + loadScripts = KlaviyoWebViewModel.initializeLoadScripts() + } + + private static func initializeLoadScripts() -> [String: WKUserScript] { + var scripts: [String: WKUserScript] = [:] + + // TODO: initialize scripts + + return scripts + } + + // MARK: handle WKWebView events + + func handleNavigationEvent(_ event: WKNavigationEvent) { + // TODO: handle navigation events + } + + func handleScriptMessage(_ message: WKScriptMessage) { + // TODO: handle script message + } +} diff --git a/Sources/KlaviyoUI/KlaviyoWebView/KlaviyoWebViewModeling.swift b/Sources/KlaviyoUI/KlaviyoWebView/KlaviyoWebViewModeling.swift new file mode 100644 index 00000000..ec2bccc0 --- /dev/null +++ b/Sources/KlaviyoUI/KlaviyoWebView/KlaviyoWebViewModeling.swift @@ -0,0 +1,23 @@ +// +// KlaviyoWebViewModeling.swift +// klaviyo-swift-sdk +// +// Created by Andrew Balmer on 10/1/24. +// + +import Combine +import Foundation +import WebKit + +protocol KlaviyoWebViewModeling { + var url: URL { get } + + /// Scripts to be injected into the ``WKWebView`` when the website loads. + var loadScripts: [String: WKUserScript]? { get } + + /// Publishes scripts for the ``WKWebView`` to execute. + var scriptSubject: PassthroughSubject<(script: String, callback: ((Result) -> Void)?), Never> { get } + + func handleNavigationEvent(_ event: WKNavigationEvent) + func handleScriptMessage(_ message: WKScriptMessage) +} diff --git a/Sources/KlaviyoUI/KlaviyoWebView/Resources/Scripts/bridge.js b/Sources/KlaviyoUI/KlaviyoWebView/Resources/Scripts/bridge.js new file mode 100644 index 00000000..94d16c8d --- /dev/null +++ b/Sources/KlaviyoUI/KlaviyoWebView/Resources/Scripts/bridge.js @@ -0,0 +1,6 @@ +// +// Bridge.js +// klaviyo-swift-sdk +// +// Created by Andrew Balmer on 10/1/24. +// diff --git a/Sources/KlaviyoUI/Models/WKNavigationEvent.swift b/Sources/KlaviyoUI/Models/WKNavigationEvent.swift new file mode 100644 index 00000000..00afc221 --- /dev/null +++ b/Sources/KlaviyoUI/Models/WKNavigationEvent.swift @@ -0,0 +1,26 @@ +// +// WKNavigationEvent.swift +// klaviyo-swift-sdk +// +// Created by Andrew Balmer on 9/30/24. +// + +enum WKNavigationEvent { + /// Invoked when a main frame navigation starts. + case didStartProvisionalNavigation + + /// Invoked when a server redirect is received for the main frame. + case didReceiveServerRedirectForProvisionalNavigation + + /// Invoked when an error occurs while starting to load data for the main frame. + case didFailProvisionalNavigation + + /// Invoked when content starts arriving for the main frame. + case didCommitNavigation + + /// Invoked when a main frame navigation completes. + case didFinishNavigation + + /// Invoked when an error occurs during a committed main frame navigation. + case didFailNavigation +} diff --git a/Sources/KlaviyoUI/Utilities/FileIO.swift b/Sources/KlaviyoUI/Utilities/FileIO.swift new file mode 100644 index 00000000..387339f4 --- /dev/null +++ b/Sources/KlaviyoUI/Utilities/FileIO.swift @@ -0,0 +1,35 @@ +// +// FileIO.swift +// KlaviyoSwiftUIWebView +// +// Created by Andrew Balmer on 9/27/24. +// + +import Foundation + +enum FileIOError: Error { + case notFound +} + +enum FileIO { + static func getFileUrl(path: String, type: String) throws -> URL { + guard let fileUrl = Bundle.module.url(forResource: path, withExtension: type) else { + throw FileIOError.notFound + } + + return fileUrl + } + + static func getFileContents(path: String, type: String) throws -> String { + guard let path = Bundle.module.path(forResource: path, ofType: type) else { + throw FileIOError.notFound + } + + do { + let contents = try String(contentsOfFile: path, encoding: String.Encoding.utf8) + return contents + } catch { + throw error + } + } +}