12 KiB
12 KiB
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 pagesDuckPlayerUserScriptPlayer: 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.