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

448 lines
12 KiB
Markdown

---
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<AnyCancellable>()
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.