Skip to content

DEMRUM-2370: webview injection fix including legacy support #328

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 5 commits into
base: feature/next-gen
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
153 changes: 102 additions & 51 deletions Applications/DevelApp/DevelApp/WebViewDemoView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,83 +22,133 @@ import SwiftUI
import WebKit

struct WebViewDemoView: View {
@State private var webView = WKWebView()
@State private var injected = false
@State private var subscription: AnyCancellable?
@State private var modernWebView = WKWebView()
@State private var legacyWebView = WKWebView()

// Store the script content in variables
private let modernScriptContent = modernScriptExample()
private let legacyScriptContent = legacyScriptExample()

var body: some View {
VStack {
Text("WebView Demo").font(.title).padding()
DemoHeaderView()
WebViewRepresentable(webView: webView, injected: injected)
.frame(height: 200)
.border(Color.gray)
.padding()

Button("Inject JavaScript") {
injectJavaScript()
}
.padding()
// Legacy WebView Section
WebViewSectionView(
caption: "WebView using current BRUM legacy API",
webView: legacyWebView,
buttonText: "Inject JavaScript (legacy, sync)",
backgroundColor: Color(red: 0.9, green: 0.93, blue: 1.0),
scriptContent: legacyScriptContent,
injectAction: injectIntoLegacyWebView
)

// Modern WebView Section
WebViewSectionView(
caption: "WebView using future BRUM async API",
webView: modernWebView,
buttonText: "Inject JavaScript (modern, async)",
backgroundColor: Color(red: 0.88, green: 1.0, blue: 0.88),
scriptContent: modernScriptContent,
injectAction: injectIntoModernWebView
)

Spacer()
}
.onAppear {
loadWebViewContent()
observeSessionIdChanges()
}
.onDisappear {
subscription?.cancel()
loadWebViewContent(for: modernWebView, scriptContent: modernScriptContent)
loadWebViewContent(for: legacyWebView, scriptContent: legacyScriptContent)
}
.navigationTitle("WebView Demo")
}

private func loadWebViewContent() {
// Load a simple HTML page with a placeholder for the session ID
private static func modernScriptExample() -> String {
return """
async function updateSessionId() {
try {
const response = await window.SplunkRumNative.getNativeSessionIdAsync();
document.getElementById('sessionId').innerText = response;
} catch (error) {
document.getElementById('sessionId').innerText = "unknown";
console.log(`Error getting native Session ID: ${error.message}`);
}
}
setInterval(updateSessionId, 1000);
"""
}

private static func legacyScriptExample() -> String {
return """
function updateSessionId() {
try {
const sessionId = window.SplunkRumNative.getNativeSessionId();
document.getElementById('sessionId').innerText = sessionId;
} catch (error) {
document.getElementById('sessionId').innerText = "unknown";
console.log(`Error getting native Session ID: ${error.message}`);
}
}
setInterval(updateSessionId, 1000);
"""
}

private func loadWebViewContent(for webView: WKWebView, scriptContent: String) {
let initialContent = """
<!DOCTYPE html>
<html>
<head>
<title>WebView Demo</title>
<script>
function updateSessionId() {
// Fetch the session ID from the native layer
const sessionId = window.SplunkRumNative.getNativeSessionId();
document.getElementById('sessionId').innerText = sessionId;
}

// Update the session ID every 1 second
setInterval(updateSessionId, 1000);
</script>
</head>
<body>
<h1>WebView Demo</h1>
<p>Current Session ID:</p>
<p id="sessionId">unknown</p>
</body>
</html>
<!DOCTYPE html>
<html>
<head>
<script>
\(scriptContent)
</script>
</head>
<body style="font-size: 48px; font-family: sans-serif">
<h3>Current Native Session ID:</h3>
<p id="sessionId">unknown</p>
</body>
</html>
"""
webView.loadHTMLString(initialContent, baseURL: nil)
}

private func injectJavaScript() {
// This integrates the web view with SplunkRum
SplunkRum.webView.integrateWithBrowserRum(webView)
injected = true
private func injectIntoModernWebView() {
SplunkRum.webView.integrateWithBrowserRum(modernWebView)
}

private func injectIntoLegacyWebView() {
SplunkRum.webView.integrateWithBrowserRum(legacyWebView)
}
}

struct WebViewSectionView: View {
let caption: String
let webView: WKWebView
let buttonText: String
let backgroundColor: Color
let scriptContent: String
let injectAction: () -> Void

private func observeSessionIdChanges() {
// Observe changes to the session ID and reload the web view content if needed
subscription = NotificationCenter.default
.publisher(for: NSNotification.Name("com.splunk.rum.session-id-did-change"))
.sink { _ in
print("Session ID changed. WebView should now reflect the updated value.")
var body: some View {
VStack {
Text(caption)
.font(.footnote)
.padding(.top)
WebViewRepresentable(webView: webView)
.frame(height: 150)
.border(Color.gray)
.padding(.horizontal)
Button(buttonText) {
injectAction()
}
.padding()
}
.background(backgroundColor)
.cornerRadius(8)
.padding()
}
}

struct WebViewRepresentable: UIViewRepresentable {
let webView: WKWebView
let injected: Bool

func makeUIView(context: Context) -> WKWebView {
return webView
Expand All @@ -108,3 +158,4 @@ struct WebViewRepresentable: UIViewRepresentable {
// Nothing to do here
}
}

Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ internal import CiscoLogger
import SplunkCommon
import WebKit

public final class WebViewInstrumentationInternal {
public final class WebViewInstrumentationInternal: NSObject {

public static var instance = WebViewInstrumentationInternal()

Expand All @@ -28,47 +28,140 @@ public final class WebViewInstrumentationInternal {
public var sharedState: AgentSharedState?

// Module conformance
public required init() {}
public required override init() {}

// MARK: - Internal Methods

// swiftlint:disable function_body_length
public func injectSessionId(into webView: WKWebView) {

guard let sessionId = sharedState?.sessionId else {
logger.log(level: .warn) {
"Session ID not available."
"Native Session ID not available for webview injection."
}
return
}

// Extracted JavaScript template for better readability and maintainability
let script = """
if (!window.SplunkRumNative) {
window.SplunkRumNative = {
cachedSessionId: '\(sessionId)',
getNativeSessionId: function() {
try {
window.webkit.messageHandlers.SplunkRumNativeUpdate.postMessage('').catch(function() {});
} catch (e) {
console.error('Error in getNativeSessionId:', e); // Improved error handling
}
return window.SplunkRumNative.cachedSessionId;
},
};
let javaScript = """
if (window.SplunkRumNative && window.SplunkRumNative._isInitialized) {
console.log("[SplunkRumNative] Already initialized; skipping.");
} else {
window.SplunkRumNative.cachedSessionId = '\(sessionId)';
}
window.SplunkRumNative = (function() {
const debounceDurationMs = 1000;
const staleAfterDurationMs = 5000;
const self = {
cachedSessionId: '\(sessionId)',
_isInitialized: false,
_lastCheckTime: Date.now(),
_updateInProgress: false,
onNativeSessionIdChanged: null,

_fetchSessionId: function() {
return window.webkit.messageHandlers.SplunkRumNativeUpdate
.postMessage({})
.then((r) => r.sessionId)
.catch( function(error) {
console.error("[SplunkRumNative] Failed to fetch native session ID:", error);
throw error;
});
},
_notifyChange: function(oldId, newId) {
if (typeof self.onNativeSessionIdChanged === "function") {
try {
self.onNativeSessionIdChanged({
currentId: newId,
previousId: oldId,
timestamp: Date.now()
});
} catch (error) {
console.error("[SplunkRumNative] Error in application-provided callback for onNativeSessionIdChanged:", error);
}
}
},
// This must be synchronous for legacy BRUM compatibility.
getNativeSessionId: function() {
const now = Date.now();
const stale = (now - self._lastCheckTime) > staleAfterDurationMs;
if (stale && !self._updateInProgress) {
self._updateInProgress = true;
self._lastCheckTime = now;
self._fetchSessionId()
.then( function(newId) {
if (newId !== self.cachedSessionId) {
const oldId = self.cachedSessionId;
self.cachedSessionId = newId;
self._notifyChange(oldId, newId);
}
})
.catch( function(error) {
console.error("[SplunkRumNative] Failed to fetch session ID from native:", error);
})
.finally( function() {
self._updateInProgress = false;
});
}
// Here we finish before above promise is fulfilled, and
// return cached ID immediately for legacy compatibility.
return self.cachedSessionId;
},
// Recommended for BRUM use in new agents going forward.
getNativeSessionIdAsync: async function() {
const newId = await self._fetchSessionId();
if (newId !== self.cachedSessionId) {
const oldId = self.cachedSessionId;
self.cachedSessionId = newId;
self._notifyChange(oldId, newId);
}
return newId;
}
};
console.log("[SplunkRumNative] Initialized with native session:", self.cachedSessionId)
console.log("[SplunkRumNative] Bridge available:", Boolean(window.webkit?.messageHandlers?.SplunkRumNativeUpdate));
return self;
}());
}
"""

webView.evaluateJavaScript(script) { _, error in
if let error = error {
self.logger.log(level: .error) {
"Error injecting JavaScript: \(error)"
}
} else {
self.logger.log(level: .debug) {
"JavaScript injected successfully."
}
}
let userScript = WKUserScript(
source: javaScript,
injectionTime: .atDocumentStart, // expected by legacy BRUM
forMainFrameOnly: false // expected by legacy BRUM
)

contentController(
forName: "SplunkRumNativeUpdate",
forWebView: webView
).addUserScript(userScript)

// Needed at first load only; user script will persist across reloads and navigation
webView.evaluateJavaScript(javaScript)
}

// swiftlint:enable function_body_length

private func contentController(forName name: String, forWebView webView: WKWebView) -> WKUserContentController {
let contentController = webView.configuration.userContentController
contentController.removeScriptMessageHandler(forName: name)
contentController.addScriptMessageHandler(self, contentWorld: .page, name: name)
return contentController
}
}

// MARK: - WKScriptMessageHandlerWithReply

extension WebViewInstrumentationInternal: WKScriptMessageHandlerWithReply {

/// Handles JavaScript messages with a reply handler for asynchronous communication
public func userContentController(
_ userContentController: WKUserContentController,
didReceive message: WKScriptMessage,
replyHandler: @escaping @MainActor @Sendable (Any?, String?) -> Void
) {
// hint: parse message.body["action"] here if you need to add features
if let sessionId = sharedState?.sessionId {
replyHandler(["sessionId": sessionId], nil)
} else {
replyHandler(nil, "Native Session ID not available")
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,6 @@ final class WebViewInstrumentationInternalTests: XCTestCase {
// Assert that the script is the expected script
XCTAssertTrue(script.contains("window.SplunkRumNative"))
XCTAssertTrue(script.contains("getNativeSessionId"))
XCTAssertTrue(script.contains("cachedSessionId"))
expectation.fulfill()
completionHandler(nil, nil) // Simulate success
}
Expand Down