--- source: ~/DuckDuckGo/apple-browsers.git/main/.cursor/rules/duckplayer-userscript-integration.mdc confidence: 0.9 namespace: work last_synced: 2026-04-28 alwaysApply: false --- # DuckPlayer UserScript Integration Guide ## Overview DuckPlayer uses two primary UserScript components to bridge native iOS functionality with web content: - `DuckPlayerUserScriptYouTube`: Manages communication with YouTube.com pages - `DuckPlayerUserScriptPlayer`: Handles communication within the DuckPlayer web view ## Architecture Overview ### UserScript Communication Flow ```swift // Communication flow: // Web Content -> UserScript -> Native Handler -> ViewModel/Presenter // Native UI -> Publisher -> UserScript -> Web Content // ✅ CORRECT - Bidirectional communication pattern final class DuckPlayerUserScriptYouTube: NSObject, Subfeature { // Incoming: Web -> Native func handler(forMethodNamed methodName: String) -> Subfeature.Handler? { switch methodName { case "onCurrentTimeStamp": return onCurrentTimeStamp case "onYoutubeError": return onYoutubeError default: return nil } } // Outgoing: Native -> Web private func pushToWebView(method: String, params: [String: String]) { broker?.push(method: method, params: params, for: self, into: webView) } } ``` ## DuckPlayerUserScriptYouTube Integration ### Component Responsibilities **Primary Role**: Bridge between YouTube.com pages and native DuckPlayer controls **Key Responsibilities**: - Manages media control events (play/pause) - Handles audio muting state - Tracks video timestamp updates - Responds to URL changes - Manages script readiness state with event queuing - Provides initial setup configuration ### Event Queuing System The UserScript implements an event queuing system to handle events before scripts are ready: ```swift // ✅ CORRECT - Event queuing implementation private enum QueuedEvent { case mediaControl(pause: Bool) case muteAudio(mute: Bool) case urlChanged(pageType: String) } private var otherEventsQueue: [QueuedEvent] = [] private var areScriptsReady = false private func handleEvent(_ event: QueuedEvent) { switch event { case .urlChanged: // URL changes are always processed immediately processEvent(event) default: if areScriptsReady { processEvent(event) } else { // Queue events until scripts are ready otherEventsQueue.append(event) } } } // Process queued events when scripts become ready func onDuckPlayerScriptsReady(params: Any, original: WKScriptMessage) -> Encodable? { areScriptsReady = true while !otherEventsQueue.isEmpty { let event = otherEventsQueue.removeFirst() processEvent(event) } return nil } ``` ### Publisher Integration Pattern ```swift // ✅ CORRECT - Reactive publisher pattern private func setupSubscriptions() { duckPlayer?.mediaControlPublisher .sink { [weak self] pause in self?.handleMediaControl(pause: pause) } .store(in: &cancellables) duckPlayer?.muteAudioPublisher .sink { [weak self] mute in self?.handleMuteAudio(mute: mute) } .store(in: &cancellables) duckPlayer?.urlChangedPublisher .sink { [weak self] url in self?.onUrlChanged(url: url) } .store(in: &cancellables) } ``` ### Message Origin Security ```swift // ✅ CORRECT - Strict origin validation let messageOriginPolicy: MessageOriginPolicy = .only(rules: [ .exact(hostname: DuckPlayerSettingsDefault.OriginDomains.duckduckgo), .exact(hostname: DuckPlayerSettingsDefault.OriginDomains.youtube), .exact(hostname: DuckPlayerSettingsDefault.OriginDomains.youtubeMobile), .exact(hostname: DuckPlayerSettingsDefault.OriginDomains.youtubeWWW), .exact(hostname: DuckPlayerSettingsDefault.OriginDomains.youtubeNoCookie), .exact(hostname: DuckPlayerSettingsDefault.OriginDomains.youtubeNoCookieWWW) ]) ``` ### Page Type Detection ```swift // ✅ CORRECT - URL-based page type detection func onUrlChanged(url: URL) { areScriptsReady = false // Determine page type for proper script behavior let pageType = DuckPlayerUserScript.getPageType(url: url) let shouldClearEvents = pageType != DuckPlayerUserScript.PageType.YOUTUBE if shouldClearEvents { // Clear queued events when navigating away from YouTube otherEventsQueue.removeAll() } // Always store the latest URL change event handleEvent(.urlChanged(pageType: pageType)) } ``` ## DuckPlayerUserScriptPlayer Integration ### Component Responsibilities **Primary Role**: Handle communication within the DuckPlayer web view **Key Responsibilities**: - Provides initial setup configuration - Updates video timestamps to the view model - Handles YouTube error states - Manages locale and page type information ### ViewModel Communication ```swift // ✅ CORRECT - Direct view model updates @MainActor private func onCurrentTimeStamp(params: Any, original: WKScriptMessage) -> Encodable? { guard let dict = params as? [String: Any], let timeString = dict["timestamp"] as? String, let timeInterval = Double(timeString) else { return [:] as [String: String] } // Update view model directly viewModel?.updateTimeStamp(timeStamp: timeInterval) return [:] as [String: String] } ``` ### Initial Setup Pattern Both UserScripts implement initial setup handlers to configure the web environment: ```swift // ✅ CORRECT - Initial setup with environment data @MainActor private func initialSetup(params: Any, original: WKScriptMessage) -> Encodable? { struct InitialSetupResult: Encodable { let locale: String let playbackPaused: Bool let pageType: String } let result = InitialSetupResult( locale: Locale.current.languageCode ?? "en", playbackPaused: false, pageType: DuckPlayerUserScript.getPageType(url: webView?.url) ) return result } ``` ## Common Integration Patterns ### Memory Management ```swift // ✅ CORRECT - Proper cleanup and weak references final class DuckPlayerUserScriptYouTube: NSObject, Subfeature { private weak var duckPlayer: DuckPlayerControlling? private weak var webView: WKWebView? private var cancellables = Set() deinit { // Clean up subscriptions cancellables.forEach { $0.cancel() } cancellables.removeAll() } } ``` ### Error Handling ```swift // ✅ CORRECT - Graceful error handling @MainActor private func onYoutubeError(params: Any, original: WKScriptMessage) -> Encodable? { // Log error for debugging if let errorData = params as? [String: Any] { os_log(.error, "YouTube error: %{public}@", errorData.description) } // Return empty response to acknowledge receipt return [:] as [String: String] } ``` ### Constants and Type Safety ```swift // ✅ CORRECT - Centralized constants struct DuckPlayerUserScript { enum Handlers { static let onCurrentTimeStamp = "onCurrentTimeStamp" static let onYoutubeError = "onYoutubeError" static let initialSetup = "initialSetup" static let onDuckPlayerScriptsReady = "onDuckPlayerScriptsReady" } enum FEEvents { static let onMediaControl = "onMediaControl" static let onMuteAudio = "onMuteAudio" static let onUrlChanged = "onUrlChanged" } enum Constants { static let featureName = "duckPlayer" static let timestamp = "timestamp" static let pause = "pause" static let mute = "mute" static let pageType = "pageType" static let locale = "locale" static let localeDefault = "en" } } ``` ## Testing UserScript Components ### Mock Testing Pattern ```swift // ✅ CORRECT - Testing with mocks final class DuckPlayerUserScriptYouTubeTests: XCTestCase { private var sut: DuckPlayerUserScriptYouTube! private var mockDuckPlayer: MockDuckPlayerControlling! private var mockBroker: MockUserScriptMessageBroker! override func setUp() { super.setUp() mockDuckPlayer = MockDuckPlayerControlling() mockBroker = MockUserScriptMessageBroker() sut = DuckPlayerUserScriptYouTube(duckPlayer: mockDuckPlayer) sut.with(broker: mockBroker) } func testMediaControlEvent() { // Given let expectation = expectation(description: "Media control sent") mockBroker.pushExpectation = expectation // When mockDuckPlayer.mediaControlPublisher.send(true) // Then wait(for: [expectation], timeout: 1.0) XCTAssertEqual(mockBroker.lastMethod, "onMediaControl") XCTAssertEqual(mockBroker.lastParams["pause"], "true") } } ``` ### Event Queue Testing ```swift // ✅ CORRECT - Testing event queuing func testEventsQueuedBeforeScriptsReady() { // Given scripts are not ready // When events are sent mockDuckPlayer.mediaControlPublisher.send(true) mockDuckPlayer.muteAudioPublisher.send(true) // Then no events are pushed to web view XCTAssertNil(mockBroker.lastMethod) // When scripts become ready _ = sut.onDuckPlayerScriptsReady(params: [:], original: mockScriptMessage) // Then queued events are processed XCTAssertEqual(mockBroker.pushedMethods, ["onMediaControl", "onMuteAudio"]) } ``` ## Integration Best Practices ### 1. Always Use Weak References ```swift // ✅ CORRECT private weak var duckPlayer: DuckPlayerControlling? private weak var webView: WKWebView? // ❌ INCORRECT - Avoid retain cycles private var duckPlayer: DuckPlayerControlling? private var webView: WKWebView? ``` ### 2. Handle Script Readiness ```swift // ✅ CORRECT - Check script readiness before sending events if areScriptsReady { processEvent(event) } else { otherEventsQueue.append(event) } // ❌ INCORRECT - Don't send events before scripts are ready pushToWebView(method: "onMediaControl", params: params) ``` ### 3. Use Type-Safe Parameters ```swift // ✅ CORRECT - Type-safe parameter handling struct TimestampUpdate: Codable { let timestamp: TimeInterval } func handleTimestamp(_ data: TimestampUpdate) { presenter.updateTimestamp(data.timestamp) } // ❌ INCORRECT - Avoid untyped dictionaries func handleMessage(_ data: [String: Any]) { if let timestamp = data["timestamp"] as? Double { // Error-prone string-based access } } ``` ### 4. Implement Proper Cleanup ```swift // ✅ CORRECT - Clean up resources deinit { cancellables.forEach { $0.cancel() } cancellables.removeAll() otherEventsQueue.removeAll() } ``` ### 5. Follow Message Origin Policy ```swift // ✅ CORRECT - Validate message origins let messageOriginPolicy: MessageOriginPolicy = .only(rules: [ .exact(hostname: "youtube.com"), .exact(hostname: "www.youtube.com") ]) // ❌ INCORRECT - Don't use overly permissive policies let messageOriginPolicy: MessageOriginPolicy = .all ``` ## Common Integration Issues ### Issue: Events Lost During Navigation ```swift // ✅ SOLUTION - Clear state on navigation func onUrlChanged(url: URL) { areScriptsReady = false if !isYouTubeURL(url) { // Clear events when leaving YouTube otherEventsQueue.removeAll() } } ``` ### Issue: Memory Leaks from Strong References ```swift // ✅ SOLUTION - Use weak self in closures duckPlayer?.mediaControlPublisher .sink { [weak self] pause in self?.handleMediaControl(pause: pause) } .store(in: &cancellables) ``` ### Issue: Race Conditions with Script Loading ```swift // ✅ SOLUTION - Queue events until ready private func handleEvent(_ event: QueuedEvent) { guard areScriptsReady else { otherEventsQueue.append(event) return } processEvent(event) } ``` This comprehensive guide ensures proper implementation of DuckPlayer UserScript components following established patterns for security, performance, and maintainability.