Files
obsidian-vault/work/wiki/apple-browsers/duckplayer-userscript-integration.md
T

12 KiB
Executable File

source, confidence, namespace, last_synced, alwaysApply
source confidence namespace last_synced alwaysApply
~/DuckDuckGo/apple-browsers.git/main/.cursor/rules/duckplayer-userscript-integration.mdc 0.9 work 2026-04-28 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

// 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:

// ✅ 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

// ✅ 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

// ✅ 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

// ✅ 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

// ✅ 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:

// ✅ 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

// ✅ 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<AnyCancellable>()
    
    deinit {
        // Clean up subscriptions
        cancellables.forEach { $0.cancel() }
        cancellables.removeAll()
    }
}

Error Handling

// ✅ 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

// ✅ 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

// ✅ 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

// ✅ 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

// ✅ 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

// ✅ 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

// ✅ 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

// ✅ CORRECT - Clean up resources
deinit {
    cancellables.forEach { $0.cancel() }
    cancellables.removeAll()
    otherEventsQueue.removeAll()
}

5. Follow Message Origin Policy

// ✅ 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

// ✅ 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

// ✅ 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

// ✅ 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.