Skip to content

Commit

Permalink
Add new WebView interface (#913)
Browse files Browse the repository at this point in the history
* Start adding new webview classes

* Add tests

* Parse the webview message

* Make one of the tests work

* Make more tests work

* Make all tests pass

* Refactor message handler

* Add subscription to Snowplow main class

* Update based on review comments

* Remove unnecessary null checks

* Add an error log if the namespace doesn't exist
  • Loading branch information
mscwilson authored Jan 16, 2025
1 parent 971d519 commit 4c8ff6b
Show file tree
Hide file tree
Showing 7 changed files with 554 additions and 16 deletions.
11 changes: 10 additions & 1 deletion Package.resolved
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,16 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-docc-plugin",
"state" : {
"revision" : "3303b164430d9a7055ba484c8ead67a52f7b74f6",
"revision" : "26ac5758409154cc448d7ab82389c520fa8a8247",
"version" : "1.3.0"
}
},
{
"identity" : "swift-docc-symbolkit",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-docc-symbolkit",
"state" : {
"revision" : "b45d1f2ed151d057b54504d653e0da5552844e34",
"version" : "1.0.0"
}
}
Expand Down
126 changes: 126 additions & 0 deletions Sources/Core/Events/WebViewReader.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
// Copyright (c) 2013-present Snowplow Analytics Ltd. All rights reserved.
//
// This program is licensed to you under the Apache License Version 2.0,
// and you may not use this file except in compliance with the Apache License
// Version 2.0. You may obtain a copy of the Apache License Version 2.0 at
// http://www.apache.org/licenses/LICENSE-2.0.
//
// Unless required by applicable law or agreed to in writing,
// software distributed under the Apache License Version 2.0 is distributed on
// an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
// express or implied. See the Apache License Version 2.0 for the specific
// language governing permissions and limitations there under.

import Foundation

/// Allows the tracking of JavaScript events from WebViews.
class WebViewReader: Event {
let selfDescribingEventData: SelfDescribingJson?
let eventName: String?
let trackerVersion: String?
let useragent: String?
let pageUrl: String?
let pageTitle: String?
let referrer: String?
let category: String?
let action: String?
let label: String?
let property: String?
let value: Double?
let pingXOffsetMin: Int?
let pingXOffsetMax: Int?
let pingYOffsetMin: Int?
let pingYOffsetMax: Int?

init(
selfDescribingEventData: SelfDescribingJson? = nil,
eventName: String? = nil,
trackerVersion: String? = nil,
useragent: String? = nil,
pageUrl: String? = nil,
pageTitle: String? = nil,
referrer: String? = nil,
category: String? = nil,
action: String? = nil,
label: String? = nil,
property: String? = nil,
value: Double? = nil,
pingXOffsetMin: Int? = nil,
pingXOffsetMax: Int? = nil,
pingYOffsetMin: Int? = nil,
pingYOffsetMax: Int? = nil
) {
self.selfDescribingEventData = selfDescribingEventData
self.eventName = eventName
self.trackerVersion = trackerVersion
self.useragent = useragent
self.pageUrl = pageUrl
self.pageTitle = pageTitle
self.referrer = referrer
self.category = category
self.action = action
self.label = label
self.property = property
self.value = value
self.pingXOffsetMin = pingXOffsetMin
self.pingXOffsetMax = pingXOffsetMax
self.pingYOffsetMin = pingYOffsetMin
self.pingYOffsetMax = pingYOffsetMax

super.init()
}

override var payload: [String : Any] {
var payload: [String: Any] = [:]

if let selfDescribingEventData = selfDescribingEventData {
payload[kSPWebViewEventData] = selfDescribingEventData
}
if let eventName = eventName {
payload[kSPEvent] = eventName
}
if let trackerVersion = trackerVersion {
payload[kSPTrackerVersion] = trackerVersion
}
if let useragent = useragent {
payload[kSPUseragent] = useragent
}
if let pageUrl = pageUrl {
payload[kSPPageUrl] = pageUrl
}
if let pageTitle = pageTitle {
payload[kSPPageTitle] = pageTitle
}
if let referrer = referrer {
payload[kSPPageRefr] = referrer
}
if let category = category {
payload[kSPStructCategory] = category
}
if let action = action {
payload[kSPStructAction] = action
}
if let label = label {
payload[kSPStructLabel] = label
}
if let property = property {
payload[kSPStructProperty] = property
}
if let value = value {
payload[kSPStructValue] = String(value)
}
if let pingXOffsetMin = pingXOffsetMin {
payload[kSPPingXOffsetMin] = String(pingXOffsetMin)
}
if let pingXOffsetMax = pingXOffsetMax {
payload[kSPPingXOffsetMax] = String(pingXOffsetMax)
}
if let pingYOffsetMin = pingYOffsetMin {
payload[kSPPingYOffsetMin] = String(pingYOffsetMin)
}
if let pingYOffsetMax = pingYOffsetMax {
payload[kSPPingYOffsetMax] = String(pingYOffsetMax)
}
return payload
}
}
54 changes: 41 additions & 13 deletions Sources/Core/Tracker/TrackerEvent.swift
Original file line number Diff line number Diff line change
Expand Up @@ -35,10 +35,12 @@ class TrackerEvent : InspectableEvent, StateMachineEvent {

var trueTimestamp: Date?

private(set) var isPrimitive: Bool
private(set) var isPrimitive: Bool = false

private(set) var isService: Bool

private(set) var isWebView: Bool = false

init(event: Event, eventId: UUID = UUID(), state: TrackerStateSnapshot? = nil) {
self.eventId = eventId
timestamp = Int64(Date().timeIntervalSince1970 * 1000)
Expand All @@ -48,12 +50,19 @@ class TrackerEvent : InspectableEvent, StateMachineEvent {
self.state = state ?? TrackerState()

isService = (event is TrackerError)
if let abstractEvent = event as? PrimitiveAbstract {
eventName = abstractEvent.eventName

switch event {
case _ as WebViewReader:
eventName = (payload[kSPEvent] as? String) ?? kSPEventUnstructured
schema = getWebViewSchema()
isWebView = true

case let primitive as PrimitiveAbstract:
eventName = primitive.eventName
isPrimitive = true
} else {
schema = (event as! SelfDescribingAbstract).schema
isPrimitive = false

default:
schema = (event as? SelfDescribingAbstract)?.schema
}
}

Expand Down Expand Up @@ -90,24 +99,43 @@ class TrackerEvent : InspectableEvent, StateMachineEvent {
}

func wrapProperties(to payload: Payload, base64Encoded: Bool) {
if isPrimitive {
if isWebView {
wrapWebViewToPayload(to: payload, base64Encoded: base64Encoded)
} else if isPrimitive {
payload.addDictionaryToPayload(self.payload)
} else {
wrapSelfDescribing(to: payload, base64Encoded: base64Encoded)
wrapSelfDescribingEventToPayload(to: payload, base64Encoded: base64Encoded)
}
}

private func wrapSelfDescribing(to payload: Payload, base64Encoded: Bool) {
guard let schema = schema else { return }

let data = SelfDescribingJson(schema: schema, andData: self.payload)
private func getWebViewSchema() -> String? {
let selfDescribingData = payload[kSPWebViewEventData] as? SelfDescribingJson
return selfDescribingData?.schema
}

private func addSelfDescribingDataToPayload(to payload: Payload, base64Encoded: Bool, data: SelfDescribingJson) {
let unstructuredEventPayload = SelfDescribingJson.dictionary(
schema: kSPUnstructSchema,
data: data.dictionary)
payload.addDictionaryToPayload(
unstructuredEventPayload,
base64Encoded: base64Encoded,
typeWhenEncoded: kSPUnstructuredEncoded,
typeWhenNotEncoded: kSPUnstructured)
typeWhenNotEncoded: kSPUnstructured
)
}

private func wrapWebViewToPayload(to payload: Payload, base64Encoded: Bool) {
let selfDescribingData = self.payload[kSPWebViewEventData] as? SelfDescribingJson
if let data = selfDescribingData {
addSelfDescribingDataToPayload(to: payload, base64Encoded: base64Encoded, data: data)
}
payload.addDictionaryToPayload(self.payload.filter { $0.key != kSPWebViewEventData })
}

private func wrapSelfDescribingEventToPayload(to payload: Payload, base64Encoded: Bool) {
guard let schema = schema else { return }
let data = SelfDescribingJson(schema: schema, andData: self.payload)
addSelfDescribingDataToPayload(to: payload, base64Encoded: base64Encoded, data: data)
}
}
138 changes: 138 additions & 0 deletions Sources/Core/Tracker/WebViewMessageHandlerV2.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
// Copyright (c) 2013-present Snowplow Analytics Ltd. All rights reserved.
//
// This program is licensed to you under the Apache License Version 2.0,
// and you may not use this file except in compliance with the Apache License
// Version 2.0. You may obtain a copy of the Apache License Version 2.0 at
// http://www.apache.org/licenses/LICENSE-2.0.
//
// Unless required by applicable law or agreed to in writing,
// software distributed under the Apache License Version 2.0 is distributed on
// an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
// express or implied. See the Apache License Version 2.0 for the specific
// language governing permissions and limitations there under.

import Foundation

#if os(iOS) || os(macOS) || os(visionOS)
import WebKit

/// Handler for messages from the JavaScript library embedded in WebViews.
/// This V2 interface works with the WebView tracker v0.3.0+.
///
/// The handler parses messages from the JavaScript library calls and forwards the tracked events to be tracked by the mobile tracker.
class WebViewMessageHandlerV2: NSObject, WKScriptMessageHandler {
/// Callback called when the message handler receives a new message.
///
/// The message dictionary should contain three properties:
/// 1. "event" with a dictionary containing the event information (structure depends on the tracked event)
/// 2. "context" (optional) with a list of self-describing JSONs
/// 3. "trackers" (optional) with a list of tracker namespaces to track the event with
func userContentController(
_ userContentController: WKUserContentController,
didReceive message: WKScriptMessage
) {
receivedMessage(message)
}

func receivedMessage(_ message: WKScriptMessage) {
if let body = message.body as? [AnyHashable : Any],
let atomicProperties = body["atomicProperties"] as? String {

guard let atomicJson = parseAtomicPropertiesFromMessage(atomicProperties) else { return }
let selfDescribingDataJson = parseSelfDescribingEventDataFromMessage(body["selfDescribingEventData"] as? String) ?? [:]
let entitiesJson = parseEntitiesFromMessage(body["entities"] as? String) ?? []
let trackers = body["trackers"] as? [String] ?? []

let event = WebViewReader(
selfDescribingEventData: createSelfDescribingJson(selfDescribingDataJson),
eventName: atomicJson["eventName"] as? String,
trackerVersion: atomicJson["trackerVersion"] as? String,
useragent: atomicJson["useragent"] as? String,
pageUrl: atomicJson["pageUrl"] as? String,
pageTitle: atomicJson["pageTitle"] as? String,
referrer: atomicJson["referrer"] as? String,
category: atomicJson["category"] as? String,
action: atomicJson["action"] as? String,
label: atomicJson["label"] as? String,
property: atomicJson["property"] as? String,
value: atomicJson["value"] as? Double,
pingXOffsetMin: atomicJson["pingXOffsetMin"] as? Int,
pingXOffsetMax: atomicJson["pingXOffsetMax"] as? Int,
pingYOffsetMin: atomicJson["pingYOffsetMin"] as? Int,
pingYOffsetMax: atomicJson["pingYOffsetMax"] as? Int
)

track(event, withEntities: entitiesJson, andTrackers: trackers)
}
}

func track(_ event: Event, withEntities entities: [[AnyHashable : Any]], andTrackers trackers: [String]) {
event.entities = parseEntities(entities)

if trackers.count > 0 {
for namespace in trackers {
if let tracker = Snowplow.tracker(namespace: namespace) {
_ = tracker.track(event)
} else {
logError(message: "WebView: Tracker with namespace \(namespace) not found.")
}
}
} else {
_ = Snowplow.defaultTracker()?.track(event)
}
}

func createSelfDescribingJson(_ map: [AnyHashable : Any]) -> SelfDescribingJson? {
if let schema = map["schema"] as? String,
let payload = map["data"] as? [String : Any] {
return SelfDescribingJson(schema: schema, andDictionary: payload)
}
return nil
}

func parseEntities(_ entities: [[AnyHashable : Any]]) -> [SelfDescribingJson] {
var contextEntities: [SelfDescribingJson] = []

for entityJson in entities {
if let entity = createSelfDescribingJson(entityJson) {
contextEntities.append(entity)
}
}
return contextEntities
}

func parseAtomicPropertiesFromMessage(_ messageString: String?) -> [String : Any]? {
guard let atomicData = messageString?.data(using: .utf8) else {
logError(message: "WebView: No atomic properties provided, skipping.")
return nil
}
guard let atomicJson = try? JSONSerialization.jsonObject(with: atomicData) as? [String : Any] else {
logError(message: "WebView: Received event payload is not serializable to JSON, skipping.")
return nil
}
return atomicJson
}

func parseSelfDescribingEventDataFromMessage(_ messageString: String?) -> [String : Any]? {
if messageString == nil { return nil }
guard let eventData = messageString?.data(using: .utf8),
let eventJson = try? JSONSerialization.jsonObject(with: eventData) as? [String : Any] else {
logError(message: "WebView: Received event payload is not serializable to JSON, skipping.")
return nil
}
return eventJson
}

func parseEntitiesFromMessage(_ messageString: String?) -> [[AnyHashable : Any]]? {
if messageString == nil { return nil }
guard let entitiesData = messageString?.data(using: .utf8),
let entitiesJson = try? JSONSerialization.jsonObject(with: entitiesData) as? [[AnyHashable : Any]] else {
logError(message: "WebView: Received event payload is not serializable to JSON, skipping.")
return nil
}
return entitiesJson
}
}


#endif
7 changes: 7 additions & 0 deletions Sources/Core/TrackerConstants.swift
Original file line number Diff line number Diff line change
Expand Up @@ -289,3 +289,10 @@ let kSPDiagnosticErrorMessage = "message"
let kSPDiagnosticErrorStack = "stackTrace"
let kSPDiagnosticErrorClassName = "className"
let kSPDiagnosticErrorExceptionName = "exceptionName"

// --- Page Pings (for WebView tracking)
let kSPPingXOffsetMin = "pp_mix"
let kSPPingXOffsetMax = "pp_max"
let kSPPingYOffsetMin = "pp_miy"
let kSPPingYOffsetMax = "pp_may"
let kSPWebViewEventData = "selfDescribingEventData"
6 changes: 4 additions & 2 deletions Sources/Snowplow/Snowplow.swift
Original file line number Diff line number Diff line change
Expand Up @@ -342,9 +342,11 @@ public class Snowplow: NSObject {
/// - Parameter webViewConfiguration: Configuration of the Web view to subscribe to events from
@objc
public class func subscribeToWebViewEvents(with webViewConfiguration: WKWebViewConfiguration) {
let messageHandler = WebViewMessageHandler()
let messageHandlerOld = WebViewMessageHandler()
let messageHandlerV2 = WebViewMessageHandlerV2()

webViewConfiguration.userContentController.add(messageHandler, name: "snowplow")
webViewConfiguration.userContentController.add(messageHandlerOld, name: "snowplow")
webViewConfiguration.userContentController.add(messageHandlerV2, name: "snowplowV2")
}

#endif
Expand Down
Loading

0 comments on commit 4c8ff6b

Please sign in to comment.