Sources/Instrumentation/WKWebView/WKWebViewInstrumentation.swift (172 lines of code) (raw):
//
// Copyright 2023 aliyun-sls Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import Foundation
//import OpenTelemetryApi
import WebKit
// MARK: - WKWebView instrumentation -
public class WKWebViewInstrumentation : NSObject {
public private(set) var webView: WKWebView
// public private(set) var tracer: Tracer
private var delegate: WKWebViewDelegate
// @objc
public init(webView: WKWebView, configuration: WKWebViewInstrumentationConfiguration) {
self.webView = webView
// self.tracer = OpenTelemetry.instance.tracerProvider.get(instrumentationName: "WKWebView", instrumentationVersion: "0.0.1")
self.delegate = WKWebViewDelegate()
super.init()
}
// @objc
open func start() {
self.hookJs()
self.setup()
}
// @objc
open func stop() {
self.destroy()
}
open class func loadRequest(webView: WKWebView, request: inout URLRequest) {
WebViewCookieManager.syncRequestCookie(request: &request)
webView.load(request)
// MARK: todo hook
}
private func hookJs() {
let userScript = WKUserScript.init(source: hookajax,
injectionTime: WKUserScriptInjectionTime.atDocumentStart,
forMainFrameOnly: false
)
webView.configuration.userContentController.removeScriptMessageHandler(forName: "OTelJSBridgeMessage")
webView.configuration.userContentController.addUserScript(userScript)
webView.configuration.userContentController.add(self, name: "OTelJSBridgeMessage")
}
private func setup() {
// sync HTTPCookieStorage to WKHTTPCookieStorage
WebViewCookieManager.copyHTTPCookieStorageToWKHTTPCookieStorageOniOS11(webView: self.webView, completion: nil)
// register URLProtocol
WKWebViewHtmlURLProtocol.register()
WKWebViewBodyCacheURLProtocol.register()
// setup navigation & ui delegate
self.webView.navigationDelegate = delegate
self.webView.uiDelegate = delegate
// self.webView.configuration.allowsInlineMediaPlayback = true
}
private func destroy() {
WKWebViewHtmlURLProtocol.unregister()
webView.configuration.userContentController.removeScriptMessageHandler(forName: "OTelJSBridgeMessage")
webView.navigationDelegate = nil
WKWebViewBodyCacheURLProtocol.unregister()
}
}
// MARK: - WKScriptMessageHandler -
extension WKWebViewInstrumentation : WKScriptMessageHandler {
public func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
// TODO: bridge support
if (message.name == "OTelJSBridgeMessage") {
let messageDictionary = (message.body as! NSDictionary) as! Dictionary<String, Any>
let messageModule = JSBridgeMessageModule(dictionary: messageDictionary)
WKWebViewInstrumentation.dispatchCallbackMessage(webView: webView, message: messageModule)
}
}
}
/// dispatch method call from js
extension WKWebViewInstrumentation {
private static var dispatchQueue: OperationQueue {
return OperationQueue()
}
private static var methodInvokeQueue: OperationQueue {
return OperationQueue()
}
static func dispatchCallbackMessage(webView: WKWebView, message: JSBridgeMessageModule) {
dispatchQueue.addOperation {
WKWebViewInstrumentation.dispatchCallbackMessageInQueue(webView: webView, message: message)
}
}
static func dispatchCallbackMessageInQueue(webView: WKWebView, message: JSBridgeMessageModule) {
guard let module = message.module, let method = message.method else {
message.callback?(nil)
return
}
// print("WKWebViewInstrumentation. dispatchCallbackMessageInQueue, module: \(module), method: \(method)")
var callback: ((_ response: [String: Any]?) -> ())? = nil
if let callbackId = message.callbackId {
callback = { response in
let callbackResponse = JSBridgeMessageModule(messageType: .callback, data: response, callbackId: callbackId)
WKWebViewInstrumentation.dispatchMessageResponse(webView: webView, responseModule: callbackResponse)
}
} else if let cbk = message.callback {
callback = cbk
}
methodInvokeQueue.addOperation {
switch method {
case "cacheAJAXBody":
XMLBodyCacheRequest.shared.cacheAjaxBody(params: message.data!, callback: callback)
case "setCookie":
WebViewCookieManager.shared.setCookie(params: message.data!, callback: callback)
case "cookie":
WebViewCookieManager.shared.getCookie(params: message.data!, callback: callback)
default:
print("WKWebViewInstrumentation. not support \(method)")
}
}
}
}
/// response to js
extension WKWebViewInstrumentation {
static func dispatchMessageResponse(webView: WKWebView, responseModule: JSBridgeMessageModule) {
let message: [String: Any] = [
"messageType": responseModule.messageType.rawValue as Any,
"callbackId": responseModule.callbackId as Any,
"eventName": responseModule.eventName as Any,
"data": responseModule.data as Any
]
self.evaluateJavaScriptFunction(data: message, webView: webView) { result, error in
guard let e = error else {
responseModule.callback?(nil)
return
}
print("OTel WKWebView JSBridge error: \(e)")
}
}
}
extension WKWebViewInstrumentation {
static func evaluateJavaScriptFunction(data: [String: Any], webView: WKWebView, completionHandler: @escaping (_ result: Any, _ error: Error?) -> ()) {
guard let jsonData = try? JSONSerialization.data(withJSONObject: data, options: []) else {
return
}
guard let dataString = String(data: jsonData, encoding: String.Encoding.utf8) else {
return
}
var javaScriptString = dataString.replacingOccurrences(of: #"\\"#, with: #"\\\\"#)
javaScriptString = javaScriptString.replacingOccurrences(of: #"\""#, with: #"\\\""#)
javaScriptString = javaScriptString.replacingOccurrences(of: #"\'"#, with: #"\\\'"#)
javaScriptString = javaScriptString.replacingOccurrences(of: #"\n"#, with: #"\\n"#)
javaScriptString = javaScriptString.replacingOccurrences(of: #"\r"#, with: #"\\r"#)
javaScriptString = javaScriptString.replacingOccurrences(of: #"\f"#, with: #"\\f"#)
javaScriptString = javaScriptString.replacingOccurrences(of: #"\u2028"#, with: #"\\u2028"#)
javaScriptString = javaScriptString.replacingOccurrences(of: #"\u2029"#, with: #"\\u2029"#)
if (Thread.current.isMainThread) {
WKWebViewInstrumentation.evaluateJavaScript(javaScriptString: javaScriptString, webView: webView, completionHandler: completionHandler)
} else {
DispatchQueue.main.sync {
WKWebViewInstrumentation.evaluateJavaScript(javaScriptString: javaScriptString, webView: webView, completionHandler: completionHandler)
}
}
}
static func evaluateJavaScript(javaScriptString: String, webView: WKWebView, completionHandler: @escaping (_ result: Any, _ error: Error?) -> ()) {
webView.evaluateJavaScript("window.OTelJSBridge._handleMessageFromNative('\(javaScriptString)')") { result, error in
let _ = webView.title
completionHandler(result as Any, error)
}
}
}
struct JSBridgeMessageModule {
enum MessageType: String {
case callback = "callback"
case event = "event"
}
let messageType: MessageType
let data: [String: Any]?
let module: String?
let method: String?
let callbackId: String?
let eventName: String?
let callback: (([String: Any]?)->Void)?
init(messageType: MessageType = .callback, data: [String: Any]?, callbackId: String?) {
self.messageType = messageType
self.data = data
self.callbackId = callbackId
self.module = nil
self.method = nil
self.eventName = nil
self.callback = nil
}
init(dictionary: [String: Any]) {
self.init(dictionary: dictionary, callback: nil)
}
init(dictionary: [String: Any], callback: (([String: Any]?) -> Void)?) {
self.messageType = (dictionary["messageType"] as? String == "callback") ? .callback : .event
self.data = dictionary["data"] as? [String: Any]
self.module = dictionary["module"] as? String
self.method = dictionary["method"] as? String
self.callbackId = dictionary["callbackId"] as? String
self.eventName = dictionary["eventName"] as? String
self.callback = callback
}
}
// https://stackoverflow.com/a/43056053/1760982
fileprivate final class ObjectAssociation<T: Any> {
private let policy: objc_AssociationPolicy
/// - Parameter policy: An association policy that will be used when linking objects.
public init(policy: objc_AssociationPolicy = .OBJC_ASSOCIATION_RETAIN_NONATOMIC) {
self.policy = policy
}
/// Accesses associated object.
/// - Parameter index: An object whose associated object is to be accessed.
public subscript(index: AnyObject) -> T? {
get { return objc_getAssociatedObject(index, Unmanaged.passUnretained(self).toOpaque()) as! T? }
set { objc_setAssociatedObject(index, Unmanaged.passUnretained(self).toOpaque(), newValue, policy) }
}
}