From 639338eda3f1c08e519747be32e9dd40e2a87af5 Mon Sep 17 00:00:00 2001 From: Alexey Martemyanov Date: Tue, 28 Apr 2026 00:30:19 +0600 Subject: [PATCH] [2026-04-28] Add apple-browsers .cursor rules (43 files) + executor v2 context --- work/wiki/apple-browsers/_index.md | 29 + .../abn-experiment-framework.mdc | 606 ++++++ .../apple-browsers/analytics-patterns.mdc | 160 ++ work/wiki/apple-browsers/anti-patterns.mdc | 392 ++++ .../app-lifecycle-state-machine.mdc | 576 ++++++ work/wiki/apple-browsers/architecture.mdc | 81 + .../branch-naming-conventions.mdc | 319 +++ .../browserserviceskit-integration.mdc | 448 +++++ work/wiki/apple-browsers/code-style.mdc | 975 +++++++++ .../design-system-designresourceskit.mdc | 359 ++++ .../apple-browsers/development-commands.mdc | 178 ++ .../duckplayer-userscript-integration.mdc | 447 +++++ work/wiki/apple-browsers/duckplayer.mdc | 767 +++++++ .../apple-browsers/feature-flags-addition.mdc | 762 +++++++ work/wiki/apple-browsers/feature-flags.mdc | 378 ++++ work/wiki/apple-browsers/general.mdc | 177 ++ work/wiki/apple-browsers/import-hygiene.mdc | 54 + .../instrumentation-facades.mdc | 207 ++ work/wiki/apple-browsers/ios-architecture.mdc | 447 +++++ .../ios-tracker-blocking-implementation.mdc | 923 +++++++++ .../apple-browsers/logging-guidelines.mdc | 581 ++++++ .../macos-singletons-removal.mdc | 220 ++ .../macos-system-integration.mdc | 424 ++++ .../macos-window-management.mdc | 307 +++ .../maestro-device-selection.mdc | 136 ++ .../network-quality-scoring.mdc | 184 ++ .../network-quality-test-config.mdc | 104 + .../network-quality-testing.mdc | 166 ++ .../network-quality-variance-scoring.mdc | 108 + .../performance-optimization.mdc | 407 ++++ .../wiki/apple-browsers/pixel-definitions.mdc | 307 +++ work/wiki/apple-browsers/pixels.mdc | 268 +++ work/wiki/apple-browsers/privacy-security.mdc | 87 + .../wiki/apple-browsers/project-structure.mdc | 344 ++++ work/wiki/apple-browsers/pull-request.mdc | 349 ++++ .../apple-browsers/securevault-guidelines.mdc | 548 +++++ work/wiki/apple-browsers/shared-packages.mdc | 362 ++++ .../subscription-architecture.mdc | 376 ++++ work/wiki/apple-browsers/swiftui-advanced.mdc | 274 +++ work/wiki/apple-browsers/swiftui-style.mdc | 336 ++++ work/wiki/apple-browsers/testing.mdc | 1668 +++++++++++++++ work/wiki/apple-browsers/ui-testing.mdc | 1785 +++++++++++++++++ .../apple-browsers/user-defaults-storage.mdc | 217 ++ work/wiki/apple-browsers/webkit-browser.mdc | 387 ++++ 44 files changed, 18230 insertions(+) create mode 100644 work/wiki/apple-browsers/_index.md create mode 100644 work/wiki/apple-browsers/abn-experiment-framework.mdc create mode 100644 work/wiki/apple-browsers/analytics-patterns.mdc create mode 100644 work/wiki/apple-browsers/anti-patterns.mdc create mode 100644 work/wiki/apple-browsers/app-lifecycle-state-machine.mdc create mode 100644 work/wiki/apple-browsers/architecture.mdc create mode 100644 work/wiki/apple-browsers/branch-naming-conventions.mdc create mode 100644 work/wiki/apple-browsers/browserserviceskit-integration.mdc create mode 100644 work/wiki/apple-browsers/code-style.mdc create mode 100644 work/wiki/apple-browsers/design-system-designresourceskit.mdc create mode 100644 work/wiki/apple-browsers/development-commands.mdc create mode 100644 work/wiki/apple-browsers/duckplayer-userscript-integration.mdc create mode 100644 work/wiki/apple-browsers/duckplayer.mdc create mode 100644 work/wiki/apple-browsers/feature-flags-addition.mdc create mode 100644 work/wiki/apple-browsers/feature-flags.mdc create mode 100644 work/wiki/apple-browsers/general.mdc create mode 100644 work/wiki/apple-browsers/import-hygiene.mdc create mode 100644 work/wiki/apple-browsers/instrumentation-facades.mdc create mode 100644 work/wiki/apple-browsers/ios-architecture.mdc create mode 100644 work/wiki/apple-browsers/ios-tracker-blocking-implementation.mdc create mode 100644 work/wiki/apple-browsers/logging-guidelines.mdc create mode 100644 work/wiki/apple-browsers/macos-singletons-removal.mdc create mode 100644 work/wiki/apple-browsers/macos-system-integration.mdc create mode 100644 work/wiki/apple-browsers/macos-window-management.mdc create mode 100644 work/wiki/apple-browsers/maestro-device-selection.mdc create mode 100644 work/wiki/apple-browsers/network-quality-scoring.mdc create mode 100644 work/wiki/apple-browsers/network-quality-test-config.mdc create mode 100644 work/wiki/apple-browsers/network-quality-testing.mdc create mode 100644 work/wiki/apple-browsers/network-quality-variance-scoring.mdc create mode 100644 work/wiki/apple-browsers/performance-optimization.mdc create mode 100644 work/wiki/apple-browsers/pixel-definitions.mdc create mode 100644 work/wiki/apple-browsers/pixels.mdc create mode 100644 work/wiki/apple-browsers/privacy-security.mdc create mode 100644 work/wiki/apple-browsers/project-structure.mdc create mode 100644 work/wiki/apple-browsers/pull-request.mdc create mode 100644 work/wiki/apple-browsers/securevault-guidelines.mdc create mode 100644 work/wiki/apple-browsers/shared-packages.mdc create mode 100644 work/wiki/apple-browsers/subscription-architecture.mdc create mode 100644 work/wiki/apple-browsers/swiftui-advanced.mdc create mode 100644 work/wiki/apple-browsers/swiftui-style.mdc create mode 100644 work/wiki/apple-browsers/testing.mdc create mode 100644 work/wiki/apple-browsers/ui-testing.mdc create mode 100644 work/wiki/apple-browsers/user-defaults-storage.mdc create mode 100644 work/wiki/apple-browsers/webkit-browser.mdc diff --git a/work/wiki/apple-browsers/_index.md b/work/wiki/apple-browsers/_index.md new file mode 100644 index 00000000..9ca0d532 --- /dev/null +++ b/work/wiki/apple-browsers/_index.md @@ -0,0 +1,29 @@ +--- +confidence: 1.0 +namespace: work +last_updated: 2026-04-28 +--- + +# Apple Browsers β€” Cursor Rules Index + +Copies of `.cursor/rules/*.mdc` from `~/DuckDuckGo/apple-browsers.git/main/.cursor/rules/`. +Originals stay in place (Cursor reads them from there). These copies are for agent search via obsidian-mcp. + +**Do not edit here** β€” edit originals in the repo and re-sync. + +## Files + +| Rule | Purpose | +|------|---------| +| architecture.mdc | Overall app architecture patterns | +| code-style.mdc | Swift code style guidelines | +| development-commands.mdc | Build commands, Xcode setup | +| testing.mdc | Unit test patterns | +| ui-testing.mdc | UI test patterns, UITestCase | +| pull-request.mdc | PR workflow (Executor overrides confirmation gates) | +| feature-flags.mdc | Feature flag usage | +| anti-patterns.mdc | What NOT to do | +| general.mdc | General guidelines | +| branch-naming-conventions.mdc | Branch naming (executor/* prefix for Executor) | + +See full list: 43 files total. diff --git a/work/wiki/apple-browsers/abn-experiment-framework.mdc b/work/wiki/apple-browsers/abn-experiment-framework.mdc new file mode 100644 index 00000000..7c8c13be --- /dev/null +++ b/work/wiki/apple-browsers/abn-experiment-framework.mdc @@ -0,0 +1,606 @@ +--- +source: ~/DuckDuckGo/apple-browsers.git/main/.cursor/rules/abn-experiment-framework.mdc +confidence: 0.9 +namespace: work +last_synced: 2026-04-28 +alwaysApply: false +--- + +# A/B/N Experiment Framework + +## Overview + +The DuckDuckGo browser includes a comprehensive A/B/N experiment framework that enables data-driven feature testing across iOS and macOS platforms. This framework allows you to safely experiment with new ideas while maintaining control groups and measuring impact. + +**Reference**: Video of knowledge sharing session: βœ“ A/B/N Experiment Framework + +## What is A/B/N Testing? + +An **A/B/N test** is a method of experimenting with multiple variants of a feature (A, B, ... N) to determine which performs better. It's like traditional A/B testing but scaled up to support more than two groups. + +### Use Cases + +You can use this framework to explore: +- **UI/UX variations**: Whether blue buttons outperform green buttons +- **Content experiments**: If showing pictures of cats 🐱 or dogs 🐢 boosts user retention +- **Feature comparisons**: Different implementations of the same functionality +- **Performance optimization**: Testing various algorithms or approaches + +### When to Use A/B/N Testing + +βœ… **Use for**: +- Comparing user behavior between two or more feature variants +- Validating hypotheses before rolling out changes to all users +- Safely experimenting with new ideas while maintaining control groups +- Measurable, impactful decisions with clear success metrics + +❌ **Don't use for**: +- Simple bug fixes or obvious improvements +- Changes without measurable impact +- Features that can't be easily reversed + +⚠️ **Note**: Not every change needs a testβ€”reserve it for measurable, impactful decisions. This is typically decided in collaboration with ODRIs and Data Science. + +## Framework Architecture + +### Remote Configuration System + +A/B/N experiments are supported via **remote configuration** on both macOS and iOS: + +- **Sub-features** are used for experiments +- **Parent features** group related experiments +- **Cohorts** define the different variants +- **Weights** control user distribution +- **Targets** allow locale-based segmentation + +## Configuration Setup + +### 1. Privacy Config Structure + +Experiments are defined in the Privacy Configuration with this structure: + +```json +"amazingMacroFeature": { + "state": "enabled", + "features": { + "petsPictures": { + "state": "enabled", + "description": "This feature shows users pictures of cute pets", + "targets": [ + { "localeLanguage": "en", "localeCountry": "US" }, + { "localeLanguage": "fr", "localeCountry": "CA" } + ], + "cohorts": [ + { "name": "cats", "weight": 1 }, + { "name": "dogs", "weight": 1 } + ] + } + } +} +``` + +### Configuration Elements + +#### **State Options** +- `enabled`: Visible to all users +- `internal`: Visible only to internal users +- `disabled`: Hidden from all users + +#### **Description** +Explains the experiment's purpose for team reference. + +#### **Targets** (Optional) +Specify user segments based on locale: +```json +"targets": [ + { "localeLanguage": "en", "localeCountry": "US" }, + { "localeLanguage": "fr", "localeCountry": "CA" } +] +``` + +#### **Cohorts** +Define experiment variants: +- `name`: Cohort identifier (e.g., "cats", "dogs") +- `weight`: Probability of assignment (normally 1 or 0) + +## Client Implementation + +### Step 1: Add Feature to PrivacyFeature (BSK) + +#### Check for Existing Features + +```swift +// In PrivacyFeature enum, check if parent feature exists +public enum PrivacyFeature: String, CaseIterable { + case amazingMacroFeature + // ... other features +} + +// Add sub-feature to existing enum or create new one +public enum AmazingMacroFeatureSubfeatures: String, CaseIterable { + case petsPictures + // ... other sub-features +} +``` + +#### Add New Features + +If the parent feature doesn't exist: +1. Add it to the `PrivacyFeature` enum +2. Create a new sub-features enum +3. Add your sub-feature to the enum + +### Step 2: Define Feature Flag + +Add your experiment to the local `FeatureFlag` enum: + +```swift +public enum FeatureFlag: String, CaseIterable { + case debugMenu + case sslCertificatesBypass + case maliciousSiteProtection + // ... existing flags + case petsPictures + + public var cohortType: (any FeatureFlagCohortDescribing.Type)? { + switch self { + case .petsPictures: + return PetsPicturesCohort.self + default: + return nil + } + } + + public enum PetsPicturesCohort: String, FeatureFlagCohortDescribing { + case cats + case dogs + } + + public var source: FeatureFlagSource { + switch self { + // ... other cases + case .petsPictures: + return .remoteReleasable(.subfeature(AmazingMacroFeatureSubfeatures.petsPictures)) + } + } + + public var supportsLocalOverriding: Bool { + switch self { + // ... other cases + case .petsPictures: + return true + } + } +} +``` + +#### Key Properties + +**`cohortType`**: Links to experiment cohorts enum +- Must conform to `String, FeatureFlagCohortDescribing` +- Defines available variants (cats, dogs) + +**`source`**: Defines feature flag toggle location +- `.disabled`: Feature is off +- `.internalOnly`: Internal users only +- `.remoteDevelopment`: Development testing +- `.remoteReleasable`: Production experiments + +**`supportsLocalOverriding`**: Enables debug menu overrides +- `true`: Internal users can override cohort assignment +- `false`: No local overrides allowed + +### Step 3: Implement Cohort Decision Logic + +Request cohort assignment when needed: + +```swift +// βœ… CORRECT: Request cohort only when decision is needed +guard let petsCohort = Application.appDelegate.featureFlagger.resolveCohort(for: .petsPictures) as? FeatureFlag.PetsPicturesCohort else { + return +} + +switch petsCohort { +case .cats: + showCatPics() +case .dogs: + showDogPics() +} +``` + +⚠️ **Important**: Only request the cohort at the moment it's needed! This ensures accurate assignment and avoids data dilution. + +### Step 4: Handle Dynamic Cohort Changes + +For features that need to respond to runtime cohort changes: + +```swift +private func subscribeToPetsExperimentFeatureFlagChanges() { + guard let overridesHandler = Application.appDelegate.featureFlagger.localOverrides?.actionHandler as? FeatureFlagOverridesPublishingHandler else { + return + } + + overridesHandler.experimentFlagDidChangePublisher + .filter { $0.0 == .petsPictures } + .sink { (_, cohort) in + guard let newCohort = FeatureFlag.PetsPicturesCohort.cohort(for: cohort) else { return } + switch newCohort { + case .cats: + // IMMEDIATELY SHOW A CUTE CAT + case .dogs: + // IMMEDIATELY SHOW A CUTE DOG + } + } + .store(in: &cancellables) +} +``` + +## Metrics and Analytics + +### Default Retention Metrics + +The framework automatically tracks core engagement metrics without additional configuration: + +#### 1. Enrollment Pixel + +Tracks when users join experiments: + +``` +Pixel Name: experiment_enroll_{experimentName}_{cohortName} +Parameters: +- enrollmentDate: Date in ET (YYYY-MM-DD format) +``` + +#### 2. Search Activity Pixels + +Monitors search behavior post-enrollment: + +``` +Pixel Name: experiment_metrics_{experimentName}_{cohortName} +Parameters: +- metric: "search" +- conversionWindowDays: Time frame (e.g., "1", "5-7") +- value: Number of searches performed +- enrollmentDate: Enrollment date (YYYY-MM-DD) +``` + +**Predefined Tracking Windows**: +- **Value 1**: Conversion windows [1, 2, 3, 4, 5, 6, 7, 5-7] +- **Values 4, 6, 11, 21, 30**: Conversion windows [5-7, 8-15] + +#### 3. App Usage Pixels + +Tracks app engagement (launches, foregrounds, etc.): + +``` +Pixel Name: experiment_metrics_{experimentName}_{cohortName} +Parameters: +- metric: "app_use" +- conversionWindowDays: Time frame (e.g., "0", "1", "5-7") +- value: Number of app usage events +- enrollmentDate: Enrollment date (YYYY-MM-DD) +``` + +**Predefined Tracking Windows**: +- **Value 1**: Conversion windows [0, 1, 2, 3, 4, 5, 6, 7, 5-7] +- **Values 4, 6, 11, 21, 30**: Conversion windows [5-7, 8-15] + +### Custom Metrics + +Track experiment-specific behaviors using `PixelExperimentKit`: + +#### Import Required Framework + +```swift +import PixelExperimentKit +``` + +#### Fire Custom Metric Pixels + +```swift +// Method 1: Direct pixel firing +func fireExperimentPixel( + for subfeatureID: SubfeatureID, + metric: String, + conversionWindowDays: ConversionWindow, + value: String +) + +// Method 2: Threshold-based pixel firing +func fireExperimentPixelIfThresholdReached( + for subfeatureID: SubfeatureID, + metric: String, + conversionWindowDays: ConversionWindow, + threshold: NumberOfCalls +) +``` + +#### Example: Button Click Tracking + +```swift +// Track immediate button clicks +PixelKit.fireExperimentPixel( + for: "petsPictures", + metric: "adopt_button_clicks", + conversionWindowDays: 1...1, + value: "1" +) + +// Track threshold-based clicks (fires after 5 clicks) +PixelKit.fireExperimentPixelIfThresholdReached( + for: "petsPictures", + metric: "button_clicks", + conversionWindowDays: 1...7, + threshold: 5 +) +``` + +#### Custom Metric Examples + +```swift +// Form completion tracking +PixelKit.fireExperimentPixel( + for: "petsPictures", + metric: "form_completed", + conversionWindowDays: 1...1, + value: "true" +) + +// Feature adoption tracking +PixelKit.fireExperimentPixel( + for: "petsPictures", + metric: "set_as_default", + conversionWindowDays: 1...7, + value: "true" +) + +// Error tracking +PixelKit.fireExperimentPixel( + for: "petsPictures", + metric: "error_occurred", + conversionWindowDays: 1...1, + value: "network_timeout" +) +``` + +## Experiment Management + +### Stop Accepting New Users to a Cohort + +To prevent new enrollments while maintaining existing users: + +```json +"cohorts": [ + { "name": "cats", "weight": 0 }, // No new users + { "name": "dogs", "weight": 1 } // All new users go here +] +``` + +**Behavior**: +- **Existing users**: Remain in their assigned cohorts +- **New users**: Only assigned to cohorts with weight > 0 +- **No enrollment**: Set all weights to 0 + +### Remove Users from a Cohort + +To completely remove a cohort and reassign users: + +```json +// Before: Two cohorts +"cohorts": [ + { "name": "cats", "weight": 1 }, + { "name": "dogs", "weight": 1 } +] + +// After: Cats cohort removed +"cohorts": [ + { "name": "dogs", "weight": 1 } +] +``` + +**Behavior**: +- **Existing users**: Automatically reassigned to remaining cohorts +- **New users**: Assigned to available cohorts +- **No cohorts**: No users enrolled if all cohorts removed + +### Stop an Experiment + +Complete cleanup requires both code and configuration changes: + +#### 1. Clean Up Code + +```swift +// ❌ Remove experiment-specific logic +// switch petsCohort { +// case .cats: +// showCatPics() +// case .dogs: +// showDogPics() +// } + +// βœ… Implement final chosen behavior +showDogPics() // Or whatever was determined to be the winner +``` + +#### 2. Update Configuration + +```json +// Remove cohorts or entire sub-feature +"amazingMacroFeature": { + "state": "enabled", + "features": { + // "petsPictures": { ... } // Remove entire sub-feature + } +} +``` + +⚠️ **Critical**: Always remove code before removing configuration to prevent runtime errors. + +## Development and Testing + +### Feature Flag Sources for Development + +Control experiment access during development: + +#### Internal-Only Testing + +```swift +public var source: FeatureFlagSource { + switch self { + case .petsPictures: + // Only internal users see this experiment + return .internalOnly(.subfeature(AmazingMacroFeatureSubfeatures.petsPictures)) + } +} +``` + +#### Development Random Assignment + +```swift +public var source: FeatureFlagSource { + switch self { + case .petsPictures: + // Internal users get random assignment based on remote config + return .remoteDevelopment(.subfeature(AmazingMacroFeatureSubfeatures.petsPictures)) + } +} +``` + +#### Production Release + +```swift +public var source: FeatureFlagSource { + switch self { + case .petsPictures: + // All users participate based on remote config + return .remoteReleasable(.subfeature(AmazingMacroFeatureSubfeatures.petsPictures)) + } +} +``` + +### Local Overrides for Testing + +Enable debug menu overrides for internal testing: + +```swift +public var supportsLocalOverriding: Bool { + switch self { + case .petsPictures: + return true // Enables debug menu cohort selection + } +} +``` + +**Usage**: +1. Open debug menu in internal builds +2. Navigate to "Feature Flag Overrides" +3. Select specific cohort for testing +4. App immediately reflects cohort change + +## Best Practices + +### βœ… DO + +```swift +// Request cohort only when needed +guard let cohort = featureFlagger.resolveCohort(for: .petsPictures) as? FeatureFlag.PetsPicturesCohort else { return } + +// Use meaningful cohort names +public enum PetsPicturesCohort: String, FeatureFlagCohortDescribing { + case cats // Clear, descriptive names + case dogs +} + +// Track relevant custom metrics +PixelKit.fireExperimentPixel( + for: "petsPictures", + metric: "adoption_success", + conversionWindowDays: 1...7, + value: "true" +) + +// Clean up after experiments +// Remove cohort logic and implement winning variant +``` + +### ❌ DON'T + +```swift +// Don't request cohorts unnecessarily +let cohort = featureFlagger.resolveCohort(for: .petsPictures) // ❌ Called too early + +// Don't use unclear cohort names +public enum TestCohort: String { + case a // ❌ Unclear what this represents + case b +} + +// Don't forget to clean up +// Leaving experiment code after completion ❌ + +// Don't remove config before code +// Can cause runtime crashes ❌ +``` + +### πŸ”’ Security and Privacy + +```swift +// Don't log sensitive cohort information +Logger.debug("User assigned to cohort: \(cohort)") // ❌ Potential privacy issue + +// Use privacy-safe logging +Logger.debug("Experiment cohort assigned") // βœ… Safe + +// Don't store cohort assignments locally +UserDefaults.standard.set(cohort.rawValue, forKey: "cohort") // ❌ Privacy risk +``` + +## Troubleshooting + +### Common Issues + +#### Cohort Assignment Not Working +```swift +// Check feature flag setup +guard let cohort = featureFlagger.resolveCohort(for: .petsPictures) else { + Logger.error("Failed to resolve cohort for petsPictures") + return +} +``` + +#### Metrics Not Appearing +```swift +// Verify PixelExperimentKit import +import PixelExperimentKit + +// Check subfeature ID matches config +PixelKit.fireExperimentPixel( + for: "petsPictures", // Must match config exactly + metric: "test_metric", + conversionWindowDays: 1...1, + value: "1" +) +``` + +#### Debug Menu Not Showing Experiment +```swift +public var supportsLocalOverriding: Bool { + switch self { + case .petsPictures: + return true // Must be true for debug menu + } +} +``` + +### Validation Checklist + +- [ ] Privacy config includes correct cohort names and weights +- [ ] FeatureFlag enum properly configured with cohort type +- [ ] Feature flag source matches intended audience +- [ ] Custom metrics fire at appropriate times +- [ ] Local overrides work in debug builds +- [ ] Experiment cleanup plan documented + +--- + +This A/B/N experiment framework provides a robust, scalable solution for data-driven feature development in the DuckDuckGo browser, enabling safe experimentation while maintaining user privacy and providing comprehensive analytics. diff --git a/work/wiki/apple-browsers/analytics-patterns.mdc b/work/wiki/apple-browsers/analytics-patterns.mdc new file mode 100644 index 00000000..2cd68ea3 --- /dev/null +++ b/work/wiki/apple-browsers/analytics-patterns.mdc @@ -0,0 +1,160 @@ +--- +source: ~/DuckDuckGo/apple-browsers.git/main/.cursor/rules/analytics-patterns.mdc +confidence: 0.9 +namespace: work +last_synced: 2026-04-28 +alwaysApply: false +--- + +# Analytics and Pixel Patterns + +## Structured Pixel Events +Use the existing Pixel.fire pattern with structured event definitions: + +```swift +// βœ… CORRECT - Use existing Pixel.fire with proper parameters +extension PixelEvent { + static let featureUsed = "feature_used" + static let performanceMetric = "performance_metric" + static let errorOccurred = "error_occurred" + static let userAction = "user_action" +} + +// Usage examples from codebase +Pixel.fire(pixel: .webKitTerminationDidReloadCurrentTab) + +Pixel.fire(pixel: .cachedTabPreviewsExceedsTabCount, withAdditionalParameters: [ + PixelParameters.tabPreviewCountDelta: "\(storedPreviews - totalTabs)" +]) + +Pixel.fire(pixel: .autofillLoginsSavePromptDisplayed, withAdditionalParameters: [ + PixelParameters.autofillPromptTrigger: "manual" +]) +``` + +## Pixel Parameters +Use the established PixelParameters constants: + +```swift +// βœ… CORRECT - Use existing PixelParameters +extension PixelParameters { + static let featureName = "fn" + static let errorType = "et" + static let performanceValue = "pv" + static let userActionSource = "uas" +} + +// Usage +Pixel.fire(pixel: .newFeatureUsed, withAdditionalParameters: [ + PixelParameters.featureName: "voice_search", + PixelParameters.userActionSource: "keyboard_shortcut" +]) +``` + +## Performance Metrics +Track performance metrics consistently: + +```swift +// βœ… CORRECT - Performance tracking pattern +final class PerformanceTracker { + static func trackPageLoad(duration: TimeInterval, url: URL) { + let parameters = [ + PixelParameters.duration: String(format: "%.3f", duration), + PixelParameters.domain: url.host ?? "unknown" + ] + + Pixel.fire(pixel: .pageLoadTime, withAdditionalParameters: parameters) + } + + static func trackMemoryUsage(bytes: Int, context: String) { + let parameters = [ + PixelParameters.memoryUsage: "\(bytes)", + PixelParameters.context: context + ] + + Pixel.fire(pixel: .memoryUsage, withAdditionalParameters: parameters) + } +} +``` + +## Error Tracking +Track errors with context: + +```swift +// βœ… CORRECT - Error tracking +extension Pixel { + static func fireError(_ error: Error, context: String = "") { + let parameters = [ + PixelParameters.errorType: String(describing: type(of: error)), + PixelParameters.context: context + ] + + Pixel.fire(pixel: .errorOccurred, withAdditionalParameters: parameters) + } +} + +// Usage +do { + try await networkService.fetchData() +} catch { + Pixel.fireError(error, context: "data_fetch") + throw error +} +``` + +## Feature Usage Tracking +Track feature adoption and usage: + +```swift +// βœ… CORRECT - Feature usage tracking +final class FeatureTracker { + static func trackFeatureUsage(_ feature: String, source: String = "") { + let parameters = [ + PixelParameters.featureName: feature, + PixelParameters.userActionSource: source + ] + + Pixel.fire(pixel: .featureUsed, withAdditionalParameters: parameters) + } + + static func trackFeatureEnabled(_ feature: String, enabled: Bool) { + let parameters = [ + PixelParameters.featureName: feature, + PixelParameters.enabled: enabled ? "true" : "false" + ] + + Pixel.fire(pixel: .featureToggled, withAdditionalParameters: parameters) + } +} +``` + +## Privacy-Safe Analytics +Ensure all analytics respect privacy: + +```swift +// βœ… CORRECT - Privacy-safe analytics +final class PrivacyAnalytics { + static func trackWithPrivacy(event: String, value: String) { + // Hash sensitive values + let hashedValue = value.sha256Hash + + let parameters = [ + PixelParameters.hashedValue: hashedValue + ] + + Pixel.fire(pixel: event, withAdditionalParameters: parameters) + } + + static func trackAggregateMetric(metric: String, count: Int) { + // Only send aggregate data, never individual events + let parameters = [ + PixelParameters.metric: metric, + PixelParameters.count: "\(count)" + ] + + Pixel.fire(pixel: .aggregateMetric, withAdditionalParameters: parameters) + } +} +``` + +See `feature-flags.md` for A/B test analytics and `privacy-security.md` for privacy requirements. diff --git a/work/wiki/apple-browsers/anti-patterns.mdc b/work/wiki/apple-browsers/anti-patterns.mdc new file mode 100644 index 00000000..76d64286 --- /dev/null +++ b/work/wiki/apple-browsers/anti-patterns.mdc @@ -0,0 +1,392 @@ +--- +source: ~/DuckDuckGo/apple-browsers.git/main/.cursor/rules/anti-patterns.mdc +confidence: 0.9 +namespace: work +last_synced: 2026-04-28 +alwaysApply: true +--- + +# Anti-patterns and Common Mistakes to Avoid + +## Singleton Anti-patterns + +### ❌ NEVER: Static Shared Instances Without Dependency Injection (.shared instance pattern) +**Example:** See [singleton-antipattern.swift](anti-patterns/singleton-antipattern.swift) + +### ❌ NEVER: Global State Access +```swift +// ❌ AVOID - Global state access +var globalSettings: [String: Any] = [:] + +func someFunction() { + globalSettings["key"] = "value" // Global state is hard to test and debug +} + +// βœ… CORRECT - Injected dependencies +final class SomeService { + private let settings: AppSettings + + init(settings: AppSettings) { + self.settings = settings + } + + func someFunction() { + settings.setValue("value", for: "key") + } +} +``` + +## Async/Await Anti-patterns + +### ❌ NEVER: UI Updates Without @MainActor +**Example:** See [async-ui-updates.swift](anti-patterns/async-ui-updates.swift) + +### ❌ NEVER: Unhandled Async Errors +```swift +// ❌ AVOID - Swallowing async errors +func fetchData() async { + let data = try? await networkService.getData() // Silently ignoring errors + // Process data... +} + +// βœ… CORRECT - Proper error handling +func fetchData() async throws { + let data = try await networkService.getData() + // Process data... +} + +// Or handle errors appropriately: +func fetchData() async { + do { + let data = try await networkService.getData() + // Process data... + } catch { + // Log error and show user-friendly message + logger.error("Failed to fetch data: \(error)") + await showError(error) + } +} +``` + +### ❌ NEVER: Blocking Main Thread with Sync Operations +```swift +// ❌ AVOID - Blocking main thread +@MainActor +func loadData() { + let data = NetworkService.fetchDataSynchronously() // Blocks UI + updateUI(with: data) +} + +// βœ… CORRECT - Use async operations +@MainActor +func loadData() async { + let data = try await NetworkService.fetchData() // Non-blocking + updateUI(with: data) +} +``` + +## Memory Management Anti-patterns + +### ❌ NEVER: Strong Reference Cycles in Closures +**Example:** See [memory-leak-closure.swift](anti-patterns/memory-leak-closure.swift) + +### ❌ NEVER: Retaining View Controllers in Cache +```swift +// ❌ AVOID - Caching view controllers without cleanup +class NavigationManager { + private var cachedViewControllers: [String: UIViewController] = [:] + + func getViewController(for identifier: String) -> UIViewController { + if let cached = cachedViewControllers[identifier] { + return cached // May contain stale data and strong references + } + let vc = createViewController(for: identifier) + cachedViewControllers[identifier] = vc + return vc + } +} + +// βœ… CORRECT - Cache view models, not view controllers +class NavigationManager { + private var cachedViewModels: [String: ViewModel] = [:] + + func getViewController(for identifier: String) -> UIViewController { + let viewModel = getOrCreateViewModel(for: identifier) + return createViewController(with: viewModel) + } + + private func getOrCreateViewModel(for identifier: String) -> ViewModel { + if let cached = cachedViewModels[identifier] { + return cached + } + let viewModel = createViewModel(for: identifier) + cachedViewModels[identifier] = viewModel + return viewModel + } +} +``` + +## Error Handling Anti-patterns + +### ❌ NEVER: Force Unwrapping Without Justification +**Example:** See [force-unwrapping.swift](anti-patterns/force-unwrapping.swift) + +### ❌ NEVER: Generic Error Messages +```swift +// ❌ AVOID - Generic error handling +func handleError(_ error: Error) { + print("Something went wrong") // Not helpful for debugging + showAlert("Error occurred") // Not helpful for users +} + +// βœ… CORRECT - Specific error handling +enum NetworkError: LocalizedError { + case noConnection + case timeout + case unauthorized + case serverError(Int) + + var errorDescription: String? { + switch self { + case .noConnection: + return "No internet connection. Please check your network settings." + case .timeout: + return "Request timed out. Please try again." + case .unauthorized: + return "You are not authorized to access this resource." + case .serverError(let code): + return "Server error (\(code)). Please try again later." + } + } +} + +func handleNetworkError(_ error: NetworkError) { + logger.error("Network error: \(error)") + showAlert(error.localizedDescription) +} +``` + +## SwiftUI Anti-patterns + +### ❌ NEVER: Heavy Computation in View Body +```swift +// ❌ AVOID - Expensive operations in body +struct ContentView: View { + let items: [Item] + + var body: some View { + List { + ForEach(items) { item in + Text(expensiveProcessing(item)) // Computed every view update + } + } + } + + private func expensiveProcessing(_ item: Item) -> String { + // Heavy computation + return item.data.complexProcessing() + } +} + +// βœ… CORRECT - Pre-compute or use lazy loading +struct ContentView: View { + @StateObject private var viewModel: ContentViewModel + + var body: some View { + List { + ForEach(viewModel.processedItems) { item in + Text(item.displayText) + } + } + .onAppear { + viewModel.processItems() + } + } +} +``` + +### ❌ NEVER: Direct State Mutation from View +```swift +// ❌ AVOID - Direct state mutation in view +struct ContentView: View { + @State private var items: [Item] = [] + + var body: some View { + List { + ForEach(items) { item in + ItemRow(item: item) { updatedItem in + // Don't mutate state directly in view + if let index = items.firstIndex(where: { $0.id == updatedItem.id }) { + items[index] = updatedItem + } + } + } + } + } +} + +// βœ… CORRECT - Use ViewModel for state management +struct ContentView: View { + @StateObject private var viewModel: ContentViewModel + + var body: some View { + List { + ForEach(viewModel.items) { item in + ItemRow(item: item) { updatedItem in + viewModel.updateItem(updatedItem) + } + } + } + } +} +``` + +## Design System Anti-patterns + +### ❌ NEVER: Hardcoded Colors or Icons +**Example:** See [design-system-violation.swift](anti-patterns/design-system-violation.swift) + +## Network and API Anti-patterns + +### ❌ NEVER: Hardcoded URLs or API Keys +```swift +// ❌ AVOID - Hardcoded values +func fetchData() async throws -> Data { + let url = URL(string: "https://api.example.com/data")! // Hardcoded URL + let apiKey = "abc123xyz" // Hardcoded API key + + var request = URLRequest(url: url) + request.addValue(apiKey, forHTTPHeaderField: "Authorization") + + let (data, _) = try await URLSession.shared.data(for: request) + return data +} + +// βœ… CORRECT - Configuration-based approach +struct APIConfiguration { + let baseURL: URL + let apiKey: String + + static let production = APIConfiguration( + baseURL: URL(string: "https://api.duckduckgo.com")!, + apiKey: Bundle.main.object(forInfoDictionaryKey: "API_KEY") as! String + ) +} + +func fetchData() async throws -> Data { + let config = APIConfiguration.production + let url = config.baseURL.appendingPathComponent("data") + + var request = URLRequest(url: url) + request.addValue(config.apiKey, forHTTPHeaderField: "Authorization") + + let (data, _) = try await URLSession.shared.data(for: request) + return data +} +``` + +## Testing Anti-patterns + +### ❌ NEVER: Testing Implementation Details +```swift +// ❌ AVOID - Testing private implementation +class ViewModelTests: XCTestCase { + func testPrivateMethod() { + let viewModel = ViewModel() + + // Don't test private methods directly + let result = viewModel.privateHelperMethod() + XCTAssertEqual(result, expected) + } +} + +// βœ… CORRECT - Test public behavior +class ViewModelTests: XCTestCase { + func testLoadDataUpdatesState() async { + let mockService = MockDataService() + let viewModel = ViewModel(service: mockService) + + await viewModel.loadData() + + // Test the observable behavior, not implementation + XCTAssertFalse(viewModel.isLoading) + XCTAssertNotNil(viewModel.data) + XCTAssertNil(viewModel.error) + } +} +``` + +### ❌ NEVER: Tests That Don't Test Anything +```swift +// ❌ AVOID - Tests without assertions +func testInitialization() { + let viewModel = ViewModel() + // Test does nothing +} + +// ❌ AVOID - Tests that can't fail +func testAlwaysTrue() { + XCTAssertTrue(true) // This test is meaningless +} + +// βœ… CORRECT - Meaningful tests with specific assertions +func testInitializationSetsDefaultState() { + let viewModel = ViewModel() + + XCTAssertEqual(viewModel.state, .idle) + XCTAssertTrue(viewModel.items.isEmpty) + XCTAssertFalse(viewModel.isLoading) +} +``` + +## Performance Anti-patterns + +### ❌ NEVER: Synchronous Operations on Main Thread +```swift +// ❌ AVOID - Blocking main thread +@MainActor +func processLargeDataSet() { + let result = heavyComputation() // Blocks UI + updateUI(with: result) +} + +// βœ… CORRECT - Background processing +@MainActor +func processLargeDataSet() async { + let result = await Task.detached(priority: .userInitiated) { + return heavyComputation() + }.value + + updateUI(with: result) +} +``` + +## Communication Anti-patterns + +### ❌ NEVER: Celebrate Partial Results or Progress +``` +// ❌ AVOID - Celebrating when work is incomplete +"βœ… MISSION ACCOMPLISHED!" (when tests still failing) +"🎯 Outstanding Achievement:" (when task isn't finished) +"πŸ“Š FINAL RESULTS:" (when results aren't final) +"βœ… Successfully achieved X" (when Y tests still failing) + +// βœ… CORRECT - Focus on what's left to do +"7 tests still failing. Continuing to fix remaining issues." +"Progress made but task incomplete. Working on remaining failures." +"X tests now passing, Y still need work." +``` + +**Never celebrate or summarize achievements when:** +- Tests are still failing +- Tasks are incomplete +- User's request hasn't been fully satisfied +- Work is in progress + +**Only summarize results when:** +- ALL tests pass (100% success rate) +- Task is completely finished +- User's request is fully satisfied +- No work remaining + +These anti-patterns should be actively avoided to maintain code quality, testability, and performance in the DuckDuckGo browser codebase. diff --git a/work/wiki/apple-browsers/app-lifecycle-state-machine.mdc b/work/wiki/apple-browsers/app-lifecycle-state-machine.mdc new file mode 100644 index 00000000..69f32978 --- /dev/null +++ b/work/wiki/apple-browsers/app-lifecycle-state-machine.mdc @@ -0,0 +1,576 @@ +--- +source: ~/DuckDuckGo/apple-browsers.git/main/.cursor/rules/app-lifecycle-state-machine.mdc +confidence: 0.9 +namespace: work +last_synced: 2026-04-28 +alwaysApply: false +--- + +# App Lifecycle State Machine Architecture + +## Overview + +The DuckDuckGo browser has moved away from traditional AppDelegate-based lifecycle handling to a **state machine architecture**. While AppDelegate still exists, it has been significantly thinned out and now delegates responsibility to a structured state machine. + +This approach ensures that lifecycle handling is **predictable, organized, and easy to maintain**. + +## Architecture Components + +### Three Core States + +The architecture revolves around a state machine with three major states: + +#### 1. **Launching** (Transient State) +- **Associated with**: `application(_:didFinishLaunchingWithOptions:)` +- **File**: `Launching.swift` +- **Purpose**: App's initial setup and dependency configuration +- **Responsibilities**: + - Initialize all services and objects + - Configure dependencies + - Prepare UI components + - Create `MainViewController` and set as `rootViewController` + +#### 2. **Foreground** (Permanent State) +- **Associated with**: `applicationDidBecomeActive(_:)` +- **File**: `Foreground.swift` +- **Purpose**: App is fully interactive and user can engage with UI +- **Responsibilities**: + - Resume suspended work + - Handle user interactions + - Manage active UI state + +#### 3. **Background** (Permanent State) +- **Associated with**: `applicationDidEnterBackground(_:)` +- **File**: `Background.swift` +- **Purpose**: App is not active and UI is not visible +- **Responsibilities**: + - Suspend ongoing work that doesn't need background execution + - Prepare for potential termination + - Handle background tasks + +## State Machine Methods + +### Core Transition Methods + +All states implement specific methods for handling transitions: + +#### `onTransition()` +- **When**: Called whenever the app enters that state from another state +- **Purpose**: Setup or cleanup during state transitions +- **Available in**: Foreground, Background + +#### `willLeave()` +- **When**: Called before transitioning away from current state +- **Purpose**: Prepare for potential state change +- **Note**: Transition may be cancelled, in which case `didReturn()` is called +- **Available in**: Foreground, Background + +#### `didReturn()` +- **When**: Called after successful transition to destination state OR when transition is cancelled +- **Purpose**: Finalize state entry or handle cancelled transition +- **Available in**: Foreground, Background + +## Common Lifecycle Scenarios + +### Cold App Start + +```swift +// Flow: Launching β†’ Foreground +1. Launching.init() // Initial setup +2. Foreground.onTransition() // Enter foreground +3. Foreground.didReturn() // Finalize foreground entry +``` + +### App Backgrounding + +```swift +// Flow: Foreground β†’ Background +1. Foreground.willLeave() // Prepare to leave foreground +2. Background.onTransition() // Enter background +3. Background.didReturn() // Finalize background entry +``` + +### App Foregrounding + +```swift +// Flow: Background β†’ Foreground +1. Background.willLeave() // Prepare to leave background +2. Foreground.onTransition() // Enter foreground +3. Foreground.didReturn() // Finalize foreground entry +``` + +### Interrupted Foreground (Alert/App Switcher) + +```swift +// User receives alert but dismisses it +1. Foreground.willLeave() // Attempt to leave +2. Foreground.didReturn() // Cancelled - stay in foreground + +// User opens App Switcher +1. Foreground.willLeave() // Attempt to leave +// Two possible outcomes: +// A. User returns directly: +2. Foreground.didReturn() // Return to foreground +// B. User switches to another app: +2. Background.onTransition() // Actually transition to background +3. Background.didReturn() // Finalize background entry +``` + +## Special iOS 18+ Scenarios + +### Face ID Authentication on Cold Start + +#### Successful Authentication +```swift +1. Launching.init() +2. Foreground.onTransition() +3. Foreground.didReturn() +``` + +#### Failed Authentication +```swift +1. Launching.init() +2. Background.onTransition() // Goes to background on auth failure +3. Background.didReturn() +``` + +### DuckDuckGo Face ID Lock + +#### Cold Start with DDG Face ID +```swift +1. Launching.init() +2. Foreground.onTransition() +3. Foreground.didReturn() +4. Foreground.willLeave() // DDG auth triggers +5. Foreground.didReturn() // User passes auth +``` + +### Critical Setup Failure + +```swift +1. Launching.init() throws // Setup fails (e.g., disk space) +2. Terminating.init() // App terminates +``` + +## Code Placement Patterns + +### βš™οΈ One-time Setup β†’ `AppConfiguration` + +**Location**: Inside `Launching.swift` + +For setup that happens once and doesn't need ongoing lifecycle management: + +```swift +class AppConfiguration { + func start() { + // Basic setup that doesn't require dependencies + setupGlobalUserAgent() + configureLogging() + } + + func finalize() { + // Setup that requires access to services or MainCoordinator + configureWithDependencies() + } +} +``` + +**Use Cases**: +- Setting global user agents +- Initial configuration +- One-time system setup + +### πŸ”„ Lifecycle-Reactive Logic β†’ `Service` + +For code that needs to react to app lifecycle events: + +```swift +class MyLifecycleService { + func resumeWork() { + // Called from Foreground.onTransition() or didReturn() + } + + func suspendWork() { + // Called from Background.onTransition() or Foreground.willLeave() + } +} + +// In Launching.swift +let myService = MyLifecycleService() +services.myService = myService // Store in services for lifecycle access +``` + +**Service Patterns**: +- **Initialize**: In `Launching.init()` +- **Resume work**: In `Foreground` methods +- **Suspend work**: In `Background` methods +- **Assign to services**: Make available to other states + +**Use Cases**: +- Network managers +- Timer services +- Data synchronization +- Background task management + +### πŸ–ΌοΈ UI-Related Logic β†’ `MainCoordinator` + +**Location**: MainCoordinator initialization and management + +For logic that involves creating or modifying the main view: + +```swift +class MainCoordinator { + func setupMainViewController() { + // UI setup and configuration + } + + func handleDeepLink(_ url: URL) { + // Navigation and UI state changes + } +} +``` + +**Use Cases**: +- View controller creation +- Navigation management +- UI state configuration +- Deep link handling + +## Practical Examples + +### πŸ“Š Example 1: Pixel Analytics Service + +**Requirement**: Send "Hello" pixel on foreground, "Goodbye" pixel on background + +```swift +// 1. Create Service +class PixelService { + func sendHelloPixel() { + // Send hello pixel + } + + func sendGoodbyePixel() { + // Send goodbye pixel + } +} + +// 2. Initialize in Launching +class Launching { + func init() { + let pixelService = PixelService() + services.pixelService = pixelService + } +} + +// 3. Use in Foreground +class Foreground { + func onTransition() { + services.pixelService.sendHelloPixel() + } +} + +// 4. Use in Background +class Background { + func onTransition() { + services.pixelService.sendGoodbyePixel() + } +} +``` + +### ⏱️ Example 2: Session Timer Service + +**Requirement**: Track session time, pause on interruptions, resume on return + +```swift +class SessionTimeService { + private var timer: Timer? + + func startTimer() { + // Start session timing + } + + func pauseTimer() { + // Pause session timing + } + + func resumeTimer() { + // Resume session timing + } +} + +// Launching +class Launching { + func init() { + let sessionService = SessionTimeService() + services.sessionService = sessionService + } +} + +// Foreground - Handle interruptions +class Foreground { + func didReturn() { + // Start/resume timer when entering or returning to foreground + services.sessionService.resumeTimer() + } + + func willLeave() { + // Pause timer when potentially leaving foreground + services.sessionService.pauseTimer() + } +} + +// Background +class Background { + func onTransition() { + // Timer already paused by Foreground.willLeave() + } +} +``` + +### 🧹 Example 3: Auto-Clear Data Service + +**Requirement**: Clear data immediately on app wake to avoid UI glitches + +```swift +class AutoClearService { + func startDataClearing() async { + // Clear user data + } + + func waitForCompletion() async { + // Wait for clearing to complete + } +} + +// Launching - Start clearing immediately +class Launching { + func init() { + let autoClearService = AutoClearService() + services.autoClearService = autoClearService + + // Start clearing immediately on cold start + Task { + await autoClearService.startDataClearing() + } + } +} + +// Foreground - Wait for completion before proceeding +class Foreground { + func onTransition() async { + // Wait for data clearing before loading URLs or handling deep links + await services.autoClearService.waitForCompletion() + handlePendingDeepLinks() + } +} + +// Background - Start clearing before transitioning to foreground +class Background { + func willLeave() { + // Start clearing early to be ready for foreground transition + Task { + await services.autoClearService.startDataClearing() + } + } + + func didReturn() { + // If transition was cancelled, clearing is still beneficial + // No action needed as clearing is irreversible + } +} +``` + +## State Context and Services + +### Service Management + +```swift +// Services are stored in StateContext for cross-state access +class StateContext { + var pixelService: PixelService! + var sessionService: SessionTimeService! + var autoClearService: AutoClearService! + // ... other services +} + +// Access pattern in states +class Foreground { + func onTransition() { + services.pixelService.sendHelloPixel() + services.sessionService.resumeTimer() + } +} +``` + +### Service Lifecycle Best Practices + +```swift +// βœ… CORRECT: Service with proper lifecycle management +class MyService { + private var isActive = false + + func activate() { + guard !isActive else { return } + isActive = true + startWork() + } + + func deactivate() { + guard isActive else { return } + isActive = false + stopWork() + } + + private func startWork() { + // Begin service operations + } + + private func stopWork() { + // Clean up service operations + } +} + +// Usage in states +class Foreground { + func didReturn() { + services.myService.activate() + } + + func willLeave() { + services.myService.deactivate() + } +} +``` + +## Decision Tree: Where Should My Code Go? + +``` +πŸ“‹ What type of code are you adding? + +β”œβ”€β”€ πŸ”§ One-time setup that doesn't need lifecycle management? +β”‚ └── ➑️ AppConfiguration (in Launching.swift) +β”‚ β”œβ”€β”€ start() for basic setup +β”‚ └── finalize() for dependency-requiring setup +β”‚ +β”œβ”€β”€ πŸ”„ Logic that reacts to app state changes? +β”‚ └── ➑️ Create a Service +β”‚ β”œβ”€β”€ Initialize in Launching.init() +β”‚ β”œβ”€β”€ Store in services for cross-state access +β”‚ β”œβ”€β”€ Resume work in Foreground methods +β”‚ └── Suspend work in Background methods +β”‚ +β”œβ”€β”€ πŸ–ΌοΈ UI setup or view management? +β”‚ └── ➑️ MainCoordinator +β”‚ β”œβ”€β”€ View controller creation +β”‚ β”œβ”€β”€ Navigation setup +β”‚ └── Deep link handling +β”‚ +└── πŸ€” Something else? + └── ➑️ Let's discuss through tech design +``` + +## Best Practices + +### βœ… DO + +```swift +// Store services for cross-state access +services.myService = MyService() + +// Use proper lifecycle methods +func didReturn() { + resumeWork() +} + +func willLeave() { + pauseWork() +} + +// Handle state transitions gracefully +func onTransition() { + await waitForCriticalWork() + proceedWithStateLogic() +} +``` + +### ❌ DON'T + +```swift +// Don't bypass the state machine +AppDelegate.shared.doSomething() // ❌ + +// Don't create services without storing them +let service = MyService() // ❌ Will be deallocated + +// Don't ignore willLeave/didReturn patterns +func onTransition() { + // Only using onTransition misses important interrupt scenarios +} + +// Don't block UI with long operations +func onTransition() { + performLongRunningTask() // ❌ Should be async +} +``` + +### πŸ”’ Memory Management + +```swift +// Services are retained by StateContext +class StateContext { + var services: [String: AnyObject] = [:] + + func addService(_ service: T, for key: String) { + services[key] = service + } +} + +// Clean up resources in state transitions +class MyService { + func cleanup() { + // Release resources, cancel operations + } +} +``` + +## Debugging and Monitoring + +### State Transition Logging + +```swift +class Foreground { + func onTransition() { + Logger.lifecycle.info("Entering Foreground state") + // State logic + } + + func willLeave() { + Logger.lifecycle.info("Will leave Foreground state") + // Cleanup logic + } + + func didReturn() { + Logger.lifecycle.info("Returned to Foreground state") + // Resume logic + } +} +``` + +### Performance Monitoring + +```swift +class Launching { + func init() { + let startTime = CFAbsoluteTimeGetCurrent() + + // Initialization logic + + let duration = CFAbsoluteTimeGetCurrent() - startTime + Logger.performance.info("Launching completed in \(duration)s") + } +} +``` + +--- + +This state machine architecture provides a robust, maintainable approach to app lifecycle management that scales with the complexity of the DuckDuckGo browser while maintaining clear separation of concerns. diff --git a/work/wiki/apple-browsers/architecture.mdc b/work/wiki/apple-browsers/architecture.mdc new file mode 100644 index 00000000..206f699f --- /dev/null +++ b/work/wiki/apple-browsers/architecture.mdc @@ -0,0 +1,81 @@ +--- +source: ~/DuckDuckGo/apple-browsers.git/main/.cursor/rules/architecture.mdc +confidence: 0.9 +namespace: work +last_synced: 2026-04-28 +alwaysApply: false +--- + +# DuckDuckGo Browser Architecture Guidelines + +## Overall Architecture +- This is a multi-platform monorepo supporting iOS and macOS browsers +- Shared code lives in `SharedPackages/` directory +- Platform-specific code in `iOS/` and `macOS/` directories +- Follow modular architecture with clear separation of concerns + +## Architecture Patterns + +### MVVM for SwiftUI Views +- Use MVVM pattern for all SwiftUI views +- ViewModels should conform to `ObservableObject` +- Use `@Published` properties for reactive updates +- Keep ViewModels testable and free from UI concerns + +Example: +```swift +class FeatureViewModel: ObservableObject { + @Published var state: FeatureState = .idle + private let service: FeatureServiceProtocol + + init(service: FeatureServiceProtocol) { + self.service = service + } +} +``` + +### Coordinator Pattern +- Use coordinators for navigation and flow control +- Main app flow managed by `MainCoordinator` +- Create feature-specific coordinators as needed +- Coordinators handle navigation logic, not views + +### Dependency Injection +- Use constructor injection for dependencies +- Define protocols for all dependencies +- Use `AppDependencyProvider` for shared dependencies +- Keep dependencies explicit and testable + +## Code Organization + +### Feature-Based Structure +- Organize code by features, not layers +- Each feature should have its own folder containing: + - Views (SwiftUI/UIKit) + - ViewModels + - Services + - Models + - Tests + +### File Naming Conventions +- ViewModels: `FeatureNameViewModel.swift` +- Views: `FeatureNameView.swift` (SwiftUI) or `FeatureNameViewController.swift` (UIKit) +- Services: `FeatureNameService.swift` +- Protocols: `FeatureNameProtocol.swift` or embed in main file + +### Extension Organization +- Split large classes into focused extensions +- Name extensions descriptively: `MainViewController+Email.swift` +- Group related functionality in extensions + +## Privacy-First Design +- All features must consider privacy implications +- Use secure storage for sensitive data +- Implement proper data clearing mechanisms +- Follow fireproofing patterns where applicable + +## Testing Requirements +- Write unit tests for all ViewModels and Services +- Test files should mirror source structure +- Use mock objects for dependencies +- Test async code with Combine publishers diff --git a/work/wiki/apple-browsers/branch-naming-conventions.mdc b/work/wiki/apple-browsers/branch-naming-conventions.mdc new file mode 100644 index 00000000..d23f0baf --- /dev/null +++ b/work/wiki/apple-browsers/branch-naming-conventions.mdc @@ -0,0 +1,319 @@ +--- +source: ~/DuckDuckGo/apple-browsers.git/main/.cursor/rules/branch-naming-conventions.mdc +confidence: 0.9 +namespace: work +last_synced: 2026-04-28 +alwaysApply: false +--- + +# Branch Naming Conventions & GitHub Flow + +## Overview + +DuckDuckGo browser development follows **GitHub Flow**, a streamlined branching strategy that maintains a single main branch with feature branches for development work. + +**Reference**: [GitHub Flow Documentation](https://docs.github.com/en/get-started/using-github/github-flow) + +## Core Principles + +### Main Branch Strategy +- **Single source of truth**: All development branches from `main` +- **Always deployable**: `main` branch should always be in a deployable state +- **Merge via PR**: All changes merged back through Pull Requests (except releases) + +### Branch Lifecycle +1. Create branch from `main` +2. Develop feature/fix on branch +3. Open Pull Request to `main` +4. Code review and testing +5. Merge to `main` +6. **Delete branch** immediately after merge + +## Branch Naming Conventions + +### Single Developer Features & Bugfixes + +For work by a single developer: + +``` +Format: / +``` + +**Examples:** +- `alice/bookmark-sync` +- `alice/fix-bookmark-sync` +- `bob/credit-card-autofill` +- `charlie/fix-crash-on-startup` + +**Guidelines:** +- Use kebab-case (lowercase with hyphens) +- Be descriptive but concise +- Include "fix-" prefix for bugfixes when helpful + +### Multi-Developer Features + +For collaborative features, use a two-stage approach: + +#### 1. Base Feature Branch +``` +Format: +Example: autofill +``` + +#### 2. Individual Developer Branches +``` +Format: // +Example: autofill/alice/settings-list-changes +``` + +**Workflow:** +1. Create base feature branch from `main` +2. Developers create individual branches from base feature branch +3. Individual branches merge into base feature branch +4. Base feature branch merges into `main` + +### Release Branches + +Release branches follow semantic versioning: + +``` +Format: release/ +Example: release/0.18.5 +``` + +**Special Notes:** +- No developer name prefix +- Use semantic versioning format +- **Exception**: Release branches merge to `main` via local merge (not PR) +- Delete immediately after merge + +### Hotfix Branches + +Critical fixes for production issues: + +``` +Format: hotfix/ +Example: hotfix/5.50.1 +``` + +**Critical Requirements:** +- Use hotfix version number +- **MUST delete immediately after merge** +- Some tooling blocks subsequent hotfixes if previous hotfix branch exists +- Higher priority than regular releases + +## Branch Management Best Practices + +### βœ… DO + +```bash +# Create feature branch from main +git checkout main +git pull origin main +git checkout -b alice/new-feature + +# Meaningful commit messages +git commit -m "Add user authentication for secure vault" + +# Keep branches up to date +git rebase main # or git merge main + +# Delete branch after merge +git branch -d alice/new-feature +git push origin --delete alice/new-feature +``` + +### ❌ DON'T + +```bash +# Don't use unclear names +git checkout -b temp +git checkout -b fix +git checkout -b test-branch + +# Don't leave merged branches +# (Clutters repository and can cause tooling issues) + +# Don't work directly on main +git checkout main +# Edit files directly... ❌ + +# Don't use inconsistent naming +git checkout -b Alice/NewFeature # Mixed case +git checkout -b alice_new_feature # Underscore instead of hyphen +``` + +## Example Workflows + +### Single Developer Feature + +```bash +# Start new feature +git checkout main +git pull origin main +git checkout -b alice/password-manager + +# Work on feature +# ... make changes ... +git add . +git commit -m "Implement password storage encryption" + +# Push and create PR +git push origin alice/password-manager +# Create PR via GitHub UI + +# After PR is merged, cleanup +git checkout main +git pull origin main +git branch -d alice/password-manager +git push origin --delete alice/password-manager +``` + +### Multi-Developer Feature + +```bash +# Team lead creates base branch +git checkout main +git pull origin main +git checkout -b autofill +git push origin autofill + +# Developer creates individual branch +git checkout autofill +git pull origin autofill +git checkout -b autofill/alice/credential-storage + +# Work and merge to base feature branch +# ... development work ... +git push origin autofill/alice/credential-storage +# Create PR to merge into 'autofill' branch + +# Eventually merge base feature to main +# Create PR from 'autofill' to 'main' +``` + +### Hotfix Workflow + +```bash +# Create hotfix from main +git checkout main +git pull origin main +git checkout -b hotfix/5.50.1 + +# Fix critical issue +# ... emergency fixes ... +git commit -m "Fix critical security vulnerability" + +# Merge and IMMEDIATELY delete +git push origin hotfix/5.50.1 +# Create PR and merge immediately +git branch -d hotfix/5.50.1 +git push origin --delete hotfix/5.50.1 +``` + +## Common Patterns + +### Feature Names + +| Type | Good Examples | Bad Examples | +|------|---------------|--------------| +| **New Features** | `user-authentication`
`bookmark-sync`
`credit-card-autofill` | `feature`
`new-stuff`
`implementation` | +| **Bug Fixes** | `fix-memory-leak`
`fix-crash-on-startup`
`fix-bookmark-deletion` | `bug`
`fix`
`temp-fix` | +| **Improvements** | `improve-performance`
`optimize-database`
`refactor-networking` | `better`
`update`
`changes` | + +### Developer Names + +| Format | Example | +|--------|---------| +| **First name** | `alice/new-feature` | +| **GitHub username** | `alice-dev/new-feature` | +| **Consistent choice** | Pick one format and stick to it | + +## Branch Protection & CI + +### Main Branch Protection +- **Required status checks**: All CI must pass +- **Required reviews**: At least one approval required +- **No force pushes**: Maintain history integrity +- **Delete head branches**: Automatic cleanup after merge + +### Feature Branch CI +- Shellcheck validation for script changes +- Unit tests must pass +- Build verification for iOS and macOS +- Code style validation + +## Git Configuration Tips + +### Helpful Git Settings + +```bash +# Auto-delete tracking branches for deleted remotes +git config --global fetch.prune true + +# Auto-setup upstream when pushing new branches +git config --global push.autoSetupRemote true + +# Use more descriptive default branch names +git config --global init.defaultBranch main +``` + +### Useful Aliases + +```bash +# Quick branch switching +git config --global alias.co checkout +git config --global alias.br branch + +# Clean up merged branches +git config --global alias.cleanup "!git branch --merged | grep -v '\\*\\|main\\|develop' | xargs -n 1 git branch -d" + +# Show branch with tracking info +git config --global alias.branches "branch -vv" +``` + +## Troubleshooting + +### Branch Already Exists +```bash +# If remote branch exists but you don't have it locally +git fetch origin +git checkout -b alice/feature-name origin/alice/feature-name +``` + +### Hotfix Branch Blocked +```bash +# If hotfix creation is blocked, check for existing hotfix branches +git branch -r | grep hotfix +# Delete any remaining hotfix branches +git push origin --delete hotfix/previous-version +``` + +### Sync with Main +```bash +# Keep feature branch updated with main +git checkout alice/feature-name +git rebase main # or: git merge main +git push origin alice/feature-name --force-with-lease # if rebased +``` + +## Integration with Development Tools + +### Xcode Integration +- Branch names appear in Xcode source control +- Use descriptive names for better identification +- Avoid special characters that might cause Xcode issues + +### CI/CD Pipeline +- Branch names used in build artifacts +- Feature branches trigger full test suites +- Release branches trigger deployment pipelines + +### Issue Tracking +- Reference GitHub issues in branch names when helpful: + - `alice/fix-issue-1234-memory-leak` + - `bob/feature-567-dark-mode` + +--- + +Following these conventions ensures consistent, organized development workflow across the DuckDuckGo browser codebase and facilitates collaboration between team members. diff --git a/work/wiki/apple-browsers/browserserviceskit-integration.mdc b/work/wiki/apple-browsers/browserserviceskit-integration.mdc new file mode 100644 index 00000000..0ce3cf21 --- /dev/null +++ b/work/wiki/apple-browsers/browserserviceskit-integration.mdc @@ -0,0 +1,448 @@ +--- +source: ~/DuckDuckGo/apple-browsers.git/main/.cursor/rules/browserserviceskit-integration.mdc +confidence: 0.9 +namespace: work +last_synced: 2026-04-28 +alwaysApply: false +--- + +# BrowserServicesKit Integration Guide + +## Overview +BrowserServicesKit is the core shared library providing essential browser functionality to both iOS and macOS DuckDuckGo applications. It ensures consistent behavior and code reuse across platforms while maintaining privacy as the primary focus. + +## Core Modules and Usage + +### Privacy & Protection +Always use BrowserServicesKit for privacy-related functionality: + +```swift +// βœ… CORRECT - Content blocking integration +import BrowserServicesKit +import ContentBlocking + +final class PrivacyManager { + private let contentBlockingManager = ContentBlockingManager.shared + + func enableContentBlocking(for webView: WKWebView) { + contentBlockingManager.enable(for: webView) + } + + func updateBlockingRules() async { + await contentBlockingManager.updateRules() + } +} + +// βœ… CORRECT - Privacy configuration +import PrivacyConfig + +final class PrivacyFeatureManager { + private let privacyConfig = PrivacyConfiguration.shared + + func isFeatureEnabled(_ feature: PrivacyFeature) -> Bool { + return privacyConfig.isEnabled(feature) + } +} +``` + +### Data Management +Use BrowserServicesKit for all data persistence: + +```swift +// βœ… CORRECT - Bookmarks management +import Bookmarks + +final class BookmarkService { + private let bookmarkManager = BookmarkManager.shared + + func saveBookmark(_ bookmark: Bookmark) async { + await bookmarkManager.save(bookmark) + } + + func fetchBookmarks() async -> [Bookmark] { + return await bookmarkManager.fetchAll() + } +} + +// βœ… CORRECT - Secure credential storage +import SecureVault + +final class CredentialManager { + private let secureVault = SecureVault.shared + + func storeCredential(_ credential: WebsiteCredential) async throws { + try await secureVault.store(credential) + } + + func retrieveCredentials(for domain: String) async throws -> [WebsiteCredential] { + return try await secureVault.credentials(for: domain) + } +} +``` + +### Navigation and URL Handling +Use BrowserServicesKit for navigation logic: + +```swift +// βœ… CORRECT - Navigation handling +import Navigation + +final class NavigationManager { + private let navigationController = NavigationController() + + func navigate(to url: URL) { + let request = NavigationRequest(url: url) + navigationController.navigate(request) + } + + func canGoBack() -> Bool { + return navigationController.canGoBack + } +} +``` + +### User Scripts and Content Injection +Use BrowserServicesKit for JavaScript injection: + +```swift +// βœ… CORRECT - User script management +import UserScript + +final class ContentScriptManager { + private let userScriptManager = UserScriptManager() + + func injectPrivacyScripts(into webView: WKWebView) { + let scripts = userScriptManager.privacyScripts + scripts.forEach { script in + webView.configuration.userContentController.addUserScript(script) + } + } +} +``` + +## Platform-Specific Integration + +### iOS Integration Pattern +```swift +// βœ… CORRECT - iOS-specific BrowserServicesKit usage +import BrowserServicesKit +import UIKit + +final class iOSBrowserViewController: UIViewController { + private let contentBlockingManager = ContentBlockingManager.shared + private let privacyDashboard = PrivacyDashboard() + + override func viewDidLoad() { + super.viewDidLoad() + setupBrowserServices() + } + + private func setupBrowserServices() { + // Configure content blocking for iOS + contentBlockingManager.configure(for: .iOS) + + // Setup privacy dashboard + privacyDashboard.delegate = self + } +} +``` + +### macOS Integration Pattern +```swift +// βœ… CORRECT - macOS-specific BrowserServicesKit usage +import BrowserServicesKit +import AppKit + +final class macOSBrowserViewController: NSViewController { + private let contentBlockingManager = ContentBlockingManager.shared + private let downloadManager = DownloadManager.shared + + override func viewDidLoad() { + super.viewDidLoad() + setupBrowserServices() + } + + private func setupBrowserServices() { + // Configure content blocking for macOS + contentBlockingManager.configure(for: .macOS) + + // Setup download handling + downloadManager.delegate = self + } +} +``` + +## Feature-Specific Integration + +### Autofill Integration +```swift +// βœ… CORRECT - Autofill implementation +import Autofill +import BrowserServicesKit + +final class AutofillCoordinator { + private let autofillManager = AutofillManager.shared + + func setupAutofill(for webView: WKWebView) { + // Configure autofill user scripts + let autofillScripts = autofillManager.userScripts + autofillScripts.forEach { script in + webView.configuration.userContentController.addUserScript(script) + } + + // Setup message handlers + autofillManager.setupMessageHandlers(for: webView) + } + + func handleAutofillRequest(_ request: AutofillRequest) async { + await autofillManager.handleRequest(request) + } +} +``` + +### Sync Integration +```swift +// βœ… CORRECT - Sync functionality +import DDGSync +import BrowserServicesKit + +final class SyncManager { + private let syncService = SyncService.shared + + func enableSync() async { + await syncService.enable() + } + + func syncBookmarks() async { + await syncService.sync(.bookmarks) + } + + func syncCredentials() async { + await syncService.sync(.credentials) + } +} +``` + +## Testing with BrowserServicesKit + +### Mock BrowserServicesKit Components +```swift +// βœ… CORRECT - Testing with mocks +import BrowserServicesKit +import XCTest + +final class MockContentBlockingManager: ContentBlockingManagerProtocol { + var enabledRules: [ContentBlockingRule] = [] + var isBlocked: Bool = false + + func enable(rules: [ContentBlockingRule]) { + enabledRules = rules + } + + func isBlocked(url: URL) -> Bool { + return isBlocked + } +} + +final class BrowserFeatureTests: XCTestCase { + private var mockContentBlocking: MockContentBlockingManager! + private var browserManager: BrowserManager! + + override func setUp() { + super.setUp() + mockContentBlocking = MockContentBlockingManager() + browserManager = BrowserManager(contentBlocking: mockContentBlocking) + } + + func testContentBlockingEnabled() { + // Given + let rules = [ContentBlockingRule.trackerRule] + + // When + browserManager.enableContentBlocking(rules: rules) + + // Then + XCTAssertEqual(mockContentBlocking.enabledRules, rules) + } +} +``` + +## Configuration and Environment + +### Environment-Based Configuration +```swift +// βœ… CORRECT - Environment configuration +import Configuration +import BrowserServicesKit + +final class AppConfiguration { + static func configure() { + // Configure BrowserServicesKit for current environment + let config = Configuration.current + + BrowserServicesKit.configure( + environment: config.environment, + privacyConfig: config.privacyConfig, + contentBlockingConfig: config.contentBlockingConfig + ) + } +} +``` + +### Feature Flag Integration +```swift +// βœ… CORRECT - Feature flag integration +import FeatureFlags +import BrowserServicesKit + +extension FeatureFlags { + var isAdvancedPrivacyEnabled: Bool { + return isEnabled(.advancedPrivacy) + } + + var isEnhancedAutofillEnabled: Bool { + return isEnabled(.enhancedAutofill) + } +} + +final class FeatureFlagBrowserManager { + func configureFeatures() { + if FeatureFlags.shared.isAdvancedPrivacyEnabled { + PrivacyConfiguration.shared.enableAdvancedFeatures() + } + + if FeatureFlags.shared.isEnhancedAutofillEnabled { + AutofillManager.shared.enableEnhancedFeatures() + } + } +} +``` + +## Performance Optimization + +### Efficient BrowserServicesKit Usage +```swift +// βœ… CORRECT - Performance-optimized usage +import BrowserServicesKit + +final class OptimizedBrowserManager { + private let contentBlockingManager = ContentBlockingManager.shared + private var cachedRules: [ContentBlockingRule] = [] + + func loadContentBlockingRules() async { + // Cache rules to avoid repeated API calls + if cachedRules.isEmpty { + cachedRules = await contentBlockingManager.loadRules() + } + + // Apply cached rules + await contentBlockingManager.apply(cachedRules) + } + + func updateRulesIfNeeded() async { + let lastUpdate = await contentBlockingManager.lastUpdateTime + let shouldUpdate = Date().timeIntervalSince(lastUpdate) > 3600 // 1 hour + + if shouldUpdate { + await loadContentBlockingRules() + } + } +} +``` + +## Error Handling + +### BrowserServicesKit Error Handling +```swift +// βœ… CORRECT - Error handling patterns +import BrowserServicesKit + +enum BrowserServiceError: LocalizedError { + case contentBlockingFailed + case bookmarkSaveFailed + case credentialStoreFailed + + var errorDescription: String? { + switch self { + case .contentBlockingFailed: + return "Failed to enable content blocking" + case .bookmarkSaveFailed: + return "Failed to save bookmark" + case .credentialStoreFailed: + return "Failed to store credential" + } + } +} + +final class ErrorHandlingBrowserManager { + func enableContentBlocking() async { + do { + try await ContentBlockingManager.shared.enable() + } catch { + // Log error and provide fallback + Logger.error("Content blocking failed: \(error)") + await showErrorToUser(BrowserServiceError.contentBlockingFailed) + } + } +} +``` + +## API Usage Guidelines + +### Consistent API Patterns +```swift +// βœ… CORRECT - Follow BrowserServicesKit API patterns +import BrowserServicesKit + +final class BrowserAPIManager { + // Use async/await for async operations + func loadData() async throws -> BrowserData { + return try await BrowserDataManager.shared.load() + } + + // Use Combine for reactive streams + func observePrivacyEvents() -> AnyPublisher { + return PrivacyManager.shared.privacyEventPublisher + } + + // Use completion handlers only when required by platform APIs + func legacyOperation(completion: @escaping (Result) -> Void) { + Task { + do { + let data = try await modernAsyncOperation() + completion(.success(data)) + } catch { + completion(.failure(error)) + } + } + } +} +``` + +## Common Integration Patterns + +### Dependency Injection with BrowserServicesKit +```swift +// βœ… CORRECT - Dependency injection pattern +protocol BrowserServiceProvider { + var contentBlockingManager: ContentBlockingManagerProtocol { get } + var bookmarkManager: BookmarkManagerProtocol { get } + var autofillManager: AutofillManagerProtocol { get } +} + +final class DefaultBrowserServiceProvider: BrowserServiceProvider { + let contentBlockingManager: ContentBlockingManagerProtocol = ContentBlockingManager.shared + let bookmarkManager: BookmarkManagerProtocol = BookmarkManager.shared + let autofillManager: AutofillManagerProtocol = AutofillManager.shared +} + +final class BrowserViewModel: ObservableObject { + private let serviceProvider: BrowserServiceProvider + + init(serviceProvider: BrowserServiceProvider = DefaultBrowserServiceProvider()) { + self.serviceProvider = serviceProvider + } +} +``` + +This guide ensures proper integration with BrowserServicesKit while maintaining privacy-first principles and cross-platform compatibility. diff --git a/work/wiki/apple-browsers/code-style.mdc b/work/wiki/apple-browsers/code-style.mdc new file mode 100644 index 00000000..7670f8a0 --- /dev/null +++ b/work/wiki/apple-browsers/code-style.mdc @@ -0,0 +1,975 @@ +--- +source: ~/DuckDuckGo/apple-browsers.git/main/.cursor/rules/code-style.mdc +confidence: 0.9 +namespace: work +last_synced: 2026-04-28 +alwaysApply: true +--- + +# Swift Code Style Guide + +*This style guide is based on the [official iOS style guide](iOS/styleguide/STYLEGUIDE.md) and incorporates DuckDuckGo-specific patterns and requirements.* + +## Correctness + +**Strive to make your code compile without warnings.** This rule informs many style decisions such as using `#selector` types instead of string literals. + +## SwiftLint + +We use [SwiftLint](https://github.com/realm/SwiftLint) for enforcing Swift style and conventions. See the [SwiftLint configuration](.swiftlint.yml) for specific rules. + +**Key SwiftLint settings**: +- Line length: 150 characters (not the default 100) +- Force cast/try: warnings (not errors for pragmatic development) +- Identifier naming: flexible for single-letter variables in closures + +## Naming Conventions + +Follow the [Swift API Design Guidelines](https://swift.org/documentation/api-design-guidelines/) with these key principles: + +### Core Principles +- **Clarity at the call site** over brevity +- **Use camelCase** (not snake_case) +- **UpperCamelCase** for types and protocols +- **lowerCamelCase** for everything else +- **Include all needed words** while omitting needless words +- **Use names based on roles**, not types + +### Type Names +```swift +// βœ… CORRECT: Descriptive, UpperCamelCase +class UserAuthenticationManager { } +struct BookmarkItem { } +enum NavigationState { } +protocol DataSourceProtocol { } + +// ❌ INCORRECT: Too generic +class Manager { } +struct Data { } +``` + +### Variable and Function Names +```swift +// βœ… CORRECT: Descriptive lowerCamelCase +let maximumRetryCount = 3 +var isLoading = false +func fetchUserData() { } + +// Boolean properties should read like assertions +var isEnabled: Bool +var hasCompleted: Bool +var canDelete: Bool + +// ❌ INCORRECT: Abbreviations and unclear names +let usrMgr = UserManager() +func calcTotal() { } +``` + +### Protocol Naming +```swift +// βœ… CORRECT: Capability protocols end in -able, -ible, -ing +protocol Loadable { } +protocol Refreshable { } +protocol UserAuthenticating { } + +// βœ… CORRECT: Type protocols are nouns +protocol DataSource { } +protocol Delegate { } +``` + +### Method Naming Patterns +```swift +// βœ… CORRECT: Method naming patterns +// Factory methods begin with "make" +func makeLocationManager() -> CLLocationManager + +// Verb methods follow -ed, -ing rule for non-mutating +func sorted() -> [Element] // non-mutating +func sort() // mutating + +// Boolean methods read like assertions +func canDelete() -> Bool +func hasCompleted() -> Bool +``` + +### Delegate Methods +When creating custom delegate methods, the **unnamed first parameter should be the delegate source**: + +```swift +// βœ… CORRECT: Delegate pattern +func namePickerView(_ namePickerView: NamePickerView, didSelectName name: String) +func namePickerViewShouldReload(_ namePickerView: NamePickerView) -> Bool + +// ❌ INCORRECT: Missing source parameter +func didSelectName(namePicker: NamePickerViewController, name: String) +func namePickerShouldReload() -> Bool +``` + +### Use Type Inferred Context +Use compiler inferred context to write shorter, clear code: + +```swift +// βœ… CORRECT: Type inferred context +let selector = #selector(viewDidLoad) +view.backgroundColor = .red +let toView = context.view(forKey: .to) +let view = UIView(frame: .zero) + +// ❌ INCORRECT: Redundant type information +let selector = #selector(ViewController.viewDidLoad) +view.backgroundColor = UIColor.red +let toView = context.view(forKey: UITransitionContextViewKey.to) +let view = UIView(frame: CGRect.zero) +``` + +### Generics +Generic type parameters should be **descriptive, UpperCamelCase names**: + +```swift +// βœ… CORRECT: Descriptive generic names +struct Stack { ... } +func write(to target: inout Target) +func swap(_ a: inout T, _ b: inout T) // T is acceptable when no meaningful relationship + +// ❌ INCORRECT: Non-descriptive or wrong case +struct Stack { ... } +func write(to target: inout target) +``` + +### Language +Use **US English spelling** to match Apple's API: + +```swift +// βœ… CORRECT: US English +let color = "red" + +// ❌ INCORRECT: British English +let colour = "red" +``` + +## Code Organization + +### File Structure +```swift +// 1. Import statements (minimal - only what's needed) +import UIKit +import Combine + +// 2. Protocol definitions +protocol FeatureDelegate: AnyObject { + func featureDidUpdate() +} + +// 3. Main type declaration +class FeatureViewController: UIViewController { + // Properties first + private let viewModel: FeatureViewModel + + // Lifecycle methods + override func viewDidLoad() { + super.viewDidLoad() + setupUI() + } + + // Private methods + private func setupUI() { } +} + +// 4. Extensions for protocol conformance +// MARK: - UITableViewDataSource +extension FeatureViewController: UITableViewDataSource { + // Protocol methods +} +``` + +### Protocol Conformance +**Prefer separate extensions** for protocol conformance to keep related methods grouped: + +```swift +// βœ… CORRECT: Separate extensions +class MyViewController: UIViewController { + // class implementation +} + +// MARK: - UITableViewDataSource +extension MyViewController: UITableViewDataSource { + // table view data source methods +} + +// MARK: - UIScrollViewDelegate +extension MyViewController: UIScrollViewDelegate { + // scroll view delegate methods +} + +// ❌ INCORRECT: All in main class declaration +class MyViewController: UIViewController, UITableViewDataSource, UIScrollViewDelegate { + // all methods mixed together +} +``` + +### Minimal Imports +**Import only the modules a source file requires**: + +```swift +// βœ… CORRECT: Minimal imports +import UIKit +var view: UIView +var deviceModels: [String] + +// βœ… CORRECT: Foundation when UIKit not needed +import Foundation +var deviceModels: [String] + +// ❌ INCORRECT: Unnecessary imports +import UIKit +import Foundation // UIKit already includes Foundation +var view: UIView +var deviceModels: [String] +``` + +### Remove Unused Code +**Remove unused (dead) code**, including Xcode template code: + +```swift +// βœ… CORRECT: Keep only implemented methods +override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + return Database.contacts.count +} + +// ❌ INCORRECT: Template code and unused methods +override func didReceiveMemoryWarning() { + super.didReceiveMemoryWarning() + // Dispose of any resources that can be recreated. +} + +override func numberOfSections(in tableView: UITableView) -> Int { + // #warning Incomplete implementation, return the number of sections + return 1 +} +``` + +## Formatting and Style + +### Line Breaks and Length +- **Line margin: 150 characters** (not the default 100) +- **Long lines should be wrapped** at around 150 characters +- **Avoid trailing whitespace** at ends of lines +- **Add single newline** at end of each file + +### Spacing +- **Indent using 4 spaces** rather than tabs +- **Method braces open on same line**, close on new line +- **One blank line between methods** +- **No blank lines after opening brace or before closing brace** + +```swift +// βœ… CORRECT: Spacing and braces +if user.isHappy { + // Do something +} else { + // Do something else +} + +// ❌ INCORRECT: Wrong brace placement +if user.isHappy +{ + // Do something +} +else { + // Do something else +} +``` + +### Colons +**Colons have no space on left, one space on right**. Exceptions: ternary operator `? :`, empty dictionary `[:]`, `#selector` syntax: + +```swift +// βœ… CORRECT: Colon spacing +class TestDatabase: Database { + var data: [String: CGFloat] = ["A": 1.2, "B": 3.2] +} + +// ❌ INCORRECT: Wrong colon spacing +class TestDatabase : Database { + var data :[String:CGFloat] = ["A" : 1.2, "B":3.2] +} +``` + +### Function Parameters +**Closing parentheses should not appear on line by themselves**: + +```swift +// βœ… CORRECT: Closing parenthesis placement +let user = try await getUser( + for: userID, + on: connection) + +// ❌ INCORRECT: Closing parenthesis on own line +let user = try await getUser( + for: userID, + on: connection +) +``` + +## Function Declarations + +### Short Functions +**Keep short function declarations on one line**: + +```swift +// βœ… CORRECT: Short function on one line +func reticulateSplines(spline: [Double]) -> Bool { + // implementation +} +``` + +### Long Function Signatures +**For functions with long signatures, put each parameter on new line**: + +```swift +// βœ… CORRECT: Long signature formatting +func reticulateSplines(spline: [Double], + adjustmentFactor: Double, + translateConstant: Int, + comment: String) -> Bool { + // implementation +} +``` + +### Return Types +**Use `Void` for closure/function outputs, `()` for inputs**: + +```swift +// βœ… CORRECT: Return type formatting +func updateConstraints() -> Void { + // implementation +} + +typealias CompletionHandler = (result) -> Void + +// ❌ INCORRECT: Wrong return type syntax +func updateConstraints() -> () { + // implementation +} + +typealias CompletionHandler = (result) -> () +``` + +## Function Calls + +**Mirror function declaration style at call sites**: + +```swift +// βœ… CORRECT: Single line when it fits +let success = reticulateSplines(splines) + +// βœ… CORRECT: Multi-line when wrapped +let success = reticulateSplines( + spline: splines, + adjustmentFactor: 1.3, + translateConstant: 2, + comment: "normalize the display") +``` + +## Closure Expressions + +### Trailing Closure Syntax +**Use trailing closure syntax only for single closure at end**: + +```swift +// βœ… CORRECT: Trailing closure usage +UIView.animate(withDuration: 1.0) { + self.myView.alpha = 0 +} + +UIView.animate(withDuration: 1.0, animations: { + self.myView.alpha = 0 +}, completion: { finished in + self.myView.removeFromSuperview() +}) + +// ❌ INCORRECT: Trailing closure with multiple closures +UIView.animate(withDuration: 1.0, animations: { + self.myView.alpha = 0 +}) { f in + self.myView.removeFromSuperview() +} +``` + +### Single-Expression Closures +**Use implicit returns for single-expression closures**: + +```swift +// βœ… CORRECT: Implicit return +attendeeList.sort { a, b in + a > b +} +``` + +### Chained Methods +**Format chained methods for clarity**: + +```swift +// βœ… CORRECT: Chained methods - compact when clear +let value = numbers.map { $0 * 2 }.filter { $0 % 3 == 0 }.index(of: 90) + +// βœ… CORRECT: Chained methods - multi-line when complex +let value = numbers + .map { $0 * 2 } + .filter { $0 > 50 } + .map { $0 + 10 } +``` + +## Types and Constants + +### Native Types +**Always use Swift's native types** when available: + +```swift +// βœ… CORRECT: Native Swift types +let width = 120.0 // Double +let widthString = "\(width)" // String + +// ❌ INCORRECT: Objective-C types +let width: NSNumber = 120.0 // NSNumber +let widthString: NSString = width.stringValue // NSString +``` + +### Constants vs Variables +**Use `let` by default, change to `var` only when compiler complains**: + +```swift +// βœ… CORRECT: Type properties for constants +enum Math { + static let e = 2.718281828459045235360287 + static let root2 = 1.41421356237309504880168872 +} + +let hypotenuse = side * Math.root2 + +// ❌ INCORRECT: Global constants +let e = 2.718281828459045235360287 // pollutes global namespace +let root2 = 1.41421356237309504880168872 +``` + +### Type Inference +**Prefer compact code and let compiler infer types**: + +```swift +// βœ… CORRECT: Type inference +let message = "Click the button" +let currentBounds = computeViewBounds() +var names = ["Mic", "Sam", "Christine"] +let maximumWidth: CGFloat = 106.5 // Specify when needed + +// ❌ INCORRECT: Unnecessary type annotations +let message: String = "Click the button" +let currentBounds: CGRect = computeViewBounds() +``` + +### Empty Collections +**Use type annotation for empty arrays and dictionaries**: + +```swift +// βœ… CORRECT: Type annotation for empty collections +var names: [String] = [] +var lookup: [String: Int] = [:] + +// ❌ INCORRECT: Constructor syntax +var names = [String]() +var lookup = [String: Int]() +``` + +### Syntactic Sugar +**Prefer shortcut type declarations**: + +```swift +// βœ… CORRECT: Syntactic sugar +var deviceModels: [String] +var employees: [Int: String] +var faxNumber: Int? + +// ❌ INCORRECT: Full generics syntax +var deviceModels: Array +var employees: Dictionary +var faxNumber: Optional +``` + +## Optionals + +### Optional Declarations +**Use `?` for optional types, `!` only when you know initialization timing**: + +```swift +// βœ… CORRECT: Optional usage +var subview: UIView? +var volume: Double? + +// Use ! only for outlets that initialize in viewDidLoad +@IBOutlet weak var tableView: UITableView! +``` + +### Optional Binding +**Shadow original names in optional binding**: + +```swift +// βœ… CORRECT: Shadow original name +if let subview = subview, let volume = volume { + // do something with unwrapped subview and volume +} + +// ❌ INCORRECT: Different names for unwrapped values +if let unwrappedSubview = optionalSubview { + if let realVolume = volume { + // do something with unwrappedSubview and realVolume + } +} +``` + +### Optional Chaining vs Binding +**Use optional chaining for single access, binding for multiple operations**: + +```swift +// βœ… CORRECT: Optional chaining for single access +textContainer?.textLabel?.setNeedsDisplay() + +// βœ… CORRECT: Optional binding for multiple operations +if let textContainer = textContainer { + // do many things with textContainer +} +``` + +## Memory Management + +### Reference Cycles +**Prevent reference cycles with `weak` and `unowned` references.** + +**Example:** See [memory-management.swift](code-style/memory-management.swift) + +### Lazy Initialization +**Use lazy initialization for fine-grained control**: + +```swift +// βœ… CORRECT: Lazy initialization +lazy var locationManager = makeLocationManager() + +private func makeLocationManager() -> CLLocationManager { + let manager = CLLocationManager() + manager.desiredAccuracy = kCLLocationAccuracyBest + manager.delegate = self + manager.requestAlwaysAuthorization() + return manager +} +``` + +## Access Control + +### Access Control Order +**Access control comes first, except for `static` and attributes**: + +```swift +// βœ… CORRECT: Access control ordering +private let message = "Great Scott!" + +class TimeMachine { + private dynamic lazy var fluxCapacitor = FluxCapacitor() + @IBAction private func activate() { } + static private let timeConstant = 88.0 +} + +// ❌ INCORRECT: Wrong ordering +fileprivate let message = "Great Scott!" + +class TimeMachine { + lazy dynamic private var fluxCapacitor = FluxCapacitor() +} +``` + +### Private vs Fileprivate +**Prefer `private` to `fileprivate`**; use `fileprivate` only when compiler requires it. + +## Control Flow + +### Loop Style +**Prefer `for-in` style over `while-condition-increment`**: + +```swift +// βœ… CORRECT: for-in style +for _ in 0..<3 { + print("Hello three times") +} + +for (index, person) in attendeeList.enumerated() { + print("\(person) is at position #\(index)") +} + +// ❌ INCORRECT: while style +var i = 0 +while i < 3 { + print("Hello three times") + i += 1 +} +``` + +### Ternary Operator +**Use ternary operator only when it increases clarity**: + +```swift +// βœ… CORRECT: Simple ternary usage +let value = 5 +result = value != 0 ? x : y + +let isHorizontal = true +result = isHorizontal ? x : y + +// ❌ INCORRECT: Complex nested ternary +result = a > b ? x = c > d ? c : d : y +``` + +### Golden Path +**Use the "golden path" pattern - don't nest `if` statements**: + +```swift +// βœ… CORRECT: Golden path with guard +func computeFFT(context: Context?, inputData: InputData?) throws -> Frequencies { + guard let context = context else { + throw FFTError.noContext + } + guard let inputData = inputData else { + throw FFTError.noInputData + } + + // use context and input to compute the frequencies + return frequencies +} + +// ❌ INCORRECT: Nested if statements +func computeFFT(context: Context?, inputData: InputData?) throws -> Frequencies { + if let context = context { + if let inputData = inputData { + // use context and input to compute the frequencies + return frequencies + } else { + throw FFTError.noInputData + } + } else { + throw FFTError.noContext + } +} +``` + +### Compound Guard Statements +**Use compound guard for multiple optionals**: + +```swift +// βœ… CORRECT: Compound guard +guard + let number1 = number1, + let number2 = number2, + let number3 = number3 +else { + fatalError("impossible") +} + +// ❌ INCORRECT: Nested optional binding +if let number1 = number1 { + if let number2 = number2 { + if let number3 = number3 { + // do something with numbers + } + } +} +``` + +## Class and Struct Definitions + +### Example Well-Styled Class +```swift +final class Circle: Shape { + var x: Int, y: Int + var radius: Double + var diameter: Double { + get { + radius * 2 + } + set { + radius = newValue / 2 + } + } + + init(x: Int, y: Int, radius: Double) { + self.x = x + self.y = y + self.radius = radius + } + + convenience init(x: Int, y: Int, diameter: Double) { + self.init(x: x, y: y, radius: diameter / 2) + } + + override func area() -> Double { + Double.pi * radius * radius + } +} + +extension Circle: CustomStringConvertible { + var description: String { + "center = \(centerString) area = \(area())" + } + + private var centerString: String { + "(\(x),\(y))" + } +} +``` + +### Use of Self +**Avoid using `self` unless required by compiler**: + +```swift +// βœ… CORRECT: Self only when required +class PhotoViewController: UIViewController { + var image: UIImage + + init(image: UIImage) { + self.image = image // Required to disambiguate + super.init(nibName: nil, bundle: nil) + } + + func setupImageView() { + imageView.image = image // self not needed + } +} +``` + +### Computed Properties +**Omit get clause for read-only computed properties**: + +```swift +// βœ… CORRECT: Implicit get for read-only +var diameter: Double { + radius * 2 +} + +// ❌ INCORRECT: Unnecessary get clause +var diameter: Double { + get { + return radius * 2 + } +} +``` + +### Final +**Use `final` when inheritance is not intended**: + +```swift +// βœ… CORRECT: Final for utility classes +final class Box { + let value: T + init(_ value: T) { + self.value = value + } +} +``` + +## DuckDuckGo-Specific Patterns + +### Design System Integration (MANDATORY) + +**ALWAYS use DesignResourcesKit** for colors, typography, and icons. + +**Example:** See [design-system-integration.swift](code-style/design-system-integration.swift) + +### Dependency Injection Pattern +**Use AppDependencyProvider for dependency injection.** + +**Example:** See [dependency-injection.swift](code-style/dependency-injection.swift) + +### Async/Await Patterns +**Example:** See [async-await-pattern.swift](code-style/async-await-pattern.swift) + +### Property Wrappers +**Example:** See [property-wrappers.swift](code-style/property-wrappers.swift) + +## Comments and Documentation + +### When to Comment +**Use comments to explain WHY, not WHAT**: + +```swift +// βœ… CORRECT: Explains why +// We delay the animation to avoid conflicting with the previous transition +DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + self.animateTransition() +} + +// ❌ INCORRECT: Explains what (obvious from code) +// Set the background color to red +view.backgroundColor = .red +``` + +### Comment Style +**Prefer double/triple-slash over C-style comments**: + +```swift +// βœ… CORRECT: Swift-style comments +// This is a comment +/// This is a documentation comment + +// ❌ INCORRECT: C-style comments +/* This is a comment */ +``` + +## String Literals + +### Multi-line Strings +**Use multi-line string syntax for long strings**: + +```swift +// βœ… CORRECT: Multi-line string formatting +let message = """ + You cannot charge the flux \ + capacitor with a 9V battery. + You must use a super-charger \ + which costs 10 credits. You currently \ + have \(credits) credits available. + """ + +// ❌ INCORRECT: Concatenation or inline text +let message = """You cannot charge the flux \ + capacitor with a 9V battery. + You must use a super-charger \ + which costs 10 credits. You currently \ + have \(credits) credits available. + """ +``` + +## Prohibited Patterns + +### No Emoji +**Do not use emoji in code** - it creates unnecessary friction: + +```swift +// ❌ FORBIDDEN: Emoji in code +let isHappy = true πŸ˜€ +func celebrate() πŸŽ‰ { } + +// βœ… CORRECT: Clear, text-based names +let isHappy = true +func celebrate() { } +``` + +### No Color/Image Literals +**Do not use `#colorLiteral` or `#imageLiteral`** - they're hard to read and maintain: + +```swift +// ❌ FORBIDDEN: Literals +let color = #colorLiteral(red: 1, green: 0, blue: 0, alpha: 1) +let image = #imageLiteral(resourceName: "icon") + +// βœ… CORRECT: Explicit constructors (but prefer DesignResourcesKit) +let color = UIColor(red: 1, green: 0, blue: 0, alpha: 1) +let image = UIImage(named: "icon") + +// βœ… BEST: DesignResourcesKit +let color = UIColor(designSystemColor: .accent) +let image = DesignSystemImages.Color.Size24.bookmark +``` + +### No Parentheses Around Conditionals +**Don't use unnecessary parentheses**: + +```swift +// βœ… CORRECT: No parentheses needed +if name == "Hello" { + print("World") +} + +// ❌ INCORRECT: Unnecessary parentheses +if (name == "Hello") { + print("World") +} +``` + +### No Semicolons +**Swift doesn't require semicolons** - don't use them: + +```swift +// βœ… CORRECT: No semicolons +let swift = "not a scripting language" + +// ❌ INCORRECT: Unnecessary semicolons +let swift = "not a scripting language"; +``` + +## Error Handling and Assertions + +### Fatal Errors +**Use `fatalError()` when app reaches unrecoverable state**: + +```swift +// βœ… CORRECT: Fatal error for impossible states +guard let viewController = storyboard.instantiateViewController(withIdentifier: "Main") as? MainViewController else { + fatalError("Failed to instantiate MainViewController from storyboard") +} +``` + +### Assertions +**Use `assert()` and `assertionFailure()` for recoverable but unexpected states**: + +```swift +// βœ… CORRECT: Assert for development debugging +func processItems(_ items: [Item]) { + assert(!items.isEmpty, "Items array should not be empty") + + // Handle empty array gracefully in release builds + guard !items.isEmpty else { return } + + // Process items... +} +``` + +## Logging + +**Use unified logging system** for all logging. + +**Example:** See [logging-pattern.swift](code-style/logging-pattern.swift) + +**See [Logging Guidelines](logging-guidelines.md) for comprehensive logging patterns.** + +## Unit Test Naming + +**Use "when/then" convention for test names**: + +```swift +// βœ… CORRECT: When/then test naming +func testWhenUrlIsNotATrackerThenMatchesIsFalse() { } +func testWhenUserTapsBookmarkButtonThenBookmarkIsAdded() { } +func testWhenNetworkFailsThenErrorIsDisplayed() { } + +// ❌ INCORRECT: Unclear test names +func testBookmarks() { } +func testNetworking() { } +``` + +## Functions vs Methods + +**Prefer methods over free functions** for discoverability: + +```swift +// βœ… CORRECT: Methods are easily discoverable +let sorted = items.mergeSorted() +rocket.launch() + +// ❌ INCORRECT: Free functions are hard to discover +let sorted = mergeSort(items) +launch(&rocket) + +// βœ… ACCEPTABLE: Free functions that feel natural +let tuples = zip(a, b) +let value = max(x, y, z) +``` + +--- + +**Remember**: This style guide ensures consistency across the DuckDuckGo browser codebase. When in doubt, prioritize clarity and follow the patterns established in existing code. diff --git a/work/wiki/apple-browsers/design-system-designresourceskit.mdc b/work/wiki/apple-browsers/design-system-designresourceskit.mdc new file mode 100644 index 00000000..30531017 --- /dev/null +++ b/work/wiki/apple-browsers/design-system-designresourceskit.mdc @@ -0,0 +1,359 @@ +--- +source: ~/DuckDuckGo/apple-browsers.git/main/.cursor/rules/design-system-designresourceskit.mdc +confidence: 0.9 +namespace: work +last_synced: 2026-04-28 +alwaysApply: true +--- + +# DuckDuckGo iOS Design System & DesignResourcesKit (DRK) + +## Overview + +The DuckDuckGo iOS design system is implemented through **DesignResourcesKit (DRK)**, a shared Swift package that contains our design tokens, type styles, colors, and design system elements. + +**Repository**: [https://github.com/duckduckgo/DesignResourcesKit](https://github.com/duckduckgo/DesignResourcesKit) + +**Figma Designs**: [πŸ–±οΈ iOS & iPadOS Components](https://www.figma.com/file/GzGKD6gR24AHoUqVykX1ah/%F0%9F%93%B1-iOS-%26-iPadOS-Components?type=design&node-id=3938%3A23329&mode=design&t=0fuiNF84nnV5zExC-1) + +### What DRK Contains + +βœ… **Currently Included**: +- **Type styles and typography** (based on system styles) +- **Semantic color system** (with light/dark mode support) +- **Design tokens and foundations** + +πŸ”„ **Future Expansion**: +- **Reusable components** (when patterns emerge) +- **Advanced interaction patterns** + +❌ **Not Included**: +- **Icons** (remain in iOS app directly for now) + +## ⚠️ Critical Rule: Don't Break the Design System + +> **If you take only one thing away from this documentation**: +> **Don't add new colors or type styles outside of the design system without reading the guidelines below.** + +Breaking the design system: +- **Undermines consistency** across the app +- **Creates maintenance debt** with scattered styles +- **Breaks accessibility** features like dynamic type +- **Fragments the user experience** + +## Typography System + +### Philosophy + +Our typography system is **based on system styles** rather than hardcoded sizes. This ensures: +- **Automatic dynamic type support** for accessibility +- **Consistent scaling** across different user preferences +- **Platform-appropriate styling** that feels native + +### UIKit Usage + +DRK defines **static functions on UIFont** for all typography. + +**Example:** See [uikit-typography-usage.swift](design-system-designresourceskit/uikit-typography-usage.swift) + +#### Available Typography Styles + +**Example:** See [uikit-typography-styles.swift](design-system-designresourceskit/uikit-typography-styles.swift) + +#### Best Practices for UIKit + +**Example:** See [uikit-typography-best-practices.swift](design-system-designresourceskit/uikit-typography-best-practices.swift) + +### SwiftUI Usage + +DRK provides **view modifiers and extensions** for SwiftUI that should be used instead of direct font access. + +**Example:** See [swiftui-typography-usage.swift](design-system-designresourceskit/swiftui-typography-usage.swift) + +#### Available SwiftUI Typography Modifiers + +**Example:** See [swiftui-typography-modifiers.swift](design-system-designresourceskit/swiftui-typography-modifiers.swift) + +#### SwiftUI Code Review Guidelines + +**When reviewing PRs**: Look for `.font()` usage as a red flag. + +**Example:** See [swiftui-code-review-red-flags.swift](design-system-designresourceskit/swiftui-code-review-red-flags.swift) + +### Emergency Escape Hatch (Avoid!) + +**For legacy layout fixes only**: If you absolutely must disable dynamic type, there's a deliberately obtusely named function: + +```swift +// ❌ LAST RESORT: Only for fixing legacy layouts +let fixedFont = UIFont.daxFontOutsideOfTheDesignSystemToFixLegacyLayoutBreakage() +``` + +**Important Notes**: +- This function **may not exist** in current DRK versions +- If you need it, you must **revert the commit** that removed it: [Commit 971979d](https://github.com/duckduckgo/DesignResourcesKit/pull/1/commits/971979d3dcd95567b9812b800eb22ab1611ce3a5) +- This is **deliberately annoying** to discourage usage +- **Always prefer** fixing the layout to support dynamic type instead + +## Color System + +### Semantic Color Approach + +Our color system uses **semantic naming** rather than literal colors (e.g., "primary text" instead of "black"). This enables: +- **Automatic dark mode support** +- **Future theme flexibility** +- **Accessibility compliance** +- **Consistent visual hierarchy** + +### Color Categories + +#### Text Colors +**UIKit Example:** See [colors-text-uikit.swift](design-system-designresourceskit/colors-text-uikit.swift) + +**SwiftUI Example:** See [colors-text-swiftui.swift](design-system-designresourceskit/colors-text-swiftui.swift) + +#### Background Colors +**Example:** See [colors-background.swift](design-system-designresourceskit/colors-background.swift) + +#### Control Colors +```swift +// UIKit +button.backgroundColor = UIColor(designSystemColor: .controlsFillPrimary) +button.backgroundColor = UIColor(designSystemColor: .controlsFillSecondary) + +// SwiftUI +Button("Action") { } + .foregroundColor(Color(designSystemColor: .controlsFillPrimary)) + .background(Color(designSystemColor: .controlsFillSecondary)) +``` + +#### Button-Specific Colors +```swift +// UIKit +primaryButton.backgroundColor = UIColor(designSystemColor: .buttonPrimaryBackground) +primaryButton.setTitleColor(UIColor(designSystemColor: .buttonPrimaryText), for: .normal) + +secondaryButton.backgroundColor = UIColor(designSystemColor: .buttonSecondaryBackground) +secondaryButton.setTitleColor(UIColor(designSystemColor: .buttonSecondaryText), for: .normal) + +// SwiftUI +Button("Primary Action") { } + .foregroundColor(Color(designSystemColor: .buttonPrimaryText)) + .background(Color(designSystemColor: .buttonPrimaryBackground)) + +Button("Secondary Action") { } + .foregroundColor(Color(designSystemColor: .buttonSecondaryText)) + .background(Color(designSystemColor: .buttonSecondaryBackground)) +``` + +#### Accent Colors +```swift +// UIKit +view.tintColor = UIColor(designSystemColor: .accent) + +// SwiftUI +Image(systemName: "heart.fill") + .foregroundColor(Color(designSystemColor: .accent)) +``` + +### Anti-patterns: What NOT to Do + +**Example:** See [colors-anti-patterns.swift](design-system-designresourceskit/colors-anti-patterns.swift) + +## Enforcement and Code Review + +### Automated Enforcement + +#### Danger Integration +**Asset catalog enforcement**: We use [Danger](https://danger.systems/) to prevent new colors being added directly to iOS app asset catalogs. + +**Example:** See [danger-integration.rb](design-system-designresourceskit/danger-integration.rb) + +### Manual Code Review Checklist + +#### βœ… Look for in PRs: +- **DRK typography usage**: `UIFont.daxTitle1()`, `.daxBody()` modifiers +- **DRK color usage**: `UIColor(designSystemColor: .textPrimary)` +- **No hardcoded colors**: No hex values, RGB tuples, or named colors +- **No `.font()` modifiers** in SwiftUI (red flag for design system violations) +- **Semantic naming**: Colors described by purpose, not appearance + +#### Code Review Examples + +**Example:** See [code-review-checklist.swift](design-system-designresourceskit/code-review-checklist.swift) + +### Opportunistic Improvements + +**Most of the iOS app currently does not use the design system**, so you're encouraged to: + +1. **Opportunistically refactor** old code to use DRK when you encounter it +2. **Update hardcoded colors** to semantic colors when working in an area +3. **Replace system fonts** with DRK typography when touching text styling +4. **File follow-up tickets** for systematic cleanup when you notice patterns + +#### Example: Opportunistic Refactoring + +**Example:** See [opportunistic-refactoring.swift](design-system-designresourceskit/opportunistic-refactoring.swift) + +## Components + +### Current State: Minimal Component Library + +We primarily use **system components** rather than custom ones, following iOS design guidelines. This is different from our Android app which has more custom components. + +**Philosophy**: +- **System components first** - leverages platform conventions +- **Custom components only when needed** - avoid overengineering +- **Reusable when patterns emerge** - extract when used in multiple places + +### Existing Custom Components + +#### Blue Button (Reusable) +Our primary custom component used across multiple screens. + +**Example:** See [blue-button-component.swift](design-system-designresourceskit/blue-button-component.swift) + +**Candidate for DRK**: This button is used in multiple places and should be extracted into DesignResourcesKit as a reusable component. + +### Future Component Strategy + +#### When to Create Components + +**βœ… Create a component when**: +- Pattern is used in **3+ different contexts** +- Styling is **complex or specialized** +- Behavior needs to be **consistent across usage** +- Component **encapsulates design system tokens** + +**❌ Don't create a component when**: +- Used in only **one place** (keep it local) +- **System component exists** that meets needs +- Component would be **overly generic** or complex + +#### Emerging Patterns to Watch + +Look for these patterns that might become components: + +```swift +// Bottom sheets - if format becomes consistent +struct BottomSheetView: View { + // Consistent styling, behavior, animation + // Could become reusable component +} + +// Info cards/panels - if layout patterns emerge +struct InfoCardView: View { + // Standard card styling with DRK colors + // Could be extracted if reused +} + +// Form elements - if custom styling is needed +struct FormFieldView: View { + // Consistent form field styling + // Could become component library +} +``` + +#### Component Creation Process + +1. **Identify the pattern** in your current work +2. **Check if existing implementations** could be generalized +3. **Design the API** to be flexible but opinionated +4. **Implement using DRK tokens** for colors, typography, spacing +5. **Add to DesignResourcesKit** package +6. **Update existing usages** to use the new component +7. **Document the component** with usage examples + +**Example:** See [component-creation-example.swift](design-system-designresourceskit/component-creation-example.swift) + +## Modularization Strategy + +### Why DRK is a Separate Package + +**High friction is a feature**: Making DRK a separate module provides beneficial constraints: + +1. **Immutability encouragement**: Changes require more thought and process +2. **API stability**: Forces consideration of breaking changes +3. **Reusability**: Can be shared across iOS/macOS if needed +4. **Clear boundaries**: Separates design tokens from app logic +5. **Version control**: Can be tagged and versioned independently + +### Design System Evolution + +**Original Discussion**: [Tech Design: How to modularise iOS/macOS design system elements](βœ“ Tech Design: How to modularise iOS/macOS design system elements) + +**Guiding Principles**: +- **Start minimal**: Don't over-engineer early +- **Evolve based on usage**: Add components when patterns emerge +- **Maintain consistency**: All additions should follow established patterns +- **Document decisions**: Keep rationale for future developers + +## Working with DesignResourcesKit + +### Adding New Design Tokens + +**Process for adding colors/typography**: + +1. **Design system first**: Ensure token is defined in Figma +2. **Semantic naming**: Use purpose-based names (`textPrimary` not `black`) +3. **Light/dark variants**: Define both light and dark mode values +4. **PR to DRK**: Add to DesignResourcesKit repository +5. **Update app**: Use new tokens in consuming apps +6. **Documentation**: Update usage examples and guidelines + +### Updating DRK Version + +**In consuming app (iOS/macOS):** + +**Example:** See [updating-drk-version.swift](design-system-designresourceskit/updating-drk-version.swift) + +**Testing DRK changes**: +- Test in both **light and dark modes** +- Verify **dynamic type scaling** works correctly +- Check **accessibility** with larger text sizes +- Test on **different device sizes** + +### Local Development + +**For iterating on DRK:** + +**Example:** See [local-development.sh](design-system-designresourceskit/local-development.sh) + +## Resources and References + +### Official Resources + +- **GitHub Repository**: [duckduckgo/DesignResourcesKit](https://github.com/duckduckgo/DesignResourcesKit) +- **Figma Designs**: [iOS & iPadOS Components](https://www.figma.com/file/GzGKD6gR24AHoUqVykX1ah/%F0%9F%93%B1-iOS-%26-iPadOS-Components?type=design&node-id=3938%3A23329&mode=design&t=0fuiNF84nnV5zExC-1) + +### Related Documentation + +- **Colors**: [Tech Design: How to organise colors and icons in iOS and macOS wrt the design system](βœ“ Tech Design: How to organise colors and icons in iOS and macOS wrt the design system) +- **Colors Update**: [Tech Design: Redefine design system colors in DesignResourcesKit](βœ“ Tech Design: Redefine design system colors in DesignResourcesKit) +- **Typography**: [Tech Design: How to organise typography/label styles in iOS and macOS wrt the design system](βœ“ Tech Design: How to organise typography/label styles in iOS and macOS wrt the design system) +- **Enforcement**: [Use danger to stop new colors being added to the iOS app](βœ“ Use danger to stop new colors being added to the iOS app) + +### Quick Reference + +#### UIKit Checklist +- [ ] Use `UIFont.daxTitle1()`, `UIFont.daxBody()`, etc. +- [ ] Use `UIColor(designSystemColor: .textPrimary)` etc. +- [ ] No hardcoded colors or fonts +- [ ] No system colors for app content + +#### SwiftUI Checklist +- [ ] Use `.daxTitle1()`, `.daxBody()` modifiers +- [ ] Use `Color(designSystemColor: .textPrimary)` etc. +- [ ] Avoid `.font()` modifier (red flag in reviews) +- [ ] No hardcoded colors + +#### Code Review Checklist +- [ ] No new colors in asset catalogs +- [ ] DRK typography used consistently +- [ ] Semantic color naming +- [ ] No hardcoded styling +- [ ] Opportunistic improvements to legacy code + +--- + +**Remember**: The design system is only as strong as our commitment to using it. Every PR is an opportunity to improve consistency and user experience. diff --git a/work/wiki/apple-browsers/development-commands.mdc b/work/wiki/apple-browsers/development-commands.mdc new file mode 100644 index 00000000..418cbc6b --- /dev/null +++ b/work/wiki/apple-browsers/development-commands.mdc @@ -0,0 +1,178 @@ +--- +source: ~/DuckDuckGo/apple-browsers.git/main/.cursor/rules/development-commands.mdc +confidence: 0.9 +namespace: work +last_synced: 2026-04-28 +alwaysApply: true +--- + +# Development Commands & Build Instructions + +## πŸ“‹ When to Use This Document + +Use these instructions when you need to: +- Build the iOS Browser app for testing or development +- Build the macOS Browser app for testing or development +- Verify that code changes compile successfully +- Prepare the app for testing or debugging +- Understand build failures and how to fix them + +## 🚦 Golden Rules for Building + +### βœ… ALWAYS DO THESE +1. **Use the full shell wrapper**: `/bin/sh -c 'set -e -o pipefail && xcodebuild ... | xcbeautify'` +2. **Detect environment first**: Never hardcode paths or simulator IDs +3. **Check exit codes**: Ensure the build succeeded before proceeding +4. **Use absolute paths**: Always use full paths for workspace files +5. **Include xcbeautify**: Output is unreadable without it + +### ❌ NEVER DO THESE +1. **Never use `-jobs` flag**: It's been removed from all commands +2. **Never skip xcbeautify**: Raw xcodebuild output is nearly impossible to parse +3. **Never use .xcodeproj files**: Always use .xcworkspace +4. **Never hardcode simulator IDs**: They change between systems +5. **Never ignore build failures**: Always check and handle errors + +## πŸ” Phase 1: Environment Detection + +### Pre-Flight Checks +Before building, validate your environment. + +**Example:** See [pre-flight-checks.sh](development-commands/pre-flight-checks.sh) + +### Required Variables to Detect + +| Variable | Purpose | Detection Command | Expected Format | +|----------|---------|-------------------|-----------------| +| `WORKSPACE_PATH` | Full path to .xcworkspace | `pwd` + `find . -name "DuckDuckGo.xcworkspace"` | `/Users/.../DuckDuckGo.xcworkspace` | +| `SIMULATOR_ID` | iOS Simulator UUID | `xcrun simctl list devices \| grep iPhone` | `XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX` | +| `ARCHITECTURE` | Mac CPU type | `uname -m` | `arm64` or `x86_64` | + +### Detection Commands + +**Example:** See [environment-detection.sh](development-commands/environment-detection.sh) + +## πŸ—οΈ Phase 2: Build Execution + +### iOS Build Command Template + +Replace the placeholders with your detected values. + +**Example:** See [ios-build-template.sh](development-commands/ios-build-template.sh) + +### macOS Build Command Template + +Replace the placeholders with your detected values. + +**Example:** See [macos-build-template.sh](development-commands/macos-build-template.sh) + +### Complete Working Examples + +#### iOS Build (Real Values) +**Example:** See [ios-build-example.sh](development-commands/ios-build-example.sh) + +#### macOS Build (Real Values) +**Example:** See [macos-build-example.sh](development-commands/macos-build-example.sh) + +## βœ… Phase 3: Build Verification + +### Signs of Success +- Command exits with code 0 +- Last line contains "BUILD SUCCEEDED" +- No error messages in red +- Build time is within expected range (see performance table below) + +### Signs of Failure +- Command exits with non-zero code +- Output contains "BUILD FAILED" +- Red error messages appear +- Build hangs for more than 15 minutes + +### Performance Expectations + +| Build Type | Expected Duration | Action if Exceeded | +|------------|------------------|-------------------| +| First build | 5-10 minutes | Normal - downloading dependencies | +| Subsequent build | 1-3 minutes | Check for errors in output | +| Clean build | 3-5 minutes | Normal - rebuilding everything | +| Incremental | 10-30 seconds | Normal for small changes | +| Hanging >15 min | Abnormal | Cancel and check for issues | + +## πŸ”§ Error Recovery + +### If Build Fails - Immediate Actions + +1. **Check the error message** - Last few red lines usually indicate the issue +2. **Clean and retry:** See [error-recovery-clean.sh](development-commands/error-recovery-clean.sh) +3. **If "No such module" errors:** See [error-recovery-derived-data.sh](development-commands/error-recovery-derived-data.sh) +4. **If simulator issues:** See [error-recovery-simulator.sh](development-commands/error-recovery-simulator.sh) + +### Common Problems and Solutions + +| Problem | Diagnosis Command | Solution | +|---------|------------------|----------| +| No workspace found | `ls *.xcworkspace` | Ensure you're in project root directory | +| Simulator not found | `xcrun simctl list devices` | Pick a different simulator ID from the list | +| "Command not found: xcbeautify" | `which xcbeautify` | Install: `brew install xcbeautify` | +| Build hangs | Check Activity Monitor | Kill xcodebuild process and retry | +| "No such module" | Check package resolution | Clean DerivedData and rebuild | +| Provisioning errors | Check Xcode account | May need manual Xcode intervention | + +## πŸ€– Complete Automation Script + +Use this script for reliable, automated builds. + +**Example:** See [complete-automation-script.sh](development-commands/complete-automation-script.sh) + +## πŸ“Š Build Flag Reference + +Understanding what each flag does: + +| Flag | Purpose | Impact | +|------|---------|--------| +| `ONLY_ACTIVE_ARCH=YES` | Build only for current architecture | 50% faster builds | +| `DEBUG_INFORMATION_FORMAT=dwarf` | Use DWARF debug symbols | Smaller build size | +| `COMPILER_INDEX_STORE_ENABLE=NO` | Skip code indexing | Faster builds | +| `-allowProvisioningUpdates` | Auto-update certificates | Prevents signing failures | +| `-disableAutomaticPackageResolution` | Skip package updates | Faster, more stable | +| `-parallelizeTargets` | Build targets in parallel | Uses all CPU cores | +| `-scheme` | Which app to build | Selects iOS or macOS | +| `-configuration` | Debug or Release | Debug = faster, Release = optimized | +| `-destination` | Where to run | Simulator/device/Mac | + +## πŸ“š Additional Resources + +### Available Schemes +- `iOS Browser` - Main iOS app +- `macOS Browser` - Main macOS app (sometimes called "DuckDuckGo") + +### Useful Commands +**Example:** See [useful-commands.sh](development-commands/useful-commands.sh) + +## βœ… Task Completion Checklist + +Before considering the build task complete, verify: + +- [ ] Build command executed without errors +- [ ] "BUILD SUCCEEDED" message appeared +- [ ] Exit code was 0 +- [ ] Build time was within expected range +- [ ] No unresolved errors in output +- [ ] If requested, both iOS and macOS builds completed + +## 🚨 Critical Warnings + +### For Release Builds +If building for release/production, change `-configuration Debug` to `-configuration Release` + +### For Device Builds +If building for a physical iOS device (not simulator), you'll need: +- Device UUID instead of simulator ID +- Valid provisioning profiles +- Device connected and trusted + +### For CI/Automation +- Always check exit codes +- Implement timeouts (15 minutes max) +- Log full output for debugging +- Clean build environment between runs diff --git a/work/wiki/apple-browsers/duckplayer-userscript-integration.mdc b/work/wiki/apple-browsers/duckplayer-userscript-integration.mdc new file mode 100644 index 00000000..c93a1f79 --- /dev/null +++ b/work/wiki/apple-browsers/duckplayer-userscript-integration.mdc @@ -0,0 +1,447 @@ +--- +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. diff --git a/work/wiki/apple-browsers/duckplayer.mdc b/work/wiki/apple-browsers/duckplayer.mdc new file mode 100644 index 00000000..cb6d1343 --- /dev/null +++ b/work/wiki/apple-browsers/duckplayer.mdc @@ -0,0 +1,767 @@ +--- +source: ~/DuckDuckGo/apple-browsers.git/main/.cursor/rules/duckplayer.mdc +confidence: 0.9 +namespace: work +last_synced: 2026-04-28 +alwaysApply: false +--- + +# DuckPlayer Implementation Guide + +## Overview + +DuckPlayer provides video playback within the app, separate from the web view-based player. The architecture separates concerns into distinct components using a presenter pattern, native UI views, and JavaScript integration for seamless video playback experiences. + +## Architecture Components + +### Core Architecture Pattern + +DuckPlayer follows a presenter-driven architecture with clear separation of concerns: + +```swift +// βœ… CORRECT - Presenter coordinates between components +final class DuckPlayerNativeUIPresenter { + private let navigationHandler: NativeDuckPlayerNavigationHandler + private let state: DuckPlayerState + private let pixelFiring: DuckPlayerPixelFiring + + func presentPlayer(for videoID: String) { + // Coordinates pill presentation, player setup, and analytics + updateState(videoID: videoID) + configurePillType() + firePixels() + } +} + +// ❌ INCORRECT - Don't manage all responsibilities in one view +struct DuckPlayerView: View { + @State private var videoID: String = "" + @State private var isPresented = false + // Don't handle navigation, state, and analytics directly in views +} +``` + +### State Management Pattern + +Use `DuckPlayerState` for centralized video state management: + +```swift +// βœ… CORRECT - Centralized state management +final class DuckPlayerState { + var videoID: String? + var hasBeenShown: Bool = false + var timestamp: TimeInterval? + + func reset() { + videoID = nil + hasBeenShown = false + timestamp = nil + } +} + +// ❌ INCORRECT - Scattered state across components +struct DuckPlayerView: View { + @State private var videoID: String = "" + @State private var timestamp: TimeInterval = 0 + // Don't duplicate state management +} +``` + +## Component Responsibilities + +### DuckPlayerNativeUIPresenter + +**Role**: Primary coordinator and state manager for the Native UI + +**Key Responsibilities**: +- Manages presentation lifecycle of player UI components +- Coordinates between pill types (welcome, entry, re-entry) +- Handles user interactions and navigation events +- Manages constraint updates and visibility state +- Integrates with app navigation and browser features +- Handles orientation changes and UI adaptations +- Manages pixel firing for analytics tracking +- Controls toast notifications and dismiss count tracking + +```swift +// βœ… CORRECT - Presenter pattern implementation +final class DuckPlayerNativeUIPresenter { + private weak var containerView: DuckPlayerContainer? + private let navigationHandler: NativeDuckPlayerNavigationHandler + private let state: DuckPlayerState + private let pixelFiring: DuckPlayerPixelFiring + + func presentWelcomePill() { + // Configure welcome pill for first-time users + configureContainerForPill(.welcome) + fireWelcomePillPixel() + } + + func presentEntryPill(for videoID: String) { + // Configure entry pill for returning users + state.videoID = videoID + configureContainerForPill(.entry) + fireEntryPillPixel() + } + + func presentReEntryPill(for videoID: String) { + // Configure re-entry pill for previously watched videos + state.videoID = videoID + configureContainerForPill(.reEntry) + fireReEntryPillPixel() + } +} +``` + +### NativeDuckPlayerNavigationHandler + +**Role**: Manages video playback navigation and browser integration + +```swift +// βœ… CORRECT - Navigation handler pattern +final class NativeDuckPlayerNavigationHandler { + private let webView: WKWebView + private let presenter: DuckPlayerNativeUIPresenter + + func handleYouTubeURL(_ url: URL) -> Bool { + guard shouldHandleNatively(url) else { return false } + + let videoID = extractVideoID(from: url) + presenter.presentPlayer(for: videoID) + return true + } + + private func shouldHandleNatively(_ url: URL) -> Bool { + // Check if URL should be handled by native player + return isYouTubeURL(url) && isNativeUIEnabled() + } +} + +// ❌ INCORRECT - Don't handle navigation directly in views +struct DuckPlayerView: View { + func handleURL(_ url: URL) { + // Don't put navigation logic in views + } +} +``` + +## View Architecture + +### Pill Management System + +DuckPlayer uses a three-tier pill system based on user interaction history: + +```swift +// βœ… CORRECT - Pill type management +enum DuckPlayerPillType { + case welcome // First-time users (priming modal not yet presented) + case entry // Returning users viewing new videos + case reEntry // Users returning to previously watched videos +} + +final class DuckPlayerContainer: UIView { + private var currentPillType: DuckPlayerPillType? + + func configurePill(_ type: DuckPlayerPillType, for videoID: String) { + switch type { + case .welcome: + presentWelcomePill() + case .entry: + presentEntryPill(videoID: videoID) + case .reEntry: + presentReEntryPill(videoID: videoID) + } + } +} +``` + +### SwiftUI View Components + +Follow reactive patterns for view models: + +```swift +// βœ… CORRECT - Reactive view model pattern +final class DuckPlayerWelcomePillViewModel: ObservableObject { + @Published var isAnimating = false + @Published var isPresented = false + + private let pixelFiring: DuckPlayerPixelFiring + private let onDismiss: () -> Void + + init(pixelFiring: DuckPlayerPixelFiring, onDismiss: @escaping () -> Void) { + self.pixelFiring = pixelFiring + self.onDismiss = onDismiss + } + + func startAnimation() { + isAnimating = true + pixelFiring.fireWelcomePillShownPixel() + } + + func handleUserTap() { + pixelFiring.fireWelcomePillTappedPixel() + onDismiss() + } +} + +// ❌ INCORRECT - Don't handle business logic directly in views +struct DuckPlayerWelcomePillView: View { + @State private var isAnimating = false + + var body: some View { + // Don't put pixel firing and business logic here + Button("Watch in DuckPlayer") { + // Business logic should be in view model + Analytics.shared.firePixel(.welcomePillTapped) + } + } +} +``` + +## UserScript Integration + +### JavaScript Bridge Pattern + +Use UserScript components for native-web communication: + +```swift +// βœ… CORRECT - UserScript integration pattern +final class DuckPlayerUserScriptYouTube: NSObject, UserScript { + private let name = "DuckPlayerUserScriptYouTube" + private let source = DuckPlayerUserScriptSource.youtube + + func messageReceived(_ message: Any) { + guard let dict = message as? [String: Any], + let messageType = dict["type"] as? String else { return } + + switch messageType { + case "timestampUpdate": + handleTimestampUpdate(dict) + case "playerStateChange": + handlePlayerStateChange(dict) + case "error": + handleError(dict) + default: + break + } + } + + private func handleTimestampUpdate(_ data: [String: Any]) { + guard let timestamp = data["timestamp"] as? TimeInterval else { return } + presenter.updateVideoTimestamp(timestamp) + } +} + +// ❌ INCORRECT - Don't handle JavaScript communication directly in views +struct DuckPlayerWebView: UIViewRepresentable { + func makeUIView(context: Context) -> WKWebView { + let webView = WKWebView() + // Don't add message handlers directly here + return webView + } +} +``` + +### Event Queuing System + +Implement event queuing for script readiness: + +```swift +// βœ… CORRECT - Event queuing pattern +final class DuckPlayerUserScript { + private var eventQueue: [UserScriptEvent] = [] + private var isScriptReady = false + + func queueEvent(_ event: UserScriptEvent) { + if isScriptReady { + processEvent(event) + } else { + eventQueue.append(event) + } + } + + func onScriptReady() { + isScriptReady = true + eventQueue.forEach { processEvent($0) } + eventQueue.removeAll() + } +} +``` + +## Analytics Integration + +### Pixel Firing Protocol + +Use protocol-based pixel firing with debouncing: + +```swift +// βœ… CORRECT - Protocol-based pixel firing +protocol DuckPlayerPixelFiring { + func fireWelcomePillShownPixel() + func fireWelcomePillTappedPixel() + func fireEntryPillShownPixel() + func fireVideoPlaybackStartedPixel() + func fireDailyPixel(_ pixel: DuckPlayerDailyPixel) +} + +final class DuckPlayerPixelHandler: DuckPlayerPixelFiring { + private let pixelKit: PixelKit + private let debouncer: PixelDebouncer + + func fireWelcomePillShownPixel() { + debouncer.debounce { + pixelKit.fire(.duckPlayerWelcomePillShown) + } + } +} + +// ❌ INCORRECT - Don't fire pixels directly from views +struct DuckPlayerView: View { + var body: some View { + Button("Play") { + // Don't fire pixels directly + PixelKit.shared.fire(.duckPlayerPlayTapped) + } + } +} +``` + +### DuckPlayer Native Pixels + +DuckPlayer Native fires various pixels to track user interactions and system events: + +#### Pill Interaction Pixels +```swift +// Welcome Pill (first-time users) +.duckPlayerNativeWelcomePillShown // When welcome pill is displayed +.duckPlayerNativeWelcomePillTapped // When user taps welcome pill +.duckPlayerNativeWelcomePillDismissed // When welcome pill is dismissed + +// Entry Pill (returning users, new videos) +.duckPlayerNativeEntryPillShown // When entry pill is displayed +.duckPlayerNativeEntryPillTapped // When user taps entry pill +.duckPlayerNativeEntryPillDismissed // When entry pill is dismissed + +// Re-entry Pill (previously watched videos) +.duckPlayerNativeReEntryPillShown // When re-entry pill is displayed +.duckPlayerNativeReEntryPillTapped // When user taps re-entry pill +.duckPlayerNativeReEntryPillDismissed // When re-entry pill is dismissed +``` + +#### Video Playback Pixels +```swift +// Playback events +.duckPlayerNativeVideoPlaybackStarted // When video starts playing +.duckPlayerNativeVideoPlaybackPaused // When video is paused +.duckPlayerNativeVideoPlaybackResumed // When video is resumed +.duckPlayerNativeVideoPlaybackCompleted // When video finishes + +// Daily unique playback tracking +.duckPlayerNativeDailyVideoPlayed // Fired once per day when any video is played +``` + +#### YouTube Error Pixels + +DuckPlayer Native tracks YouTube-specific errors with both volume (impression) and daily-unique pixels: + +```swift +// Sign-in Required Errors +.duckPlayerNativeYouTubeSignInErrorImpression // Every occurrence +.duckPlayerNativeYouTubeSignInErrorDaily // Once per day + +// Age-Restricted Content Errors +.duckPlayerNativeYouTubeAgeRestrictedErrorImpression // Every occurrence +.duckPlayerNativeYouTubeAgeRestrictedErrorDaily // Once per day + +// No-Embed Errors (embedding disabled) +.duckPlayerNativeYouTubeNoEmbedErrorImpression // Every occurrence +.duckPlayerNativeYouTubeNoEmbedErrorDaily // Once per day + +// Unknown/Generic Errors +.duckPlayerNativeYouTubeUnknownErrorImpression // Every occurrence +.duckPlayerNativeYouTubeUnknownErrorDaily // Once per day +``` + +#### Error Handling Implementation + +YouTube errors are handled in the UserScript layer: + +```swift +// In DuckPlayerUserScriptPlayer.swift +@MainActor +private func onYoutubeError(params: Any, original: WKScriptMessage) -> Encodable? { + let (volumePixel, dailyPixel) = getPixelsForNativeYouTubeErrorParams(params) + DailyPixel.fire(pixel: dailyPixel) + Pixel.fire(pixel: volumePixel) + return [:] as [String: String] +} + +private func getPixelsForNativeYouTubeErrorParams(_ params: Any) -> (Pixel.Event, Pixel.Event) { + if let paramsDict = params as? [String: Any], + let errorParam = paramsDict["error"] as? String { + switch errorParam { + case "sign-in-required": + return (.duckPlayerNativeYouTubeSignInErrorImpression, .duckPlayerNativeYouTubeSignInErrorDaily) + case "age-restricted": + return (.duckPlayerNativeYouTubeAgeRestrictedErrorImpression, .duckPlayerNativeYouTubeAgeRestrictedErrorDaily) + case "no-embed": + return (.duckPlayerNativeYouTubeNoEmbedErrorImpression, .duckPlayerNativeYouTubeNoEmbedErrorDaily) + default: + return (.duckPlayerNativeYouTubeUnknownErrorImpression, .duckPlayerNativeYouTubeUnknownErrorDaily) + } + } + return (.duckPlayerNativeYouTubeUnknownErrorImpression, .duckPlayerNativeYouTubeUnknownErrorDaily) +} +``` + +### Analytics Best Practices + +```swift +// βœ… CORRECT - Centralized analytics tracking +final class DuckPlayerAnalytics { + private let pixelFiring: DuckPlayerPixelFiring + + func trackPillPresentation(_ type: DuckPlayerPillType) { + switch type { + case .welcome: + pixelFiring.fireWelcomePillShownPixel() + case .entry: + pixelFiring.fireEntryPillShownPixel() + case .reEntry: + pixelFiring.fireReEntryPillShownPixel() + } + } + + func trackVideoPlayback(duration: TimeInterval) { + let parameters = ["duration": String(duration)] + pixelFiring.fireVideoPlaybackPixel(parameters: parameters) + } + + func trackYouTubeError(_ error: DuckPlayerError) { + // Errors are tracked in UserScript layer + // This method exists for future expansion + } +} +``` + +### Pixel Naming Convention + +All DuckPlayer Native pixels follow this naming pattern: +- **Volume pixels**: `duckplayer_native_{event}_impression_ios_{formfactor}` +- **Daily pixels**: `duckplayer_native_{event}_daily-unique_ios_{formfactor}` + +The formfactor (phone/tablet) is automatically appended by the pixel infrastructure. + +## Toast Notification System + +### Toast Implementation Pattern + +```swift +// βœ… CORRECT - Toast notification system +final class DuckPlayerToastManager { + private weak var containerView: UIView? + + func showToast(_ message: String, position: ToastPosition = .top) { + let toastView = DuckPlayerToastView(message: message) + + containerView?.addSubview(toastView) + toastView.show(at: position) { [weak self] in + self?.hideToast(toastView) + } + } + + private func hideToast(_ toastView: DuckPlayerToastView) { + toastView.hide { + toastView.removeFromSuperview() + } + } +} + +struct DuckPlayerToastView: View { + let message: String + @State private var isVisible = false + + var body: some View { + Text(message) + .padding() + .background(Color(designSystemColor: .surface)) + .cornerRadius(8) + .scaleEffect(isVisible ? 1.0 : 0.8) + .opacity(isVisible ? 1.0 : 0.0) + .animation(.spring(response: 0.3), value: isVisible) + .onAppear { + isVisible = true + } + } +} +``` + +## Variant Management + +### Variant Configuration Pattern + +```swift +// βœ… CORRECT - Variant configuration system +enum DuckPlayerVariant: String, CaseIterable { + case classic = "Web" + case nativeOptIn = "Opt-in" + case nativeOptOut = "Opt-out" + + var configuration: DuckPlayerConfiguration { + switch self { + case .classic: + return DuckPlayerConfiguration( + nativeUIEnabled: false, + playerMode: .alwaysAsk, + newTabBehavior: true, + serpIntegration: false + ) + case .nativeOptIn: + return DuckPlayerConfiguration( + nativeUIEnabled: true, + playerMode: .askUserPreference, + autoplayEnabled: true, + serpIntegration: true, + primingModalEnabled: true + ) + case .nativeOptOut: + return DuckPlayerConfiguration( + nativeUIEnabled: true, + playerMode: .automatic, + autoplayEnabled: true, + serpIntegration: true, + primingModalEnabled: false + ) + } + } +} + +// ❌ INCORRECT - Don't hardcode variant configurations +struct DuckPlayerSettings { + var isNativeEnabled: Bool { + // Don't hardcode variant logic + return UserDefaults.standard.bool(forKey: "native_enabled") + } +} +``` + +### Runtime Variant Switching + +```swift +// βœ… CORRECT - Runtime variant management +final class DuckPlayerVariantManager { + private let appSettings: AppSettings + + var currentVariant: DuckPlayerVariant { + get { + let rawValue = appSettings.duckPlayerVariant + return DuckPlayerVariant(rawValue: rawValue) ?? .classic + } + set { + appSettings.duckPlayerVariant = newValue.rawValue + applyVariantConfiguration(newValue.configuration) + } + } + + private func applyVariantConfiguration(_ config: DuckPlayerConfiguration) { + appSettings.duckPlayerNativeUIEnabled = config.nativeUIEnabled + appSettings.duckPlayerSerpIntegration = config.serpIntegration + appSettings.duckPlayerAutoplayEnabled = config.autoplayEnabled + + // Notify components of configuration change + NotificationCenter.default.post(name: .duckPlayerVariantChanged, object: config) + } +} +``` + +## Performance Optimization + +### Lazy Loading Pattern + +```swift +// βœ… CORRECT - Lazy loading for performance +final class DuckPlayerNativeUIPresenter { + private lazy var welcomePillViewModel = DuckPlayerWelcomePillViewModel( + pixelFiring: pixelFiring, + onDismiss: { [weak self] in self?.dismissWelcomePill() } + ) + + private lazy var entryPillViewModel = DuckPlayerEntryPillViewModel( + pixelFiring: pixelFiring, + onPlay: { [weak self] in self?.startVideoPlayback() } + ) + + func presentWelcomePill() { + // Only create view model when needed + containerView?.configurePill(.welcome, viewModel: welcomePillViewModel) + } +} + +// ❌ INCORRECT - Don't create all view models upfront +final class DuckPlayerNativeUIPresenter { + private let welcomePillViewModel: DuckPlayerWelcomePillViewModel + private let entryPillViewModel: DuckPlayerEntryPillViewModel + private let reEntryPillViewModel: DuckPlayerMiniPillViewModel + + init() { + // Don't create all view models immediately + welcomePillViewModel = DuckPlayerWelcomePillViewModel(...) + entryPillViewModel = DuckPlayerEntryPillViewModel(...) + reEntryPillViewModel = DuckPlayerMiniPillViewModel(...) + } +} +``` + +## Testing Patterns + +### Presenter Testing + +```swift +// βœ… CORRECT - Testing presenter components +final class DuckPlayerNativeUIPresenterTests: XCTestCase { + private var sut: DuckPlayerNativeUIPresenter! + private var mockNavigationHandler: MockNativeDuckPlayerNavigationHandler! + private var mockPixelFiring: MockDuckPlayerPixelFiring! + private var mockState: DuckPlayerState! + + override func setUp() { + super.setUp() + mockNavigationHandler = MockNativeDuckPlayerNavigationHandler() + mockPixelFiring = MockDuckPlayerPixelFiring() + mockState = DuckPlayerState() + + sut = DuckPlayerNativeUIPresenter( + navigationHandler: mockNavigationHandler, + pixelFiring: mockPixelFiring, + state: mockState + ) + } + + func testPresentWelcomePill() { + // When + sut.presentWelcomePill() + + // Then + XCTAssertTrue(mockPixelFiring.fireWelcomePillShownPixelCalled) + XCTAssertEqual(sut.currentPillType, .welcome) + } +} +``` + +### UserScript Testing + +```swift +// βœ… CORRECT - Testing UserScript components +final class DuckPlayerUserScriptTests: XCTestCase { + private var sut: DuckPlayerUserScriptYouTube! + private var mockPresenter: MockDuckPlayerPresenter! + + func testTimestampUpdateMessage() { + // Given + let message = ["type": "timestampUpdate", "timestamp": 120.5] + + // When + sut.messageReceived(message) + + // Then + XCTAssertEqual(mockPresenter.lastTimestampUpdate, 120.5) + } +} +``` + +## Common Patterns + +### Error Handling + +```swift +// βœ… CORRECT - Comprehensive error handling +enum DuckPlayerError: Error { + case videoNotFound + case networkError + case playbackError(underlying: Error) + case invalidConfiguration +} + +final class DuckPlayerErrorHandler { + private let pixelFiring: DuckPlayerPixelFiring + + func handleError(_ error: DuckPlayerError) { + switch error { + case .videoNotFound: + pixelFiring.fireErrorPixel(.videoNotFound) + showErrorToast("Video not available") + case .networkError: + pixelFiring.fireErrorPixel(.networkError) + showErrorToast("Network connection required") + case .playbackError(let underlying): + pixelFiring.fireErrorPixel(.playbackError, parameters: ["error": underlying.localizedDescription]) + showErrorToast("Playback error occurred") + case .invalidConfiguration: + pixelFiring.fireErrorPixel(.invalidConfiguration) + // Handle configuration errors silently + } + } +} +``` + +### Memory Management + +```swift +// βœ… CORRECT - Proper memory management +final class DuckPlayerNativeUIPresenter { + private weak var containerView: DuckPlayerContainer? + private var cancellables = Set() + + deinit { + cancellables.removeAll() + cleanupResources() + } + + private func cleanupResources() { + // Clean up any retained resources + containerView?.removeFromSuperview() + state.reset() + } +} +``` + +## Migration Guidelines + +### Integrating DuckPlayer + +When adding DuckPlayer to new areas: + +1. **Use the presenter pattern** - Don't put business logic in views +2. **Follow the pill system** - Implement appropriate pill types for user journey +3. **Integrate analytics** - Use the pixel firing protocol for tracking +4. **Handle variants** - Support all three DuckPlayer variants +5. **Test thoroughly** - Write tests for presenter, UserScript, and view components + +### Common Integration Mistakes + +```swift +// ❌ INCORRECT - Don't bypass the presenter +struct MyFeatureView: View { + @State private var showDuckPlayer = false + + var body: some View { + Button("Play Video") { + // Don't create DuckPlayer components directly + showDuckPlayer = true + } + } +} + +// βœ… CORRECT - Use the presenter pattern +struct MyFeatureView: View { + private let duckPlayerPresenter: DuckPlayerNativeUIPresenter + + var body: some View { + Button("Play Video") { + duckPlayerPresenter.presentPlayer(for: videoID) + } + } +} +``` + +This guide provides the foundation for implementing and maintaining DuckPlayer components following established patterns and best practices in the DuckDuckGo browser codebase. diff --git a/work/wiki/apple-browsers/feature-flags-addition.mdc b/work/wiki/apple-browsers/feature-flags-addition.mdc new file mode 100644 index 00000000..304ef32a --- /dev/null +++ b/work/wiki/apple-browsers/feature-flags-addition.mdc @@ -0,0 +1,762 @@ +--- +source: ~/DuckDuckGo/apple-browsers.git/main/.cursor/rules/feature-flags-addition.mdc +confidence: 0.9 +namespace: work +last_synced: 2026-04-28 +description: "Interactive pattern for adding feature flags to iOS and/or macOS with proper configuration" +alwaysApply: false +--- + +# Feature Flag Addition Pattern + +## When This Pattern Applies + +This pattern is activated when the user explicitly requests to add a feature flag, such as: +- "Add a feature flag for [feature name]" +- "Create a feature flag for [feature name] on [platform]" +- "I need a feature flag to control [feature name]" + +## Overview + +Adding a feature flag requires careful consideration of several factors: +1. **Platform** (iOS, macOS, or both) +2. **Source type** (how the flag is controlled) +3. **Default value** (fallback behavior) +4. **Local overriding** (debug menu access) +5. **Remote configuration** (if applicable) + +## Step 1: Validate and Check for Duplicates + +Before adding a new feature flag, check if a similar flag already exists: + +```bash +# Search for similar flags +grep -i "case.*[searchTerm]" iOS/Core/FeatureFlag.swift +grep -i "case.*[searchTerm]" macOS/LocalPackages/FeatureFlags/Sources/FeatureFlags/FeatureFlag.swift +``` + +## Step 1.5: Create Asana Task (REQUIRED) + +**STOP:** Before proceeding with implementation, the user must create an Asana task. + +Instruct the user: +``` +Please create an Asana task in the Apple Feature Flags Registry: + +1. Open Asana +2. Navigate to the "Apple Feature Flags Registry" project +3. Create a new task default feature flag task +4. Copy the task URL + +Paste the Asana task URL when ready to continue. +``` + +**This is mandatory** - all feature flags must be tracked in the Apple Feature Flags Registry. + +## Step 2: Ask Clarifying Questions + +### Question 1: Platform Selection + +**Ask the user:** +``` +Which platform(s) should this feature flag target? + a) iOS only + b) macOS only + c) Both iOS and macOS +``` + +**Default:** Infer from user's request. If ambiguous, ask. + +### Question 2: Feature Flag Source Type + +**Ask the user:** +``` +What source type should this feature flag use? + + a) .remoteReleasable - Can be controlled remotely in production (RECOMMENDED for most features) + β€’ Allows gradual rollout + β€’ Can be toggled without app updates + β€’ Requires Privacy Config setup + + b) .remoteDevelopment - Remote control in development environments only + β€’ For testing remote config before production + β€’ Not visible in production builds + + c) .internalOnly() - Only enabled for internal users + β€’ Always on for internal users + β€’ Always off for external users + β€’ No remote control + + d) .disabled - Always off for everyone + β€’ Placeholder for future features + β€’ Code is present but inactive + +Which option? (a is recommended for new features) +``` + +**Important:** If user selects `a` or `b`, proceed to Question 2b. + +### Question 2b: Parent Feature Selection (for remote flags) + +**Ask the user:** +``` +For remote feature flags, we need to add a subfeature to PrivacyFeature.swift. + +Which parent feature should this belong to? + +Platform-specific generic: + a) macOSBrowserConfig - Generic macOS browser features + b) iOSBrowserConfig - Generic iOS browser features + +Domain-specific (if applicable): + c) aiChat - AI Chat related features + d) sync - Sync related features + e) privacyPro - Privacy Pro subscription features + f) autofill - Autofill related features + g) networkProtection - VPN related features + h) duckPlayer - Duck Player features + i) dbp - Data Broker Protection features + j) htmlNewTabPage - New Tab Page features + k) maliciousSiteProtection - Malicious site protection + l) Other existing parent feature (specify name) + m) Create NEW parent feature (requires additional setup) + +Which option? +``` + +**Guidance for selection:** +- Use platform-specific generic (a/b) when feature doesn't fit existing domains +- Use domain-specific when feature clearly belongs to an existing area +- Creating a new parent feature (m) requires: + 1. Adding case to `PrivacyFeature` enum + 2. Creating new `[FeatureName]Subfeature` enum + 3. Coordinating with backend team for remote config + +### Question 3: Default Value + +**Ask the user:** +``` +What should the default value be? + + a) false - Feature OFF when remote config unavailable (RECOMMENDED) + β€’ Safer option + β€’ Opt-in behavior + β€’ Better for new/experimental features + + b) true - Feature ON when remote config unavailable + β€’ Used when feature should be on by default + β€’ Useful for rollback safety (can disable remotely) + β€’ Better for stable features being gradually enabled + +Which option? (a is recommended for new features) +``` + +**Explanation:** The default value is used when: +- Remote config is unavailable +- Flag source is local-only (`.internalOnly`, `.disabled`) +- Network is down or config fetch fails + +### Question 4: Local Overriding + +**Ask the user:** +``` +Should this feature flag support local overriding? + + a) true - Allow internal users to toggle in debug menu (RECOMMENDED) + β€’ Enables testing both states + β€’ Useful during development + β€’ No effect on external users + + b) false - No local override available + β€’ Use for production pixels/metrics + β€’ Use for security-critical flags + β€’ Use when override would break functionality + +Which option? (a is recommended unless there's a specific reason) +``` + +### Question 5: Asana Task Link + +**REQUIRED:** Before proceeding, the user must create an Asana task. + +**Instruct the user:** +``` +Please create an Asana task for this feature flag: + +1. Go to Asana +2. Navigate to: Apple Feature Flags Registry +3. Create a new task with: + - Title: [Feature name] feature flag + - Add any relevant context or description in the task +4. Copy the task URL + +Once created, paste the Asana task URL here: +``` + +**Note:** All feature flags MUST have an associated Asana task in the Apple Feature Flags Registry for tracking and documentation purposes. + +## Step 3: Implementation + +### File Locations + +- **iOS:** `iOS/Core/FeatureFlag.swift` +- **macOS:** `macOS/LocalPackages/FeatureFlags/Sources/FeatureFlags/FeatureFlag.swift` +- **Shared (remote flags):** `SharedPackages/BrowserServicesKit/Sources/BrowserServicesKit/PrivacyConfig/Features/PrivacyFeature.swift` + +### 3.1: Add Feature Flag Enum Case + +#### For iOS (`iOS/Core/FeatureFlag.swift`) + +```swift +public enum FeatureFlag: String { + // ... existing cases ... + + /// https://app.asana.com/[task-url] + case yourFeatureName +``` + +#### For macOS (`macOS/LocalPackages/FeatureFlags/Sources/FeatureFlags/FeatureFlag.swift`) + +```swift +public enum FeatureFlag: String, CaseIterable { + // ... existing cases ... + + /// https://app.asana.com/[task-url] + case yourFeatureName +``` + +**Naming conventions:** +- Use camelCase +- Be descriptive but concise +- Follow existing patterns in the file + +### 3.2: Add to `defaultValue` Switch + +Find the `defaultValue` computed property and add your case: + +```swift +public var defaultValue: Bool { + switch self { + // If default is TRUE, add to this group: + case .existingTrueCase1, + .existingTrueCase2, + .yourFeatureName: // Add here if default is true + true + default: + false // All other cases default to false + } +} +``` + +**OR** if default is false, no change needed (handled by `default` case). + +### 3.3: Add to `source` Switch + +```swift +public var source: FeatureFlagSource { + switch self { + // ... other cases ... + + case .yourFeatureName: + return .remoteReleasable(.subfeature(MacOSBrowserConfigSubfeature.yourFeatureName)) + // OR + return .internalOnly() + // OR + return .disabled + } +} +``` + +**Examples by source type:** + +```swift +// Remote releasable with macOS-specific subfeature +case .macOSFeature: + return .remoteReleasable(.subfeature(MacOSBrowserConfigSubfeature.macOSFeature)) + +// Remote releasable with iOS-specific subfeature +case .iOSFeature: + return .remoteReleasable(.subfeature(iOSBrowserConfigSubfeature.iOSFeature)) + +// Remote releasable with domain-specific subfeature +case .aiFeature: + return .remoteReleasable(.subfeature(AIChatSubfeature.aiFeature)) + +// Remote releasable with parent feature (no subfeature) +case .newParentFeature: + return .remoteReleasable(.feature(.newParentFeature)) + +// Remote development (testing) +case .experimentalFeature: + return .remoteDevelopment(.subfeature(MacOSBrowserConfigSubfeature.experimentalFeature)) + +// Internal only +case .debugFeature: + return .internalOnly() + +// Always disabled (placeholder) +case .futureFeature: + return .disabled +``` + +### 3.4: Add to `supportsLocalOverriding` Switch + +```swift +public var supportsLocalOverriding: Bool { + switch self { + case .existingOverridableFlag1, + .existingOverridableFlag2, + .yourFeatureName: // Add here if supports local override + return true + case .existingNonOverridableFlag1, + .existingNonOverridableFlag2: + return false + } +} +``` + +**Note:** Most flags should support local overriding for testing purposes. + +### 3.5: Add Subfeature to PrivacyFeature.swift (Remote Flags Only) + +**File:** `SharedPackages/BrowserServicesKit/Sources/BrowserServicesKit/PrivacyConfig/Features/PrivacyFeature.swift` + +**Important Documentation Note:** +- **MacOSBrowserConfigSubfeature** and **iOSBrowserConfigSubfeature**: Include documentation comments with Asana task URLs +- **All other domain-specific subfeatures** (PrivacyPro, AIChat, Sync, DBP, etc.): NO documentation comments - just the case name + +#### For macOS-specific features: + +```swift +public enum MacOSBrowserConfigSubfeature: String, PrivacySubfeature { + public var parent: PrivacyFeature { + .macOSBrowserConfig + } + + // ... existing cases ... + + /// https://app.asana.com/[task-url] + case yourFeatureName +} +``` + +#### For iOS-specific features: + +```swift +public enum iOSBrowserConfigSubfeature: String, PrivacySubfeature { + public var parent: PrivacyFeature { + .iOSBrowserConfig + } + + // ... existing cases ... + + /// https://app.asana.com/[task-url] + case yourFeatureName +} +``` + +#### For domain-specific features (e.g., PrivacyPro, AIChat, Sync, etc.): + +**Important:** Domain-specific subfeatures should NOT include documentation comments in PrivacyFeature.swift. Keep them clean and simple with just the case name. + +```swift +public enum [DomainName]Subfeature: String, PrivacySubfeature { + public var parent: PrivacyFeature { .[domainName] } + + // ... existing cases ... + + case yourFeatureName +} +``` + +**Example for PrivacyPro features:** +```swift +public enum PrivacyProSubfeature: String, Equatable, PrivacySubfeature { + public var parent: PrivacyFeature { .privacyPro } + + // ... existing cases ... + + case yourNewFeature +} +``` + +### 3.6: Creating a New Parent Feature (Advanced) + +If you need to create a NEW parent feature: + +**Step 1:** Add to `PrivacyFeature` enum: + +```swift +public enum PrivacyFeature: String { + // ... existing cases ... + case yourNewFeature +} +``` + +**Step 2:** Create subfeature enum: + +```swift +public enum YourNewFeatureSubfeature: String, PrivacySubfeature { + public var parent: PrivacyFeature { + .yourNewFeature + } + + case firstSubfeature + case secondSubfeature +} +``` + +**Step 3:** Coordinate with backend team to add feature to remote Privacy Config. + +### 3.7: Add Feature Flag Category (macOS Only) + +**File:** `macOS/LocalPackages/FeatureFlags/Sources/FeatureFlags/FeatureFlagCategory.swift` + +On macOS, feature flags can be organized into categories for better organization in the debug menu. Consider if your feature flag should be categorized. + +**Available Categories:** +- `duckAI` - Duck.ai related features +- `dbp` - Personal Information Removal +- `subscription` - Subscription/Privacy Pro features +- `sync` - Sync related features +- `updates` - Update related features +- `vpn` - VPN related features +- `osSupportWarnings` - OS Support Warnings +- `other` - Default for uncategorized flags + +**When to categorize:** +- If the feature belongs to a clear domain (Subscription, VPN, Sync, Duck.ai, etc.), add it to the appropriate category +- If unsure or the feature is general browser functionality, it can remain in `.other` (default) + +**How to categorize:** + +**Step 1:** If needed, add a new category to the enum: + +```swift +public enum FeatureFlagCategory: String, CaseIterable, Comparable { + case duckAI = "Duck.ai" + // ... existing cases ... + case yourNewCategory = "Your Category Name" + // ... other cases ... +} +``` + +**Step 2:** Add your feature flag to the appropriate category in the `category` computed property: + +```swift +extension FeatureFlag: FeatureFlagCategorization { + public var category: FeatureFlagCategory { + switch self { + // ... existing cases ... + + case .yourFeatureFlag1, + .yourFeatureFlag2: + return .yourCategory + + default: + return .other + } + } +} +``` + +**Example for Subscription features:** + +```swift +case .privacyProAuthV2, + .privacyProFreeTrial, + .paidAIChat, + .tierMessagingEnabled, + .allowProTierPurchase: + return .subscription +``` + +**Note:** iOS does not have feature flag categories - this is macOS-specific functionality. + +## Step 4: Usage in Code + +### Basic Usage + +```swift +// Check if feature is enabled +if featureFlagger.isFeatureOn(.yourFeatureName) { + // Feature-specific code +} +``` + +### With Dependency Injection + +```swift +final class MyViewController { + private let featureFlagger: FeatureFlagger + + init(featureFlagger: FeatureFlagger) { + self.featureFlagger = featureFlagger + } + + func setupUI() { + if featureFlagger.isFeatureOn(.yourFeatureName) { + setupNewUI() + } else { + setupLegacyUI() + } + } +} +``` + +### iOS-specific (via AppDependencies) + +```swift +if AppDependencies.shared.featureFlagger.isFeatureOn(.yourFeatureName) { + // iOS-specific feature code +} +``` + +### macOS-specific (via Application) + +```swift +if Application.appDelegate.featureFlagger.isFeatureOn(.yourFeatureName) { + // macOS-specific feature code +} +``` + +## Complete Example + +### Example: Add "Enhanced Bookmarks UI" feature flag for macOS + +**Step 1: User Request** +``` +User: "Add a feature flag for enhanced bookmarks UI on macOS" +``` + +**Step 2: Questions** +``` +1. Platform: macOS βœ“ +2. Source: a) .remoteReleasable +3. Parent: a) macOSBrowserConfig +4. Default: a) false +5. Local override: a) true +6. Asana: https://app.asana.com/0/123456789/987654321 +``` + +**Step 3: Implementation** + +**File 1:** `macOS/LocalPackages/FeatureFlags/Sources/FeatureFlags/FeatureFlag.swift` + +```swift +public enum FeatureFlag: String, CaseIterable { + // ... existing cases ... + + /// https://app.asana.com/0/123456789/987654321 + case enhancedBookmarksUI +} + +extension FeatureFlag: FeatureFlagDescribing { + public var defaultValue: Bool { + switch self { + // ... existing true cases ... + default: + false // enhancedBookmarksUI uses default false + } + } + + public var supportsLocalOverriding: Bool { + switch self { + case .existingFlag1, + .existingFlag2, + .enhancedBookmarksUI: // ← Added here + return true + // ... rest of cases + } + } + + public var source: FeatureFlagSource { + switch self { + // ... other cases ... + case .enhancedBookmarksUI: + return .remoteReleasable(.subfeature(MacOSBrowserConfigSubfeature.enhancedBookmarksUI)) + } + } +} +``` + +**File 2:** `SharedPackages/BrowserServicesKit/Sources/BrowserServicesKit/PrivacyConfig/Features/PrivacyFeature.swift` + +```swift +public enum MacOSBrowserConfigSubfeature: String, PrivacySubfeature { + public var parent: PrivacyFeature { + .macOSBrowserConfig + } + + // ... existing cases ... + + /// https://app.asana.com/0/123456789/987654321 + case enhancedBookmarksUI +} +``` + +## Anti-Patterns to Avoid + +### ❌ DON'T: Add feature flag without Asana task + +```swift +// ❌ BAD: No tracking or documentation +case mysteriousFeature +``` + +```swift +// βœ… GOOD: Clear documentation with Asana task from Apple Feature Flags Registry +/// https://app.asana.com/0/123456789/987654321 +case tabGrouping +``` + +**CRITICAL:** Every feature flag MUST have an Asana task in the Apple Feature Flags Registry. This is not optional. + +### ❌ DON'T: Use generic names + +```swift +// ❌ BAD: Too vague +case newFeature +case experiment1 +case testFlag +``` + +```swift +// βœ… GOOD: Descriptive names +case improvedTabSwitcher +case aiChatSidebar +case passwordAutofillV2 +``` + +### ❌ DON'T: Forget to add to all required switches + +```swift +// ❌ BAD: Missing from supportsLocalOverriding +case newFeature // Added to enum +// source: return .remoteReleasable(...) +// defaultValue: false (via default) +// supportsLocalOverriding: ❌ MISSING! +``` + +### ❌ DON'T: Use wrong parent for domain-specific features + +```swift +// ❌ BAD: AI feature in generic config +case aiNewFeature: + return .remoteReleasable(.subfeature(MacOSBrowserConfigSubfeature.aiNewFeature)) + +// βœ… GOOD: AI feature in AI domain +case aiNewFeature: + return .remoteReleasable(.subfeature(AIChatSubfeature.aiNewFeature)) +``` + +### ❌ DON'T: Add to iOS when feature is macOS-only (or vice versa) + +```swift +// ❌ BAD: Adding macOS-specific flag to iOS +// In iOS/Core/FeatureFlag.swift: +case macOSOnlyFeature // This doesn't make sense! +``` + +### ❌ DON'T: Add documentation comments to domain-specific subfeatures in PrivacyFeature.swift + +```swift +// ❌ BAD: Adding comments to domain-specific subfeatures (e.g., PrivacyPro, AIChat, Sync) +public enum PrivacyProSubfeature: String, Equatable, PrivacySubfeature { + public var parent: PrivacyFeature { .privacyPro } + + /// https://app.asana.com/... + case tierMessagingEnabled // ❌ Don't add ANY comments here! +} +``` + +```swift +// βœ… GOOD: Domain-specific subfeatures without comments +public enum PrivacyProSubfeature: String, Equatable, PrivacySubfeature { + public var parent: PrivacyFeature { .privacyPro } + + case tierMessagingEnabled // βœ… Clean and simple + case allowProTierPurchase +} +``` + +**Note:** Only `MacOSBrowserConfigSubfeature` and `iOSBrowserConfigSubfeature` should have documentation comments. All other domain-specific subfeatures (PrivacyPro, AIChat, Sync, DBP, etc.) should be kept clean without comments. + +## Testing Your Feature Flag + +### Manual Testing + +1. **Internal user testing:** + - Enable internal user mode + - Access debug menu to toggle flag + - Test both on/off states + +2. **Production simulation:** + - Disable internal user mode + - Verify default value behavior + - Test without remote config + +### Debug Menu Access + +**macOS:** +- Develop menu β†’ Feature Flags +- Toggle individual flags +- Changes persist across sessions + +**iOS:** +- Settings β†’ Debug β†’ Feature Flags +- Toggle individual flags +- Changes persist across sessions + +## Remote Configuration (Next Steps) + +After adding the feature flag code, coordinate with backend team to: + +1. Add feature to Privacy Configuration JSON +2. Set initial state (enabled/disabled/internal) +3. Configure rollout percentage (if gradual rollout) +4. Set up A/B test cohorts (if applicable) + +Example Privacy Config structure: + +```json +{ + "macOSBrowserConfig": { + "state": "enabled", + "features": { + "enhancedBookmarksUI": { + "state": "internal", + "rollout": { + "steps": [ + { "percent": 10 } + ] + } + } + } + } +} +``` + +## Summary Checklist + +When adding a feature flag, ensure you: + +- [ ] Checked for existing similar flags +- [ ] **Created Asana task in Apple Feature Flags Registry (REQUIRED)** +- [ ] Asked all required questions +- [ ] Added enum case with Asana task link +- [ ] Updated `defaultValue` switch (if non-default) +- [ ] Updated `source` switch +- [ ] Updated `supportsLocalOverriding` switch +- [ ] Added subfeature to PrivacyFeature.swift (if remote) +- [ ] Added to appropriate category in FeatureFlagCategory.swift (macOS only, if applicable) +- [ ] Used descriptive naming +- [ ] Tested in debug menu +- [ ] Coordinated with backend (if remote) + +## Reference Documentation + +For more information, see: +- `feature-flags.md` - Type-safe feature flag patterns +- `abn-experiment-framework.md` - A/B testing with feature flags +- `SharedPackages/BrowserServicesKit/Sources/BrowserServicesKit/FeatureFlagger/FeatureFlagger.swift` - Core implementation diff --git a/work/wiki/apple-browsers/feature-flags.mdc b/work/wiki/apple-browsers/feature-flags.mdc new file mode 100644 index 00000000..3a303023 --- /dev/null +++ b/work/wiki/apple-browsers/feature-flags.mdc @@ -0,0 +1,378 @@ +--- +source: ~/DuckDuckGo/apple-browsers.git/main/.cursor/rules/feature-flags.mdc +confidence: 0.9 +namespace: work +last_synced: 2026-04-28 +alwaysApply: false +--- + +# Feature Flag Patterns + +## Type-Safe Feature Flags +Use enum-based feature flags with protocols for type safety: + +```swift +// βœ… CORRECT - Type-safe feature flags +protocol FeatureFlag: RawRepresentable where RawValue == String { + var defaultValue: Bool { get } + var description: String { get } +} + +enum UIFeatureFlag: String, FeatureFlag { + case newTabPageRedesign = "new_tab_page_redesign" + case advancedPrivacySettings = "advanced_privacy_settings" + case voiceSearch = "voice_search" + case experimentalUI = "experimental_ui" + + var defaultValue: Bool { + switch self { + case .newTabPageRedesign: return false + case .advancedPrivacySettings: return true + case .voiceSearch: return false + case .experimentalUI: return false + } + } + + var description: String { + switch self { + case .newTabPageRedesign: + return "Enable redesigned new tab page" + case .advancedPrivacySettings: + return "Show advanced privacy settings" + case .voiceSearch: + return "Enable voice search functionality" + case .experimentalUI: + return "Enable experimental UI components" + } + } +} + +enum NetworkFeatureFlag: String, FeatureFlag { + case networkProtectionV2 = "network_protection_v2" + case enhancedBlocking = "enhanced_blocking" + + var defaultValue: Bool { + switch self { + case .networkProtectionV2: return false + case .enhancedBlocking: return true + } + } + + var description: String { + switch self { + case .networkProtectionV2: + return "Enable Network Protection V2" + case .enhancedBlocking: + return "Enhanced content blocking" + } + } +} +``` + +## FeatureFlagger Protocol Implementation +Extend the existing FeatureFlagger with type-safe methods: + +```swift +// βœ… CORRECT - Type-safe FeatureFlagger extension +extension FeatureFlagger { + func isEnabled(_ flag: Flag) -> Bool { + return isFeatureOn(flag.rawValue) ?? flag.defaultValue + } + + func setEnabled(_ enabled: Bool, for flag: Flag) { + setFeatureOn(flag.rawValue, enabled: enabled) + } +} + +// Usage in code +final class FeatureViewModel: ObservableObject { + private let featureFlagger: FeatureFlagger + + init(featureFlagger: FeatureFlagger) { + self.featureFlagger = featureFlagger + } + + func loadContent() { + if featureFlagger.isEnabled(UIFeatureFlag.newTabPageRedesign) { + loadNewDesign() + } else { + loadLegacyDesign() + } + } +} +``` + +## Feature Flag ViewModifier +Create SwiftUI modifiers for conditional UI: + +```swift +// βœ… ADVANCED - SwiftUI feature flag modifier +struct FeatureFlagModifier: ViewModifier { + let flag: Flag + let featureFlagger: FeatureFlagger + let fallback: () -> AnyView + + func body(content: Content) -> some View { + if featureFlagger.isEnabled(flag) { + content + } else { + fallback() + } + } +} + +extension View { + func featureFlag( + _ flag: Flag, + featureFlagger: FeatureFlagger, + @ViewBuilder fallback: @escaping () -> some View = { EmptyView() } + ) -> some View { + modifier(FeatureFlagModifier( + flag: flag, + featureFlagger: featureFlagger, + fallback: { AnyView(fallback()) } + )) + } +} + +// Usage +struct ContentView: View { + @Environment(\.dependencies) var dependencies + + var body: some View { + VStack { + NewFeatureView() + .featureFlag(UIFeatureFlag.experimentalUI, + featureFlagger: dependencies.featureFlagger) { + LegacyFeatureView() + } + } + } +} +``` + +## Feature Flag Property Wrapper +Create a property wrapper for reactive feature flags: + +```swift +// βœ… ADVANCED - Reactive feature flag property wrapper +@propertyWrapper +struct FeatureFlagState: DynamicProperty { + @ObservedObject private var flagger: ObservableFeatureFlagger + private let flag: Flag + + var wrappedValue: Bool { + get { flagger.isEnabled(flag) } + nonmutating set { flagger.setEnabled(newValue, for: flag) } + } + + var projectedValue: Binding { + Binding( + get: { wrappedValue }, + set: { wrappedValue = $0 } + ) + } + + init(_ flag: Flag) { + self.flag = flag + self._flagger = ObservedObject(wrappedValue: ObservableFeatureFlagger.shared) + } +} + +// Usage in SwiftUI views +struct SettingsView: View { + @FeatureFlagState(UIFeatureFlag.voiceSearch) var voiceSearchEnabled + @FeatureFlagState(UIFeatureFlag.experimentalUI) var experimentalUIEnabled + + var body: some View { + Form { + Toggle("Voice Search", isOn: $voiceSearchEnabled) + Toggle("Experimental UI", isOn: $experimentalUIEnabled) + } + } +} +``` + +## A/B Testing Integration +Integrate feature flags with A/B testing: + +```swift +// βœ… ADVANCED - A/B testing with feature flags +enum ABTestVariant: String, CaseIterable { + case control = "control" + case variantA = "variant_a" + case variantB = "variant_b" + + var displayName: String { + switch self { + case .control: return "Control Group" + case .variantA: return "Variant A" + case .variantB: return "Variant B" + } + } +} + +protocol ABTestFeatureFlag: FeatureFlag { + var variants: [ABTestVariant] { get } + var currentVariant: ABTestVariant { get } +} + +enum ExperimentalFeatureFlag: String, ABTestFeatureFlag { + case newOnboardingFlow = "new_onboarding_flow" + case redesignedSearch = "redesigned_search" + + var defaultValue: Bool { true } + + var description: String { + switch self { + case .newOnboardingFlow: return "New onboarding flow experiment" + case .redesignedSearch: return "Redesigned search interface experiment" + } + } + + var variants: [ABTestVariant] { + [.control, .variantA, .variantB] + } + + var currentVariant: ABTestVariant { + // Get variant from A/B testing service + return ABTestingService.shared.getVariant(for: self.rawValue) + } +} + +// Usage with variants +func configureOnboarding() { + let experiment = ExperimentalFeatureFlag.newOnboardingFlow + + switch experiment.currentVariant { + case .control: + showLegacyOnboarding() + case .variantA: + showNewOnboardingVariantA() + case .variantB: + showNewOnboardingVariantB() + } + + // Track experiment exposure + PixelFiring.fire(.experimentExposure(experiment.rawValue, experiment.currentVariant.rawValue)) +} +``` + +## Remote Feature Flags +Integrate with remote configuration: + +```swift +// βœ… ADVANCED - Remote feature flag management +protocol RemoteFeatureFlag: FeatureFlag { + var remoteKey: String { get } + var localOverrideKey: String? { get } +} + +extension RemoteFeatureFlag { + var remoteKey: String { rawValue } + var localOverrideKey: String? { "local_override_\(rawValue)" } +} + +enum RemoteUIFeatureFlag: String, RemoteFeatureFlag { + case serverDrivenUI = "server_driven_ui" + case dynamicThemes = "dynamic_themes" + + var defaultValue: Bool { false } + + var description: String { + switch self { + case .serverDrivenUI: return "Server-driven UI configuration" + case .dynamicThemes: return "Dynamic theme system" + } + } +} + +final class RemoteFeatureFlagger: FeatureFlagger { + private let remoteConfig: RemoteConfigProtocol + private let localDefaults: UserDefaults + + init(remoteConfig: RemoteConfigProtocol, localDefaults: UserDefaults) { + self.remoteConfig = remoteConfig + self.localDefaults = localDefaults + } + + func isEnabled(_ flag: Flag) -> Bool { + // Check local override first + if let overrideKey = flag.localOverrideKey, + let localOverride = localDefaults.object(forKey: overrideKey) as? Bool { + return localOverride + } + + // Check remote config + if let remoteValue = remoteConfig.boolValue(for: flag.remoteKey) { + return remoteValue + } + + // Fall back to default + return flag.defaultValue + } + + func setLocalOverride(_ enabled: Bool?, for flag: Flag) { + guard let overrideKey = flag.localOverrideKey else { return } + + if let enabled = enabled { + localDefaults.set(enabled, forKey: overrideKey) + } else { + localDefaults.removeObject(forKey: overrideKey) + } + } +} +``` + +## Debug Feature Flag Interface +Create debug interface for testing: + +```swift +// βœ… DEBUG - Feature flag debug interface +#if DEBUG +struct FeatureFlagDebugView: View { + @StateObject private var debugFlags = DebugFeatureFlags() + + var body: some View { + NavigationView { + List { + Section("UI Features") { + ForEach(UIFeatureFlag.allCases, id: \.rawValue) { flag in + FeatureFlagRow(flag: flag, debugFlags: debugFlags) + } + } + + Section("Network Features") { + ForEach(NetworkFeatureFlag.allCases, id: \.rawValue) { flag in + FeatureFlagRow(flag: flag, debugFlags: debugFlags) + } + } + } + .navigationTitle("Feature Flags") + } + } +} + +struct FeatureFlagRow: View where Flag: CaseIterable { + let flag: Flag + @ObservedObject var debugFlags: DebugFeatureFlags + + var body: some View { + Toggle(isOn: Binding( + get: { debugFlags.isEnabled(flag) }, + set: { debugFlags.setEnabled($0, for: flag) } + )) { + VStack(alignment: .leading) { + Text(flag.rawValue) + .font(.headline) + Text(flag.description) + .font(.caption) + .foregroundColor(.secondary) + } + } + } +} +#endif +``` + +See `analytics-patterns.md` for pixel firing patterns and `configuration-management.md` for advanced configuration management. diff --git a/work/wiki/apple-browsers/general.mdc b/work/wiki/apple-browsers/general.mdc new file mode 100644 index 00000000..65d31c94 --- /dev/null +++ b/work/wiki/apple-browsers/general.mdc @@ -0,0 +1,177 @@ +--- +source: ~/DuckDuckGo/apple-browsers.git/main/.cursor/rules/general.mdc +confidence: 0.9 +namespace: work +last_synced: 2026-04-28 +alwaysApply: true +--- + +# DuckDuckGo Browser Development Rules Overview + +## Project Context +This is the DuckDuckGo browser for iOS and macOS, built with privacy-first principles, modern Swift patterns, and cross-platform architecture. + +**Key Directories:** +- `iOS/` - iOS browser app (UIKit + SwiftUI hybrid) +- `macOS/` - macOS browser app (AppKit + SwiftUI hybrid) +- `SharedPackages/` - Cross-platform Swift packages + +## Architecture Summary +- **Pattern**: MVVM + Coordinators + Dependency Injection +- **UI**: SwiftUI preferred, UIKit/AppKit for legacy +- **Storage**: Core Data + GRDB + Keychain for sensitive data +- **Design**: DesignResourcesKit for colors/icons (MANDATORY) +- **Testing**: >80% coverage required + +## Available Rules (`.cursor/rules/`) + +Development rules are stored in `.cursor/rules/`. +You MUST list all the available rules and you MUST consult the appropriate rule file before starting any work! + +### Core (Always Apply) +- `anti-patterns.mdc` - What NOT to do; use with ViewModels, testing, WebView work +- `code-style.mdc` - Swift style guide +- `privacy-security.mdc` - Privacy requirements; use with network calls, analytics, credentials +- `import-hygiene.mdc` - Import management and SwiftUI preview scoping +- `logging-guidelines.mdc` - Logger usage (never print()) + +### Architecture & Patterns +- `architecture.mdc` - MVVM, DI patterns; use for new ViewModels +- `project-structure.mdc` - Directory layout +- `browserserviceskit-integration.mdc` - BSK integration; use for cross-platform code +- `shared-packages.mdc` - Cross-platform packages; use for cross-platform code +- `subscription-architecture.mdc` - Privacy Pro subscription + +### Feature Development +- `feature-flags.mdc` + `feature-flags-addition.mdc` - Feature flags +- `abn-experiment-framework.mdc` - A/B testing +- `user-defaults-storage.mdc` - UserDefaults, @UserDefaultsWrapper; use for settings/preferences + +### UI Development +- `swiftui-style.mdc` - SwiftUI + DesignResourcesKit; use for new ViewModels, UI work +- `swiftui-advanced.mdc` - Advanced SwiftUI patterns +- `design-system-designresourceskit.mdc` - Colors, typography, icons (MANDATORY) +- `webkit-browser.mdc` - WebView patterns + +### Platform-Specific +- `ios-architecture.mdc` - iOS AppDependencyProvider, MainCoordinator, UIKit +- `ios-tracker-blocking-implementation.mdc` - iOS content blocking +- `macos-window-management.mdc` - macOS windows +- `macos-system-integration.mdc` - macOS system services +- `macos-singletons-removal.mdc` - Removing singletons from macOS + +### Feature-Specific +- `duckplayer.mdc` + `duckplayer-userscript-integration.mdc` - DuckPlayer +- `securevault-guidelines.mdc` - Credentials/vault storage +- `app-lifecycle-state-machine.mdc` - App state management +- `network-quality-*.mdc` (4 files) - Network quality assessment + +### Testing & Quality +- `testing.mdc` - Testing patterns, xcodebuild commands +- `ui-testing.mdc` - UI testing for macOS browser +- `maestro-device-selection.mdc` - Maestro test device config +- `performance-optimization.mdc` - Performance; use with network calls + +### Workflow & Process +- `development-commands.mdc` - Build commands +- `pull-request.mdc` + `branch-naming-conventions.mdc` - PRs and git workflow +- `analytics-patterns.mdc` - Pixel analytics + +## Quick Start Checklist + +### Before Writing Any Code: +1. βœ… Read `privacy-security.mdc` - Privacy is non-negotiable +2. βœ… Check platform rules (`ios-architecture.mdc` or `macos-system-integration.mdc`) +3. βœ… Review `anti-patterns.mdc` - Avoid common mistakes +4. βœ… REMEMBER: NEVER commit, push, or run tests without explicit user permission or unless explicitly asked to + +### For UI Development: +1. βœ… Use `swiftui-style.mdc` for SwiftUI components +2. βœ… MUST use DesignResourcesKit colors: `Color(designSystemColor: .textPrimary)` +3. βœ… MUST use DesignResourcesKit icons: `DesignSystemImages.Glyphs.Size16.add` + +### For New Features: +1. βœ… Follow `architecture.mdc` for MVVM + DI patterns +2. βœ… Use AppDependencyProvider (iOS) or equivalent (macOS) +3. βœ… Write tests per `testing.mdc` requirements + +## Critical Don'ts (from anti-patterns.mdc) +- ❌ NEVER commit, push changes, create or delete branches on git or trigger github actions without EXPLICIT user permission +- ❌ NEVER run tests without EXPLICIT user permission or if user explicitly asked to in their prompt +- ❌ NEVER use `.shared` singletons - use dependency injection instead +- ❌ NEVER hardcode colors/icons (use DesignResourcesKit) +- ❌ NEVER update UI without @MainActor +- ❌ NEVER ignore privacy implications +- ❌ NEVER force unwrap without justification +- ❌ NEVER use `print()` statements - use appropriate Logger extensions instead + +## Logging Guidelines + +**NEVER use `print()` in production code. ALWAYS use appropriate Logger extensions:** + +**Example:** See [logging-guidelines.swift](general/logging-guidelines.swift) + +**Available Logger categories:** +- `Logger.general` - General app functionality +- `Logger.network` - Network requests and responses +- `Logger.ui` - UI updates and user interactions +- `Logger.tests` - Test-specific logging (import `os.log` in tests) + +**Benefits of Logger extensions:** +- Structured logging with categories and levels +- Better performance than print() statements +- Automatic log collection and filtering +- Integration with system logging infrastructure + +## Dependency Injection Pattern (iOS) +**Example:** See [dependency-injection-pattern.swift](general/dependency-injection-pattern.swift) + +## Design System Usage (MANDATORY) +**Example:** See [design-system-usage.swift](general/design-system-usage.swift) + +## Code Review Checklist +1. Privacy implications assessed (`privacy-security.mdc`) +2. Design system properly used (`design-system-designresourceskit.mdc`) +3. Architecture patterns followed (platform-specific rules) +4. Anti-patterns avoided (`anti-patterns.mdc`) +5. Tests written and passing (`testing.mdc`) +6. Performance considered (`performance-optimization.mdc`) +7. PR template followed (`pull-request.mdc`) + +## Git & Testing Workflow Rules + +### 🚨 MANDATORY: Never Auto-Execute Commands +**NEVER commit, push, or run tests without EXPLICIT user permission.** + +#### Git Workflow: +1. Make file changes as requested +2. **STOP** before any `git add`, `git commit`, or `git push` commands +3. **ASK** the user: "Should I commit/push these changes?" +4. **WAIT** for explicit permission (e.g., "yes", "commit it", "push it", "go ahead") +5. Only then execute git commands + +#### Testing Workflow: +1. Write or modify code as requested +2. **STOP** before running any tests (`swift test`, `npm test`, `xcodebuild test`, etc.) +3. **ASK** the user: "Should I run the tests?" +4. **WAIT** for explicit permission (e.g., "yes", "run tests", "test it") +5. Only then execute test commands + +#### What NOT to Do: +**Example:** See [git-workflow-wrong.sh](general/git-workflow-wrong.sh) + +#### What TO Do: +**Example:** See [git-workflow-correct.sh](general/git-workflow-correct.sh) + +**These rules have NO exceptions. Always ask before executing git or test commands.** + +## Communication Style + +- Keep responses concise and focused on the task +- Avoid enthusiastic language like "Perfect!", "You are absolutely right!", "Excellent!" +- Keep work summaries brief - focus on what was changed, not how great it is +- Let the code quality speak for itself rather than using excessive praise + +--- + +This overview ensures you understand the project context and know which specific rules to consult for your development task. diff --git a/work/wiki/apple-browsers/import-hygiene.mdc b/work/wiki/apple-browsers/import-hygiene.mdc new file mode 100644 index 00000000..1c298647 --- /dev/null +++ b/work/wiki/apple-browsers/import-hygiene.mdc @@ -0,0 +1,54 @@ +--- +source: ~/DuckDuckGo/apple-browsers.git/main/.cursor/rules/import-hygiene.mdc +confidence: 0.9 +namespace: work +last_synced: 2026-04-28 +alwaysApply: true +--- + +# Import Hygiene & Preview-Only Imports + +## Purpose +Prevent accidental or unrequested import churn that causes build/lint issues and diffs unrelated to the task. Ensure SwiftUI is only imported where required (e.g., #Preview blocks) and avoid touching existing imports unless strictly necessary. + +## Rules (Always Apply) + +1. Do not change imports unless: + - A new symbol is introduced that the compiler cannot resolve without the import + - An existing import is provably unused and removal is part of the explicit task scope + - The change resolves a red compiler error you introduced in this edit + +2. Keep platform/framework imports minimal and local: + - Prefer `import AppKit` for macOS UI code + - Prefer `import UIKit` for iOS UI code + - Do not add `import SwiftUI` to AppKit/UIKit view controllers unless they embed SwiftUI. + +3. Scope SwiftUI to previews: + - Only import `SwiftUI` inside `#if DEBUG` blocks for `#Preview` declarations + - **Example:** See [swiftui-preview-import.swift](import-hygiene/swiftui-preview-import.swift) + +4. Keep Shared Modules stable: + - Do not remove `import Common` or other project modules unless a dedicated cleanup task + - If a module is required elsewhere in the file, do not move or duplicate it + +5. Lint & Build first, then adjust: + - If a file shows missing-types errors after your edits (e.g., `Cannot find type 'FireproofDomains'`), prefer adding the specific missing import required for those existing symbols + - Avoid speculative imports + +6. No import reordering for style-only reasons unless the repository enforces it via formatter + +## Rationale +- Unnecessary import edits generate churn and can break platform- or target-specific build settings +- Scoping SwiftUI to previews avoids accidental framework inclusion and linking in non-preview code paths + +## Examples + +- **CORRECT (AppKit-only controller):** See [appkit-only-controller.swift](import-hygiene/appkit-only-controller.swift) + +- **CORRECT (preview-only SwiftUI):** See [preview-only-swiftui.swift](import-hygiene/preview-only-swiftui.swift) + +- **AVOID:** See [import-to-avoid.swift](import-hygiene/import-to-avoid.swift) + +## Enforcement Guidance +- During PR review, reject changes that add/remove imports without a clear necessity +- Prefer comments in code review over automated reordering unless enforced by tooling diff --git a/work/wiki/apple-browsers/instrumentation-facades.mdc b/work/wiki/apple-browsers/instrumentation-facades.mdc new file mode 100644 index 00000000..7dfbbdec --- /dev/null +++ b/work/wiki/apple-browsers/instrumentation-facades.mdc @@ -0,0 +1,207 @@ +--- +source: ~/DuckDuckGo/apple-browsers.git/main/.cursor/rules/instrumentation-facades.mdc +confidence: 0.9 +namespace: work +last_synced: 2026-04-28 +description: Pattern for abstracting pixel and wide event instrumentation behind domain-specific protocols. +alwaysApply: false +--- +# Instrumentation Facades + +Feature code often becomes verbose when sending many pixels, or mixing pixel calls and wide event lifecycle management. + +Consider a subscription purchase flow that needs to: +- Fire a daily pixel when purchase starts +- Start a wide event flow +- Update the wide event with timing data +- Fire unique pixels on success +- Complete the wide event with success/failure/cancelled status +- Handle multiple error cases with different failing steps + +This leads to instrumentation code scattered throughout the feature, making it hard to: +- Understand the feature's core logic +- Test the feature in isolation +- Modify instrumentation without touching feature code +- Ensure all instrumentation points are covered + +We can improve this using the facade pattern, abstracting our instrumentation behind a protocol. This is done by defining a protocol with domain-specific hooks that the feature calls, then implementing the protocol in a dedicated object that handles all instrumentation. + +### Benefits + +1. **Feature code emits domain events only** - Cleaner, more readable feature logic +2. **Instrumentation logic is centralized** - Easy to audit and modify +3. **Easy to inject mocks for unit testing** - Test feature behavior without pixel dependencies + +## File Organization + +Instrumentation facades should be placed in the **same module as the feature** they instrument: + +| Component | Location | +|-----------|----------| +| Protocol | Feature module (e.g., `Subscription/SubscriptionPurchaseInstrumentation.swift`) | +| Default Implementation | Same module as protocol | +| Mock for Testing | Test target or same module | + +For features that span iOS and macOS, place the protocol and implementation in a shared package (e.g., `BrowserServicesKit`). + +## Pattern Structure + +### Step 1: Define the Protocol + +Create a protocol with methods for each instrumentation hook your feature needs. Name methods after domain events, not pixels: + +```swift +public protocol SubscriptionPurchaseInstrumentation: AnyObject { + func purchaseAttemptStarted(selectionID: String, freeTrialEligible: Bool, ...) + func purchaseCancelled() + func purchaseFailed(step: FailingStep, error: Error) + func activationSucceeded() + // ... other domain events +} +``` + +### Step 2: Implement the Default Class + +Create an implementation that translates domain events to pixels and wide events. The implementation: +- Fires appropriate pixels (standard, daily, or unique) +- Manages wide event lifecycle (start, update, complete) +- Tracks internal state like the current wide event data + +```swift +public final class DefaultSubscriptionPurchaseInstrumentation: SubscriptionPurchaseInstrumentation { + private let wideEvent: WideEventManaging + private var purchaseWideEventData: SubscriptionPurchaseWideEventData? + + public func purchaseAttemptStarted(...) { + DailyPixel.fireDailyAndCount(pixel: .subscriptionPurchaseAttempt, ...) + purchaseWideEventData = SubscriptionPurchaseWideEventData(...) + wideEvent.startFlow(purchaseWideEventData!) + } + + public func activationSucceeded() { + UniquePixel.fire(pixel: .subscriptionActivated) + wideEvent.completeFlow(purchaseWideEventData!, status: .success, ...) + } +} +``` + +### Step 3: Use in Feature Code + +Inject the instrumentation protocol and call it at appropriate points in your feature logic: + +```swift +final class SubscriptionPurchaseFeature { + private let instrumentation: SubscriptionPurchaseInstrumentation + + func subscriptionSelected(...) async { + instrumentation.purchaseAttemptStarted(...) + + switch await performPurchase() { + case .success: + instrumentation.activationSucceeded() + case .failure(let error): + instrumentation.purchaseFailed(step: .accountPayment, error: error) + } + } +} +``` + +## Dependency Injection + +### Constructor Injection (Preferred) + +Pass the instrumentation as an init parameter: + +```swift +final class SubscriptionPurchaseFeature { + private let instrumentation: SubscriptionPurchaseInstrumentation + + init(instrumentation: SubscriptionPurchaseInstrumentation = DefaultSubscriptionPurchaseInstrumentation()) { + self.instrumentation = instrumentation + } +} +``` + +### Property Injection + +For cases where the instrumentation is set after initialization (e.g., UserScripts): + +```swift +final class DebugUserScript { + weak var instrumentation: TabInstrumentationProtocol? +} + +// In the parent object: +private let instrumentation = TabInstrumentation() + +func configureUserScripts() { + userScripts.debugScript.instrumentation = instrumentation +} +``` + +## Testing with Mocks + +Create a mock that records method calls for verification: + +```swift +final class MockSubscriptionPurchaseInstrumentation: SubscriptionPurchaseInstrumentation { + private(set) var purchaseAttemptStartedCalls: [...] = [] + private(set) var activationSucceededCallCount = 0 + + func purchaseAttemptStarted(...) { + purchaseAttemptStartedCalls.append(...) + } +} +``` + +Then verify behavior in tests: + +```swift +func testPurchaseSuccess() async { + let mock = MockSubscriptionPurchaseInstrumentation() + let feature = SubscriptionPurchaseFeature(instrumentation: mock) + + await feature.subscriptionSelected(...) + + XCTAssertEqual(mock.activationSucceededCallCount, 1) +} +``` + +## Alternative: EventMapping for Shared Packages + +For features in shared Swift packages that can't directly import `Pixel` or `PixelKit`, use `EventMapping` instead of a full instrumentation facade. See `SharedPackages/BrowserServicesKit/Sources/Common/EventMapping.swift` for the base class. + +The pattern: +1. **Define events in the shared package** as an enum (e.g., `MyFeatureEvent`) +2. **Create an EventMapper in the app target** that switches on events and fires the appropriate pixels +3. **Inject the EventMapping** into your shared package class + +See `MaliciousSiteProtectionEventMapper` in `iOS/DuckDuckGo/MaliciousSiteProtection/Events/` for a production example. + +**When to use EventMapping vs Instrumentation Facades:** +- Use **EventMapping** when: Feature is in a shared package, events are simple fire-and-forget +- Use **Instrumentation Facades** when: Feature needs wide event lifecycle management, complex state tracking, or many related instrumentation calls + +## When to Use Instrumentation Facades + +Use this pattern when any of the following are true: + +- A feature has 3+ distinct instrumentation calls +- Pixels and wide events are mixed in the same flow +- You need to test feature logic without pixel side effects +- Instrumentation logic is complex (conditional firing, parameter assembly) + +Skip this pattern for: + +- Simple features with 1-2 pixels + +## Design Guidelines + +1. **Name methods after domain events, not pixels**: Use `purchaseAttemptStarted`, not `fireSubscriptionPurchaseAttemptPixel`. +2. **Keep the protocol focused**: One protocol per feature/flow. Don't create a mega-protocol for all app instrumentation. +3. **Hide implementation details**: The protocol shouldn't expose whether something is a daily pixel, unique pixel, or wide event. +4. **Document expected call order**: If methods must be called in sequence (e.g., `startPurchase` before `completePurchase`), document this in the protocol. + +## Related Documentation + +- `pixels.mdc` - One-off instrumentation events diff --git a/work/wiki/apple-browsers/ios-architecture.mdc b/work/wiki/apple-browsers/ios-architecture.mdc new file mode 100644 index 00000000..33a2f131 --- /dev/null +++ b/work/wiki/apple-browsers/ios-architecture.mdc @@ -0,0 +1,447 @@ +--- +source: ~/DuckDuckGo/apple-browsers.git/main/.cursor/rules/ios-architecture.mdc +confidence: 0.9 +namespace: work +last_synced: 2026-04-28 +alwaysApply: false +--- + +# iOS DuckDuckGo Browser Architecture Rules + +## Dependency Injection and AppDependencies + +### Use AppDependencyProvider Pattern +ALWAYS use the centralized dependency provider for all service dependencies: + +```swift +// βœ… CORRECT - Use the shared dependency provider +final class FeatureViewModel: ObservableObject { + private let networkService: NetworkServiceProtocol + + init(dependencies: DependencyProvider = AppDependencyProvider.shared) { + self.networkService = dependencies.networkService + } +} + +// ❌ INCORRECT - Direct singleton access +final class FeatureViewModel: ObservableObject { + private let networkService = NetworkService.shared // Avoid singletons +} +``` + +### Protocol-Based Dependencies +ALWAYS define protocols for dependencies to enable testing: + +```swift +// βœ… CORRECT - Protocol abstraction +protocol FeatureServiceProtocol { + func fetchData() async throws -> [Item] +} + +final class FeatureService: FeatureServiceProtocol { + // Implementation +} + +// ❌ INCORRECT - Concrete dependency +final class ViewModel { + private let service: FeatureService // Hard to test +} +``` + +## AppSettings and Configuration + +### Use AppSettings Protocol +ALWAYS access settings through the AppSettings protocol: + +```swift +// βœ… CORRECT - Protocol-based settings access +final class SettingsViewModel: ObservableObject { + private let appSettings: AppSettings + + init(appSettings: AppSettings) { + self.appSettings = appSettings + } + + var isFeatureEnabled: Bool { + get { appSettings.featureEnabled } + set { appSettings.featureEnabled = newValue } + } +} + +// ❌ INCORRECT - Direct UserDefaults access +final class SettingsViewModel: ObservableObject { + var isFeatureEnabled: Bool { + get { UserDefaults.standard.bool(forKey: "feature_enabled") } + set { UserDefaults.standard.set(newValue, forKey: "feature_enabled") } + } +} +``` + +### UserDefaults Property Wrapper +Use the established @UserDefaultsWrapper pattern for new settings: + +```swift +// βœ… CORRECT - Property wrapper usage +extension AppUserDefaults { + @UserDefaultsWrapper(key: .newFeatureEnabled, defaultValue: false) + var newFeatureEnabled: Bool +} + +// ❌ INCORRECT - Manual UserDefaults handling +extension AppUserDefaults { + var newFeatureEnabled: Bool { + get { userDefaults.bool(forKey: "new_feature_enabled") } + set { userDefaults.set(newValue, forKey: "new_feature_enabled") } + } +} +``` + +## Navigation and Coordinators + +### Use MainCoordinator for App-Level Navigation +ALWAYS use MainCoordinator for deep links, URL handling, and app-level navigation: + +```swift +// βœ… CORRECT - MainCoordinator usage +@MainActor +final class FeatureCoordinator { + private weak var mainCoordinator: MainCoordinator? + + func handleFeatureAction() { + mainCoordinator?.handleURL(featureURL) + } +} + +// ❌ INCORRECT - Direct navigation from ViewModels +final class FeatureViewModel: ObservableObject { + func handleAction() { + // Don't navigate directly from ViewModels + navigationController?.pushViewController(detailVC, animated: true) + } +} +``` + +### URL Handling Pattern +Implement URLHandling protocol for custom URL schemes: + +```swift +// βœ… CORRECT - URLHandling protocol implementation +extension FeatureCoordinator: URLHandling { + func handleURL(_ url: URL) { + guard url.scheme == "duckduckgo", + url.host == "feature" else { return } + + presentFeature(with: url.queryParameters) + } + + func shouldProcessDeepLink(_ url: URL) -> Bool { + return url.scheme == "duckduckgo" && url.host == "feature" + } +} +``` + +## SwiftUI and Design System Integration + +### Use DesignResourcesKit Colors +ALWAYS use semantic colors from DesignResourcesKit: + +```swift +// βœ… CORRECT - Semantic color usage +struct FeatureView: View { + var body: some View { + VStack { + Text("Title") + .foregroundColor(Color(designSystemColor: .textPrimary)) + + Rectangle() + .fill(Color(designSystemColor: .surface)) + } + .background(Color(designSystemColor: .background)) + } +} + +// ❌ INCORRECT - Hardcoded colors +struct FeatureView: View { + var body: some View { + VStack { + Text("Title") + .foregroundColor(.black) // Don't hardcode colors + + Rectangle() + .fill(.gray) // Use semantic colors instead + } + } +} +``` + +### Use DesignResourcesKit Icons +ALWAYS use icons from DesignResourcesKitIcons: + +```swift +// βœ… CORRECT - Design system icons +struct IconButton: View { + var body: some View { + Button(action: action) { + Image(uiImage: DesignSystemImages.Glyphs.Size16.add) + .foregroundColor(Color(designSystemColor: .accent)) + } + } +} + +// ❌ INCORRECT - System icons or custom images +struct IconButton: View { + var body: some View { + Button(action: action) { + Image(systemName: "plus") // Use design system icons + } + } +} +``` + +### Theme Integration +Use Theme protocol for complex color requirements: + +```swift +// βœ… CORRECT - Theme integration +struct ThemedView: View { + @EnvironmentObject var themeManager: ThemeManager + + var body: some View { + Rectangle() + .fill(Color(themeManager.currentTheme.backgroundColor)) + } +} +``` + +## MVVM Pattern Implementation + +### ObservableObject ViewModels +ALWAYS use ObservableObject for SwiftUI ViewModels: + +```swift +// βœ… CORRECT - ObservableObject ViewModel +@MainActor +final class FeatureViewModel: ObservableObject { + @Published private(set) var items: [Item] = [] + @Published private(set) var isLoading = false + @Published private(set) var error: Error? + + private let service: FeatureServiceProtocol + private var cancellables = Set() + + init(service: FeatureServiceProtocol) { + self.service = service + setupBindings() + } + + func loadData() async { + isLoading = true + defer { isLoading = false } + + do { + items = try await service.fetchItems() + error = nil + } catch { + self.error = error + } + } + + private func setupBindings() { + // Setup Combine bindings + } +} +``` + +### Published Property Guidelines +- Use `@Published private(set)` for read-only state +- Use `@Published` for two-way bindings +- Always mark ViewModels with `@MainActor` + +## Async/Await Patterns + +### MainActor Usage +ALWAYS use @MainActor for UI-related async operations: + +```swift +// βœ… CORRECT - MainActor for UI updates +@MainActor +final class CoordinatorClass { + func presentModal() async { + let viewModel = try await createViewModel() + let hostingController = UIHostingController(rootView: FeatureView(viewModel: viewModel)) + navigationController.present(hostingController, animated: true) + } +} + +// ❌ INCORRECT - UI updates without MainActor +final class CoordinatorClass { + func presentModal() async { + let viewModel = try await createViewModel() + // This will crash - UI updates must be on main thread + navigationController.present(hostingController, animated: true) + } +} +``` + +### Task Management +Use Task for async operations in ViewModels: + +```swift +// βœ… CORRECT - Task usage in ViewModels +final class ViewModel: ObservableObject { + func performAction() { + Task { + await loadData() + } + } +} +``` + +## Singleton Pattern Guidelines + +### Avoid Singletons - Use Dependency Injection +```swift +// ❌ INCORRECT - Singleton pattern +final class FeatureManager { + static let shared = FeatureManager() + private init() {} +} + +// βœ… CORRECT - Dependency injection +protocol FeatureManagerProtocol { + func performAction() +} + +final class FeatureManager: FeatureManagerProtocol { + func performAction() { + // Implementation + } +} + +// Register in AppDependencyProvider +extension AppDependencyProvider { + var featureManager: FeatureManagerProtocol { + return FeatureManager() + } +} +``` + +### Acceptable Singleton Usage +Only use singletons for truly global state that must be shared across the entire app: + +```swift +// βœ… ACCEPTABLE - Global theme management +final class ThemeManager { + static let shared = ThemeManager() + private init() {} +} + +// βœ… ACCEPTABLE - Content blocking state +final class ContentBlocking { + static let shared = ContentBlocking() + private init() {} +} +``` + +## Error Handling + +### Use Result Types for Service Layer +```swift +// βœ… CORRECT - Result types in services +protocol NetworkServiceProtocol { + func fetchData() async -> Result +} + +// βœ… CORRECT - Async throws in ViewModels +final class ViewModel: ObservableObject { + func loadData() async { + do { + let result = try await service.fetchData() + // Handle success + } catch { + // Handle error + } + } +} +``` + +### Custom Error Types +Define domain-specific error types: + +```swift +// βœ… CORRECT - Domain-specific errors +enum FeatureError: LocalizedError { + case networkUnavailable + case invalidData + case unauthorized + + var errorDescription: String? { + switch self { + case .networkUnavailable: + return "Network connection unavailable" + case .invalidData: + return "Invalid data received" + case .unauthorized: + return "User not authorized" + } + } +} +``` + +## Performance Optimization + +### Lazy Loading for Expensive Operations +```swift +// βœ… CORRECT - Lazy property initialization +final class DataManager { + private lazy var expensiveResource: ExpensiveResource = { + return ExpensiveResource() + }() +} +``` + +### Background Processing +```swift +// βœ… CORRECT - Background processing with main thread UI updates +final class ViewModel: ObservableObject { + @Published var result: ProcessedData? + + func processData() { + Task.detached(priority: .userInitiated) { + let processed = await heavyProcessing() + + await MainActor.run { + self.result = processed + } + } + } +} +``` + +## Testing Requirements + +### Testable Architecture +ALWAYS write testable code using dependency injection: + +```swift +// βœ… CORRECT - Testable implementation +final class FeatureViewModel: ObservableObject { + private let service: FeatureServiceProtocol + + init(service: FeatureServiceProtocol) { + self.service = service + } +} + +// Test setup +final class FeatureViewModelTests: XCTestCase { + func testLoadData() async { + let mockService = MockFeatureService() + let viewModel = FeatureViewModel(service: mockService) + + await viewModel.loadData() + + XCTAssertTrue(mockService.fetchDataCalled) + } +} +``` diff --git a/work/wiki/apple-browsers/ios-tracker-blocking-implementation.mdc b/work/wiki/apple-browsers/ios-tracker-blocking-implementation.mdc new file mode 100644 index 00000000..9f9ddf77 --- /dev/null +++ b/work/wiki/apple-browsers/ios-tracker-blocking-implementation.mdc @@ -0,0 +1,923 @@ +--- +source: ~/DuckDuckGo/apple-browsers.git/main/.cursor/rules/ios-tracker-blocking-implementation.mdc +confidence: 0.9 +namespace: work +last_synced: 2026-04-28 +alwaysApply: false +--- + +# iOS Tracker Blocking Implementation Guide + +## Overview + +This document provides a comprehensive overview of how content blocking has been implemented on iOS to protect user privacy and block tracking attempts. Our implementation uses a dual-approach strategy that combines the efficiency of WebKit's native content blocking with the flexibility of JavaScript-based protection. + +**Implementation Strategy:** +- **Primary Layer**: Content Blocker Rules (WebKit native, compiled to bytecode) +- **Secondary Layer**: JavaScript injection with Tracker Radar data (gap coverage + surrogates) + +## Content Blocker Rules + +### Architecture Overview + +Content Blocker Rules form the primary layer of our tracker blocking implementation. These rules are converted from our Tracker Radar dataset into Apple's Content Blocker Rules format and compiled by WebKit into efficient bytecode for optimal performance. + +**Key Characteristics:** +- βœ… **High Performance**: Rules are compiled to bytecode by WebKit +- βœ… **Low Memory Footprint**: Optimized for mobile devices +- βœ… **Battery Efficient**: Minimal CPU overhead +- ❌ **Limited Flexibility**: Cannot support complex logic or surrogates +- ❌ **No Runtime Modification**: Rules are static once compiled + +### Implementation Details + +#### ContentBlockerRulesManager + +The `ContentBlockerRulesManager` is responsible for converting Tracker Radar data into Apple's Content Blocker format and managing the rule compilation process. + +```swift +import WebKit +import BrowserServicesKit + +final class ContentBlockerRulesManager { + private let trackerDataManager: TrackerDataManager + private let compilationQueue = DispatchQueue(label: "content-blocker-compilation", qos: .utility) + + init(trackerDataManager: TrackerDataManager) { + self.trackerDataManager = trackerDataManager + } + + /// Converts Tracker Radar data to Content Blocker Rules format + func generateContentBlockerRules() async throws -> [WKContentRuleList] { + return try await withCheckedThrowingContinuation { continuation in + compilationQueue.async { + do { + let trackerData = self.trackerDataManager.embeddedTrackerData + let rules = self.convertToContentBlockerFormat(trackerData) + let ruleList = try self.compileRules(rules) + continuation.resume(returning: [ruleList]) + } catch { + continuation.resume(throwing: error) + } + } + } + } + + /// Converts TrackerData to Apple's Content Blocker Rules JSON format + private func convertToContentBlockerFormat(_ trackerData: TrackerData) -> [[String: Any]] { + var rules: [[String: Any]] = [] + + for (domain, tracker) in trackerData.trackers { + for rule in tracker.rules ?? [] { + let contentBlockerRule: [String: Any] = [ + "trigger": [ + "url-filter": rule.rule, + "resource-type": rule.resourceTypes, + "if-domain": rule.whitelist?.compactMap { "*\($0)" } + ].compactMapValues { $0 }, + "action": [ + "type": "block" + ] + ] + rules.append(contentBlockerRule) + } + } + + return rules + } + + /// Compiles rules using WebKit's WKContentRuleListStore + private func compileRules(_ rules: [[String: Any]]) throws -> WKContentRuleList { + let jsonData = try JSONSerialization.data(withJSONObject: rules) + let jsonString = String(data: jsonData, encoding: .utf8)! + + return try await withCheckedThrowingContinuation { continuation in + WKContentRuleListStore.default().compileContentRuleList( + forIdentifier: "DuckDuckGoContentBlocker", + encodedContentRuleList: jsonString + ) { ruleList, error in + if let error = error { + continuation.resume(throwing: error) + } else if let ruleList = ruleList { + continuation.resume(returning: ruleList) + } else { + continuation.resume(throwing: ContentBlockerError.compilationFailed) + } + } + } + } +} + +enum ContentBlockerError: Error { + case compilationFailed + case invalidRuleFormat + case trackerDataUnavailable +} +``` + +#### Integration with WKWebView + +Content Blocker Rules are applied to WKWebView through the `WKUserContentController`: + +```swift +extension BrowserWebView { + func applyContentBlockerRules() async { + do { + let ruleLists = try await contentBlockerManager.generateContentBlockerRules() + + await MainActor.run { + for ruleList in ruleLists { + webView.configuration.userContentController.add(ruleList) + } + Logger.privacy.info("Applied \(ruleLists.count) content blocker rule lists") + } + } catch { + Logger.privacy.error("Failed to apply content blocker rules: \(error)") + // Fallback to JavaScript-only blocking + await applyJavaScriptBlockingOnly() + } + } +} +``` + +### ContentBlockerRulesUserScript + +To understand which resources were blocked by Content Blocker Rules, we inject JavaScript that monitors network requests and infers blocking behavior: + +```swift +final class ContentBlockerRulesUserScript { + static let source = """ + (function() { + 'use strict'; + + const blockedResources = new Set(); + const allowedResources = new Set(); + + // Monitor XMLHttpRequest + const originalXHROpen = XMLHttpRequest.prototype.open; + XMLHttpRequest.prototype.open = function(method, url, async, user, password) { + const startTime = Date.now(); + + this.addEventListener('loadend', function() { + const duration = Date.now() - startTime; + + if (this.status === 0 && duration < 10) { + // Likely blocked by content blocker + blockedResources.add(url); + reportBlockedResource(url, 'xhr'); + } else { + allowedResources.add(url); + } + }); + + return originalXHROpen.call(this, method, url, async, user, password); + }; + + // Monitor Fetch API + const originalFetch = window.fetch; + window.fetch = function(input, init) { + const url = typeof input === 'string' ? input : input.url; + const startTime = Date.now(); + + return originalFetch.call(this, input, init) + .then(response => { + allowedResources.add(url); + return response; + }) + .catch(error => { + const duration = Date.now() - startTime; + if (duration < 10) { + blockedResources.add(url); + reportBlockedResource(url, 'fetch'); + } + throw error; + }); + }; + + // Monitor image loading + const originalImageSrc = Object.getOwnPropertyDescriptor(Image.prototype, 'src'); + Object.defineProperty(Image.prototype, 'src', { + set: function(value) { + const img = this; + + img.addEventListener('error', function() { + if (img.naturalWidth === 0 && img.naturalHeight === 0) { + blockedResources.add(value); + reportBlockedResource(value, 'image'); + } + }); + + img.addEventListener('load', function() { + allowedResources.add(value); + }); + + return originalImageSrc.set.call(this, value); + }, + get: originalImageSrc.get + }); + + function reportBlockedResource(url, type) { + if (window.webkit && window.webkit.messageHandlers && window.webkit.messageHandlers.contentBlocker) { + window.webkit.messageHandlers.contentBlocker.postMessage({ + type: 'blocked', + url: url, + resourceType: type, + timestamp: Date.now() + }); + } + } + + // Report statistics periodically + setInterval(function() { + if (window.webkit && window.webkit.messageHandlers && window.webkit.messageHandlers.contentBlocker) { + window.webkit.messageHandlers.contentBlocker.postMessage({ + type: 'statistics', + blocked: blockedResources.size, + allowed: allowedResources.size, + timestamp: Date.now() + }); + } + }, 5000); + })(); + """ +} +``` + +## JavaScript Injection with Tracker Radar + +### Purpose and Advantages + +While Content Blocker Rules provide excellent performance, they have limitations that JavaScript injection can address: + +**JavaScript Injection Advantages:** +- βœ… **Surrogate Support**: Can inject replacement scripts to prevent page breakage +- βœ… **Runtime Logic**: Complex blocking decisions based on page context +- βœ… **Gap Coverage**: Handles edge cases missed by static rules +- βœ… **Real-time Updates**: Can adapt to new tracking techniques +- ❌ **Performance Overhead**: Higher CPU and memory usage +- ❌ **Battery Impact**: More intensive than native blocking + +### ContentBlockerUserScript Implementation + +The `ContentBlockerUserScript` implements the full Tracker Radar blocking algorithm in JavaScript: + +```swift +final class ContentBlockerUserScript { + private let trackerDataManager: TrackerDataManager + private let surrogateManager: SurrogateManager + + init(trackerDataManager: TrackerDataManager, surrogateManager: SurrogateManager) { + self.trackerDataManager = trackerDataManager + self.surrogateManager = surrogateManager + } + + var source: String { + return generateTrackerBlockingScript() + } + + private func generateTrackerBlockingScript() -> String { + let trackerData = trackerDataManager.embeddedTrackerData + let surrogates = surrogateManager.allSurrogates + + return """ + (function() { + 'use strict'; + + // Embedded tracker data + const TRACKER_DATA = \(trackerData.jsonString); + const SURROGATES = \(surrogates.jsonString); + + class TrackerBlocker { + constructor() { + this.blockedCount = 0; + this.allowedCount = 0; + this.setupInterception(); + } + + setupInterception() { + this.interceptXMLHttpRequest(); + this.interceptFetch(); + this.interceptScriptLoading(); + this.interceptImageLoading(); + this.setupMutationObserver(); + } + + shouldBlockResource(url, type, initiator) { + try { + const urlObj = new URL(url, window.location.href); + const domain = urlObj.hostname; + + // Check if domain is in tracker list + const tracker = TRACKER_DATA.trackers[domain]; + if (!tracker) return false; + + // Apply tracker rules + if (tracker.rules) { + for (const rule of tracker.rules) { + if (this.matchesRule(url, rule, type)) { + // Check for whitelist exceptions + if (rule.whitelist && this.matchesWhitelist(window.location.hostname, rule.whitelist)) { + return false; + } + return true; + } + } + } + + return false; + } catch (error) { + console.warn('Error checking tracker status:', error); + return false; + } + } + + matchesRule(url, rule, resourceType) { + // Simple regex matching for rule.rule + try { + const regex = new RegExp(rule.rule, 'i'); + if (!regex.test(url)) return false; + + // Check resource type restrictions + if (rule.resourceTypes && rule.resourceTypes.length > 0) { + return rule.resourceTypes.includes(resourceType); + } + + return true; + } catch (error) { + return false; + } + } + + matchesWhitelist(hostname, whitelist) { + return whitelist.some(domain => { + if (domain.startsWith('*.')) { + const suffix = domain.substring(2); + return hostname === suffix || hostname.endsWith('.' + suffix); + } + return hostname === domain; + }); + } + + getSurrogate(url) { + for (const surrogate of SURROGATES) { + if (surrogate.matches.some(pattern => { + try { + const regex = new RegExp(pattern, 'i'); + return regex.test(url); + } catch { + return false; + } + })) { + return surrogate.replacement; + } + } + return null; + } + + interceptXMLHttpRequest() { + const originalOpen = XMLHttpRequest.prototype.open; + const self = this; + + XMLHttpRequest.prototype.open = function(method, url, async, user, password) { + if (self.shouldBlockResource(url, 'xmlhttprequest', 'script')) { + self.blockedCount++; + self.reportBlocked(url, 'xhr'); + + // Simulate blocked request + setTimeout(() => { + const event = new Event('error'); + this.dispatchEvent(event); + }, 0); + return; + } + + self.allowedCount++; + return originalOpen.call(this, method, url, async, user, password); + }; + } + + interceptFetch() { + const originalFetch = window.fetch; + const self = this; + + window.fetch = function(input, init) { + const url = typeof input === 'string' ? input : input.url; + + if (self.shouldBlockResource(url, 'xmlhttprequest', 'script')) { + self.blockedCount++; + self.reportBlocked(url, 'fetch'); + return Promise.reject(new TypeError('Failed to fetch')); + } + + self.allowedCount++; + return originalFetch.call(this, input, init); + }; + } + + interceptScriptLoading() { + const self = this; + const originalCreateElement = document.createElement; + + document.createElement = function(tagName) { + const element = originalCreateElement.call(this, tagName); + + if (tagName.toLowerCase() === 'script') { + const originalSrcSetter = Object.getOwnPropertyDescriptor(HTMLScriptElement.prototype, 'src').set; + + Object.defineProperty(element, 'src', { + set: function(value) { + if (self.shouldBlockResource(value, 'script', 'document')) { + self.blockedCount++; + self.reportBlocked(value, 'script'); + + // Check for surrogate + const surrogate = self.getSurrogate(value); + if (surrogate) { + self.injectSurrogate(surrogate); + } + return; + } + + self.allowedCount++; + return originalSrcSetter.call(this, value); + }, + get: function() { + return this.getAttribute('src'); + } + }); + } + + return element; + }; + } + + interceptImageLoading() { + const self = this; + const originalImageSrc = Object.getOwnPropertyDescriptor(Image.prototype, 'src'); + + Object.defineProperty(Image.prototype, 'src', { + set: function(value) { + if (self.shouldBlockResource(value, 'image', 'document')) { + self.blockedCount++; + self.reportBlocked(value, 'image'); + return; + } + + self.allowedCount++; + return originalImageSrc.set.call(this, value); + }, + get: originalImageSrc.get + }); + } + + setupMutationObserver() { + const self = this; + const observer = new MutationObserver(function(mutations) { + mutations.forEach(function(mutation) { + mutation.addedNodes.forEach(function(node) { + if (node.nodeType === Node.ELEMENT_NODE) { + self.processNewElement(node); + } + }); + }); + }); + + observer.observe(document.body || document.documentElement, { + childList: true, + subtree: true + }); + } + + processNewElement(element) { + // Check scripts + if (element.tagName === 'SCRIPT' && element.src) { + if (this.shouldBlockResource(element.src, 'script', 'document')) { + this.blockedCount++; + this.reportBlocked(element.src, 'script'); + element.remove(); + + // Inject surrogate if available + const surrogate = this.getSurrogate(element.src); + if (surrogate) { + this.injectSurrogate(surrogate); + } + } + } + + // Check images (tracking pixels) + if (element.tagName === 'IMG' && element.src) { + if (this.shouldBlockResource(element.src, 'image', 'document')) { + this.blockedCount++; + this.reportBlocked(element.src, 'image'); + element.remove(); + } + } + + // Check iframes + if (element.tagName === 'IFRAME' && element.src) { + if (this.shouldBlockResource(element.src, 'subdocument', 'document')) { + this.blockedCount++; + this.reportBlocked(element.src, 'iframe'); + element.remove(); + } + } + } + + injectSurrogate(surrogateCode) { + try { + const script = document.createElement('script'); + script.textContent = surrogateCode; + script.setAttribute('data-surrogate', 'true'); + (document.head || document.documentElement).appendChild(script); + } catch (error) { + console.warn('Failed to inject surrogate:', error); + } + } + + reportBlocked(url, type) { + if (window.webkit && window.webkit.messageHandlers && window.webkit.messageHandlers.trackerBlocked) { + window.webkit.messageHandlers.trackerBlocked.postMessage({ + url: url, + type: type, + timestamp: Date.now() + }); + } + } + + getStatistics() { + return { + blocked: this.blockedCount, + allowed: this.allowedCount + }; + } + } + + // Initialize tracker blocker + window.duckduckgoTracker = new TrackerBlocker(); + + // Report statistics periodically + setInterval(function() { + const stats = window.duckduckgoTracker.getStatistics(); + if (window.webkit && window.webkit.messageHandlers && window.webkit.messageHandlers.trackerStats) { + window.webkit.messageHandlers.trackerStats.postMessage(stats); + } + }, 10000); + + })(); + """ + } +} +``` + +### Surrogate Support + +One of the key advantages of JavaScript injection is the ability to provide surrogate scripts that replace blocked trackers to prevent page breakage: + +```swift +final class SurrogateManager { + private let surrogates: [Surrogate] + + struct Surrogate: Codable { + let name: String + let matches: [String] // Regex patterns + let replacement: String // JavaScript code + } + + var allSurrogates: [Surrogate] { + return surrogates + } + + init() { + // Load surrogates from embedded data + self.surrogates = loadEmbeddedSurrogates() + } + + private func loadEmbeddedSurrogates() -> [Surrogate] { + // Common surrogates for popular tracking libraries + return [ + Surrogate( + name: "Google Tag Manager", + matches: ["googletagmanager\\.com/gtm\\.js"], + replacement: """ + window.dataLayer = window.dataLayer || []; + function gtag(){dataLayer.push(arguments);} + gtag('js', new Date()); + gtag('config', 'GA_MEASUREMENT_ID', { 'send_page_view': false }); + """ + ), + Surrogate( + name: "Google Analytics", + matches: ["google-analytics\\.com/analytics\\.js", "googletagmanager\\.com/gtag/js"], + replacement: """ + window.ga = window.ga || function() { + (ga.q = ga.q || []).push(arguments); + }; + ga.l = +new Date; + ga('create', 'UA-XXXXXXXX-X', 'auto'); + ga('send', 'pageview'); + """ + ), + Surrogate( + name: "Facebook Pixel", + matches: ["connect\\.facebook\\.net/.*?/fbevents\\.js"], + replacement: """ + window.fbq = function() {}; + window.fbq.push = function() {}; + window.fbq.loaded = true; + window.fbq.version = '2.0'; + window.fbq.queue = []; + """ + ) + ] + } +} +``` + +## Hybrid Integration Strategy + +### Layered Protection Approach + +The most effective tracker blocking combines both techniques: + +1. **Content Blocker Rules** handle the majority of tracking requests efficiently +2. **JavaScript Injection** provides gap coverage and surrogate support +3. **Message Handlers** coordinate between both layers + +```swift +extension BrowserViewController: WKScriptMessageHandler { + func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) { + switch message.name { + case "trackerBlocked": + handleTrackerBlocked(message.body) + case "trackerStats": + handleTrackerStatistics(message.body) + case "contentBlocker": + handleContentBlockerMessage(message.body) + default: + break + } + } + + private func handleTrackerBlocked(_ messageBody: Any) { + guard let data = messageBody as? [String: Any], + let url = data["url"] as? String, + let type = data["type"] as? String else { return } + + // Update privacy dashboard + privacyDashboard.recordBlockedTracker(url: url, type: type) + + // Update UI indicators + updateTrackerCountIndicator() + + Logger.privacy.debug("Blocked tracker: \(url) (\(type))") + } + + private func handleTrackerStatistics(_ messageBody: Any) { + guard let stats = messageBody as? [String: Any], + let blocked = stats["blocked"] as? Int, + let allowed = stats["allowed"] as? Int else { return } + + privacyDashboard.updateStatistics(blocked: blocked, allowed: allowed) + } +} +``` + +### Configuration and Initialization + +The complete setup combines both blocking techniques: + +```swift +extension BrowserWebView { + func setupTrackerBlocking() async { + let configuration = webView.configuration + let userContentController = configuration.userContentController + + // 1. Apply Content Blocker Rules + await applyContentBlockerRules() + + // 2. Add JavaScript-based blocking + let contentBlockerScript = WKUserScript( + source: ContentBlockerUserScript( + trackerDataManager: trackerDataManager, + surrogateManager: surrogateManager + ).source, + injectionTime: .atDocumentStart, + forMainFrameOnly: false + ) + userContentController.addUserScript(contentBlockerScript) + + // 3. Add inference script for Content Blocker Rules + let inferenceScript = WKUserScript( + source: ContentBlockerRulesUserScript.source, + injectionTime: .atDocumentStart, + forMainFrameOnly: false + ) + userContentController.addUserScript(inferenceScript) + + // 4. Register message handlers + userContentController.add(self, name: "trackerBlocked") + userContentController.add(self, name: "trackerStats") + userContentController.add(self, name: "contentBlocker") + + Logger.privacy.info("Tracker blocking initialized with hybrid approach") + } +} +``` + +## Why Not Content Blocking Extension? + +**Content Blocking Extensions** would apply to every WKWebView in iOS system-wide, but we chose our approach for specific reasons: + +### UX Control +- **Manual Activation Required**: Content Blocking Extensions require users to manually enable them in Settings +- **Limited User Guidance**: Difficult to provide contextual help for activation +- **Our Approach**: Complete control over blocking state and user experience + +### Surrogate Support +- **Extensions Cannot Inject**: Content Blocking Extensions can only block, not replace with surrogates +- **Page Breakage Risk**: Many sites depend on tracking scripts for functionality +- **Our Approach**: Intelligent replacement prevents site breakage + +### Dynamic Configuration +- **Static Rules Only**: Extensions cannot modify behavior based on user preferences +- **No A/B Testing**: Cannot experiment with different blocking strategies +- **Our Approach**: Runtime configuration and experimentation capability + +## Performance Considerations + +### Memory Usage +```swift +class TrackerBlockingPerformanceMonitor { + private var lastMemoryWarning: Date? + + func optimizeForMemoryPressure() { + // Reduce JavaScript blocker complexity under memory pressure + if let lastWarning = lastMemoryWarning, + Date().timeIntervalSince(lastWarning) < 60 { + // Rely more heavily on Content Blocker Rules + disableComplexJavaScriptBlocking() + } + } + + func handleMemoryWarning() { + lastMemoryWarning = Date() + + // Clear caches + trackerDataManager.clearCache() + surrogateManager.clearCache() + + // Temporarily disable non-essential blocking + temporarilyReduceBlockingComplexity() + } +} +``` + +### Battery Impact Monitoring +```swift +extension BrowserViewController { + func monitorBlockingPerformance() { + // Monitor JavaScript execution time + let startTime = CFAbsoluteTimeGetCurrent() + + webView.evaluateJavaScript("window.duckduckgoTracker.getStatistics()") { result, error in + let executionTime = CFAbsoluteTimeGetCurrent() - startTime + + if executionTime > 0.1 { // 100ms threshold + Logger.privacy.warning("Tracker blocking JS execution time: \(executionTime)s") + // Consider optimizing or reducing complexity + } + } + } +} +``` + +## Testing and Debugging + +### Unit Testing Content Blocker Rules +```swift +class ContentBlockerRulesTests: XCTestCase { + func testTrackerDataConversion() async throws { + let mockTrackerData = TrackerData.mock + let rulesManager = ContentBlockerRulesManager(trackerDataManager: MockTrackerDataManager(data: mockTrackerData)) + + let rules = try await rulesManager.generateContentBlockerRules() + + XCTAssertFalse(rules.isEmpty) + XCTAssertEqual(rules.count, 1) + } + + func testRuleCompilation() async throws { + let simpleRule = [ + [ + "trigger": [ + "url-filter": ".*tracker\\.com.*" + ], + "action": [ + "type": "block" + ] + ] + ] + + let jsonData = try JSONSerialization.data(withJSONObject: simpleRule) + let jsonString = String(data: jsonData, encoding: .utf8)! + + let ruleList = try await withCheckedThrowingContinuation { continuation in + WKContentRuleListStore.default().compileContentRuleList( + forIdentifier: "test", + encodedContentRuleList: jsonString + ) { ruleList, error in + if let error = error { + continuation.resume(throwing: error) + } else if let ruleList = ruleList { + continuation.resume(returning: ruleList) + } else { + continuation.resume(throwing: ContentBlockerError.compilationFailed) + } + } + } + + XCTAssertNotNil(ruleList) + } +} +``` + +### UI Testing with Tracker Blocking +```swift +class TrackerBlockingUITests: XCTestCase { + func testTrackerCounterUpdates() { + let app = XCUIApplication() + app.launch() + + // Navigate to a page with known trackers + app.textFields["urlField"].tap() + app.textFields["urlField"].typeText("https://example.com/page-with-trackers") + app.buttons["Go"].tap() + + // Wait for page load and blocking to take effect + sleep(3) + + // Check that tracker counter updated + let trackerCounter = app.staticTexts["trackerCount"] + XCTAssertTrue(trackerCounter.exists) + + let counterText = trackerCounter.label + XCTAssertTrue(counterText.contains("blocked")) + } +} +``` + +## Maintenance and Updates + +### Tracker Radar Updates +```swift +class TrackerDataUpdateManager { + func checkForUpdates() async { + let latestVersion = try await fetchLatestTrackerDataVersion() + let currentVersion = trackerDataManager.currentVersion + + if latestVersion > currentVersion { + await downloadAndApplyUpdate(version: latestVersion) + } + } + + private func downloadAndApplyUpdate(version: String) async { + do { + let newTrackerData = try await downloadTrackerData(version: version) + + // Validate data integrity + guard validateTrackerData(newTrackerData) else { + throw TrackerDataError.invalidData + } + + // Apply update + trackerDataManager.updateData(newTrackerData) + + // Regenerate Content Blocker Rules + await regenerateContentBlockerRules() + + Logger.privacy.info("Updated tracker data to version \(version)") + } catch { + Logger.privacy.error("Failed to update tracker data: \(error)") + } + } +} +``` + +## Conclusion + +The iOS tracker blocking implementation provides comprehensive protection through a sophisticated dual-layer approach: + +- **Content Blocker Rules** deliver high-performance blocking for the majority of tracking attempts +- **JavaScript injection** ensures complete coverage and enables advanced features like surrogates +- **Hybrid coordination** maximizes both performance and protection effectiveness + +This architecture gives us complete control over the user experience while maintaining the privacy protection that DuckDuckGo users expect, without the limitations of system-wide Content Blocking Extensions. + +**Key Benefits:** +- βœ… **Performance**: WebKit-optimized native blocking for most requests +- βœ… **Completeness**: JavaScript layer catches edge cases and provides surrogates +- βœ… **Control**: Full UX control without requiring manual user configuration +- βœ… **Flexibility**: Runtime configuration and A/B testing capabilities +- βœ… **Maintenance**: Easy updates and improvements to blocking logic + +This implementation forms the foundation of DuckDuckGo's iOS privacy protection, ensuring users browse with confidence knowing their privacy is protected by industry-leading tracker blocking technology. diff --git a/work/wiki/apple-browsers/logging-guidelines.mdc b/work/wiki/apple-browsers/logging-guidelines.mdc new file mode 100644 index 00000000..216785c1 --- /dev/null +++ b/work/wiki/apple-browsers/logging-guidelines.mdc @@ -0,0 +1,581 @@ +--- +source: ~/DuckDuckGo/apple-browsers.git/main/.cursor/rules/logging-guidelines.mdc +confidence: 0.9 +namespace: work +last_synced: 2026-04-28 +alwaysApply: false +--- + +# Logging Guidelines & Telemetry Capture + +## Overview + +The DuckDuckGo browser apps for iOS and macOS leverage **Apple's Unified Logging System** for capturing telemetry and debugging information. This system enables efficient tracking of app behavior, issue diagnosis, and performance monitoring in a structured and privacy-conscious manner. + +**Key Benefits**: +- **Privacy-first**: Built-in privacy controls for sensitive data +- **Performance**: Optimized for minimal overhead +- **Integration**: Native Apple ecosystem support +- **Debugging**: Rich contextual information and filtering + +## How to Log + +### Using the Logger Class + +We utilize the `Logger` class from Apple's `os` framework for all logging activities: + +```swift +import os + +// Basic logging examples +Logger.yourFeatureName.debug("Something to log, with info: \(infoVar)") +Logger.anotherFeatureName.error("Some error happened: \(error.localizedDescription, privacy: .public)") +Logger.networking.info("API request completed for endpoint: \(endpoint, privacy: .public)") +Logger.performance.debug("Operation took \(duration)ms to complete") +``` + +### Creating Custom Loggers + +#### Single Feature Logger + +For new features, create a dedicated logger file named `Logger+YourFeatureName.swift`: + +```swift +import os + +public extension Logger { + static var yourFeatureName: Logger = { + Logger(subsystem: "Your Feature Name", category: "") + }() + + static var anotherFeatureName: Logger = { + Logger(subsystem: "Another feature name", category: "Subsystem in the feature") + }() +} +``` + +#### Multiple Feature Loggers + +For related features, add to existing logger extensions (e.g., `Logger+Multiple.swift`): + +```swift +import os + +public extension Logger { + // Networking loggers + static var networking: Logger = { + Logger(subsystem: "Networking", category: "API") + }() + + static var cache: Logger = { + Logger(subsystem: "Networking", category: "Cache") + }() + + // UI loggers + static var tabManagement: Logger = { + Logger(subsystem: "UI", category: "Tab Management") + }() + + static var bookmarks: Logger = { + Logger(subsystem: "UI", category: "Bookmarks") + }() +} +``` + +### Logger Placement Strategy + +**Framework/Package Level**: For shared functionality across iOS and macOS +```swift +// In BrowserServicesKit +public extension Logger { + static var secureVault: Logger = { + Logger(subsystem: "BrowserServicesKit", category: "SecureVault") + }() + + static var sync: Logger = { + Logger(subsystem: "BrowserServicesKit", category: "Sync") + }() +} +``` + +**App Level**: For platform-specific features +```swift +// In iOS app +public extension Logger { + static var widgets: Logger = { + Logger(subsystem: "iOS App", category: "Widgets") + }() +} + +// In macOS app +public extension Logger { + static var windowManagement: Logger = { + Logger(subsystem: "macOS App", category: "Window Management") + }() +} +``` + +## Subsystem and Category Guidelines + +### Subsystem Naming + +**Purpose**: Corresponds to large functional areas of your app + +**Examples**: +- `"Networking"` - All network-related functionality +- `"UI"` - User interface components +- `"Data Storage"` - Database and persistence +- `"Security"` - Authentication and encryption +- `"Performance"` - Performance monitoring and optimization + +### Category Naming + +**Purpose**: Specific components or features within subsystems + +**Examples**: +```swift +// Networking subsystem categories +Logger(subsystem: "Networking", category: "API Calls") +Logger(subsystem: "Networking", category: "Cache Management") +Logger(subsystem: "Networking", category: "Request Retry") + +// UI subsystem categories +Logger(subsystem: "UI", category: "Tab Management") +Logger(subsystem: "UI", category: "Settings") +Logger(subsystem: "UI", category: "Bookmarks") + +// Data Storage subsystem categories +Logger(subsystem: "Data Storage", category: "SecureVault") +Logger(subsystem: "Data Storage", category: "Core Data") +Logger(subsystem: "Data Storage", category: "User Defaults") +``` + +## Log Levels and Privacy + +### Choosing Log Levels + +#### `debug` - Development and Troubleshooting +- **Purpose**: Verbose output for development debugging +- **Retention**: Short-lived in memory +- **Use cases**: Variable values, execution flow, temporary debugging + +```swift +Logger.networking.debug("Request headers: \(headers)") +Logger.ui.debug("User tapped button at coordinates: \(point)") +Logger.performance.debug("Cache hit for key: \(key)") +``` + +#### `info` - Important Events +- **Purpose**: Interesting or important information +- **Retention**: Longer than debug, available for analysis +- **Use cases**: User actions, system state changes, feature usage + +```swift +Logger.auth.info("User successfully authenticated") +Logger.sync.info("Sync operation completed with \(itemCount) items") +Logger.features.info("Feature flag \(flagName, privacy: .public) enabled") +``` + +#### `error` - Handled Errors +- **Purpose**: Something went wrong but was handled gracefully +- **Retention**: Available for longer-term analysis +- **Requirements**: Always include `error.localizedDescription` + +```swift +Logger.networking.error("API request failed: \(error.localizedDescription, privacy: .public)") +Logger.database.error("Failed to save context: \(error.localizedDescription, privacy: .public)") +Logger.auth.error("Keychain access denied: \(error.localizedDescription, privacy: .public)") +``` + +#### `fault` - Critical Issues +- **Purpose**: Critical issues preventing normal app function +- **Retention**: Highest priority, always preserved +- **Requirements**: Include error description when available + +```swift +Logger.database.fault("Database corruption detected: \(error.localizedDescription, privacy: .public)") +Logger.security.fault("Critical security violation: \(details, privacy: .public)") +Logger.system.fault("App unable to initialize required services") +``` + +### Privacy Settings + +#### Default Privacy Behavior + +**All interpolated values are `.private` by default** - only visible in debug builds: + +```swift +// These values are private by default +Logger.auth.info("User \(username) logged in") // username is private +Logger.network.debug("Response time: \(responseTime)ms") // responseTime is private +``` + +#### Public Information + +Mark non-sensitive information as `.public` for visibility in release builds: + +```swift +// Error descriptions should typically be public +Logger.network.error("Connection failed: \(error.localizedDescription, privacy: .public)") + +// System information can be public +Logger.performance.info("App launched in \(launchTime, privacy: .public)ms") + +// Feature flags and settings (non-PII) can be public +Logger.features.info("Dark mode: \(isDarkMode, privacy: .public)") +``` + +#### Privacy Decision Matrix + +| Data Type | Privacy Level | Example | +|-----------|---------------|---------| +| **User PII** | `.private` (default) | Email, username, personal data | +| **Error descriptions** | `.public` | `error.localizedDescription` | +| **System metrics** | `.public` | Performance timings, counts | +| **Feature states** | `.public` | Feature flags, app settings | +| **Debug values** | `.private` (default) | Variable contents, internal state | + +## Best Practices + +### βœ… DO + +#### Direct Logging +```swift +// βœ… CORRECT: Log directly where events occur +func authenticateUser() { + Logger.auth.info("Starting user authentication") + + do { + let result = try performAuthentication() + Logger.auth.info("Authentication successful") + } catch { + Logger.auth.error("Authentication failed: \(error.localizedDescription, privacy: .public)") + } +} +``` + +#### Meaningful Context +```swift +// βœ… CORRECT: Include relevant context +Logger.sync.info("Sync completed: \(syncedItems, privacy: .public) items, \(conflicts, privacy: .public) conflicts") +Logger.network.debug("Cache hit for URL: \(url.absoluteString, privacy: .public)") +Logger.ui.debug("View controller \(type(of: self)) appeared") +``` + +#### Consistent Logger Usage +```swift +// βœ… CORRECT: Use established loggers consistently +extension BookmarkManager { + func addBookmark(_ bookmark: Bookmark) { + Logger.bookmarks.info("Adding bookmark: \(bookmark.title ?? "Untitled")") + // Implementation + } + + func deleteBookmark(_ bookmark: Bookmark) { + Logger.bookmarks.info("Deleting bookmark: \(bookmark.title ?? "Untitled")") + // Implementation + } +} +``` + +### ❌ DON'T + +#### Wrapper Functions +```swift +// ❌ AVOID: Wrapper functions obscure context +func logError(_ message: String) { + Logger.general.error("\(message)") // Loses class, line number context +} + +// Use direct logging instead +Logger.networking.error("Connection timeout: \(error.localizedDescription, privacy: .public)") +``` + +#### Logger Injection +```swift +// ❌ AVOID: Injecting loggers +class NetworkManager { + private let logger: Logger + + init(logger: Logger) { // Unnecessary complexity + self.logger = logger + } +} + +// βœ… CORRECT: Use global logger extensions +class NetworkManager { + func performRequest() { + Logger.networking.info("Starting network request") + } +} +``` + +#### Overly Verbose Debug Logging +```swift +// ❌ AVOID: Too much debug noise +func processItems(_ items: [Item]) { + Logger.processing.debug("Starting to process items") + for item in items { + Logger.processing.debug("Processing item: \(item.id)") + Logger.processing.debug("Item name: \(item.name)") + Logger.processing.debug("Item processed successfully") + } + Logger.processing.debug("Finished processing all items") +} + +// βœ… CORRECT: Focused, meaningful debug logs +func processItems(_ items: [Item]) { + Logger.processing.debug("Processing \(items.count) items") + // Process items... + Logger.processing.debug("Item processing completed") +} +``` + +## Reading and Filtering Logs + +### 1. Xcode Console + +**Best for**: App-specific debugging during development + +#### Setup for Optimal Readability + +1. **Add columns**: Type, Library, Subsystem, Category +2. **Filter by process**: Your app name +3. **Use contextual menu**: Show/Hide specific log types + +#### Console Filtering + +``` +// Filter by subsystem +subsystem:com.yourapp.Networking + +// Filter by category +category:API + +// Hide system noise +subsystem:com.apple. (!contains) + +// Show only errors and faults +type:error OR type:fault +``` + +### 2. Console.app + +**Best for**: System-wide debugging and cross-app analysis + +#### Recommended Filters for DuckDuckGo + +``` +// Focus on DuckDuckGo process +process:duckduckgo (contains) + +// Hide system noise +subsystem:com.apple. (!contains) +subsystem:PrototypeTools (!contains) +library:Security (!contains) +library:TextInput (!contains) + +// Show specific subsystems +subsystem:Networking (contains) +subsystem:UI (contains) +``` + +#### Advanced Filtering Examples + +``` +// Errors in the last hour +type:error AND time:>-1h + +// Specific feature debugging +subsystem:BrowserServicesKit AND category:SecureVault + +// Performance monitoring +message:performance (contains) AND type:info +``` + +### 3. Command Line Tool + +**Best for**: Scripting and automated analysis + +#### Basic Usage + +```bash +# Show logs for specific subsystem +log show --predicate 'subsystem == "com.duckduckgo.Networking"' --info + +# Show recent errors +log show --predicate 'messageType == "Error"' --last 1h + +# Export logs to file +log show --predicate 'process == "DuckDuckGo"' --start '2024-01-01 00:00:00' > app_logs.txt + +# Real-time streaming +log stream --predicate 'subsystem == "com.duckduckgo.UI"' +``` + +#### Advanced Command Examples + +```bash +# Debugging specific feature +log show --predicate 'subsystem == "BrowserServicesKit" AND category == "SecureVault"' --debug + +# Performance analysis +log show --predicate 'message CONTAINS "performance"' --info --last 24h + +# Error analysis with context +log show --predicate 'messageType >= "Error"' --info --start '2024-01-01' + +# Multiple conditions +log show --predicate 'subsystem BEGINSWITH "com.duckduckgo" AND messageType == "Error"' --last 2h +``` + +### 4. Sysdiagnose + +**Best for**: Remote debugging and Apple DTS submissions + +#### What's Included +- Complete system snapshot +- All system and app logs +- Memory usage data +- Kernel information +- Crash reports +- Network status +- Performance data + +#### Usage +```bash +# Generate sysdiagnose +sudo sysdiagnose + +# The generated file can be analyzed with Console.app +# Located in /var/tmp/ or Desktop +``` + +## Log Export for Internal Users + +### macOS Debug Menu Export + +**Available to**: Internal users only +**Platform**: macOS only + +#### How to Export + +1. Open **Debug menu** +2. Navigate to **Logging** > **Export logs** +3. Logs are exported as a ZIP file to Desktop +4. Includes filtered logs based on app subsystems + +#### Export Contents + +The exported ZIP contains: +- App-specific logs filtered by subsystem +- Recent system logs relevant to the app +- Crash reports if available +- Basic system information + +## Logging Patterns by Feature + +### Authentication & Security + +```swift +public extension Logger { + static var auth: Logger = { Logger(subsystem: "Security", category: "Authentication") }() + static var keychain: Logger = { Logger(subsystem: "Security", category: "Keychain") }() + static var encryption: Logger = { Logger(subsystem: "Security", category: "Encryption") }() +} + +// Usage examples +Logger.auth.info("User authentication attempt") +Logger.keychain.error("Keychain access failed: \(error.localizedDescription, privacy: .public)") +Logger.encryption.debug("Encrypting data with algorithm: \(algorithm, privacy: .public)") +``` + +### Networking & API + +```swift +public extension Logger { + static var networking: Logger = { Logger(subsystem: "Networking", category: "HTTP") }() + static var api: Logger = { Logger(subsystem: "Networking", category: "API") }() + static var cache: Logger = { Logger(subsystem: "Networking", category: "Cache") }() +} + +// Usage examples +Logger.networking.info("HTTP request to \(endpoint, privacy: .public)") +Logger.api.error("API call failed: \(error.localizedDescription, privacy: .public)") +Logger.cache.debug("Cache hit for key: \(cacheKey)") +``` + +### Data & Storage + +```swift +public extension Logger { + static var database: Logger = { Logger(subsystem: "Data Storage", category: "Core Data") }() + static var secureVault: Logger = { Logger(subsystem: "Data Storage", category: "SecureVault") }() + static var sync: Logger = { Logger(subsystem: "Data Storage", category: "Sync") }() +} + +// Usage examples +Logger.database.info("Core Data migration completed") +Logger.secureVault.error("SecureVault operation failed: \(error.localizedDescription, privacy: .public)") +Logger.sync.info("Sync completed: \(itemCount, privacy: .public) items") +``` + +### Performance Monitoring + +```swift +public extension Logger { + static var performance: Logger = { Logger(subsystem: "Performance", category: "Metrics") }() + static var memory: Logger = { Logger(subsystem: "Performance", category: "Memory") }() + static var startup: Logger = { Logger(subsystem: "Performance", category: "Startup") }() +} + +// Usage examples +Logger.performance.info("Operation completed in \(duration, privacy: .public)ms") +Logger.memory.debug("Memory usage: \(memoryUsage, privacy: .public)MB") +Logger.startup.info("App launch completed in \(launchTime, privacy: .public)ms") +``` + +## Integration with App Lifecycle + +### State Machine Logging + +```swift +// In app lifecycle state machine +class Launching { + func init() { + Logger.lifecycle.info("App entering Launching state") + // Initialization logic + Logger.lifecycle.info("Launching state completed") + } +} + +class Foreground { + func onTransition() { + Logger.lifecycle.info("App transitioning to Foreground") + } + + func didReturn() { + Logger.lifecycle.info("App returned to Foreground state") + } +} +``` + +### Service Lifecycle Logging + +```swift +class MyService { + func start() { + Logger.services.info("Starting \(type(of: self)) service") + // Service startup logic + } + + func stop() { + Logger.services.info("Stopping \(type(of: self)) service") + // Service cleanup logic + } +} +``` + +--- + +Following these logging guidelines ensures consistent, privacy-conscious, and effective telemetry capture across the DuckDuckGo browser ecosystem, enabling better debugging, monitoring, and user experience optimization. diff --git a/work/wiki/apple-browsers/macos-singletons-removal.mdc b/work/wiki/apple-browsers/macos-singletons-removal.mdc new file mode 100644 index 00000000..3fec85db --- /dev/null +++ b/work/wiki/apple-browsers/macos-singletons-removal.mdc @@ -0,0 +1,220 @@ +--- +source: ~/DuckDuckGo/apple-browsers.git/main/.cursor/rules/macos-singletons-removal.mdc +confidence: 0.9 +namespace: work +last_synced: 2026-04-28 +alwaysApply: false +--- + +## macOS Singleton Removal Rules + +### Purpose + +Define a concrete pattern for removing `.shared` singletons in the macOS app by replacing them with app-owned instances and dependency injection. The `AIChatPreferences` and `AboutPreferences` refactors in `AppDelegate` are canonical examples. + +### Rules + +1. **Do not introduce new singletons** + - Never add new `static let shared` or similar global singletons. + - New dependencies must be passed in via initializers or factory methods, not fetched from global state. + +2. **Move ownership to the composition root (AppDelegate)** + - Add a stored property on the macOS composition root (currently `AppDelegate`) for the dependency, for example: + - `let aiChatPreferences: AIChatPreferences` + - Construct the instance during app setup using real dependencies: + - Inject storage (e.g. `DefaultAIChatPreferencesStorage`) + - Inject configuration objects (e.g. `AIChatMenuVisibilityConfigurable`) + - Inject window managers via protocols (e.g. `WindowControllersManagerProtocol`) + - Inject feature flaggers and other services as needed + - Prefer protocol-typed properties in `AppDelegate` when the dependency has a clear protocol (to keep testing and substitution easy). + +3. **Thread the dependency through initializers** + - For view controllers and models that need the former singleton, add initializer parameters and store them as non-optional properties. Example: + - `init(..., aiChatPreferences: AIChatPreferences = NSApp.delegateTyped.aiChatPreferences, ...)` + - Avoid using `NSApp.delegateTyped` or `Application.appDelegate` and prefer to pass the dependency down from a parent object. + - If a parent object doesn't contain the dependency, it should be updated to have it passed down from its own parent object, observing the exceptions mentioned below. + - It is explicitly allowed to use `NSApp.delegateTyped`: + - In the `Tab` initializer (following the existing pattern for other dependencies) + - In default parameter values for the `MainViewController` initializer (this is the entry point for the dependency chain) + - In default parameter values for the `TabCollectionViewModel` initializer (following the existing pattern for other dependencies) + - In default parameter values for the `TabViewModel` initializer (temporary exception, will be refactored later) + - **Exception**: For `@MainActor` initializers, use optional parameters with `nil` defaults and assign from `NSApp.delegateTyped` in the initializer body. + - **Important: Main actor isolation**: If an initializer is marked `@MainActor` and you need to default a parameter from `NSApp.delegateTyped`, use an optional parameter with `nil` default instead of accessing `NSApp.delegateTyped` in the default value. Then assign the value inside the initializer body: + - ❌ `init(savedZoomLevelsCoordinating: SavedZoomLevelsCoordinating = NSApp.delegateTyped.accessibilityPreferences)` (causes main actor isolation warning) + - βœ… `init(savedZoomLevelsCoordinating: SavedZoomLevelsCoordinating? = nil)` with `self.savedZoomLevelsCoordinating = savedZoomLevelsCoordinating ?? NSApp.delegateTyped.accessibilityPreferences` in the body + - When creating child objects from a parent that already has the dependency, pass the property down rather than re-reading from `NSApp.delegateTyped`. + - **For preferences models that need to reach SwiftUI views**, thread through the entire chain: + - `MainViewController` (with default parameter) β†’ `BrowserTabViewController` β†’ `PreferencesViewController` β†’ `PreferencesSidebarModel` β†’ `PreferencesRootView` + - Follow the existing pattern used by other preferences (e.g., `searchPreferences`, `tabsPreferences`, `aiChatPreferences`) + - When adding to `PreferencesSidebarModel`, add the property alongside existing preferences and update both the main `init` and convenience `init` + - **For dependencies that need to reach UserScripts initialization** (e.g., `DuckPlayerPreferences`), thread through the content blocking infrastructure: + - `AppDelegate` β†’ `AppContentBlocking` β†’ `UserContentUpdating` β†’ `ScriptSourceProvider` (via `ScriptSourceProviding` protocol) β†’ `UserScripts` + - Add the dependency to `ScriptSourceProviding` protocol as a property + - Add it to `ScriptSourceProvider` struct (property and initializer parameter) + - Add it to `UserContentUpdating` initializer and pass to `ScriptSourceProvider` in `makeValue` closure + - Add it to `AppContentBlocking` initializers (both convenience and main) and pass to `UserContentUpdating` + - Pass it from `AppDelegate` to `AppContentBlocking` initialization + - In `UserScripts`, access via `sourceProvider.duckPlayerPreferences` instead of using a default parameter + - This follows the same pattern as `WebTrackingProtectionPreferences` and `CookiePopupProtectionPreferences` + +4. **Update utility code and extensions carefully** + - For helpers like `URL` extensions where dependency injection is impractical, read the instance from the composition root instead of a singleton: + - `NSApp.delegateTyped.aiChatPreferences` instead of `AIChatPreferences.shared`. + - Keep these usages minimal; prefer passing dependencies into call sites where it's feasible. + - **In AppDelegate extensions**: When the code is in an extension of `AppDelegate` (e.g., `extension AppDelegate`), access properties directly via `self.propertyName` rather than `NSApp.delegateTyped.propertyName`: + - βœ… `duckPlayerPreferences.reset()` (in `extension AppDelegate`) + - ❌ `NSApp.delegateTyped.duckPlayerPreferences.reset()` (unnecessary indirection) + - **For protocol-typed dependencies**: If a class conforms to a protocol (e.g., `AccessibilityPreferences` conforms to `SavedZoomLevelsCoordinating`), you can use the protocol type in initializers. The dependency can be passed as the protocol type while still being owned as the concrete type in `AppDelegate`. + - **In SwiftUI views**, once a dependency is available on a model (e.g., `PreferencesSidebarModel`), use the model's property rather than accessing via `NSApp.delegateTyped`: + - βœ… `AboutView(model: model.aboutPreferences)` + - ❌ `AboutView(model: NSApp.delegateTyped.aboutPreferences)` + - This ensures the view uses the injected instance and maintains proper dependency flow. + +5. **Simplify protocol wrappers that only exist for the singleton** + - If a protocol exists solely to hide a singleton (e.g. a minimal `AIFeaturesStatusProviding` that just wraps `AIChatPreferences.shared`), prefer depending directly on the concrete type once it is injectible. + - Update initializers and stored properties to use the concrete type (`AIChatPreferences`) when it already exposes the required API and publishers. + +6. **Update tests to construct their own instances** + - In tests, build the dependency explicitly instead of using global state. For example: + - `AIChatPreferences(storage: MockAIChatPreferencesStorage(), aiChatMenuConfiguration: MockAIChatConfig(), windowControllersManager: WindowControllersManagerMock(), featureFlagger: MockFeatureFlagger())` + - Pass these instances into the subject under test via its initializer (e.g. `PreferencesSidebarModel`, `BrowserTabViewController`, `PreferencesViewController`). + - Remove ad-hoc singleton-like test doubles (e.g. `MockAIChatPreferences.shared`) once real instances are injected. + - **Update all test helper methods**: When a test file has helper methods that create instances (e.g., `PreferencesSidebarModel` factory methods), update all of them to include the new dependency parameter. + - **Reuse existing mocks**: When initializing the dependency in tests, reuse existing mock objects from the test setup: + - Use `mockFeatureFlagger.internalUserDecider` if `MockFeatureFlagger` is already available + - Use existing `windowControllersManager` instances (e.g., `WindowControllersManagerMock()`) + - Example: `AboutPreferences(internalUserDecider: mockFeatureFlagger.internalUserDecider, featureFlagger: mockFeatureFlagger, windowControllersManager: windowControllersManager)` + - **Create shared test instances**: For test classes that have many tests using the dependency, create a shared instance as a property: + - `let accessibilityPreferences = AccessibilityPreferences()` at the class level + - Reuse this instance across multiple test methods to avoid creating duplicate instances + - **Update helper methods in test extensions**: Don't forget to update helper methods in extensions (e.g., `TabViewModel.aTabViewModel` static property) that create instances + - **Search comprehensively**: Use `grep` to find all test files that instantiate classes requiring the dependency, including: + - Direct instantiations in test methods + - Helper/factory methods that create instances + - Integration tests that create full object graphs + - Static helper properties/methods in test extensions + +7. **Remove the singleton API last** + - After all production code and tests use the injected instance or the app-owned property, delete `static let shared` and any remaining references to it. + - Ensure you do not leave a mixed state where some call sites use the injected instance and others still use `.shared` for the same type within the modified area. + - Change `private init` to `init` to make the initializer publicly accessible once the singleton is removed. + +### Example: AboutPreferences Refactoring + +The `AboutPreferences.shared` singleton removal demonstrates the complete pattern: + +1. **AppDelegate**: Added `let aboutPreferences: AboutPreferences` and initialized it with dependencies (`internalUserDecider`, `featureFlagger`, `windowControllersManager`) + +2. **MainViewController**: Added `aboutPreferences: AboutPreferences = NSApp.delegateTyped.aboutPreferences` parameter (with default) and passed it to `BrowserTabViewController` + +3. **BrowserTabViewController**: Added `aboutPreferences` property and parameter, stored it, and passed it to `PreferencesViewController` + +4. **PreferencesViewController**: Added `aboutPreferences` parameter and passed it to `PreferencesSidebarModel` + +5. **PreferencesSidebarModel**: Added `let aboutPreferences: AboutPreferences` property and updated both initializers to accept and store it + +6. **PreferencesRootView**: Updated to use `model.aboutPreferences` instead of `NSApp.delegateTyped.aboutPreferences` + +7. **Test files**: Updated all test files that create instances in the dependency chain: + - `PreferencesSidebarModelTests.swift`: Updated 3 helper methods to include `aboutPreferences` parameter + - `BrowserTabViewControllerOnboardingTests.swift`: Added `aboutPreferences` to `BrowserTabViewController` initialization + - `RootViewV2Tests.swift`: Added `aboutPreferences` to `PreferencesSidebarModel` initialization + - All tests reuse existing mocks: `AboutPreferences(internalUserDecider: mockFeatureFlagger.internalUserDecider, featureFlagger: mockFeatureFlagger, windowControllersManager: windowControllersManager)` + +8. **AboutPreferences**: Removed `static let shared` and changed `private init` to `init` + +This pattern ensures the dependency flows through the entire chain while maintaining testability and avoiding global state access in views. + +### Example: AccessibilityPreferences Refactoring + +The `AccessibilityPreferences.shared` singleton removal demonstrates additional patterns: + +1. **AppDelegate**: Added `let accessibilityPreferences: AccessibilityPreferences` and initialized it with default dependencies + +2. **Dependency chain**: Threaded through `MainViewController` β†’ `BrowserTabViewController` β†’ `PreferencesViewController` β†’ `PreferencesSidebarModel` β†’ `PreferencesRootView` + +3. **TabViewModel**: Updated existing `accessibilityPreferences` parameter default from `.shared` to `NSApp.delegateTyped.accessibilityPreferences` + +4. **Fire initializer (Main actor isolation)**: Used optional parameter pattern to avoid main actor isolation warning: + ```swift + @MainActor + init(savedZoomLevelsCoordinating: SavedZoomLevelsCoordinating? = nil, ...) { + self.savedZoomLevelsCoordinating = savedZoomLevelsCoordinating ?? NSApp.delegateTyped.accessibilityPreferences + } + ``` + +5. **Protocol conformance**: `AccessibilityPreferences` conforms to `SavedZoomLevelsCoordinating`, allowing it to be passed as a protocol type where needed + +6. **Test patterns**: Created shared `accessibilityPreferences` instance in test classes: + ```swift + final class TabViewModelTests: XCTestCase { + let accessibilityPreferences = AccessibilityPreferences() + // ... tests reuse this instance + } + ``` + +7. **Test helper methods**: Updated all helper methods including static properties in extensions (e.g., `TabViewModel.aTabViewModel`) + +### Example: DuckPlayerPreferences Refactoring + +The `DuckPlayerPreferences.shared` singleton removal demonstrates the pattern for dependencies that need to reach `UserScripts` initialization: + +1. **AppDelegate**: Added `let duckPlayerPreferences: DuckPlayerPreferences` and initialized it with dependencies (`privacyConfigurationManager`, `internalUserDecider`) + +2. **Dependency chain for UserScripts**: Threaded through: + - `AppDelegate` β†’ `AppContentBlocking` β†’ `UserContentUpdating` β†’ `ScriptSourceProvider` (via `ScriptSourceProviding` protocol) β†’ `UserScripts` + - This follows the same pattern as `WebTrackingProtectionPreferences` and `CookiePopupProtectionPreferences` + +3. **ScriptSourceProviding protocol**: Added `var duckPlayerPreferences: DuckPlayerPreferences { get }` property + +4. **ScriptSourceProvider**: Added `duckPlayerPreferences` property and parameter to initializer + +5. **UserContentUpdating**: Added `duckPlayerPreferences` parameter and passed it to `ScriptSourceProvider` in the `makeValue` closure + +6. **AppContentBlocking**: Added `duckPlayerPreferences` to both convenience and main initializers, passed it to `UserContentUpdating` + +7. **AppDelegate**: Passed `duckPlayerPreferences` to `AppContentBlocking` initialization (both DEBUG and release paths) + +8. **UserScripts**: Removed default parameter `duckPlayerPreferences: DuckPlayerPreferences = NSApp.delegateTyped.duckPlayerPreferences` and accessed it via `sourceProvider.duckPlayerPreferences` instead + +9. **Preferences view chain**: Also threaded through `MainViewController` β†’ `BrowserTabViewController` β†’ `PreferencesViewController` β†’ `PreferencesSidebarModel` β†’ `PreferencesRootView` for SwiftUI views + +10. **MainMenuActions**: Updated to use `duckPlayerPreferences` directly (since it's in `extension AppDelegate`) + +This pattern ensures dependencies that need to reach `UserScripts` initialization are properly injected through the content blocking infrastructure, avoiding default parameters that access `NSApp.delegateTyped` during initialization. + +### Test Updates Checklist + +When removing a singleton, ensure all tests are updated: + +1. **Find all test files** that instantiate classes in the dependency chain: + ```bash + grep -r "ClassName(" macOS/UnitTests macOS/IntegrationTests + ``` + +2. **Update helper methods**: If test files have helper/factory methods that create instances, update all of them: + - Look for `private func` methods that return the type + - Look for `create*` or `make*` helper methods + - Example: `PreferencesSidebarModelTests.swift` had 3 helper methods that all needed `aboutPreferences` + +3. **Reuse existing mocks**: When creating the dependency instance in tests: + - Check what mocks are already available in `setUp()` or test properties + - Use `mockFeatureFlagger.internalUserDecider` if available + - Reuse `WindowControllersManagerMock()` instances already created + - Avoid creating duplicate mock instances + +4. **Verify compilation**: After updates, ensure: + - No linting errors + - All test files compile successfully + - Run tests to verify they pass + +### Enforcement + +- **Never approve PRs that add new `.shared`-style singletons in macOS code.** +- **When reviewing singleton removals, require:** + - A clearly owned instance on the composition root (`AppDelegate`). + - Dependencies threaded via initializers with sensible defaults from `NSApp.delegateTyped`. + - Tests constructing their own instances without relying on global state. + - No remaining usages of the removed `TypeName.shared` in the modified scope. + + diff --git a/work/wiki/apple-browsers/macos-system-integration.mdc b/work/wiki/apple-browsers/macos-system-integration.mdc new file mode 100644 index 00000000..bbe7b53c --- /dev/null +++ b/work/wiki/apple-browsers/macos-system-integration.mdc @@ -0,0 +1,424 @@ +--- +source: ~/DuckDuckGo/apple-browsers.git/main/.cursor/rules/macos-system-integration.mdc +confidence: 0.9 +namespace: work +last_synced: 2026-04-28 +alwaysApply: false +--- + +# macOS System Integration Patterns + +## Background Agents and Services +Use proper service management for background agents: + +```swift +// βœ… CORRECT - Background service management +final class BackgroundServiceManager { + private let agentIdentifier = "com.duckduckgo.agent" + private let extensionIdentifier = "com.duckduckgo.extension" + + func registerBackgroundAgent() throws { + let service = SMAppService.agent(plistName: "BackgroundAgent.plist") + + do { + try service.register() + print("Background agent registered successfully") + } catch { + print("Failed to register background agent: \(error)") + throw error + } + } + + func unregisterBackgroundAgent() throws { + let service = SMAppService.agent(plistName: "BackgroundAgent.plist") + + do { + try service.unregister() + print("Background agent unregistered successfully") + } catch { + print("Failed to unregister background agent: \(error)") + throw error + } + } + + func checkServiceStatus() -> SMAppService.Status { + let service = SMAppService.agent(plistName: "BackgroundAgent.plist") + return service.status + } +} + +// ❌ INCORRECT - Direct background processing in main app +final class FeatureManager { + func startBackgroundWork() { + // Don't run continuous background work in main app + DispatchQueue.global().async { + while true { + // This will drain battery and violate sandboxing + self.performWork() + Thread.sleep(forTimeInterval: 60) + } + } + } +} +``` + +## System Extensions +Use proper system extension lifecycle management: + +```swift +// βœ… CORRECT - System extension management +import SystemExtensions + +final class SystemExtensionManager: NSObject { + private let extensionIdentifier = "com.duckduckgo.network-extension" + + func installExtension() { + let request = OSSystemExtensionRequest.activationRequest( + forExtensionWithIdentifier: extensionIdentifier, + queue: .main + ) + request.delegate = self + OSSystemExtensionManager.shared.submitRequest(request) + } + + func uninstallExtension() { + let request = OSSystemExtensionRequest.deactivationRequest( + forExtensionWithIdentifier: extensionIdentifier, + queue: .main + ) + request.delegate = self + OSSystemExtensionManager.shared.submitRequest(request) + } + + func checkExtensionStatus() async -> OSSystemExtensionRequest.Result? { + // Check if extension is already installed + return await withCheckedContinuation { continuation in + let request = OSSystemExtensionRequest.propertiesRequest( + forExtensionWithIdentifier: extensionIdentifier, + queue: .main + ) + + // Handle the properties request to determine status + // Implementation details... + continuation.resume(returning: nil) + } + } +} + +// MARK: - OSSystemExtensionRequestDelegate +extension SystemExtensionManager: OSSystemExtensionRequestDelegate { + func request( + _ request: OSSystemExtensionRequest, + actionForReplacingExtension existing: OSSystemExtensionProperties, + withExtension extension: OSSystemExtensionProperties + ) -> OSSystemExtensionRequest.ReplacementAction { + return .replace + } + + func requestNeedsUserApproval(_ request: OSSystemExtensionRequest) { + print("System extension requires user approval") + // Show UI to guide user through approval process + showUserApprovalGuidance() + } + + func request( + _ request: OSSystemExtensionRequest, + didFinishWithResult result: OSSystemExtensionRequest.Result + ) { + switch result { + case .completed: + print("System extension request completed successfully") + handleExtensionActivated() + case .willCompleteAfterReboot: + print("System extension will be activated after reboot") + showRebootRequiredMessage() + @unknown default: + print("Unknown system extension result: \(result)") + } + } + + func request(_ request: OSSystemExtensionRequest, didFailWithError error: Error) { + print("System extension request failed: \(error)") + handleExtensionError(error) + } + + private func showUserApprovalGuidance() { + // Show UI to guide user through System Preferences + } + + private func handleExtensionActivated() { + // Update UI to reflect extension is active + } + + private func showRebootRequiredMessage() { + // Show UI indicating reboot is required + } + + private func handleExtensionError(_ error: Error) { + // Handle extension installation errors + } +} +``` + +## Login Items Management +Use the modern SMAppService API for login items: + +```swift +// βœ… CORRECT - Modern login items API +import ServiceManagement + +final class LoginItemsManager { + func enableLoginItem() throws { + do { + try SMAppService.mainApp.register() + print("Login item enabled successfully") + } catch { + print("Failed to enable login item: \(error)") + throw LoginItemError.registrationFailed(error) + } + } + + func disableLoginItem() throws { + do { + try SMAppService.mainApp.unregister() + print("Login item disabled successfully") + } catch { + print("Failed to disable login item: \(error)") + throw LoginItemError.unregistrationFailed(error) + } + } + + var isLoginItemEnabled: Bool { + return SMAppService.mainApp.status == .enabled + } + + var loginItemStatus: SMAppService.Status { + return SMAppService.mainApp.status + } +} + +enum LoginItemError: LocalizedError { + case registrationFailed(Error) + case unregistrationFailed(Error) + + var errorDescription: String? { + switch self { + case .registrationFailed(let error): + return "Failed to register login item: \(error.localizedDescription)" + case .unregistrationFailed(let error): + return "Failed to unregister login item: \(error.localizedDescription)" + } + } +} + +// ❌ INCORRECT - Deprecated APIs +final class OldLoginItemsManager { + func enableLoginItem() { + // Don't use deprecated LSSharedFileList APIs + let loginItems = LSSharedFileListCreate(nil, kLSSharedFileListSessionLoginItems, nil) + // ... deprecated implementation + } +} +``` + +## Workspace Integration +Integrate properly with macOS workspace: + +```swift +// βœ… CORRECT - Workspace integration +final class WorkspaceIntegration { + func openFileInFinder(at url: URL) { + NSWorkspace.shared.selectFile(nil, inFileViewerRootedAtPath: url.path) + } + + func revealInFinder(fileAt url: URL) { + NSWorkspace.shared.selectFile(url.path, inFileViewerRootedAtPath: url.deletingLastPathComponent().path) + } + + func openWithDefaultApplication(url: URL) { + NSWorkspace.shared.open(url) + } + + func openWithApplication(url: URL, applicationURL: URL) { + NSWorkspace.shared.open([url], withApplicationAt: applicationURL, configuration: NSWorkspace.OpenConfiguration()) + } + + func getDefaultApplication(for url: URL) -> URL? { + return NSWorkspace.shared.urlForApplication(toOpen: url) + } +} +``` + +## Dock Integration +Handle dock interactions properly: + +```swift +// βœ… CORRECT - Dock integration +final class DockIntegration { + func setBadgeCount(_ count: Int) { + NSApp.dockTile.badgeLabel = count > 0 ? "\(count)" : nil + } + + func clearBadge() { + NSApp.dockTile.badgeLabel = nil + } + + func setDockMenu(_ menu: NSMenu) { + NSApp.dockTile.contentView = nil + NSApp.dockTile.showsApplicationBadge = true + // Custom dock menu would be set through app delegate + } +} + +// In AppDelegate +extension AppDelegate: NSApplicationDelegate { + func applicationDockMenu(_ sender: NSApplication) -> NSMenu? { + let dockMenu = NSMenu() + + dockMenu.addItem(NSMenuItem( + title: "New Window", + action: #selector(newWindow), + keyEquivalent: "" + )) + + dockMenu.addItem(NSMenuItem( + title: "New Private Window", + action: #selector(newPrivateWindow), + keyEquivalent: "" + )) + + return dockMenu + } + + @objc func newWindow() { + WindowsManager.openNewWindow() + } + + @objc func newPrivateWindow() { + WindowsManager.openNewWindow(burnerMode: .burner) + } +} +``` + +## Notification Center Integration +Handle notifications properly: + +```swift +// βœ… CORRECT - User notification handling +import UserNotifications + +final class NotificationManager: NSObject { + func requestNotificationPermission() async -> Bool { + let center = UNUserNotificationCenter.current() + + do { + let granted = try await center.requestAuthorization(options: [.alert, .sound, .badge]) + return granted + } catch { + print("Failed to request notification permission: \(error)") + return false + } + } + + func scheduleNotification(title: String, body: String, identifier: String) async { + let content = UNMutableNotificationContent() + content.title = title + content.body = body + content.sound = .default + + let request = UNNotificationRequest( + identifier: identifier, + content: content, + trigger: nil + ) + + do { + try await UNUserNotificationCenter.current().add(request) + } catch { + print("Failed to schedule notification: \(error)") + } + } +} + +// MARK: - UNUserNotificationCenterDelegate +extension NotificationManager: UNUserNotificationCenterDelegate { + func userNotificationCenter( + _ center: UNUserNotificationCenter, + didReceive response: UNNotificationResponse, + withCompletionHandler completionHandler: @escaping () -> Void + ) { + // Handle notification tap + handleNotificationResponse(response) + completionHandler() + } + + func userNotificationCenter( + _ center: UNUserNotificationCenter, + willPresent notification: UNNotification, + withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void + ) { + // Show notification even when app is in foreground + completionHandler([.banner, .sound]) + } + + private func handleNotificationResponse(_ response: UNNotificationResponse) { + // Handle different notification actions + switch response.actionIdentifier { + case UNNotificationDefaultActionIdentifier: + // User tapped the notification + break + case UNNotificationDismissActionIdentifier: + // User dismissed the notification + break + default: + break + } + } +} +``` + +## App Group UserDefaults +Use app group UserDefaults for settings shared with system extensions: + +```swift +// βœ… CORRECT - App group UserDefaults +extension AppUserDefaults { + private static let appGroupUserDefaults = UserDefaults(suiteName: "group.com.duckduckgo.app") + + var networkProtectionEnabled: Bool { + get { + appGroupUserDefaults?.bool(forKey: "network_protection_enabled") ?? false + } + set { + appGroupUserDefaults?.set(newValue, forKey: "network_protection_enabled") + // Notify system extension of change + notifySystemExtension(of: .networkProtectionToggled(newValue)) + } + } + + var vpnServerLocation: String? { + get { + appGroupUserDefaults?.string(forKey: "vpn_server_location") + } + set { + appGroupUserDefaults?.set(newValue, forKey: "vpn_server_location") + } + } + + private func notifySystemExtension(of change: SystemExtensionNotification) { + // Send notification to system extension via app group communication + let notificationName = "com.duckduckgo.settings.changed" + DistributedNotificationCenter.default().post( + name: Notification.Name(notificationName), + object: change.rawValue + ) + } +} + +enum SystemExtensionNotification: String { + case networkProtectionToggled = "network_protection_toggled" + case vpnServerChanged = "vpn_server_changed" +} +``` + +See `macos-window-management.md` for window management patterns and `macos-preferences.md` for preferences UI patterns. diff --git a/work/wiki/apple-browsers/macos-window-management.mdc b/work/wiki/apple-browsers/macos-window-management.mdc new file mode 100644 index 00000000..3e6da6af --- /dev/null +++ b/work/wiki/apple-browsers/macos-window-management.mdc @@ -0,0 +1,307 @@ +--- +source: ~/DuckDuckGo/apple-browsers.git/main/.cursor/rules/macos-window-management.mdc +confidence: 0.9 +namespace: work +last_synced: 2026-04-28 +alwaysApply: false +--- + +# macOS Window Management and AppKit Patterns + +## WindowsManager for Window Operations +ALWAYS use WindowsManager for creating and managing browser windows: + +```swift +// βœ… CORRECT - WindowsManager usage +@MainActor +final class FeatureCoordinator { + func openNewWindow() { + let tabCollection = TabCollectionViewModel() + WindowsManager.openNewWindow( + with: tabCollection, + burnerMode: .regular, + droppingPoint: nil + ) + } + + func openWindowWithURL(_ url: URL) { + let tabCollection = TabCollectionViewModel() + let window = WindowsManager.openNewWindow(with: tabCollection) + window?.tabCollectionViewModel.addTab(with: url) + } +} + +// ❌ INCORRECT - Direct window creation +final class FeatureCoordinator { + func openNewWindow() { + let window = NSWindow() // Don't create windows directly + window.makeKeyAndOrderFront(nil) + } +} +``` + +## Window Controller Architecture +Use NSWindowController for complex window management: + +```swift +// βœ… CORRECT - NSWindowController pattern +final class FeatureWindowController: NSWindowController { + private let viewModel: FeatureViewModel + + init(viewModel: FeatureViewModel) { + self.viewModel = viewModel + super.init(window: nil) + setupWindow() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func setupWindow() { + let contentViewController = FeatureViewController(viewModel: viewModel) + + window = NSWindow(contentViewController: contentViewController) + window?.setContentSize(NSSize(width: 800, height: 600)) + window?.minSize = NSSize(width: 400, height: 300) + window?.center() + window?.title = "Feature Window" + + // Configure window behavior + window?.isRestorable = true + window?.identifier = NSUserInterfaceItemIdentifier("FeatureWindow") + } + + override func windowDidLoad() { + super.windowDidLoad() + + // Additional window setup + window?.delegate = self + setupToolbar() + } +} + +// MARK: - NSWindowDelegate +extension FeatureWindowController: NSWindowDelegate { + func windowWillClose(_ notification: Notification) { + // Clean up resources + viewModel.cleanup() + } + + func windowDidBecomeMain(_ notification: Notification) { + // Handle window becoming main + viewModel.windowDidBecomeActive() + } +} +``` + +## Multi-Window State Management +Use TabCollectionViewModel for window-specific state: + +```swift +// βœ… CORRECT - Window-specific state management +@MainActor +final class WindowCoordinator { + private let tabCollectionViewModel: TabCollectionViewModel + private weak var windowController: NSWindowController? + + init(tabCollectionViewModel: TabCollectionViewModel) { + self.tabCollectionViewModel = tabCollectionViewModel + } + + func currentTab() -> Tab? { + return tabCollectionViewModel.selectedTab + } + + func addNewTab(with url: URL? = nil) { + tabCollectionViewModel.addTab(with: url) + } + + func closeCurrentTab() { + guard let currentTab = tabCollectionViewModel.selectedTab else { return } + tabCollectionViewModel.removeTab(currentTab) + } + + func closeWindow() { + windowController?.close() + } +} +``` + +## Window State Restoration +Implement proper state restoration: + +```swift +// βœ… CORRECT - Window state restoration +extension FeatureWindowController { + override func restoreState(with coder: NSCoder) { + super.restoreState(with: coder) + + // Restore window-specific state + if let savedData = coder.decodeObject(forKey: "viewModelState") as? Data { + viewModel.restoreState(from: savedData) + } + } + + override func encodeRestorableState(with coder: NSCoder) { + super.encodeRestorableState(with: coder) + + // Save window-specific state + if let stateData = viewModel.encodeState() { + coder.encode(stateData, forKey: "viewModelState") + } + } +} +``` + +## NSViewController and SwiftUI Integration +Use NSHostingView for SwiftUI integration: + +```swift +// βœ… CORRECT - NSHostingView integration +final class FeatureViewController: NSViewController { + private let viewModel: FeatureViewModel + + init(viewModel: FeatureViewModel) { + self.viewModel = viewModel + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func loadView() { + view = NSHostingView(rootView: FeatureView(viewModel: viewModel)) + } + + override func viewDidLoad() { + super.viewDidLoad() + title = "Feature" + preferredContentSize = NSSize(width: 400, height: 300) + } + + override func viewWillAppear() { + super.viewWillAppear() + viewModel.viewWillAppear() + } + + override func viewDidAppear() { + super.viewDidAppear() + viewModel.viewDidAppear() + } +} +``` + +## View Controller Lifecycle +Follow AppKit view controller patterns: + +```swift +// βœ… CORRECT - AppKit lifecycle management +final class FeatureViewController: NSViewController { + private var observations: Set = [] + + override func viewDidLoad() { + super.viewDidLoad() + setupUI() + bindViewModel() + setupNotifications() + } + + override func viewWillAppear() { + super.viewWillAppear() + viewModel.refreshData() + } + + override func viewDidAppear() { + super.viewDidAppear() + // View is fully visible + viewModel.trackViewAppearance() + } + + override func viewWillDisappear() { + super.viewWillDisappear() + viewModel.saveUserChanges() + } + + private func setupNotifications() { + let observation = NotificationCenter.default.addObserver( + forName: .dataUpdated, + object: nil, + queue: .main + ) { [weak self] _ in + self?.viewModel.refreshData() + } + observations.insert(observation) + } + + deinit { + observations.forEach { NotificationCenter.default.removeObserver($0) } + observations.removeAll() + } +} +``` + +## Memory Management for Multiple Windows +Implement proper cleanup for window controllers: + +```swift +// βœ… CORRECT - Window memory management +final class FeatureWindowController: NSWindowController { + private var observers: Set = [] + + deinit { + // Clean up resources + observers.forEach { NotificationCenter.default.removeObserver($0) } + observers.removeAll() + viewModel.cleanup() + } + + override func close() { + // Prepare for closure + viewModel.saveState() + super.close() + } + + func cleanupBeforeClose() { + // Cancel any ongoing operations + viewModel.cancelOngoingOperations() + + // Remove from window tracking + WindowTracker.shared.removeWindow(self) + } +} +``` + +## Window Cascading and Positioning +Handle window positioning properly: + +```swift +// βœ… CORRECT - Window positioning +extension WindowsManager { + static func positionNewWindow(_ window: NSWindow) { + if let lastWindow = NSApp.orderedWindows.first { + let origin = lastWindow.frame.origin + let offset: CGFloat = 30 + + let newOrigin = NSPoint( + x: origin.x + offset, + y: origin.y - offset + ) + + // Ensure window stays on screen + let screenFrame = window.screen?.visibleFrame ?? NSScreen.main?.visibleFrame ?? .zero + + if screenFrame.contains(NSRect(origin: newOrigin, size: window.frame.size)) { + window.setFrameOrigin(newOrigin) + } else { + window.center() + } + } else { + window.center() + } + } +} +``` + +See `macos-system-integration.md` for system-level integration patterns and `macos-preferences.md` for preferences management. diff --git a/work/wiki/apple-browsers/maestro-device-selection.mdc b/work/wiki/apple-browsers/maestro-device-selection.mdc new file mode 100644 index 00000000..4987360f --- /dev/null +++ b/work/wiki/apple-browsers/maestro-device-selection.mdc @@ -0,0 +1,136 @@ +--- +source: ~/DuckDuckGo/apple-browsers.git/main/.cursor/rules/maestro-device-selection.mdc +confidence: 0.9 +namespace: work +last_synced: 2026-04-28 +alwaysApply: false +--- + +# Maestro Test Device Selection (iPhone/iPad) + +## Overview +Maestro tests can run on either iPhone or iPad simulators based on tags in the test YAML files. This allows you to test iPad-specific UI layouts and features while maintaining a single test suite. + +## How It Works +- **Default**: Tests run on iPhone 16 with iOS 18.2 +- **iPad Tests**: Add `ipad` tag to run on iPad 10th generation with iOS 18.2 + +## Usage + +### For Individual iPad Tests +Add the `ipad` tag to your test file: + +```yaml +appId: com.duckduckgo.mobile.ios +tags: + - ipad + - your-other-tags +name: Your iPad Test + +--- +# Your test steps here +``` + +### Running Tests +The same commands work as before: +```bash +# Run a single test (device selected based on tags) +./run_ui_tests.sh path/to/test.yaml + +# Run all tests in a folder (each test runs on appropriate device) +./run_ui_tests.sh path/to/tests/ +``` + +## Implementation Details + +### Setup Phase (`setup_ui_tests.sh`) +1. Creates both iPhone and iPad simulators upfront +2. Boots and configures both simulators with English locale +3. Installs the app on both devices +4. Saves both simulator UUIDs for test execution + +### Test Execution (`run_ui_tests.sh`) +1. Opens Simulator app if not already running +2. Checks each test file for the `ipad` tag using grep +3. Boots the required simulator if it's not already running +4. Uses the pre-created simulator based on the tag +5. Reinstalls the app before each test for clean state +6. Reports which device type was used in test results + +## Simulator Specifications +- **iPhone**: iPhone 16, iOS 18.2 +- **iPad**: iPad (10th generation), iOS 18.2 + +Both simulators are configured with: +- Language: English (en) +- Locale: en_US +- Name suffix: "(maestro)" for easy identification + +## Example Test Structure +```yaml +appId: com.duckduckgo.mobile.ios +tags: + - ipad + - duckplayer +name: DuckPlayer iPad Layout Test + +--- +# This test will automatically run on iPad simulator + +- runFlow: + file: ../shared/setup.yaml + +# Test iPad-specific UI elements +- assertVisible: "Split View" # Example iPad-specific element +``` + +## Technical Implementation + +### Tag Detection Function +The script uses awk to detect the 'ipad' tag: +```bash +check_for_ipad_tag() { + local test_file=$1 + + # Check if the test has an 'ipad' tag + if awk '/^tags:/{flag=1} flag && /^[^-]/{exit} flag && /- ipad/{found=1; exit} END{exit !found}' "$test_file" 2>/dev/null; then + echo "true" + else + echo "false" + fi +} +``` + +### Pre-created Simulators +Both simulators are created during setup for better performance: +```bash +# In setup_ui_tests.sh +create_or_get_simulator() { + local device_name=$1 + local device_type=$2 + local simulator_name="$device_name $target_os (maestro)" + # Creates simulator if it doesn't exist + # Returns simulator UUID +} + +# Creates both simulators +iphone_uuid=$(create_or_get_simulator "iPhone-16" "iPhone-16") +ipad_uuid=$(create_or_get_simulator "iPad-10th-generation" "iPad-10th-generation") +``` + +### Simulator UUID Storage +UUIDs are saved to files for test execution: +- iPhone UUID: `DerivedData/device_uuid.txt` +- iPad UUID: `DerivedData/device_uuid_ipad.txt` + +## Benefits +- **Single Test Suite**: Maintain one set of tests for both device types +- **Automatic Selection**: No need to manually switch simulators +- **Efficient Testing**: Only creates simulators when needed +- **Clear Indication**: Test output shows which device type is being used + +## Future Enhancements +- Support for additional iPad models +- Different iOS versions per device type +- Landscape/portrait orientation configuration +- Device-specific timeout adjustments diff --git a/work/wiki/apple-browsers/network-quality-scoring.mdc b/work/wiki/apple-browsers/network-quality-scoring.mdc new file mode 100644 index 00000000..2f065cda --- /dev/null +++ b/work/wiki/apple-browsers/network-quality-scoring.mdc @@ -0,0 +1,184 @@ +--- +source: ~/DuckDuckGo/apple-browsers.git/main/.cursor/rules/network-quality-scoring.mdc +confidence: 0.9 +namespace: work +last_synced: 2026-04-28 +alwaysApply: false +--- + +# NetworkQualityMonitor Scoring Algorithm + +## Overview + +NetworkQualityMonitor uses a browser-optimized scoring algorithm that prioritizes latency and consistency over raw bandwidth. The scoring is weighted as follows: + +- **HTTP Response (40%)**: Most critical for browser experience +- **Bandwidth (35%)**: Important for media and downloads +- **DNS (10%)**: Foundation of all connections +- **Buffer Bloat (15%)**: Network congestion under load + +## HTTP Response Scoring (40% Weight) + +### Measurement Process + +1. **Endpoints**: Tests 10+ global endpoints including DuckDuckGo, major CDNs, and popular platforms +2. **Sampling**: 15 requests per endpoint with interleaved ordering to avoid bias +3. **Statistical Analysis**: + - Calculates median response time for each site + - Uses **median of all site medians** for overall response time + - Calculates standard deviation for each site's measurements + - Uses **median of all site standard deviations** for consistency metric + +### Response Time Calculation + +```swift +// For each site: calculate median of its measurements +let siteMedians = sites.map { calculateMedian($0.measurements) } + +// Overall response time: median of all site medians +let overallResponseTime = median(siteMedians) +``` + +This approach: +- Resists outliers at both per-site and cross-site levels +- Provides typical latency experience across geographic regions +- Doesn't get skewed by one particularly fast CDN or slow server + +### Consistency Calculation + +```swift +// For each site: calculate standard deviation of its measurements +let siteStdDevs = sites.map { calculateStdDev($0.measurements) } + +// Overall consistency: median of all site standard deviations +let overallStdDev = median(siteStdDevs) +``` + +This avoids mixing different latency populations (e.g., 20ms CDN vs 300ms cross-ocean). + +### Coefficient of Variation (CV) Scoring + +The scoring uses Coefficient of Variation to fairly compare different latency ranges: + +```swift +// CV = stdDev / mean - normalizes variance relative to baseline +let coefficientOfVariation = stdDev / averageResponseTime + +// Apply CV-based penalty (fairer than raw standard deviation) +// Example: 10ms variance on 50ms latency (20% CV) penalized more than +// 10ms variance on 200ms latency (5% CV) +``` + +#### CV Penalty Thresholds + +- **< 10% CV**: No penalty (excellent consistency) +- **10-20% CV**: -10 points (good consistency) +- **20-35% CV**: -25 points (acceptable consistency) +- **35-50% CV**: -40 points (poor consistency) +- **50-75% CV**: -55 points (very poor consistency) +- **> 75% CV**: -70 points (severe instability) + +### Response Time Thresholds + +- **Excellent (100 pts)**: < 50ms - Instantaneous +- **Very Good (85 pts)**: 50-75ms - Very responsive +- **Good (70 pts)**: 75-100ms - Target for production +- **Fair (55 pts)**: 100-150ms - Slight delay noticeable +- **Below Average (40 pts)**: 150-200ms - Definitely noticeable +- **Poor (25 pts)**: 200-300ms - Users frustrated +- **Very Poor (10 pts)**: > 300ms - Severe issues + +## Bandwidth Scoring (35% Weight) + +### Download (85% of bandwidth score) + +Tests multiple endpoints with adaptive sizing: +- CloudFlare: 25MB +- OVH: 10MB +- Hetzner: 10MB + +#### Download Speed Thresholds + +- **100+ Mbps (100 pts)**: Excellent - instant page loads, 4K streaming +- **50-100 Mbps (85 pts)**: Very good - smooth HD streaming +- **25-50 Mbps (70 pts)**: Good - normal browsing experience +- **10-25 Mbps (55 pts)**: Fair - acceptable for most tasks +- **5-10 Mbps (40 pts)**: Below average - noticeable delays +- **2-5 Mbps (25 pts)**: Poor - significant limitations +- **< 2 Mbps (10 pts)**: Very poor - barely usable + +### Upload (15% of bandwidth score) + +Tests 5MB chunks to multiple endpoints. + +#### Upload Speed Thresholds + +- **50+ Mbps (100 pts)**: Excellent +- **20-50 Mbps (85 pts)**: Very good +- **10-20 Mbps (70 pts)**: Good +- **5-10 Mbps (55 pts)**: Fair +- **2-5 Mbps (40 pts)**: Below average +- **1-2 Mbps (25 pts)**: Poor +- **< 1 Mbps (10 pts)**: Very poor + +## DNS Scoring (10% Weight) + +### Measurement + +- Tests 11 popular domains +- Uses median resolution time (resistant to outliers) +- Measures with `CFHostStartInfoResolution` + +### DNS Resolution Thresholds + +- **< 10ms (100 pts)**: Excellent - likely cached +- **10-30ms (85 pts)**: Good - fast resolver +- **30-50ms (70 pts)**: Fair - acceptable +- **50-100ms (50 pts)**: Poor - noticeable delay +- **> 100ms (30 pts)**: Very poor - severe issues + +## Buffer Bloat Scoring (15% Weight) + +### Measurement Process + +1. **Baseline**: 10 latency measurements without load +2. **Loaded**: 15 measurements during concurrent download +3. **Calculation**: `loadedLatency - baselineLatency` + +### Buffer Bloat Grades + +- **Grade A (90 pts)**: < 5ms increase - No congestion +- **Grade B (70 pts)**: 5-30ms increase - Minimal impact +- **Grade C (50 pts)**: 30-100ms increase - Noticeable under load +- **Grade D (30 pts)**: 100-200ms increase - Significant congestion +- **Grade F (10 pts)**: > 200ms increase - Severe issues + +## Overall Score Calculation + +```swift +overallScore = (httpResponse * 0.40) + + (bandwidth * 0.35) + + (dns * 0.10) + + (bufferBloat * 0.15) +``` + +## Quality Determination + +```swift +func determineQuality(from score: Double) -> NetworkQuality { + switch score { + case 80...: return .excellent // 🟒 + case 60..<80: return .good // 🟑 + case 40..<60: return .fair // 🟠 + default: return .poor // πŸ”΄ + } +} +``` + +## Key Design Decisions + +1. **Median-based calculations**: Resistant to outliers and network spikes +2. **Coefficient of Variation**: Fair comparison across different latency ranges +3. **Browser-optimized weights**: Prioritizes latency over bandwidth +4. **Interleaved testing**: Avoids consecutive requests to same endpoint +5. **Per-site then overall**: Avoids mixing different geographic populations diff --git a/work/wiki/apple-browsers/network-quality-test-config.mdc b/work/wiki/apple-browsers/network-quality-test-config.mdc new file mode 100644 index 00000000..9e3cef05 --- /dev/null +++ b/work/wiki/apple-browsers/network-quality-test-config.mdc @@ -0,0 +1,104 @@ +--- +source: ~/DuckDuckGo/apple-browsers.git/main/.cursor/rules/network-quality-test-config.mdc +confidence: 0.9 +namespace: work +last_synced: 2026-04-28 +alwaysApply: false +--- + +# NetworkQualityMonitor Test Configuration + +## Optimized Test Parameters + +The NetworkQualityMonitor uses carefully tuned parameters to balance speed and accuracy for browser performance testing. + +## Test Phases and Data Sizes + +### HTTP Response Testing (Latency) +- **Samples**: 15 per endpoint +- **Endpoints**: ~12 globally distributed CDNs and services +- **Methodology**: Warm-up phase + interleaved sampling +- **Timeout**: 5 seconds +- **Total time**: ~3-4 minutes + +### Bandwidth Testing (Download) +- **File size**: 50MB per server (reduced from 100MB) +- **Servers**: 3 test servers (CloudFlare, OVH, Hetzner) +- **Runs**: 1 per server (reduced from 2) +- **Total download**: ~150MB (was 800MB) +- **Timeout**: 20 seconds per server +- **Measurement window**: + - At 100 Mbps: ~4 seconds + - At 25 Mbps: ~16 seconds + - At 10 Mbps: ~40 seconds + +### Upload Testing +- **Chunk size**: 20MB (reduced from 50MB) +- **Chunks**: 2 sequential uploads +- **Total upload**: 40MB (was 100MB) +- **Timeout**: 25 seconds total +- **Servers**: 3 endpoints + +### DNS Resolution Testing +- **Domains**: 11 popular domains +- **Tests**: Resolution time and failure rate +- **Timeout**: Default system resolver timeout + +## Performance Optimizations + +### Why These Sizes? + +**50MB for Downloads:** +- Large enough to overcome TCP slow start +- Provides stable measurement window (4-40 seconds) +- Small enough to complete quickly on slower connections +- Balances accuracy with user experience + +**20MB for Uploads:** +- Sufficient to measure upload capacity +- Most users have asymmetric connections (slower upload) +- Reduces test time significantly + +### Total Test Duration + +**Typical completion times:** +- Fast connection (100+ Mbps): ~2-3 minutes +- Good connection (25-50 Mbps): ~3-4 minutes +- Fair connection (10-25 Mbps): ~4-5 minutes +- Poor connection (<10 Mbps): ~5-7 minutes + +### Data Usage + +**Total data transferred:** +- Download: ~150MB +- Upload: ~40MB +- **Total: ~190MB** (reduced from ~900MB) + +## Configuration Code + +```swift +TestConfiguration( + latencyTestURLs: [/* 12 CDN endpoints */], + bandwidthTestURLs: [ + "https://speed.cloudflare.com/__down?bytes=52428800", // 50MB + "https://proof.ovh.net/files/50Mb.dat", // 50MB + "https://speed.hetzner.de/50MB.bin" // 50MB + ], + uploadTestURLs: [/* 3 upload endpoints */], + dnsTestDomains: [/* 11 popular domains */], + latencySamplesPerEndpoint: 15, + bandwidthRunsPerServer: 1, + uploadChunkSize: 20_971_520, // 20MB + uploadChunkCount: 2, + latencyTestTimeout: 5, + bandwidthTestTimeout: 20, + uploadTestTimeout: 25 +) +``` + +## Quick Test Mode + +For rapid connectivity checks, the system also supports: +- 10MB quick downloads for server selection +- HEAD requests for basic connectivity +- Reduced sample counts for faster results diff --git a/work/wiki/apple-browsers/network-quality-testing.mdc b/work/wiki/apple-browsers/network-quality-testing.mdc new file mode 100644 index 00000000..10f3ba8d --- /dev/null +++ b/work/wiki/apple-browsers/network-quality-testing.mdc @@ -0,0 +1,166 @@ +--- +source: ~/DuckDuckGo/apple-browsers.git/main/.cursor/rules/network-quality-testing.mdc +confidence: 0.9 +namespace: work +last_synced: 2026-04-28 +alwaysApply: false +--- + +# NetworkQualityMonitor Testing Framework + +## Overview + +The NetworkQualityMonitor is a comprehensive network quality testing framework designed for the DuckDuckGo Privacy Browser. It provides pre-flight network connectivity and performance checks to ensure optimal browser performance. + +## Architecture Principles + +### SOLID Design +- **Single Responsibility**: Each tester handles one specific network metric +- **Open/Closed**: Protocol-based design allows extension without modification +- **Dependency Injection**: All dependencies injected for testability +- **Interface Segregation**: Focused protocols for each test type + +### Component Structure +``` +NetworkQualityMonitor (Orchestrator) +β”œβ”€β”€ HttpResponseTester (Latency) +β”œβ”€β”€ BandwidthTester (Speed) +β”œβ”€β”€ DNSTester (Resolution) +β”œβ”€β”€ BufferBloatTester (Congestion) +└── NetworkScoreCalculator (Scoring) +``` + +## Test Implementation Details + +### HTTP Response Testing (Latency) +- **Multi-endpoint sampling**: Tests CDN endpoints (CloudFlare, Fastly, CloudFront) +- **Statistical analysis**: Calculates median, mean, standard deviation, CV +- **Smart aggregation**: Best site selection with weighted penalties +- **Metrics**: P50/P95 percentiles, variance, failure rate + +### Bandwidth Testing +- **Server selection**: Quick 10MB test, then full test on best servers +- **Download measurement**: Multiple runs, returns maximum speed +- **Upload measurement**: Chunked uploads (50MB x 2) +- **Optimization**: Range requests, cache-busting, timeout protection + +### DNS Testing +- **Domain resolution**: Popular domains (google.com, cloudflare.com) +- **Timing precision**: CFAbsoluteTime for microsecond accuracy +- **Failure tracking**: Resolution success/failure rates +- **System resolver**: Uses native DNS resolution + +### Buffer Bloat Testing +- **Baseline measurement**: Unloaded network latency +- **Load testing**: Concurrent downloads during latency measurement +- **Grade assignment**: A-F based on latency increase percentage +- **Real-time impact**: Critical for video calls, gaming + +## Scoring Algorithm + +### Component Weights +- HTTP Response: 25% +- Bandwidth: 35% +- DNS: 15% +- Buffer Bloat: 25% + +### Quality Ratings +- **Excellent (80-100)**: Optimal performance +- **Good (60-79)**: Good for most tasks +- **Fair (40-59)**: May experience issues +- **Poor (0-39)**: Significant issues likely + +## Testing Best Practices + +### Unit Testing +```swift +// Use protocol-based mocks +class MockHttpResponseTester: HttpResponseTesting { + func performTest(...) async throws -> HttpResponseResult { + // Return deterministic results + } +} +``` + +### Integration Testing +- Mock NetworkSession for controlled responses +- Test error scenarios and edge cases +- Verify progress callback behavior + +### Performance Testing +- Monitor memory usage during large downloads +- Verify timeout handling +- Test concurrent execution + +## Usage Patterns + +### Basic Implementation +```swift +let monitor = NetworkQualityMonitor() +let results = try await monitor.runTest() +print("Quality: \(results.quality.rawValue)") +``` + +### With Progress Reporting +```swift +monitor.progressCallback = { progress, message in + // Update UI with progress +} +``` + +### Custom Configuration +```swift +let config = TestConfiguration( + latencyTestURLs: customURLs, + latencySamplesPerEndpoint: 20 +) +let monitor = NetworkQualityMonitor(configuration: config) +``` + +## Security Considerations + +- **HTTPS only**: All endpoints use secure connections +- **No user data**: Only generic test payloads +- **Certificate validation**: Standard validation enabled +- **Rate limiting**: Built-in delays between samples + +## Error Handling + +### Error Types +- `invalidResponse`: HTTP errors, malformed data +- `allTestsFailed`: Complete connectivity loss +- `insufficientData`: Not enough samples collected +- `timeout`: Test exceeded time limit + +### Recovery Strategies +- Continue testing if individual endpoints fail +- Provide partial results when possible +- Clear error reporting with localized descriptions + +## Performance Optimizations + +- **HEAD requests**: Minimal data for latency tests +- **Range requests**: Efficient server selection +- **Connection reuse**: URLSession connection pooling +- **Memory streaming**: Large downloads streamed, not buffered + +## Package Integration + +### Adding to Project +1. Add NetworkQualityMonitor package dependency +2. Import NetworkQualityMonitor module +3. Initialize with configuration +4. Handle async test execution + +### Debug Menu Integration +- Available under Debug β†’ Network Quality +- Individual test execution +- Detailed result display + +## Future Enhancements + +- IPv6 testing separation +- Jitter analysis +- Packet loss detection +- Geographic server selection +- Historical trending diff --git a/work/wiki/apple-browsers/network-quality-variance-scoring.mdc b/work/wiki/apple-browsers/network-quality-variance-scoring.mdc new file mode 100644 index 00000000..75fb6710 --- /dev/null +++ b/work/wiki/apple-browsers/network-quality-variance-scoring.mdc @@ -0,0 +1,108 @@ +--- +source: ~/DuckDuckGo/apple-browsers.git/main/.cursor/rules/network-quality-variance-scoring.mdc +confidence: 0.9 +namespace: work +last_synced: 2026-04-28 +alwaysApply: false +--- + +# Network Quality Variance Scoring Using Coefficient of Variation + +## Overview + +NetworkQualityMonitor displays variance in **milliseconds** (user-friendly) but scores using **Coefficient of Variation (CV)** internally. This provides fair comparison across different latency ranges. + +## Display vs Scoring + +- **UI Display**: Shows variance in milliseconds (e.g., "5.2 ms (10.4%)") +- **Internal Scoring**: Uses CV = (stdDev/mean) Γ— 100 for penalties +- **Quality Labels**: Based on CV percentage thresholds + +## Why CV for Scoring? + +A 10ms variance means different things at different latencies: +- 10ms variance on 50ms latency = 20% CV (moderate issue) +- 10ms variance on 200ms latency = 5% CV (excellent consistency) + +Using CV ensures fair scoring regardless of base latency. + +## CV Thresholds and Test Iterations + +| CV Range | Quality | Penalty | Test Iterations | Testing Impact | +|----------|---------|---------|-----------------|----------------| +| <10% | Excellent | 0 pts | ~30 iterations | Quick reliable tests | +| 10-20% | Good | -20 pts | ~100 iterations | Reasonable test time | +| 20-40% | Fair | -40 pts | ~400 iterations | Lengthy test cycles | +| >40% | Poor | -60 to -80 pts | 1000+ iterations | Practically unreliable | + +## Implementation + +```swift +// Display shows milliseconds +value: String(format: "%.1f ms", responseVariance) + +// Quality label uses CV internally +let cv = (variance / avgResponseTime) * 100 +if cv < 10 { return "Excellent" } +else if cv < 20 { return "Good" } +else if cv < 40 { return "Fair" } +else { return "Poor" } + +// Scoring penalty based on CV +switch coefficientOfVariation { +case ..<10: penalty = 0 +case 10..<20: penalty = 20 +case 20..<40: penalty = 40 +case 40..<60: penalty = 60 +default: penalty = 80 +} +``` + +## Statistical Analysis + +Smart Warm-up Phase: + - Initial "cold" request to each endpoint (DNS resolution, TLS handshake) + - These measurements are discarded to eliminate first-request bias + - Ensures subsequent measurements reflect warm connection performance + +Interleaved Sampling: + - Endpoints tested in randomized rounds (not consecutively) + - Prevents TCP connection reuse artifacts + - 15 samples per endpoint with 50ms delays between measurements + - More representative of real browsing patterns + +Per-Site Calculations: + - Calculate median response time (robust to outliers) + - Calculate variance and standard deviation + - Track individual site consistency + +Global Aggregation: + adjustedResponseTime = median(all_site_medians) + +Variance Scoring: + - Display: Standard deviation in milliseconds (user-friendly) + - Scoring: Coefficient of Variation (CV = stdDev/mean Γ— 100) + - CV determines penalties based on relative variance + +Dual Penalty System: + 1. CV-based: Relative variance penalties (up to 80 points) + 2. P95-P50 Spread: Percentage-based spike penalties (up to 40 points) + +## Key Metrics + +- **averageResponseTime**: Median of all site medians (geographic reality) +- **responseVariance**: Standard deviation in ms (consistency indicator) +- **latencySpread**: P95-P50 difference (spike detection) +- **p50/p95**: Percentiles for typical and worst-case assessment + +## Testing Impact + +The CV directly determines test reliability: +- **<10% CV**: Standard test suite (30-50 iterations) +- **10-20% CV**: Increase to 100-150 iterations +- **20-40% CV**: Need 400+ iterations for confidence +- **>40% CV**: Results unreliable even with 1000+ iterations + +## Key Principle + +**Variance has HUGE impact on performance testing.** High CV connections can turn a 1-hour test into a 10-hour marathon with less reliable results than a 30-minute test on a stable connection. diff --git a/work/wiki/apple-browsers/performance-optimization.mdc b/work/wiki/apple-browsers/performance-optimization.mdc new file mode 100644 index 00000000..1c605ada --- /dev/null +++ b/work/wiki/apple-browsers/performance-optimization.mdc @@ -0,0 +1,407 @@ +--- +source: ~/DuckDuckGo/apple-browsers.git/main/.cursor/rules/performance-optimization.mdc +confidence: 0.9 +namespace: work +last_synced: 2026-04-28 +alwaysApply: false +--- + +# Performance Optimization Guidelines + +## Memory Management + +### Avoid Retain Cycles +```swift +// Use weak/unowned references appropriately +class ViewController: UIViewController { + private var timer: Timer? + + override func viewDidLoad() { + super.viewDidLoad() + + // Bad - Creates retain cycle + timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { _ in + self.updateUI() + } + + // Good - Weak reference prevents retain cycle + timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in + self?.updateUI() + } + } + + deinit { + timer?.invalidate() + } +} +``` + +### Lazy Loading +```swift +class DataManager { + // Load expensive resources only when needed + private lazy var database: Database = { + return Database() + }() + + // Use computed properties for lightweight calculations + var itemCount: Int { + return items.count + } + + // Cache expensive computations + private var _processedData: [ProcessedItem]? + var processedData: [ProcessedItem] { + if let cached = _processedData { + return cached + } + let processed = items.map { ProcessedItem($0) } + _processedData = processed + return processed + } +} +``` + +### Memory-Efficient Collections +```swift +// Use appropriate collection types +struct LargeDataSet { + // Bad - Loads all data into memory + var allItems: [Item] { + return database.fetchAll() + } + + // Good - Use lazy sequences + var items: LazySequence<[Item]> { + return database.fetchAll().lazy + } + + // Better - Use pagination + func items(page: Int, pageSize: Int = 50) -> [Item] { + return database.fetch(offset: page * pageSize, limit: pageSize) + } +} +``` + +## UI Performance + +### Main Thread Protection +```swift +class ImageLoader { + func loadImage(from url: URL, completion: @escaping (UIImage?) -> Void) { + Task { + // Perform heavy work on background queue + let data = try? await URLSession.shared.data(from: url).0 + let image = data.flatMap { UIImage(data: $0) } + + // Always update UI on main thread + await MainActor.run { + completion(image) + } + } + } +} +``` + +### Efficient Table/Collection Views +```swift +class OptimizedTableViewController: UITableViewController { + override func viewDidLoad() { + super.viewDidLoad() + + // Register reusable cells + tableView.register(CustomCell.self, forCellReuseIdentifier: "Cell") + + // Set estimated heights for better scrolling + tableView.estimatedRowHeight = 44.0 + tableView.rowHeight = UITableView.automaticDimension + + // Enable prefetching + tableView.prefetchDataSource = self + } + + // Reuse cells efficiently + override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let cell = tableView.dequeueReusableCell(withIdentifier: "Cell", for: indexPath) as! CustomCell + + // Configure cell with minimal work + cell.configure(with: items[indexPath.row]) + + // Cancel any ongoing async work + cell.prepareForReuse() + + return cell + } +} + +extension OptimizedTableViewController: UITableViewDataSourcePrefetching { + func tableView(_ tableView: UITableView, prefetchRowsAt indexPaths: [IndexPath]) { + // Preload data for upcoming cells + let urls = indexPaths.compactMap { items[$0.row].imageURL } + ImageCache.shared.preload(urls: urls) + } +} +``` + +### Image Optimization +```swift +extension UIImage { + // Resize images to appropriate size + func resized(to targetSize: CGSize) -> UIImage? { + let renderer = UIGraphicsImageRenderer(size: targetSize) + return renderer.image { _ in + self.draw(in: CGRect(origin: .zero, size: targetSize)) + } + } + + // Decode images on background queue + func decodedImage() -> UIImage? { + guard let cgImage = cgImage else { return nil } + + let colorSpace = CGColorSpaceCreateDeviceRGB() + let context = CGContext( + data: nil, + width: cgImage.width, + height: cgImage.height, + bitsPerComponent: 8, + bytesPerRow: cgImage.width * 4, + space: colorSpace, + bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue + ) + + context?.draw(cgImage, in: CGRect(x: 0, y: 0, width: cgImage.width, height: cgImage.height)) + + guard let decodedImage = context?.makeImage() else { return nil } + return UIImage(cgImage: decodedImage) + } +} +``` + +## Network Performance + +### Efficient API Calls +```swift +class APIClient { + private let session: URLSession + private let cache = URLCache( + memoryCapacity: 10 * 1024 * 1024, // 10 MB + diskCapacity: 50 * 1024 * 1024, // 50 MB + diskPath: nil + ) + + init() { + let configuration = URLSessionConfiguration.default + configuration.urlCache = cache + configuration.requestCachePolicy = .returnCacheDataElseLoad + configuration.timeoutIntervalForRequest = 30 + configuration.httpMaximumConnectionsPerHost = 5 + + self.session = URLSession(configuration: configuration) + } + + // Batch requests when possible + func fetchMultipleItems(ids: [String]) async throws -> [Item] { + // Bad - Multiple individual requests + // let items = try await ids.asyncMap { try await fetchItem(id: $0) } + + // Good - Single batch request + let request = BatchRequest(ids: ids) + return try await fetch(request) + } + + // Use compression + func createRequest(url: URL) -> URLRequest { + var request = URLRequest(url: url) + request.addValue("gzip, deflate", forHTTPHeaderField: "Accept-Encoding") + return request + } +} +``` + +### Download Optimization +```swift +class DownloadManager { + // Use background sessions for large downloads + private lazy var backgroundSession: URLSession = { + let configuration = URLSessionConfiguration.background(withIdentifier: "com.duckduckgo.downloads") + configuration.isDiscretionary = true + configuration.sessionSendsLaunchEvents = true + return URLSession(configuration: configuration, delegate: self, delegateQueue: nil) + }() + + // Resume interrupted downloads + func resumeDownload(from resumeData: Data) { + let task = backgroundSession.downloadTask(withResumeData: resumeData) + task.resume() + } + + // Limit concurrent downloads + private let downloadQueue = OperationQueue() + + init() { + downloadQueue.maxConcurrentOperationCount = 3 + } +} +``` + +## Database Performance + +### Efficient Queries +```swift +import GRDB + +class DatabaseManager { + // Use indexes for frequently queried columns + func createIndexes(_ db: Database) throws { + try db.create(index: "idx_bookmarks_url", on: "bookmarks", columns: ["url"]) + try db.create(index: "idx_history_date", on: "history", columns: ["visitDate"]) + } + + // Batch operations + func insertMultipleItems(_ items: [Item]) throws { + try dbQueue.write { db in + // Use transactions for bulk operations + try items.forEach { item in + try item.insert(db) + } + } + } + + // Use appropriate fetch limits + func fetchRecentHistory(limit: Int = 100) throws -> [HistoryItem] { + try dbQueue.read { db in + try HistoryItem + .order(Column("visitDate").desc) + .limit(limit) + .fetchAll(db) + } + } + + // Optimize complex queries + func searchBookmarks(query: String) throws -> [Bookmark] { + try dbQueue.read { db in + // Use FTS (Full Text Search) for text searching + let pattern = "%\(query)%" + return try Bookmark + .filter(Column("title").like(pattern) || Column("url").like(pattern)) + .limit(50) + .fetchAll(db) + } + } +} +``` + +## Algorithm Optimization + +### Use Efficient Data Structures +```swift +// Choose appropriate data structures +class URLMatcher { + // Bad - O(n) lookup + private var blockedURLs: [String] = [] + + func isBlocked(_ url: String) -> Bool { + return blockedURLs.contains(url) + } + + // Good - O(1) lookup + private var blockedURLSet: Set = [] + + func isBlockedOptimized(_ url: String) -> Bool { + return blockedURLSet.contains(url) + } +} +``` + +### Avoid Expensive Operations +```swift +extension Array { + // Bad - Creates multiple intermediate arrays + func processItems() -> [ProcessedItem] { + return self + .compactMap { $0 as? Item } + .filter { $0.isValid } + .map { ProcessedItem($0) } + .sorted { $0.priority > $1.priority } + } + + // Good - Use lazy evaluation + func processItemsOptimized() -> [ProcessedItem] { + return self.lazy + .compactMap { $0 as? Item } + .filter { $0.isValid } + .map { ProcessedItem($0) } + .sorted { $0.priority > $1.priority } + } +} +``` + +## Monitoring and Profiling + +### Performance Metrics +```swift +class PerformanceMonitor { + static func measure( + _ title: String, + operation: () throws -> T + ) rethrows -> T { + let startTime = CFAbsoluteTimeGetCurrent() + defer { + let timeElapsed = CFAbsoluteTimeGetCurrent() - startTime + print("⏱ \(title): \(timeElapsed)s") + + // Log slow operations + if timeElapsed > 1.0 { + Pixel.fire(.performanceWarning(operation: title, duration: timeElapsed)) + } + } + return try operation() + } +} + +// Usage +let results = PerformanceMonitor.measure("Database Query") { + try database.fetchAllBookmarks() +} +``` + +### Memory Monitoring +```swift +class MemoryMonitor { + static var currentMemoryUsage: Double { + var info = mach_task_basic_info() + var count = mach_msg_type_number_t(MemoryLayout.size) / 4 + + let result = withUnsafeMutablePointer(to: &info) { + $0.withMemoryRebound(to: integer_t.self, capacity: 1) { + task_info(mach_task_self_, + task_flavor_t(MACH_TASK_BASIC_INFO), + $0, + &count) + } + } + + return result == KERN_SUCCESS ? Double(info.resident_size) / 1024.0 / 1024.0 : 0 + } + + static func logMemoryUsage(_ context: String) { + let usage = currentMemoryUsage + print("πŸ’Ύ Memory usage (\(context)): \(usage) MB") + + if usage > 200 { // 200 MB threshold + Pixel.fire(.highMemoryUsage(context: context, usage: usage)) + } + } +} +``` + +## Best Practices Summary + +1. **Profile First**: Use Instruments to identify actual bottlenecks +2. **Measure Impact**: Quantify performance improvements +3. **Cache Wisely**: Cache expensive computations but watch memory usage +4. **Async Everything**: Keep UI responsive with background processing +5. **Batch Operations**: Combine multiple operations when possible +6. **Lazy Loading**: Load data only when needed +7. **Resource Management**: Release resources promptly +8. **Monitor Production**: Track performance metrics in production diff --git a/work/wiki/apple-browsers/pixel-definitions.mdc b/work/wiki/apple-browsers/pixel-definitions.mdc new file mode 100644 index 00000000..0c343bad --- /dev/null +++ b/work/wiki/apple-browsers/pixel-definitions.mdc @@ -0,0 +1,307 @@ +--- +source: ~/DuckDuckGo/apple-browsers.git/main/.cursor/rules/pixel-definitions.mdc +confidence: 0.9 +namespace: work +last_synced: 2026-04-28 +description: Rules for creating and maintaining pixel definition JSON5 files that document pixels fired by the iOS and macOS apps +alwaysApply: false +--- +# Pixel Registry Definitions + +## Overview + +Pixel definitions are JSON5 files that document pixels and wide events fired by the iOS and macOS apps. They live in: + +- **iOS:** `iOS/PixelDefinitions/pixels/definitions/*.json5` +- **macOS:** `macOS/PixelDefinitions/pixels/definitions/*.json5` +- **iOS wide events:** `iOS/PixelDefinitions/wide_events/definitions/*.json5` + +Each platform has its own `params_dictionary.json5` and `suffixes_dictionary.json5` for reusable definitions. + +**Note:** Each definitions directory contains a `TEMPLATE.json5` file. These are scaffolds for creating new definition files β€” they are not real pixel definitions. Ignore them when reviewing or auditing existing definitions (their placeholder `expires` dates are intentional examples). + +## Pixel Definition Structure + +Each `.json5` file is a JSON5 object where keys are pixel names and values describe the pixel: + +```json5 +{ + "pixel_name_here": { + "description": "When and why this pixel fires", + "owners": ["githubUsername"], + "triggers": ["other"], + "suffixes": ["first_daily_count", "platform", "form_factor"], + "parameters": ["appVersion", "errorCode", "errorDomain"], + // Only for temporary pixels β€” omit for permanent ones + "expires": "2025-06-30" + } +} +``` + +### Required Fields + +| Field | Type | Description | +|-------|------|-------------| +| `description` | string | When the pixel fires and its purpose | +| `owners` | string[] | GitHub usernames of responsible people | +| `triggers` | string[] | What causes the pixel to fire (see trigger values below) | + +### Optional Fields + +| Field | Type | Description | +|-------|------|-------------| +| `suffixes` | array | Dynamic parts appended to the pixel name | +| `parameters` | array | Query parameters sent with the pixel | +| `expires` | string | ISO date (`YYYY-MM-DD`) for temporary pixels | + +### Trigger Values + +Valid trigger values: `"other"`, `"scheduled"`, `"startup"`, `"page_load"`, `"new_tab"`, `"exception"`, `"user_submitted"`, `"search_ddg"`. + +Most pixels use `"other"`. Use `"scheduled"` for daily/periodic pixels, `"startup"` for app-launch pixels, and `"page_load"` for navigation-related pixels. + +## Determining Parameters from Swift Code + +Pixel definitions must document **all** query parameters sent over the wire, including default ones. To determine the correct parameters: + +### Always-included Parameters + +**`appVersion`** is added by default to every pixel by PixelKit. Include `"appVersion"` in every definition, unless the pixel call disables it. + +**`pixelSource`** is automatically added only if the pixel's `standardParameters` property returns `[.pixelSource]`. Check the pixel event's `standardParameters` computed property in Swift β€” if it returns `[.pixelSource]`, include `"pixelSource"` in the definition. + +### Error Parameters + +If the pixel event carries an `Error` (via associated value or the `error` property), PixelKit automatically extracts and sends: +- `errorCode` (key: `"e"`) and `errorDomain` (key: `"d"`) +- `underlyingErrorCode` (key: `"ue"`) and `underlyingErrorDomain` (key: `"ud"`) if present + +Include these dictionary references in the definition when the pixel carries error information. + +### Custom Parameters + +Check the pixel event's `parameters` computed property in Swift for any additional parameters. Also inspect the call site where the pixel is fired β€” look for `withAdditionalParameters:` arguments and trace any helper functions that build those parameters. These are pixel-specific and must be included in the definition (either as dictionary references or inline objects). + +### Where to Look in Swift + +- **iOS:** `iOS/Core/PixelEvent.swift` defines pixel names. `iOS/Core/Pixel.swift` has `PixelParameters` constants. Check the `parameters` and `standardParameters` properties on the pixel event enum. +- **macOS:** `macOS/DuckDuckGo/Statistics/GeneralPixel.swift` defines many pixel names, parameters, and standard parameters. However, pixel events can also be defined in dedicated files (e.g. `UpdateFlowPixels.swift`, `CrashReportPixels.swift`) β€” search for types conforming to `PixelKitEvent`. +- **Shared:** `SharedPackages/BrowserServicesKit/Sources/PixelKit/` contains `PixelKit.swift` (firing logic) and `PixelKitEvent.swift` (protocol). + +## Reusing Parameters from the Dictionary + +`params_dictionary.json5` defines common parameters. Reference them by key name as a string: + +```json5 +"parameters": [ + "appVersion", // Reuses definition from params_dictionary.json5 + "errorCode", // key: "e", type: integer + "errorDomain", // key: "d", type: string + "underlyingErrorCode", + "underlyingErrorDomain" +] +``` + +To define a custom inline parameter, use an object instead: + +```json5 +"parameters": [ + "appVersion", + { + "key": "customParam", + "type": "string", + "description": "What this parameter represents", + "enum": ["value1", "value2"] + } +] +``` + +### Parameter Object Fields + +- `key` β€” the actual query parameter key sent in the pixel (use this for fixed keys) +- `keyPattern` β€” regex pattern for dynamic keys (e.g. `"^ue[0-9]?$"` for `ue`, `ue0`, `ue1`, etc.) +- `type` β€” `"string"`, `"integer"`, `"number"`, or `"boolean"` +- `description` β€” what the parameter represents +- `enum` β€” allowed values (optional) +- `pattern` β€” regex validation pattern (optional) +- `examples` β€” example values (optional) + +Use `key` or `keyPattern`, not both. + +## Suffixes + +A suffix is a string appended to the base pixel name to create distinct variants of the same pixel. For example, a pixel with the `"daily"` suffix set will produce a variant with `_daily` appended to the name (e.g. `m_mac_default-browser_daily`). When a pixel has multiple suffix sets, PixelKit generates all combinations β€” so `["first_daily_count", "platform", "form_factor"]` produces variants like `m_pixel_count_ios_phone`, `m_pixel_daily_ios_tablet`, etc. + +In some cases a suffix value is always present (rather than being a set of variants). For example, a pixel fired with the legacy daily frequency always gets `_d` appended to its name. When the suffix is fixed and always present, it may be baked directly into the pixel name in the definition (e.g. `m_mac_daily_active_user_d`) rather than declared in the `suffixes` field. See "Legacy Suffix Patterns" below. + +### Reusing Suffixes from the Dictionary + +`suffixes_dictionary.json5` defines common suffixes. Reference them by key name: + +```json5 +// Each string references a suffix set from the dictionary +"suffixes": ["first_daily_count", "platform", "form_factor"] +``` + +Common suffix keys (both platforms unless noted): +- `first_daily_count` β€” `["first", "daily", "count"]` +- `legacy_daily_count` β€” `["d", "c"]` +- `daily` β€” `["daily"]` +- `daily_standard` β€” `["daily", ""]` +- `count` β€” `["count"]` +- `daily_count` β€” `["daily", "count"]` (macOS only) +- `platform` β€” `["ios"]` (iOS only) +- `form_factor` β€” `["phone", "tablet"]` (iOS only) +- `time_bucket` β€” `["0", "0.1", "0.5", "1", "5", "10", "20", "40", "more"]` (iOS only) + +For custom inline suffixes, use an object: + +```json5 +"suffixes": [ + "first_daily_count", + { + "description": "The result of the operation", + "enum": ["success", "failure"] + } +] +``` + +Inline suffix objects support `description`, `enum`, and optionally `key`, `type`, and `pattern` (same fields as parameter objects). + +### Mapping Swift Firing Methods to Suffixes + +The suffix you use depends on how the pixel is fired in Swift: + +**iOS** (uses `DailyPixel` / `Pixel` / `UniquePixel`): + +| Swift Method | Suffix Dictionary Key | +|---|---| +| `Pixel.fire(pixel:)` | No scheduling suffix (use only `platform`/`form_factor`) | +| `DailyPixel.fire(pixel:)` | `"daily"` | +| `DailyPixel.fireDailyAndCount(pixel:)` | `"first_daily_count"` | +| `DailyPixel.fireDailyAndCount(pixel:, pixelNameSuffixes: .legacyDailyPixelSuffixes)` | `"legacy_daily_count"` | +| `UniquePixel.fire(pixel:)` | No suffix (pixel name typically ends in `_u` or `_unique`) | + +On iOS, most pixels also get `platform` and `form_factor` suffixes appended automatically. Include `["platform", "form_factor"]` for iOS pixels unless you confirm otherwise. + +**macOS** (uses `PixelKit.fire(event, frequency:)`): + +| `frequency:` Value | Suffix Dictionary Key | +|---|---| +| `.standard` (or omitted) | No scheduling suffix | +| `.daily` | `"daily"` | +| `.dailyAndCount` | `"daily_count"` (macOS) or `"first_daily_count"` (iOS) | +| `.dailyAndStandard` | `"daily_standard"` | +| `.legacyDaily` | No suffix field β€” the `_d` is baked into the pixel name (see "Legacy Suffix Patterns") | +| `.legacyDailyAndCount` | `"legacy_daily_count"` | + +Note: iOS `DailyPixel.fireDailyAndCount` uses `"first_daily_count"` (includes a `_first` pixel on first-ever fire). macOS `.dailyAndCount` uses `"daily_count"` (no `_first`). Check which platform you are writing for. + +### Compound Suffixes + +Suffixes can be nested in an inner array to form compound suffixes (combined into a single suffix segment): + +```json5 +"suffixes": [["platform", "form_factor"]] +``` + +This produces suffixes like `ios_phone`, `ios_tablet` rather than separate independent suffix positions. + +### Legacy Suffix Patterns + +Some older pixels use legacy firing frequencies (`.legacyDaily`, `.legacyDailyAndCount`) where the pixel library appends short suffixes like `_d` (daily) or `_c` (count) to the pixel name. In these cases, the suffix is baked directly into the pixel name in the definition rather than declared in the `suffixes` field. + +For example, `m_mac_daily_active_user_d` is fired with `frequency: .legacyDaily`. The Swift code defines the base name as `"m_mac_daily_active_user"`, and PixelKit appends `_d` automatically. The definition uses the full name `m_mac_daily_active_user_d` with no `suffixes` field β€” this is correct. + +Compare this to the modern pattern where `m_mac_default-browser` uses `frequency: .daily` and declares `"suffixes": ["daily"]`, producing `m_mac_default-browser_daily`. + +When writing definitions for legacy pixels, use the full pixel name (including the baked-in suffix) and use the `"legacy_daily_count"` suffix dictionary entry only if the pixel uses `.legacyDailyAndCount` (which produces both `_d` and `_c` variants). + +## Wide Events (iOS) + +Wide events use a different, richer schema in `iOS/PixelDefinitions/wide_events/definitions/`. They have a hierarchical structure with `meta`, `feature`, and `feature.data` sections: + +```json5 +{ + "event-name": { + "description": "The purpose of the wide event", + "owners": ["githubUsername"], + "meta": { + "type": "unique-event-name", + "version": "0.0" + }, + "feature": { + "name": "feature-name", + "status": ["SUCCESS", "FAILURE", "UNKNOWN"], + "data": { + "ext": { + "custom_field": { + "type": "string", + "description": "A custom extension field", + "enum": ["val1", "val2"] + }, + // Can reference props_dictionary.json entries by string + "application_state": "foregroundBackgroundState" + }, + "error": { + "domain": { "type": "string", "description": "Error domain" }, + "code": { "type": "integer", "description": "Error code" } + } + } + } + } +} +``` + +Wide events sent as standard pixels (in `pixels/definitions/`) use `wideEvent*` parameters from the params dictionary (e.g. `wideEventAppName`, `wideEventFeatureStatus`, `wideEventErrorDomain`). + +## Naming Conventions + +- **iOS pixels:** typically prefixed with `m_` (e.g. `m_netp_ev_good_latency`), though some lack this prefix (e.g. `autofill_extension_*`, `attributed_metric_*`) +- **macOS pixels:** typically prefixed with `m_mac_` (e.g. `m_mac_daily_active_user_d`) +- **iOS wide events (as pixels):** prefixed with `m_ios_wide_` (e.g. `m_ios_wide_vpn_connection`) +- Use lowercase with underscores or hyphens +- Always use the exact pixel name string from the Swift code (e.g. from `PixelEvent.swift` or `GeneralPixel.swift`) + +## File Organization + +Group related pixels into a single definition file named after the feature area, for example: + +- `navigation.json5` β€” Browser navigation pixels +- `onboarding.json5` β€” Onboarding pixels +- `vpn_latency.json5` β€” VPN latency measurement pixels + +See existing files in the `definitions/` directory for naming patterns. + +## Validation and Linting + +Run from the `iOS/` or `macOS/` directory: + +```bash +# Install dependencies (from repo root) +npm clean-install --include-workspace-root + +# Full validation (schema + formatting) +npm run validate-pixel-defs + +# Schema validation only +npm run validate-defs-without-formatting + +# Check formatting +npm run pixel-lint + +# Auto-fix formatting +npm run pixel-lint.fix +``` + +**Always run `npm run validate-pixel-defs`** from the relevant platform directory after making changes. + +## Quick Reference: Adding a New Pixel + +1. Determine the pixel name and parameters from the iOS/macOS codebase +2. Find or create the appropriate `.json5` file in `{platform}/PixelDefinitions/pixels/definitions/` +3. Add your pixel entry with `description`, `owners`, `triggers` +4. Add `suffixes` and `parameters` β€” reuse dictionary entries wherever possible +5. Check whether the pixel should be temporary, and if so then add `"expires": "YYYY-MM-DD"` +6. Run `npm run validate-pixel-defs` from the platform directory +7. Run `npm run pixel-lint.fix` if formatting issues are reported diff --git a/work/wiki/apple-browsers/pixels.mdc b/work/wiki/apple-browsers/pixels.mdc new file mode 100644 index 00000000..99acdb3c --- /dev/null +++ b/work/wiki/apple-browsers/pixels.mdc @@ -0,0 +1,268 @@ +--- +source: ~/DuckDuckGo/apple-browsers.git/main/.cursor/rules/pixels.mdc +confidence: 0.9 +namespace: work +last_synced: 2026-04-28 +description: How to define, name, and fire pixels on iOS and macOS. +alwaysApply: false +--- +# Pixels + +Pixels are one-off telemetry events sent via HTTP GET with a name and optional parameters. They are used for: + +- Basic feature usage events (e.g., button clicks, screen impressions) +- Errors (e.g., network failures, parsing errors) +- Conversion and retention (e.g, subscription purchase and activation) + +Pixels have the following requirements: + +- Use clear & transparent naming, so it's obvious what the pixel and parameters are for. Pixel names should be self-documenting - avoid cryptic abbreviations or shorthand. +- Only include information that is essential for the pixel +- Do not use values that are overly precise, e.g. if using an integer value in a parameter, bucket it into ranges rather than including the value verbatim +- Never include PII, URLs, or other forms of user-identifiable information in pixel names or parameters + +## Types of Pixels + +### Standard Pixels + +Sent every time the event occurs. + +```swift +Pixel.fire(pixel: .subscriptionRestoreAfterPurchaseAttempt) + +Pixel.fire(pixel: .autofillLoginsSavePromptDisplayed, withAdditionalParameters: [ + PixelParameters.autofillPromptTrigger: "manual" +]) +``` + +### Daily Pixels + +Sent once per day per event. Used to determine the number of users affected by a particular error + +```swift +DailyPixel.fireDailyAndCount(pixel: .subscriptionPurchaseAttempt, pixelNameSuffixes: DailyPixel.Constant.legacyDailyPixelSuffixes) +``` + +### Unique Pixels + +Sent once per install for the lifetime of the install. + +```swift +UniquePixel.fire(pixel: .subscriptionActivated) +``` + +## Pixel Definition Patterns + +### iOS Pixels + +iOS pixels are defined as cases on `Pixel.Event` in `iOS/Core/PixelEvent.swift`. Each enum case maps to an HTTP pixel name string via a computed `name` property. + +#### Adding a New iOS Pixel + +1. Add a new enum case to `Pixel.Event` in `iOS/Core/PixelEvent.swift`. +2. Add a corresponding case to the `name` computed property (also in `PixelEvent.swift`) that returns the pixel's string name. +3. Fire the pixel using `Pixel.fire`, `DailyPixel.fireDailyAndCount`, or `UniquePixel.fire`. +4. Add the matching pixel definition in `iOS/PixelDefinitions/pixels/definitions/*.json5` - see the `Pixel Validation` section below for more. + +#### Enum Case Definition + +```swift +extension Pixel { + public enum Event { + case appInstall + case appLaunch + case subscriptionPurchaseAttempt + case subscriptionPurchaseSuccess + case subscriptionActivated + // ... + } +} +``` + +#### Enum-to-String Mapping + +The `Pixel.Event` enum has a computed `name` property that maps each case to its HTTP pixel name string: + +```swift +extension Pixel.Event { + public var name: String { + switch self { + case .appInstall: return "m_install" + case .appLaunch: return "ml" + case .subscriptionPurchaseAttempt: return "m_subscribe" + // ... + } + } +} +``` + +#### Naming Convention + +iOS pixel names follow these conventions: + +- **Prefix**: Always start with `m_` (for "mobile"). +- **Separators**: Use underscores (`_`) or hyphens (`-`) between words. +- **Format**: Use `m_feature_action` or `m_feature-sub-feature_action`. +- **Clarity**: Names should be clear and self-documenting. Anyone reading the pixel name should understand what it represents without needing additional context. + +**Avoid legacy shorthand naming.** The codebase contains legacy pixels with cryptic names like `ml`, `mp`, `mf`, `m_r`. These are difficult to understand and should not be used as a template for new pixels. New pixels should use descriptive names. + +Examples: + +| Style | Enum Case | String Name | Notes | +|-------|-----------|-------------|-------| +| Good | `.pullToRefresh` | `"m_pull-to-reload"` | Clear and descriptive | +| Good | `.autofillLoginsSavePromptDisplayed` | `"m_autofill_logins_save_prompt_displayed"` | Self-documenting | +| Legacy | `.appLaunch` | `"ml"` | Avoid this style for new pixels | +| Legacy | `.privacyDashboardOpened` | `"mp"` | Avoid this style for new pixels | + +#### Parameterized Pixel Cases + +Enum cases can have associated values that are interpolated into the pixel name: + +```swift +// Enum definition with associated value +case networkProtectionLatency(quality: String) +case syncLocalTimestampResolutionTriggered(Feature) + +// In the name property +case .networkProtectionLatency(let quality): + return "m_netp_ev_\(quality)_latency" +case .syncLocalTimestampResolutionTriggered(let feature): + return "m_sync_\(feature.name)_local_timestamp_resolution_triggered" +``` + +### macOS Pixels (PixelKit) + +macOS uses `PixelKitEvent` protocol, typically in feature-specific files. The implementation of the protocol looks like this: + +```swift +// SharedPackages/BrowserServicesKit/Sources/PixelKit/PixelKitEvent.swift +public protocol PixelKitEvent { + var name: String { get } + var standardParameters: [PixelKitStandardParameter]? { get } + var parameters: [String: String]? { get } + var error: NSError? { get } +} +``` + +An example implementation of this protocol is: + +```swift +// macOS/DuckDuckGo/Statistics/SubscriptionPixel.swift +enum SubscriptionPixel: PixelKitEvent { + case subscriptionActive(AuthVersion) + case subscriptionPurchaseAttempt + case subscriptionPurchaseSuccess + // ... + + var name: String { + switch self { + case .subscriptionActive: return "m_mac_privacy-pro_app_subscription_active" + case .subscriptionPurchaseAttempt: return "m_mac_privacy-pro_terms-conditions_subscribe_click" + // ... + } + } + + var parameters: [String: String]? { + switch self { + case .subscriptionActive(let authVersion): + return [AuthVersion.key: authVersion.rawValue] + default: + return nil + } + } +} +``` + +## Pixel Parameters + +Use structured parameter keys when reusing them across multiple pixels: + +```swift +extension PixelParameters { + static let source = "source" +} + +Pixel.fire(pixel: .featureUsed, withAdditionalParameters: [ + PixelParameters.source: "keyboard_shortcut" +]) +``` + +## PixelFiring Protocol + +For dependency injection and testing, use the `PixelFiring` protocol: + +```swift +// iOS/Core/PixelFiring.swift +public protocol PixelFiring { + static func fire(_ pixel: Pixel.Event, + withAdditionalParameters params: [String: String], + includedParameters: [Pixel.QueryParameters], + onComplete: @escaping (Error?) -> Void) + + static func fire(_ pixel: Pixel.Event, + withAdditionalParameters params: [String: String]) +} +``` + +## Best Practices + +1. **Choose the right pixel type**: When using a standard pixel, consider using a daily pixel as well in order to determine how many users may be impacted by an issue. +2. **Use structured parameters**: Define parameter keys as constants to avoid typos and enable refactoring. +3. **Include error context**: When firing error pixels, include the error object as part of its error parameters. +4. **Consider using EventMapping**: When sending pixels from inside a shared Swift package, define pixels as new enum and emit them using `EventMapping`, then implement the event mapper on the client side. +5. **Consider using instrumentation facades**: For features with multiple related pixels, consider defining an instrumentation protocol to centralize pixel firing logic. See `instrumentation-facade.mdc`. + +## Pixel Validation + +Pixel definitions are validated from the `PixelDefinitions` folders in each platform: + +- `iOS/PixelDefinitions/` +- `macOS/PixelDefinitions/` + +Validation runs via `npm run validate-pixel-defs` from the platform folder. JSON5 formatting is enforced with Prettier. + +### Directory Layout + +- `pixels/definitions/*.json5`: The actual pixel definitions. Each file contains multiple pixel entries. +- `pixels/params_dictionary.json5`: Shared parameter definitions referenced by key. +- `pixels/suffixes_dictionary.json5`: Shared suffix definitions referenced by key. +- `product.json`: Product metadata. + +### Defining a Pixel in JSON5 + +Each JSON5 file defines a map of `pixel_name` to metadata. Use `TEMPLATE.json5` from each platform folder as a starting point. Example: + +```json5 +{ + "m_feature_action": { + "description": "Describe when the pixel fires and its purpose", + "owners": ["github-username"], + "triggers": ["other"], + "suffixes": ["first_daily_count", "platform"], + "parameters": ["appVersion"], + "expires": "2025-01-30" + } +} +``` + +Key fields: +- `description`: Clear explanation of the pixel’s purpose and timing. +- `owners`: GitHub usernames responsible for the pixel. +- `triggers`: One or more trigger categories used by validation. +- `suffixes`: Either shared suffix keys from `suffixes_dictionary.json5` or inline suffix definitions. +- `parameters`: Either shared parameter keys from `params_dictionary.json5` or inline parameter definitions. +- `expires` (optional): Date for temporary pixels; omit for permanent pixels. + +Prefer referencing shared suffix/parameter keys where possible to keep definitions consistent and validatable. + +Pixels have some default values, please check the Pixel implementation in the respective platform to determine what those are. + +## Related Files + +- `iOS/Core/Pixel.swift` - iOS pixel firing implementation +- `iOS/Core/DailyPixel.swift` - Daily pixel implementation +- `iOS/Core/UniquePixel.swift` - Unique pixel implementation +- `iOS/Core/PixelEvent.swift` - iOS pixel event definitions +- `SharedPackages/BrowserServicesKit/Sources/PixelKit/` - Shared PixelKit implementation diff --git a/work/wiki/apple-browsers/privacy-security.mdc b/work/wiki/apple-browsers/privacy-security.mdc new file mode 100644 index 00000000..c1cac6c1 --- /dev/null +++ b/work/wiki/apple-browsers/privacy-security.mdc @@ -0,0 +1,87 @@ +--- +source: ~/DuckDuckGo/apple-browsers.git/main/.cursor/rules/privacy-security.mdc +confidence: 0.9 +namespace: work +last_synced: 2026-04-28 +alwaysApply: true +--- + +# Privacy & Security Guidelines + +## Core Principles + +### Privacy by Design +- Never collect or transmit user data without explicit consent +- All features must have privacy implications documented +- Default to the most private option +- Implement data minimization - only collect what's absolutely necessary + +### Secure Storage +**Example:** See [secure-storage.swift](privacy-security/secure-storage.swift) + +## Data Handling + +### User Data Classification +1. **Sensitive Data**: Passwords, credentials, personal information + - Must use Keychain or encrypted storage + - Never log or transmit in plain text + - Clear on app logout/uninstall + +2. **Private Data**: Browsing history, bookmarks, settings + - Store locally only + - Implement proper data clearing + - Respect fireproofing settings + +3. **Anonymous Data**: Crash reports, usage statistics + - Only collect with user consent + - Strip all identifying information + - Use differential privacy where applicable + +### Network Security +**Example:** See [network-security.swift](privacy-security/network-security.swift) + +## Content Blocking + +### Tracker Protection +**Example:** See [tracker-protection.swift](privacy-security/tracker-protection.swift) + +### Cookie Management +**Example:** See [cookie-management.swift](privacy-security/cookie-management.swift) + +## Authentication & Authorization + +### Biometric Authentication +**Example:** See [biometric-authentication.swift](privacy-security/biometric-authentication.swift) + +### Credential Management +**Example:** See [credential-management.swift](privacy-security/credential-management.swift) + +## Error Handling + +### Secure Error Messages +**Example:** See [secure-error-messages.swift](privacy-security/secure-error-messages.swift) + +## Code Security + +### Input Validation +**Example:** See [input-validation.swift](privacy-security/input-validation.swift) + +### Secure Defaults +**Example:** See [secure-defaults.swift](privacy-security/secure-defaults.swift) + +## Testing Security + +### Security Test Cases +**Example:** See [security-test-cases.swift](privacy-security/security-test-cases.swift) + +## Review Checklist + +Before committing code, ensure: +- [ ] No hardcoded secrets or API keys +- [ ] All user data is properly classified and protected +- [ ] Network requests use HTTPS +- [ ] Input validation is implemented +- [ ] Error messages don't leak sensitive information +- [ ] Logging doesn't include PII +- [ ] Data clearing mechanisms are tested +- [ ] Privacy impact has been assessed diff --git a/work/wiki/apple-browsers/project-structure.mdc b/work/wiki/apple-browsers/project-structure.mdc new file mode 100644 index 00000000..0be8b08d --- /dev/null +++ b/work/wiki/apple-browsers/project-structure.mdc @@ -0,0 +1,344 @@ +--- +source: ~/DuckDuckGo/apple-browsers.git/main/.cursor/rules/project-structure.mdc +confidence: 0.9 +namespace: work +last_synced: 2026-04-28 +alwaysApply: false +--- + +# Project Structure & Organization + +## Workspace Structure + +### Root Level +``` +DuckDuckGo.xcworkspace/ # Main workspace (ALWAYS open this) +β”œβ”€β”€ iOS/ # iOS app target +β”œβ”€β”€ macOS/ # macOS app target +β”œβ”€β”€ SharedPackages/ # Cross-platform Swift packages +β”œβ”€β”€ fastlane/ # CI/CD automation +└── README.md # Main project documentation +``` + +### iOS App Structure +``` +iOS/ +β”œβ”€β”€ DuckDuckGo/ # Main iOS app +β”‚ β”œβ”€β”€ AppDelegate.swift # App lifecycle +β”‚ β”œβ”€β”€ MainViewController.swift # Primary browser interface +β”‚ β”œβ”€β”€ BrowserTab.swift # Tab state and WebKit integration +β”‚ β”œβ”€β”€ AIChat/ # AI chat integration +β”‚ β”œβ”€β”€ AppLifecycle/ # App lifecycle management +β”‚ β”œβ”€β”€ Autofill/ # Form autofill features +β”‚ β”œβ”€β”€ Bookmarks/ # Bookmark management +β”‚ β”œβ”€β”€ BrowsingMenu/ # Browser menu UI +β”‚ β”œβ”€β”€ Configuration/ # App configuration +β”‚ β”œβ”€β”€ DataImport/ # Data import utilities +β”‚ β”œβ”€β”€ HealthKitReporting/ # Health data reporting +β”‚ β”œβ”€β”€ MainWindow/ # Main window controllers +β”‚ β”œβ”€β”€ Subscription/ # Premium features +β”‚ β”œβ”€β”€ SyncPrompt/ # Sync feature prompts +β”‚ β”œβ”€β”€ TabSwitcher/ # Tab switching UI +β”‚ └── WebView/ # Web view management +β”œβ”€β”€ Core/ # iOS-specific shared utilities +β”œβ”€β”€ AutofillCredentialProvider/ # Password autofill extension +β”œβ”€β”€ PacketTunnelProvider/ # VPN network extension +β”œβ”€β”€ OpenAction/ # Share sheet integration +β”œβ”€β”€ Widgets/ # Home screen widgets +└── Configuration/ # Build configurations +``` + +### macOS App Structure +``` +macOS/ +β”œβ”€β”€ DuckDuckGo/ # Main macOS app +β”‚ β”œβ”€β”€ AppDelegate.swift # App lifecycle +β”‚ β”œβ”€β”€ MainWindow.swift # Primary window controller +β”‚ β”œβ”€β”€ BrowserTabViewController.swift # Web view management +β”‚ β”œβ”€β”€ AIChat/ # AI chat integration +β”‚ β”œβ”€β”€ Autofill/ # Form autofill features +β”‚ β”œβ”€β”€ Bookmarks/ # Bookmark management UI +β”‚ β”œβ”€β”€ Downloads/ # Download handling +β”‚ β”œβ”€β”€ NavigationBar/ # URL bar and navigation +β”‚ β”œβ”€β”€ NetworkProtection/ # VPN integration +β”‚ β”œβ”€β”€ Preferences/ # Settings and preferences +β”‚ β”œβ”€β”€ Subscription/ # Premium features +β”‚ β”œβ”€β”€ SyncPrompt/ # Sync feature prompts +β”‚ β”œβ”€β”€ TabBar/ # Tab management UI +β”‚ └── WebView/ # Web view management +β”œβ”€β”€ DuckDuckGoVPN/ # Standalone VPN app +β”œβ”€β”€ NetworkProtectionSystemExtension/ # System-level VPN +β”œβ”€β”€ DuckDuckGoDBPBackgroundAgent/ # Data Broker Protection +β”œβ”€β”€ DuckDuckGoNotifications/ # System notifications +└── Configuration/ # Build configurations +``` + +## Dependencies and Packages + +### Primary Dependency: BrowserServicesKit +```swift +// βœ… CORRECT - Always use BrowserServicesKit for shared functionality +import BrowserServicesKit + +// Features provided by BrowserServicesKit: +// - Content blocking and privacy protection +// - Bookmarks and history management +// - Secure credential storage +// - Autofill functionality +// - Navigation handling +// - User script injection +// - Privacy configuration +// - Sync functionality +``` + +### Shared Packages +``` +SharedPackages/ +β”œβ”€β”€ AIChat/ # AI chat functionality +β”œβ”€β”€ BrowserServicesKit/ # Core browser services +β”œβ”€β”€ DataBrokerProtectionCore/ # Data broker protection +β”œβ”€β”€ DesignResourcesKitIcons/ # Shared icon resources +β”œβ”€β”€ Onboarding/ # User onboarding experience +β”œβ”€β”€ UIComponents/ # Reusable UI components +└── VPN/ # VPN functionality +``` + +### Package Dependencies +```swift +// βœ… CORRECT - Use shared packages for cross-platform features +import DesignResourcesKitIcons +import UIComponents +import BrowserServicesKit + +// ❌ INCORRECT - Don't duplicate functionality across platforms +// Keep platform-specific code in iOS/ and macOS/ directories only +``` + +## Build Configuration + +### Xcode Workspace Setup +```swift +// βœ… CORRECT - Always open workspace, not individual projects +// Open: DuckDuckGo.xcworkspace +// Don't open: iOS/DuckDuckGo-iOS.xcodeproj or macOS/DuckDuckGo-macOS.xcodeproj +``` + +### Build Requirements +``` +iOS: +- Xcode 15.0 or later +- Swift 5.9 or later +- iOS 15.0+ deployment target +- Valid Apple Developer account +- Provisioning profiles for extensions + +macOS: +- Xcode 15.0 or later +- Swift 5.9 or later +- macOS 11.4+ deployment target +- Developer ID certificate (for notarization) +- System extension entitlements +``` + +### Configuration Files +``` +iOS/Configuration/ +β”œβ”€β”€ Configuration.xcconfig # Base configuration +β”œβ”€β”€ Configuration-Alpha.xcconfig # Alpha build settings +β”œβ”€β”€ Configuration-Debug.xcconfig # Debug build settings +└── BuildNumber.xcconfig # Build number management + +macOS/Configuration/ +β”œβ”€β”€ Base.xcconfig # Base configuration +β”œβ”€β”€ Debug.xcconfig # Debug build settings +β”œβ”€β”€ Release.xcconfig # Release build settings +└── AppStore.xcconfig # App Store specific +``` + +## Development Setup + +### Initial Setup +```bash +# βœ… CORRECT - Development setup steps +# 1. Open workspace at root level +open DuckDuckGo.xcworkspace + +# 2. Install Ruby dependencies (for Fastlane) +bundle install + +# 3. Ensure all certificates and provisioning profiles are installed +# 4. SwiftLint is enforced - run before committing +``` + +### Key Technologies +```swift +// iOS Stack +// - Language: Swift 5.9+ +// - UI: UIKit with SwiftUI components +// - Architecture: MVVM with AppDependencyProvider +// - Web Engine: WebKit with privacy enhancements + +// macOS Stack +// - Language: Swift 5.9+ +// - UI: AppKit with SwiftUI components +// - Architecture: MVVM with Combine +// - Web Engine: WebKit with privacy enhancements +// - System Integration: Native macOS features +``` + +## App Extensions and System Integration + +### iOS Extensions +```swift +// βœ… CORRECT - iOS extension organization +// AutofillCredentialProvider/ - Password autofill +// PacketTunnelProvider/ - VPN network extension +// OpenAction/ - Share sheet integration +// Widgets/ - Home screen widgets +``` + +### macOS Extensions +```swift +// βœ… CORRECT - macOS extension organization +// NetworkProtectionSystemExtension/ - System-level VPN +// DuckDuckGoDBPBackgroundAgent/ - Background data protection +// DuckDuckGoNotifications/ - System notifications +// VPNProxyExtension/ - VPN proxy functionality +``` + +## Testing Structure + +### iOS Testing +``` +iOS/ +β”œβ”€β”€ DuckDuckGoTests/ # Unit tests +β”œβ”€β”€ IntegrationTests/ # Integration tests +β”œβ”€β”€ PerformanceTests/ # Performance benchmarks +β”œβ”€β”€ UITests/ # UI automation tests +β”œβ”€β”€ SharedTestUtils/ # Shared test utilities +└── WebViewUnitTests/ # WebKit-specific tests +``` + +### macOS Testing +``` +macOS/ +β”œβ”€β”€ UnitTests/ # Unit test suite +β”œβ”€β”€ IntegrationTests/ # Integration testing +β”œβ”€β”€ UITests/ # UI automation tests +└── SyncE2EUITests/ # End-to-end sync tests +``` + +## Important Files and Entry Points + +### iOS Key Files +```swift +// Core Application Files +AppDelegate.swift # App lifecycle and initialization +MainViewController.swift # Primary browser interface +BrowserTab.swift # Tab state management +TabsBarViewController.swift # Tab bar UI (iPad) +BookmarksViewController.swift # Bookmarks management +PrivacyDashboardViewController.swift # Privacy protection UI +``` + +### macOS Key Files +```swift +// Core Application Files +AppDelegate.swift # App lifecycle management +MainWindow.swift # Primary window controller +BrowserTabViewController.swift # Web view management +NavigationBarViewController.swift # URL bar and controls +PreferencesViewController.swift # Settings interface +BookmarksBarViewController.swift # Bookmarks toolbar +``` + +## Common Development Tasks + +### Running the Apps +```swift +// βœ… CORRECT - Running applications +// iOS: Select "iOS Browser" scheme and simulator/device +// macOS: Select "DuckDuckGo" scheme and click Run +``` + +### Adding New Features +```swift +// βœ… CORRECT - Feature development flow +// 1. Determine if feature belongs in: +// - BrowserServicesKit (shared functionality) +// - Platform-specific code (iOS/ or macOS/) +// 2. Use appropriate dependency injection patterns +// 3. Follow existing architecture patterns +// 4. Write comprehensive tests +``` + +### Debugging and Testing +```swift +// βœ… CORRECT - Testing and debugging +// - Run tests: Cmd+U or Test navigator +// - Use Safari Web Inspector for web content debugging +// - Enable verbose logging in Debug builds +// - Use Instruments for performance profiling +``` + +## Build Schemes and Configurations + +### iOS Build Schemes +``` +- DuckDuckGo (iOS) - Main app +- iOS Browser - Browser-specific build +- Alpha - Alpha testing build +- Debug - Development build +``` + +### macOS Build Schemes +``` +- DuckDuckGo - Main app +- Debug - Development build +- Release - Release build +- Review - Review build +``` + +## Platform-Specific Considerations + +### iOS-Specific Features +```swift +// iOS-specific implementations +// - iPad-specific UI adaptations +// - iPhone-specific layouts +// - iOS system integration +// - App Store requirements +``` + +### macOS-Specific Features +```swift +// macOS-specific implementations +// - Menu bar integration +// - Touch Bar support (MacBook Pro) +// - Dock integration +// - System extensions +// - Universal Binary (Intel + Apple Silicon) +// - Notarization requirements +``` + +## Development Best Practices + +### Code Organization +```swift +// βœ… CORRECT - Follow established patterns +// - Use dependency injection via AppDependencyProvider +// - Keep platform-specific code in appropriate directories +// - Share common functionality through BrowserServicesKit +// - Follow MVVM architecture patterns +``` + +### Performance Considerations +```swift +// βœ… CORRECT - Performance optimization +// - Native Apple Silicon support +// - Efficient memory management +// - Hardware acceleration +// - Optimized content blocking +``` + +This structure ensures maintainable, testable code while providing comprehensive browser functionality across both iOS and macOS platforms. diff --git a/work/wiki/apple-browsers/pull-request.mdc b/work/wiki/apple-browsers/pull-request.mdc new file mode 100644 index 00000000..2c6e2cb7 --- /dev/null +++ b/work/wiki/apple-browsers/pull-request.mdc @@ -0,0 +1,349 @@ +--- +source: ~/DuckDuckGo/apple-browsers.git/main/.cursor/rules/pull-request.mdc +confidence: 0.9 +namespace: work +last_synced: 2026-04-28 +description: "PR, pull request, commit, push, git push, gh pr create, open PR, create PR, reviewer, Asana task, merge, GitHub" +alwaysApply: false +--- + +# Pull Request Guidelines & Workflow + +## 🚨 CRITICAL: Required Information and Approval Before Creating PR + +**MANDATORY**: Before creating any PR, you MUST: + +### Step 1: Gather Required Information + +### 1. Task/Issue URL +**Ask**: "What is the Asana task URL for this PR?" +- **NEVER proceed** with placeholder text like `[TASK_ID]` or `[INSERT_URL]` +- **NEVER assume** you can skip this step +- **ONLY proceed** if user explicitly says to omit it or provides the URL + +### 2. PR Reviewer Assignment (CRITICAL for Asana Integration) +**Ask**: "Who should review this PR?" +- Ask if they want to: + - Assign a specific reviewer (get their GitHub username for `--reviewer` flag) + - Use auto-assignment (`--reviewer Apple-dev` team) + - Handle it themselves after PR creation +- **NEVER proceed** without understanding the reviewer assignment strategy +- **ONLY proceed** if user explicitly provides the information or strategy + +**WHY THIS MATTERS**: +- GitHub Action only creates Asana subtask when reviewer is **assigned via GitHub's reviewer mechanism** +- Using `--reviewer` flag triggers the `review_requested` event that runs the Asana integration +- Without reviewer assignment, no Asana subtask is created automatically + +### 3. Tech Design URL (For Significant Changes) +- **Default to "N/A"** for minor changes and bug fixes +- **ASK for significant changes** (new features, architectural changes) +- **Can be omitted** if user doesn't explicitly provide one - use "N/A" +- Unlike Task/Issue URL, this is **optional** and can default to "N/A" + +### 4. Exception: User Explicitly Opts Out +The **ONLY** acceptable reason to skip asking for Task URL and Reviewer is if the user explicitly states: +- "Skip Asana task" or "No Asana task" +- "I'll assign reviewer myself" or "Use auto-assignment" + +**Failure to ask for Task URL and Reviewer = violation of PR workflow.** + +--- + +### Step 2: Get User Approval Before Creating PR + +**MANDATORY**: After gathering all information, you MUST: + +1. **Present the complete PR body text** to the user for review and approval +2. **Include the reviewer name** that will be assigned +3. **Show the exact text** that will be used in the PR body (not the command) +4. **Wait for explicit approval** before proceeding +5. **ONLY after approval**: Execute the `gh pr create` command + +**Do NOT create the PR without showing the user the exact PR body text first.** + +**Format for approval request:** +``` +Here's the PR I'm about to create: +**Title:** [PR title] + +**Reviewer:** @username + +**PR Body:** +[Show complete PR body text here] + +Proceed with creating the PR? +``` + +After user approves, then execute the `gh pr create` command. + +## 🚨 CRITICAL: Always Open PR URL After Creation + +**MANDATORY**: After creating or updating a PR, **IMMEDIATELY** run: +```bash +open +``` + +This ensures the PR is accessible and properly formatted in the browser. + +## Objective + +- **Maintain a clear and maintainable list** of open PRs in the Apple repositories +- **Improve PR review turnaround time** through proper assignment and notification processes +- **Establish clear rules** for internal (Apple team) and external (FrontEnd, etc.) contributions +- **Remove PR assignment** as part of the Apple Weekly process + +## PR Types and Assignment Strategy + +We have **two different types** of code contributions: + +### **Projects** +Large features or significant changes with designated technical reviewers. + +### **Tasks** +Small improvements or bug fixes that require flexible reviewer assignment. + +**Key Principle**: A PR **assignee** is the PR author, a PR **reviewer** is whoever will review it. + +## Assignment Workflows + +### Projects Workflow + +For significant features and planned work: + +1. **Use Technical Reviewer**: The technical reviewer should be the default person to assign the PR review +2. **No MM Posting**: There's no need to post the PR link on MM (Mattermost) +3. **Review Assignment Process**: + - Create PR with: `gh pr create --reviewer TECHNICAL_REVIEWER_USERNAME` + - This automatically creates Asana subtask and assigns it to the reviewer + - No need to manually ping on Asana (automation handles it) +4. **Shared Responsibility**: Both the technical reviewer and developer are responsible for staying in sync +5. **Fallback**: If the technical reviewer can't review the PR, request different reviewer in GitHub UI (triggers new Asana assignment) + +### Tasks Workflow + +For bug fixes and small improvements: + +1. **Pre-Agreement**: Think about who's the best person to review this task and **agree with them to be the reviewer even before posting the PR** (similar to choosing technical reviewer for projects) + +2. **When Uncertain**: If you don't know who would be the best person, or the problem is generic and doesn't require domain knowledge, use **GitHub auto assignment** with `--reviewer Apple-dev` + +3. **Assignment Process**: + - Create PR with: `gh pr create --reviewer USERNAME` (or `--reviewer Apple-dev` for auto) + - Asana subtask is automatically created and assigned + - No need to manually ping on Asana (automation notifies them) + - If reviewer is AFK, request different reviewer in GitHub UI (triggers new assignment) + +4. **Availability Management**: + - Set your GitHub to "away" to prevent auto-selection if unavailable + - Use your best judgment for availability + +5. **Reviewer Flexibility**: If assigned as reviewer but can't review or don't feel comfortable with the area, discuss reassignment with the PR author + +## Auto Review Assignment + +**Algorithm**: Load balance routing to equally distribute review work + +**Process**: +1. Use `gh pr create --reviewer Apple-dev` OR manually select the **"Apple-dev" team** as reviewer in GitHub UI +2. GitHub will automatically assign an individual based on load balancing +3. Asana workflow automatically creates subtask and assigns to the selected reviewer's Asana account + +### Assignment on Asana + +**AUTOMATED**: When you assign a reviewer on GitHub (via `--reviewer` or UI), the workflow automatically: +- Extracts Asana task ID from PR body +- Creates a "Code Review" subtask in that Asana task +- Assigns the subtask to the GitHub reviewer's Asana account + +**Manual steps (if needed):** +- If automation fails or reviewer doesn't match, manually create subtask in Asana +- **Reviewer completes** the code review subtask once review is finished +- **Communication**: Use best judgment to contact PR author via Asana, MM, or PR comments for review feedback + +## Draft PRs + +**Purpose**: Share in-progress work for early feedback + +**Guidelines**: +- Use Draft PRs for work-in-progress sharing +- **Your responsibility**: Don't let drafts stay around for long periods +- **No rigid timeframes**: Use best judgment on when to close drafts +- **Goal**: Keep open PR list as clean as possible + +## PR Labels + +Use pre-defined labels to classify PR intention/state: + +### Current Available Labels + +- **`[Hacktoberfest]` & `[hacktoberfest-accepted]`**: For PRs related to Hacktoberfest event +- **`[Pending Product Review]`**: PR is being reviewed in Ship Reviews - **NEVER merge** if this tag is present +- **`[dependencies]`**: Automatically used by Dependabot + +**Adding New Labels**: Discuss with the team before creating new labels + +## Auto-Merge on Approval + +**Feature**: Automatically merge PR after review approval + +**Setup Process**: +1. Set PR to auto-merge using GitHub's built-in functionality +2. No specific labels required +3. **Documentation**: [GitHub Auto-merge Guide](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/incorporating-changes-from-a-pull-request/automatically-merging-a-pull-request) + +**Requirement**: At least one green review is required due to branch protection + +## Branch Protection + +**Requirement**: **At least one green review** is required to merge any PR + +## Feature Flags & PR Size Guidelines + +### PR Size Best Practices + +- **Keep PRs as short as possible** for efficiency and respectful time management +- **Use feature flags** (static or dynamic) so changes can be merged without affecting the final product +- **Smaller PRs = better feedback**: More likely to receive constructive review comments + +### When Uncertain + +- **Talk with technical reviewer** and/or project advisor about breaking down PRs +- **Use feature flags** to enable gradual rollout and safe merging + +## Pull Request Template + +**MANDATORY**: When creating Pull Requests, ALWAYS follow this template structure: + +```markdown +Task/Issue URL: [MUST ASK USER - Do not proceed with placeholder] +Tech Design URL: [ASK USER for significant changes; can default to N/A if not provided] +CC: [ASK USER for stakeholders; can default to N/A if not provided] + +### Description +[Provide a clear and concise description of the changes as a bulleted list. List changes in order of significance - most impactful/critical changes first, implementation details last. Be brief and omit small changes that are not directly related to the core issue being fixed. Format as bullet points without subsection titles] + +### Testing Steps +[List detailed manual testing steps only. Do not include "run tests" or similar - CI runs automated tests. Focus on manual verification steps that require human interaction] + +### Impact and Risks +**Impact Level: [Assess as High, Medium, Low, or None]** + +#### What could go wrong? +[List potential risks and mitigation strategies] + +### Quality Considerations +[Include relevant considerations for edge cases, performance, monitoring, documentation, and privacy/security] + +### Notes to Reviewer +[Include any specific notes for the reviewer, if applicable] +``` + +### Template Guidelines + +#### Required Information +- **Task/Issue URL**: **MUST ASK USER** - Always obtain the actual Asana task URL, never use placeholders +- **Tech Design URL**: **ASK USER** for significant changes; can be omitted and default to "N/A" if not explicitly provided +- **CC**: **ASK USER** for relevant stakeholders; can default to "N/A" if not explicitly provided +- **Description**: Clear, concise bulleted list of changes in order of significance - most critical/impactful changes first, implementation details last. Be brief and omit small changes not directly related to the core issue. No subsection titles +- **Testing Steps**: Manual testing steps only (CI runs automated tests). Focus on human verification steps +- **Impact Assessment**: Use guidelines below +- **Risk Analysis**: Potential issues and mitigation strategies +- **Quality Considerations**: Edge cases, performance, monitoring, documentation, privacy/security + +#### Impact Level Assessment + +- **High**: Changes affecting user privacy/security, data loss potential, core functionality breaks, billing impacts, significant performance effects +- **Medium**: Feature disruption, user flow changes, significant UI changes, analytics/tracking impacts +- **Low**: Minor bug fixes, small UI adjustments, existing feature improvements, non-critical feature additions +- **None**: Internal tooling, documentation, refactoring without behavior changes, test improvements + +#### Quality Considerations Checklist + +- **Edge cases** that have been considered +- **Performance impacts** and optimizations made +- **Monitoring and analytics** additions or changes +- **Documentation updates** required +- **Privacy and security** considerations, if applicable + +## PR Creation Workflow + +**CRITICAL**: After creating or updating a PR, **ALWAYS open the PR URL** in the browser immediately. + +### Steps: +1. **Create PR with reviewer assignment**: + ```bash + # For specific reviewer + gh pr create --reviewer USERNAME + + # For auto-assignment (Apple-dev team) + gh pr create --reviewer Apple-dev + ``` + +2. **Immediately run**: `open ` (the URL returned by gh command) +3. Verify the PR appears correctly in the browser + +### ⚠️ CRITICAL: Reviewer Assignment Requirement + +**The Asana integration ONLY works when reviewers are assigned via GitHub's reviewer mechanism.** + +**How it works:** +- GitHub Action `.github/workflows/create_asana_pr_subtask.yml` triggers on `review_requested` event +- Extracts Asana task ID from PR body (looks for `Task/Issue URL: https://app.asana.com/...`) +- Creates subtask in Asana and assigns it to the GitHub reviewer + +**This means:** +- βœ… **CORRECT**: `gh pr create --reviewer USERNAME` (triggers Asana assignment) +- βœ… **CORRECT**: Manually request reviewer in GitHub UI (triggers Asana assignment) +- ❌ **WRONG**: Only mentioning reviewer in PR description (does NOT trigger Asana assignment) +- ❌ **WRONG**: Creating PR without `--reviewer` flag (does NOT trigger Asana assignment) + +**If you forget to assign reviewer during creation:** +1. Request reviewer manually in GitHub UI +2. This will trigger the workflow and create the Asana subtask + +## Review Process Best Practices + +### For PR Authors +1. **Pre-review checklist**: Ensure all template sections are complete +2. **Self-review**: Review your own changes before requesting review +3. **Context**: Provide sufficient context for reviewers +4. **Responsive**: Address review comments promptly +5. **Asana updates**: Keep related Asana tasks updated + +### For PR Reviewers +1. **Timely reviews**: Prioritize PR reviews to maintain good turnaround time +2. **Thorough but efficient**: Balance thoroughness with review speed +3. **Constructive feedback**: Provide actionable suggestions +4. **Asana completion**: Mark code review subtasks as complete +5. **Communication**: Use appropriate channels (Asana, MM, PR comments) for feedback + +## Workflow Summary + +### For Projects: +1. Technical reviewer assigned by default +2. Ready for review β†’ **Use `gh pr create --reviewer USERNAME`** β†’ Asana subtask auto-created +3. No MM posting required (Asana handles it) +4. **Open PR URL in browser** + +### For Tasks: +1. Pre-agree on reviewer OR use auto-assignment +2. **Use `gh pr create --reviewer USERNAME`** or `--reviewer Apple-dev` for auto-assignment +3. Asana subtask is automatically created and assigned to reviewer +4. Handle AFK reviewers by requesting different reviewer in GitHub (triggers new Asana assignment) +5. **Open PR URL in browser** + +### For All PRs: +1. **CRITICAL**: Use `--reviewer` flag when creating PR (enables Asana integration) +2. Include valid Asana task URL in PR body (required for automation) +3. Use feature flags for safe merging +4. Keep PRs small and focused +5. Apply appropriate labels +6. Set auto-merge if desired +7. Follow template requirements +8. Maintain clean draft PR list +9. **ALWAYS open PR URL after creation/update** + +--- + +**Goal**: Efficient, clear, and maintainable PR workflows that respect everyone's time while maintaining code quality. diff --git a/work/wiki/apple-browsers/securevault-guidelines.mdc b/work/wiki/apple-browsers/securevault-guidelines.mdc new file mode 100644 index 00000000..cf19fd92 --- /dev/null +++ b/work/wiki/apple-browsers/securevault-guidelines.mdc @@ -0,0 +1,548 @@ +--- +source: ~/DuckDuckGo/apple-browsers.git/main/.cursor/rules/securevault-guidelines.mdc +confidence: 0.9 +namespace: work +last_synced: 2026-04-28 +alwaysApply: false +--- + +# SecureVault Implementation Guidelines + +## Introduction + +These guidelines encapsulate vital information for using SecureVault in our application. SecureVault is our secure storage system built on GRDB with SQLCipher encryption, providing a layered security approach for sensitive data. + +For general GRDB knowledge, refer to [GRDB documentation](https://github.com/groue/GRDB.swift). This guide covers DuckDuckGo-specific patterns and requirements. + +## Essential Knowledge + +### SecureVault Architecture Overview + +SecureVault is a protocol-based system that provides: + +- **L0**: Not encrypted (currently unused) +- **L1**: Secret key encrypted (usernames, domains, duck addresses) +- **L2**: User password encrypted with time-based access (user passwords) +- **L3**: User password required at request time (future: credit cards, sensitive data) + +```swift +// Core SecureVault protocol pattern +public protocol SecureVault { + associatedtype DatabaseProvider: SecureStorageDatabaseProvider + init(providers: SecureStorageProviders) +} + +// Example: AutofillSecureVault implementation +public protocol AutofillSecureVault: SecureVault { + func storeWebsiteCredentials(_ credentials: SecureVaultModels.WebsiteCredentials) throws -> Int64 + func websiteCredentialsFor(domain: String) throws -> [SecureVaultModels.WebsiteCredentials] + // ... other autofill-specific methods +} +``` + +## Database Location and Setup + +### Choosing the Database Location + +**Important Note**: If you need to share the Database through AppGroup, please refer to [Sharing Database through AppGroup](#sharing-database-through-appgroup) section below. + +For typical single-app usage: +- Create a dedicated folder for the database file +- This helps separate it from other files and makes file coordination easier +- Makes future migration simpler if needed + +```swift +// Example database location setup +let databaseDirectory = FileManager.default + .urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] + .appendingPathComponent("SecureVault") + +try FileManager.default.createDirectory(at: databaseDirectory, + withIntermediateDirectories: true) + +let databaseURL = databaseDirectory.appendingPathComponent("vault.sqlite") +``` + +### Database Configuration + +The simplest and best (future-proof) way to setup database is to use the following configuration: + +```swift +var config = Configuration() +config.prepareDatabase { database in + try database.usePassphrase(key) + try database.execute(sql: "PRAGMA cipher_plaintext_header_size = 32") +} +``` + +**Why the PRAGMA statement?** +- This PRAGMA enables sharing databases across App Groups +- It has no negative impact when used for single-app scenarios +- Setting it upfront makes future AppGroup sharing easier +- For details, see [iOS] Requirements to store Vault in AppGroup Container + +### Choosing DatabaseWriter + +GRDB operates with two possible writers: + +#### DatabaseQueue +- **Single writer, single reader** +- Simpler configuration +- Suitable for single-threaded access + +#### DatabasePool +- **Single writer, multiple readers** +- Enables WAL mode on SQLite file +- Better performance for multi-threaded access + +```swift +// DatabaseQueue example +let queue = try DatabaseQueue(path: databaseURL.path, configuration: config) + +// DatabasePool example +let pool = try DatabasePool(path: databaseURL.path, configuration: config) +``` + +**Decision criteria:** +- Single thread access (e.g., UserScripts only): Either option works +- Multi-threaded access: Use `DatabasePool` for better performance + +## SecureVault Instance Management + +### βœ… CORRECT: Single Instance Pattern + +> GRDB documentation is extremely clear on the topic: +> "Open one single DatabaseQueue or DatabasePool per database file, for the whole duration of your use of the database. Not for the duration of each database access, but really for the duration of all database accesses to this file." + +**Follow DuckDuckGo patterns**: Combine this with [βœ“ Approved] Avoiding static vars and singletons: + +```swift +// βœ… CORRECT: Factory pattern with dependency injection +public let AutofillSecureVaultFactory: AutofillVaultFactory = SecureVaultFactory( + makeCryptoProvider: { + return AutofillCryptoProvider() + }, + makeKeyStoreProvider: { reporter in + return AutofillKeyStoreProvider(reporter: reporter) + }, + makeDatabaseProvider: { key, _ in + return try DefaultAutofillDatabaseProvider(key: key) + } +) + +// Usage in ViewModels via dependency injection +final class MyFeatureViewModel: ObservableObject { + private let vault: any AutofillSecureVault + + init(dependencies: DependencyProvider = AppDependencyProvider.shared) { + self.vault = dependencies.autofillSecureVault + } +} +``` + +### ❌ AVOID: Multiple Instances + +```swift +// ❌ DON'T DO THIS - creates multiple instances +func someMethod() { + let vault1 = try AutofillSecureVaultFactory.makeVault(reporter: nil) + // ... later in another method + let vault2 = try AutofillSecureVaultFactory.makeVault(reporter: nil) + // This can cause database corruption and performance issues +} +``` + +## Sharing Database through AppGroup + +Since we've started sharing SecureVault (GRDB) in AppGroup with the intention of using system-wide extensions to access it, there are important considerations and requirements. + +### Side Effects and Challenges + +When sharing across App Groups, you must handle: + +1. **File-access constraints**: Multiple processes accessing simultaneously +2. **System file lock constraints**: OS monitors SQLite access locks +3. **WAL mode performance**: Proper setup for read/write performance +4. **Persistent WAL**: Correct read-only access between processes + +### Critical Setup Requirements + +#### 1. Database Configuration for AppGroup + +```swift +var config = Configuration() +config.prepareDatabase { database in + try database.usePassphrase(key) + // MANDATORY for AppGroup sharing + try database.execute(sql: "PRAGMA cipher_plaintext_header_size = 32") + // Additional AppGroup optimizations + try database.execute(sql: "PRAGMA wal_checkpoint = TRUNCATE") +} + +// Use DatabasePool for AppGroup scenarios +let pool = try DatabasePool(path: sharedDatabaseURL.path, configuration: config) +``` + +#### 2. Background Task Management + +**Critical**: Extend host app lifecycle to prevent 0xdead10cc crashes: + +```swift +// βœ… REQUIRED: Use BackgroundTask during database operations +func performDatabaseOperation() async { + let backgroundTask = await UIApplication.shared.beginBackgroundTask(withName: "SecureVault Operation") { + // Cleanup if needed + } + + defer { + if backgroundTask != .invalid { + UIApplication.shared.endBackgroundTask(backgroundTask) + } + } + + // Perform your database operations here + try await vault.performOperation() +} +``` + +#### 3. Using ExpiringActivity (Alternative) + +For more sophisticated background management: + +```swift +import ActivityKit + +func performDatabaseOperationWithActivity() async { + let activity = try? Activity.request( + attributes: DatabaseActivityAttributes(), + content: .init(state: .active), + pushToken: nil + ) + + defer { + Task { + await activity?.end() + } + } + + // Database operations + try await vault.performOperation() +} +``` + +### Crash Prevention: 0xdead10cc + +**Problem**: App crashes with `Termination Reason: RUNNINGBOARD 0xdead10cc` when: +- App transitions from Background to Suspended state +- Database locks are still held +- Extensions need database access + +**Solution**: Always use background tasks or expiring activities during database operations in AppGroup scenarios. + +## Performance Optimization + +### WAL Mode Optimization + +```swift +// Optimize WAL checkpoints for AppGroup usage +config.prepareDatabase { database in + try database.usePassphrase(key) + try database.execute(sql: "PRAGMA cipher_plaintext_header_size = 32") + + // Performance optimizations + try database.execute(sql: "PRAGMA journal_mode = WAL") + try database.execute(sql: "PRAGMA synchronous = NORMAL") + try database.execute(sql: "PRAGMA cache_size = -16384") // 16MB cache + try database.execute(sql: "PRAGMA temp_store = MEMORY") +} +``` + +### Efficient Database Operations + +```swift +// βœ… GOOD: Batch operations +try vault.inDatabaseTransaction { database in + for credential in credentials { + try vault.storeWebsiteCredentials(credential, in: database) + } +} + +// ❌ AVOID: Individual transactions +for credential in credentials { + try vault.storeWebsiteCredentials(credential) // Creates separate transaction each time +} +``` + +## Encryption and Security + +### Multi-Layer Encryption Implementation + +```swift +public class DefaultAutofillSecureVault: AutofillSecureVault { + + // L1: Secret key encryption (stored in Keychain) + private func l1Encrypt(data: Data) throws -> Data { + let l1Key = try providers.keystore.l1Key() + return try providers.crypto.encrypt(data, withKey: l1Key) + } + + // L2: User password encryption (with expiring access) + private func l2Encrypt(data: Data, using l2Key: Data? = nil) throws -> Data { + let key: Data = try { + if let l2Key { + return l2Key + } + let password = try passwordInUse() + return try l2KeyFrom(password: password) + }() + return try providers.crypto.encrypt(data, withKey: key) + } + + // Password-based access with expiration + public func authWith(password: Data) throws -> any AutofillSecureVault { + lock.lock() + defer { lock.unlock() } + + do { + _ = try self.l2KeyFrom(password: password) // Validates password + self.expiringPassword.value = password + return self + } catch { + let error = error as? SecureStorageError ?? .authError(cause: error) + throw error + } + } +} +``` + +### Password Management + +```swift +// Secure password reset +public func resetL2Password(oldPassword: Data?, newPassword: Data) throws { + lock.lock() + defer { lock.unlock() } + + // Force re-auth on future calls + self.expiringPassword.value = nil + + do { + // Use provided old password or stored generated password + let generatedPassword = try self.providers.keystore.generatedPassword() + guard let oldPassword = oldPassword ?? generatedPassword else { + throw SecureStorageError.invalidPassword + } + + // Get decrypted L2 key using old password + let l2Key = try self.l2KeyFrom(password: oldPassword) + + // Derive new encryption key + let newEncryptionKey = try self.providers.crypto.deriveKeyFromPassword(newPassword) + + // Encrypt L2 key with new encryption key + let encryptedKey = try self.providers.crypto.encrypt(l2Key, withKey: newEncryptionKey) + + // Store encrypted L2 key + try self.providers.keystore.storeEncryptedL2Key(encryptedKey) + + // Clear generated password + try self.providers.keystore.clearGeneratedPassword() + } catch { + if let error = error as? SecureStorageError { + throw error + } else { + throw SecureStorageError.databaseError(cause: error) + } + } +} +``` + +## Testing Patterns + +### Mock SecureVault Implementation + +```swift +// βœ… FOLLOW: Use existing mock patterns +typealias MockVaultFactory = SecureVaultFactory> + +let MockSecureVaultFactory = SecureVaultFactory( + makeCryptoProvider: { + let provider = MockCryptoProvider() + provider._derivedKey = "derived".data(using: .utf8) + return provider + }, + makeKeyStoreProvider: { _ in + let provider = MockKeyStoreProvider() + provider._l1Key = "key".data(using: .utf8) + return provider + }, + makeDatabaseProvider: { key, _ in + return try MockDatabaseProvider(key: key) + } +) + +// Usage in tests +final class MockSecureVault: AutofillSecureVault { + var storedAccounts: [SecureVaultModels.WebsiteAccount] = [] + var storedCredentials: [Int64: SecureVaultModels.WebsiteCredentials] = [:] + + // Simplified implementations for testing + func encrypt(_ data: Data, using key: Data) throws -> Data { data } + func decrypt(_ data: Data, using key: Data) throws -> Data { data } + + // Test-specific storage + func storeWebsiteCredentials(_ credentials: SecureVaultModels.WebsiteCredentials) throws -> Int64 { + let id = Int64(storedCredentials.count + 1) + storedCredentials[id] = credentials + if let account = credentials.account { + storedAccounts.append(account) + } + return id + } +} +``` + +### Testing Database Operations + +```swift +func testSecureVaultStorage() async throws { + let vault = try MockSecureVaultFactory.makeVault(reporter: nil) + + let account = SecureVaultModels.WebsiteAccount( + title: "Test Site", + username: "testuser", + domain: "example.com" + ) + + let credentials = SecureVaultModels.WebsiteCredentials( + account: account, + password: "testpassword".data(using: .utf8) + ) + + // Test storage + let storedId = try vault.storeWebsiteCredentials(credentials) + XCTAssertGreaterThan(storedId, 0) + + // Test retrieval + let retrievedCredentials = try vault.websiteCredentialsFor(domain: "example.com") + XCTAssertEqual(retrievedCredentials.count, 1) + XCTAssertEqual(retrievedCredentials.first?.account?.username, "testuser") +} +``` + +## Updating GRDB + +Instead of using GRDB directly as source code, we package it into an XCFramework to speed up compilation time. + +### Update Process + +1. **Check the fork**: Go to our [GRDB fork](https://github.com/duckduckgo/GRDB.swift) +2. **Follow instructions**: Run the provided script to create a new release +3. **If script fails**: + - The script patches GRDB code that may change between releases + - Create a task in **Apple Developer Infrastructure (CI, Releases, DevEx)** + - Request script fixes and new release creation + +### GRDB Version Considerations + +```swift +// Check current GRDB version compatibility +import GRDB + +// Ensure new features/APIs are available +#if compiler(>=5.9) && canImport(GRDB, _version: 6.0) + // Use newer GRDB features +#else + // Fallback to older patterns +#endif +``` + +## Error Handling Patterns + +### SecureVault-Specific Errors + +```swift +// Handle authentication errors gracefully +func handleSecureVaultOperation() async { + do { + let data = try await vault.getSensitiveData() + processData(data) + } catch SecureStorageError.authRequired { + // Prompt user for password + await requestUserAuthentication() + } catch SecureStorageError.invalidPassword { + // Show password error message + showPasswordError() + } catch SecureStorageError.databaseError(let cause) { + // Handle database-specific errors + Logger.secureStorage.error("Database error: \(cause)") + showGenericError() + } catch { + // Handle other errors + Logger.secureStorage.error("Unexpected error: \(error)") + showGenericError() + } +} +``` + +### Background Access Error Handling + +```swift +// Handle AppGroup background access gracefully +func performBackgroundVaultOperation() async { + let backgroundTask = await UIApplication.shared.beginBackgroundTask(withName: "Vault Access") + + defer { + if backgroundTask != .invalid { + UIApplication.shared.endBackgroundTask(backgroundTask) + } + } + + do { + try await vault.performOperation() + } catch { + // If we're in background and get access errors, defer operation + if UIApplication.shared.applicationState == .background { + queueOperationForForeground() + } else { + throw error + } + } +} +``` + +## Best Practices Summary + +### βœ… DO + +- Use the factory pattern with dependency injection +- Set up database configuration with AppGroup PRAGMA upfront +- Use background tasks for AppGroup database operations +- Implement proper error handling for authentication states +- Follow the single instance pattern for database writers +- Use DatabasePool for multi-threaded access +- Batch database operations when possible +- Use existing mock patterns for testing + +### ❌ DON'T + +- Create multiple SecureVault instances for the same database +- Perform database operations without background tasks in AppGroup scenarios +- Ignore authentication required errors +- Use force unwrapping with SecureVault operations +- Mix DatabaseQueue and DatabasePool for the same database file +- Perform individual transactions for batch operations +- Skip error handling for background access scenarios + +### πŸ”’ Security Reminders + +- Never log decrypted password data +- Always validate user passwords before storing +- Use appropriate encryption layers (L1/L2/L3) for data sensitivity +- Implement proper cleanup for background tasks +- Handle device lock scenarios gracefully +- Clear sensitive data from memory when appropriate + +--- + +This documentation ensures secure, performant, and maintainable SecureVault implementation across the DuckDuckGo browser ecosystem. diff --git a/work/wiki/apple-browsers/shared-packages.mdc b/work/wiki/apple-browsers/shared-packages.mdc new file mode 100644 index 00000000..4dbb865b --- /dev/null +++ b/work/wiki/apple-browsers/shared-packages.mdc @@ -0,0 +1,362 @@ +--- +source: ~/DuckDuckGo/apple-browsers.git/main/.cursor/rules/shared-packages.mdc +confidence: 0.9 +namespace: work +last_synced: 2026-04-28 +alwaysApply: false +--- + +# Shared Packages Development Guidelines + +## Package Structure + +### Standard Package Layout +``` +SharedPackages/ +β”œβ”€β”€ FeatureName/ +β”‚ β”œβ”€β”€ Package.swift +β”‚ β”œβ”€β”€ README.md +β”‚ β”œβ”€β”€ Sources/ +β”‚ β”‚ └── FeatureName/ +β”‚ β”‚ β”œβ”€β”€ Public/ # Public API +β”‚ β”‚ β”œβ”€β”€ Internal/ # Internal implementation +β”‚ β”‚ └── Resources/ # Assets and resources +β”‚ └── Tests/ +β”‚ └── FeatureNameTests/ +β”‚ └── FeatureTests.swift +``` + +### Package.swift Configuration +```swift +// swift-tools-version: 5.7 +import PackageDescription + +let package = Package( + name: "FeatureName", + platforms: [ + .iOS(.v15), + .macOS(.v12) + ], + products: [ + .library( + name: "FeatureName", + targets: ["FeatureName"] + ) + ], + dependencies: [ + // Only include truly necessary dependencies + .package(url: "https://github.com/DuckDuckGo/BrowserServicesKit", from: "1.0.0") + ], + targets: [ + .target( + name: "FeatureName", + dependencies: ["BrowserServicesKit"], + resources: [ + .process("Resources") + ] + ), + .testTarget( + name: "FeatureNameTests", + dependencies: ["FeatureName"] + ) + ] +) +``` + +## Cross-Platform Compatibility + +### Platform-Specific Code +```swift +#if os(iOS) +import UIKit +public typealias PlatformView = UIView +public typealias PlatformViewController = UIViewController +public typealias PlatformColor = UIColor +#elseif os(macOS) +import AppKit +public typealias PlatformView = NSView +public typealias PlatformViewController = NSViewController +public typealias PlatformColor = NSColor +#endif + +// Use platform-agnostic types +public protocol CrossPlatformViewProtocol { + var backgroundColor: PlatformColor? { get set } +} +``` + +### Conditional Compilation +```swift +public class FeatureManager { + public func performAction() { + #if os(iOS) + performIOSAction() + #elseif os(macOS) + performMacOSAction() + #endif + } + + #if os(iOS) + private func performIOSAction() { + // iOS-specific implementation + } + #endif + + #if os(macOS) + private func performMacOSAction() { + // macOS-specific implementation + } + #endif +} +``` + +## API Design + +### Public API Guidelines +```swift +// Mark public APIs clearly +public protocol FeatureServiceProtocol { + func fetchData() async throws -> [Item] +} + +public final class FeatureService: FeatureServiceProtocol { + // Use dependency injection + private let networkClient: NetworkClientProtocol + + public init(networkClient: NetworkClientProtocol) { + self.networkClient = networkClient + } + + public func fetchData() async throws -> [Item] { + // Implementation + } +} +``` + +### Internal Implementation +```swift +// Keep implementation details internal +internal final class FeatureImplementation { + // Not exposed to package consumers +} + +// Use extensions for internal helpers +internal extension String { + var sanitized: String { + // Internal helper method + } +} +``` + +## Resource Management + +### Bundled Resources +```swift +public enum FeatureResources { + private static let bundle = Bundle.module + + public static var configuration: Data { + guard let url = bundle.url(forResource: "config", withExtension: "json"), + let data = try? Data(contentsOf: url) else { + fatalError("Missing required resource: config.json") + } + return data + } + + public static func image(named name: String) -> PlatformImage? { + #if os(iOS) + return UIImage(named: name, in: bundle, with: nil) + #elseif os(macOS) + return bundle.image(forResource: name) + #endif + } +} +``` + +## Dependency Management + +### Minimal Dependencies +```swift +// Avoid unnecessary dependencies +// Bad: Importing entire framework for one function +import HeavyFramework + +// Good: Implement minimal version or use protocol +protocol DateFormatterProtocol { + func string(from date: Date) -> String +} +``` + +### Version Management +```swift +// Use semantic versioning +// Package.swift +dependencies: [ + .package(url: "https://github.com/example/package", + from: "1.0.0"), // Allows 1.x.x + .package(url: "https://github.com/example/strict", + exact: "2.1.0"), // Exact version + .package(url: "https://github.com/example/range", + "1.0.0"..<"2.0.0") // Version range +] +``` + +## Testing Shared Packages + +### Cross-Platform Tests +```swift +import XCTest +@testable import FeatureName + +final class FeatureTests: XCTestCase { + func testCrossPlatformBehavior() { + let feature = Feature() + + #if os(iOS) + XCTAssertNotNil(feature.iosSpecificProperty) + #elseif os(macOS) + XCTAssertNotNil(feature.macOSSpecificProperty) + #endif + + // Test common behavior + XCTAssertEqual(feature.commonProperty, expectedValue) + } +} +``` + +### Test Utilities +```swift +// Provide test utilities in a separate target +public extension XCTestCase { + func waitForCondition( + _ condition: @autoclosure () -> Bool, + timeout: TimeInterval = 1.0, + message: String = "Condition not met" + ) { + let expectation = expectation(description: message) + + Task { + while !condition() { + try? await Task.sleep(nanoseconds: 100_000_000) // 0.1s + } + expectation.fulfill() + } + + wait(for: [expectation], timeout: timeout) + } +} +``` + +## Documentation + +### Package Documentation +```swift +/// A service for managing user preferences across platforms. +/// +/// This service provides a unified interface for storing and retrieving +/// user preferences, with platform-specific implementations for iOS and macOS. +/// +/// ## Usage Example +/// ```swift +/// let preferences = UserPreferencesService() +/// preferences.set("value", for: .theme) +/// let theme = preferences.get(.theme) +/// ``` +public final class UserPreferencesService { + /// Initializes a new preferences service. + /// + /// - Parameter storage: The storage backend to use. Defaults to UserDefaults. + public init(storage: PreferencesStorage = .userDefaults) { + // Implementation + } +} +``` + +## Migration and Versioning + +### API Evolution +```swift +public protocol FeatureProtocolV2 { + // New required method + func newRequiredMethod() + + // Existing method + func existingMethod() +} + +// Provide default implementation for backward compatibility +public extension FeatureProtocolV2 { + func newRequiredMethod() { + // Default implementation + } +} + +// Deprecation +@available(*, deprecated, renamed: "newMethod()") +public func oldMethod() { + newMethod() +} +``` + +### Breaking Changes +```swift +// Use versioned types when making breaking changes +public struct ConfigurationV1 { + public let setting: String +} + +public struct ConfigurationV2 { + public let setting: String + public let newRequired: Bool + + // Provide migration + public init(from v1: ConfigurationV1) { + self.setting = v1.setting + self.newRequired = false // Default value + } +} +``` + +## Performance Considerations + +### Lazy Loading +```swift +public final class ResourceManager { + // Lazy load expensive resources + private lazy var heavyResource: HeavyResource = { + return HeavyResource() + }() + + // Use computed properties for lightweight calculations + public var lightweightValue: String { + return "Calculated on demand" + } +} +``` + +### Memory Management +```swift +public final class CacheManager { + private let cache = NSCache() + + public init() { + // Configure cache limits + cache.countLimit = 100 + cache.totalCostLimit = 50 * 1024 * 1024 // 50MB + + // Respond to memory warnings + #if os(iOS) + NotificationCenter.default.addObserver( + self, + selector: #selector(clearCache), + name: UIApplication.didReceiveMemoryWarningNotification, + object: nil + ) + #endif + } + + @objc private func clearCache() { + cache.removeAllObjects() + } +} +``` diff --git a/work/wiki/apple-browsers/subscription-architecture.mdc b/work/wiki/apple-browsers/subscription-architecture.mdc new file mode 100644 index 00000000..08fcc9a7 --- /dev/null +++ b/work/wiki/apple-browsers/subscription-architecture.mdc @@ -0,0 +1,376 @@ +--- +source: ~/DuckDuckGo/apple-browsers.git/main/.cursor/rules/subscription-architecture.mdc +confidence: 0.9 +namespace: work +last_synced: 2026-04-28 +alwaysApply: false +--- + +# Subscription Architecture & Implementation + +## Overview +DuckDuckGo's subscription system provides access to premium features including VPN (Network Protection), Personal Information Removal (PIR), Identity Theft Restoration (ITR), and AI Chat. The system supports multiple purchase platforms and cross-platform activation. + +## Core Architecture + +### Shared Foundation: BrowserServicesKit +All subscription logic is centralized in `BrowserServicesKit/Sources/Subscription/`: + +```swift +// βœ… CORRECT - Use BrowserServicesKit for core subscription logic +import BrowserServicesKit + +final class SubscriptionViewModel: ObservableObject { + private let subscriptionManager: SubscriptionManager + + init(subscriptionManager: SubscriptionManager = SubscriptionManager.shared) { + self.subscriptionManager = subscriptionManager + } +} + +// ❌ INCORRECT - Don't duplicate subscription logic in platform code +final class SubscriptionViewModel: ObservableObject { + func checkSubscriptionStatus() { + // Don't reimplement subscription logic + } +} +``` + +### Platform-Specific Purchase Methods + +#### iOS +- **Purchase Method**: App Store only (StoreKit) +- **Geographic Coverage**: Global +- **Cross-Platform**: Can activate Stripe subscriptions from other platforms + +#### macOS App Store Build +- **Purchase Method**: App Store only (StoreKit) +- **Geographic Coverage**: Global +- **Cross-Platform**: Can activate Stripe subscriptions + +#### macOS Direct Download Build +- **US Users**: Stripe web purchases +- **Non-US Users**: Redirected to iOS App Store +- **Cross-Platform**: Primary platform for Stripe purchases + +### Version Management +ALWAYS use V2 implementations for new code: + +```swift +// βœ… CORRECT - Use V2 implementations +let subscriptionManager = SubscriptionManagerV2() +let purchaseManager = StorePurchaseManagerV2() +let purchaseFlow = AppStorePurchaseFlowV2() + +// ❌ INCORRECT - Don't use V1 implementations +let subscriptionManager = SubscriptionManager() // Legacy +let purchaseFlow = AppStorePurchaseFlow() // Legacy +``` + +## Premium Features Implementation + +### Feature Entitlements +```swift +// βœ… CORRECT - Check entitlements through SubscriptionManager +final class FeatureViewModel: ObservableObject { + private let subscriptionManager: SubscriptionManager + + var isFeatureEnabled: Bool { + subscriptionManager.hasEntitlement(for: .networkProtection) + } + + var availableFeatures: [SubscriptionFeature] { + subscriptionManager.entitlements.compactMap { entitlement in + switch entitlement { + case .networkProtection: + return .vpn + case .dataBrokerProtection: + return .personalInformationRemoval + case .identityTheftRestoration: + return .identityTheftRestoration + default: + return nil + } + } + } +} +``` + +### VPN Integration +```swift +// βœ… CORRECT - VPN entitlement integration +final class VPNManager: ObservableObject { + private let subscriptionManager: SubscriptionManager + + func enableVPN() async { + guard subscriptionManager.hasEntitlement(for: .networkProtection) else { + await showSubscriptionPrompt() + return + } + + // Enable VPN functionality + await startVPNConnection() + } +} +``` + +### Personal Information Removal (PIR) +```swift +// βœ… CORRECT - PIR implementation with freemium support +final class PIRManager: ObservableObject { + private let subscriptionManager: SubscriptionManager + + var isFreemiumEligible: Bool { + // Check feature flag and eligibility + FeatureFlags.shared.isEnabled(.freemiumPIR) && + !subscriptionManager.isUserSubscribed && + isUSUser + } + + func performScan() async { + if subscriptionManager.hasEntitlement(for: .dataBrokerProtection) { + await performFullScan() + } else if isFreemiumEligible { + await performLimitedScan() + } else { + await showSubscriptionPrompt() + } + } +} +``` + +## Purchase Flow Implementation + +### Free Trial Support +```swift +// βœ… CORRECT - Free trial implementation +final class SubscriptionPurchaseViewModel: ObservableObject { + @Published var isTrialEligible = false + @Published var trialPeriod: String = "" + + func checkTrialEligibility() async { + guard FeatureFlags.shared.isEnabled(.privacyProFreeTrial) else { + isTrialEligible = false + return + } + + // Check server-side eligibility + let eligible = await subscriptionManager.checkFreshFreeTrialEligibility() + await MainActor.run { + isTrialEligible = eligible + if let product = subscriptionManager.currentProduct, + let offer = product.introductoryOffer { + trialPeriod = offer.localizedPeriod + } + } + } +} +``` + +### Platform-Specific Purchase +```swift +// βœ… CORRECT - Platform-aware purchase flow +final class PurchaseFlowCoordinator { + private let subscriptionManager: SubscriptionManager + + func initiatePurchase() async { + #if os(iOS) + // iOS always uses App Store + await purchaseViaAppStore() + #elseif os(macOS) + if Bundle.main.isMacAppStore { + await purchaseViaAppStore() + } else { + // Direct download build + if isUSUser { + await purchaseViaStripe() + } else { + await redirectToiOSApp() + } + } + #endif + } +} +``` + +## Cross-Platform Activation + +### URL Handling +```swift +// βœ… CORRECT - Subscription URL handling +final class SubscriptionURLHandler { + func handleSubscriptionURL(_ url: URL) { + guard url.scheme == "duckduckgo", + url.host == "subscription" else { return } + + let components = URLComponents(url: url, resolvingAgainstBaseURL: false) + + if let token = components?.queryItems?.first(where: { $0.name == "token" })?.value { + Task { + await subscriptionManager.activateSubscription(with: token) + } + } + } +} +``` + +### Authentication Bridge +```swift +// βœ… CORRECT - V1 to V2 authentication migration +final class AuthenticationManager { + func migrateToV2() async { + let bridge = SubscriptionAuthV1toV2Bridge() + + if let v1Token = await bridge.extractV1Token() { + await subscriptionManager.migrateFromV1(token: v1Token) + } + } +} +``` + +## Testing Patterns + +### Mock Subscription Manager +```swift +// βœ… CORRECT - Mock for testing +final class MockSubscriptionManager: SubscriptionManager { + var mockEntitlements: [SubscriptionEntitlement] = [] + var mockSubscriptionStatus: Bool = false + + override var isUserSubscribed: Bool { + mockSubscriptionStatus + } + + override var entitlements: [SubscriptionEntitlement] { + mockEntitlements + } + + override func hasEntitlement(for feature: SubscriptionFeature) -> Bool { + mockEntitlements.contains(where: { $0.feature == feature }) + } +} +``` + +### Test Subscription States +```swift +// βœ… CORRECT - Test different subscription states +final class SubscriptionViewModelTests: XCTestCase { + private var viewModel: SubscriptionViewModel! + private var mockManager: MockSubscriptionManager! + + func testSubscribedUser() { + // Given + mockManager.mockSubscriptionStatus = true + mockManager.mockEntitlements = [.networkProtection, .dataBrokerProtection] + + // When + viewModel.checkSubscriptionStatus() + + // Then + XCTAssertTrue(viewModel.isSubscribed) + XCTAssertTrue(viewModel.hasVPNAccess) + XCTAssertTrue(viewModel.hasPIRAccess) + } + + func testFreeTrialEligibility() async { + // Given + FeatureFlags.shared.enable(.privacyProFreeTrial) + mockManager.mockTrialEligibility = true + + // When + await viewModel.checkTrialEligibility() + + // Then + XCTAssertTrue(viewModel.isTrialEligible) + } +} +``` + +## Feature Flags and Configuration + +### Subscription Feature Flags +```swift +// βœ… CORRECT - Feature flag usage +enum SubscriptionFeatureFlag: String, CaseIterable { + case privacyProFreeTrial = "privacyProFreeTrial" + case iosStripeSubscriptions = "iosStripeSubscriptions" + case freemiumPIR = "DBPSubfeature.freemium" + + var isEnabled: Bool { + FeatureFlags.shared.isEnabled(self) + } +} +``` + +### Environment Configuration +```swift +// βœ… CORRECT - Environment-based configuration +extension SubscriptionEnvironment { + static var `default`: SubscriptionEnvironment { + #if os(iOS) + // iOS always uses App Store + return .appStore + #elseif os(macOS) + if Bundle.main.isMacAppStore { + return .appStore + } else { + return FeatureFlags.shared.isEnabled(.iosStripeSubscriptions) ? .stripe : .appStore + } + #endif + } +} +``` + +## Analytics and Tracking + +### Subscription Pixels +```swift +// βœ… CORRECT - Analytics implementation +final class SubscriptionAnalytics { + func trackPurchaseFlow(origin: SubscriptionFunnelOrigin) { + PixelKit.fire( + pixel: .subscriptionPurchaseFlowStarted, + parameters: [ + "origin": origin.rawValue, + "platform": currentPlatform.rawValue + ] + ) + } + + func trackTrialEligibility(eligible: Bool) { + PixelKit.fire( + pixel: .subscriptionTrialEligibilityCheck, + parameters: [ + "eligible": eligible.description + ] + ) + } +} +``` + +## Important Implementation Notes + +### Security Considerations +- Store authentication tokens in Keychain only +- Use HTTPS for all subscription API calls +- Validate receipts server-side +- Implement proper token refresh logic + +### Performance Optimization +- Cache subscription status locally +- Use background queues for API calls +- Implement offline capability for cached states +- Minimize UI blocking operations + +### User Experience +- Provide clear trial information +- Handle purchase failures gracefully +- Support subscription restoration +- Maintain consistent UI across platforms + +### Common Pitfalls to Avoid +- Don't hardcode subscription URLs +- Don't bypass entitlement checks +- Don't duplicate subscription logic across platforms +- Don't ignore V1 to V2 migration paths +- Don't forget to handle cross-platform activation diff --git a/work/wiki/apple-browsers/swiftui-advanced.mdc b/work/wiki/apple-browsers/swiftui-advanced.mdc new file mode 100644 index 00000000..f779298a --- /dev/null +++ b/work/wiki/apple-browsers/swiftui-advanced.mdc @@ -0,0 +1,274 @@ +--- +source: ~/DuckDuckGo/apple-browsers.git/main/.cursor/rules/swiftui-advanced.mdc +confidence: 0.9 +namespace: work +last_synced: 2026-04-28 +alwaysApply: false +--- + +# Advanced SwiftUI Patterns + +## ViewModifier Composition +Create reusable ViewModifiers for common styling: + +```swift +// βœ… ADVANCED - Composable ViewModifiers +struct DuckDuckGoButtonStyle: ViewModifier { + let style: ButtonStyleType + + func body(content: Content) -> some View { + content + .padding(.horizontal, 16) + .padding(.vertical, 12) + .background(backgroundColorForStyle()) + .foregroundColor(textColorForStyle()) + .cornerRadius(8) + .font(.body.weight(.medium)) + } + + private func backgroundColorForStyle() -> Color { + switch style { + case .primary: + return Color(designSystemColor: .buttonPrimaryBackground) + case .secondary: + return Color(designSystemColor: .buttonSecondaryBackground) + case .ghost: + return .clear + } + } + + private func textColorForStyle() -> Color { + switch style { + case .primary: + return Color(designSystemColor: .buttonPrimaryText) + case .secondary: + return Color(designSystemColor: .buttonSecondaryText) + case .ghost: + return Color(designSystemColor: .textLink) + } + } +} + +// Usage +extension View { + func duckDuckGoButtonStyle(_ style: ButtonStyleType) -> some View { + modifier(DuckDuckGoButtonStyle(style: style)) + } +} +``` + +## PreferenceKey for Cross-View Communication +Use PreferenceKey for sophisticated view communication: + +```swift +// βœ… ADVANCED - PreferenceKey for collecting data from child views +struct ViewSizePreferenceKey: PreferenceKey { + static var defaultValue: [String: CGSize] = [:] + + static func reduce(value: inout [String: CGSize], nextValue: () -> [String: CGSize]) { + value.merge(nextValue()) { $1 } + } +} + +struct SizeReportingView: View { + let id: String + let content: Content + + init(id: String, @ViewBuilder content: () -> Content) { + self.id = id + self.content = content() + } + + var body: some View { + content + .background( + GeometryReader { geometry in + Color.clear + .preference(key: ViewSizePreferenceKey.self, + value: [id: geometry.size]) + } + ) + } +} + +// Usage +struct ParentView: View { + @State private var childSizes: [String: CGSize] = [:] + + var body: some View { + VStack { + SizeReportingView(id: "header") { + HeaderView() + } + + SizeReportingView(id: "content") { + ContentView() + } + } + .onPreferenceChange(ViewSizePreferenceKey.self) { sizes in + self.childSizes = sizes + } + } +} +``` + +## Environment-based Dependency Injection +Use SwiftUI Environment for dependency injection: + +```swift +// βœ… ADVANCED - Environment-based DI +struct DependencyProviderKey: EnvironmentKey { + static let defaultValue: DependencyProvider = AppDependencyProvider.shared +} + +extension EnvironmentValues { + var dependencies: DependencyProvider { + get { self[DependencyProviderKey.self] } + set { self[DependencyProviderKey.self] = newValue } + } +} + +// Usage in views +struct FeatureView: View { + @Environment(\.dependencies) var dependencies + @StateObject private var viewModel: FeatureViewModel + + init() { + // Note: This approach has limitations - see ios-architecture.md for preferred DI pattern + } + + var body: some View { + // View implementation + } +} +``` + +## Complex Animation Patterns +Use sophisticated animations for better UX: + +```swift +// βœ… ADVANCED - Coordinated animations +struct TabSwitcherView: View { + @State private var selectedTab: Int = 0 + @State private var animationPhase: AnimationPhase = .idle + + enum AnimationPhase { + case idle, switching, settled + } + + var body: some View { + VStack { + TabPickerView(selectedTab: $selectedTab) + .animation(.easeInOut(duration: 0.3), value: selectedTab) + + TabContent(selectedTab: selectedTab) + .transition(.asymmetric( + insertion: .move(edge: .trailing).combined(with: .opacity), + removal: .move(edge: .leading).combined(with: .opacity) + )) + .animation(.spring(response: 0.6, dampingFraction: 0.8), value: selectedTab) + } + .onChange(of: selectedTab) { newValue in + withAnimation(.easeInOut(duration: 0.1)) { + animationPhase = .switching + } + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { + withAnimation(.easeOut(duration: 0.1)) { + animationPhase = .settled + } + } + } + } +} +``` + +## Custom Layout with Layout Protocol (iOS 16+) +Create sophisticated layouts: + +```swift +// βœ… ADVANCED - Custom layout for complex arrangements +@available(iOS 16.0, macOS 13.0, *) +struct FlexibleGrid: Layout { + let spacing: CGFloat + let itemSize: CGSize + + func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize { + let containerWidth = proposal.width ?? 300 + let itemsPerRow = max(1, Int(containerWidth / (itemSize.width + spacing))) + let rows = (subviews.count + itemsPerRow - 1) / itemsPerRow + + let totalWidth = CGFloat(itemsPerRow) * itemSize.width + CGFloat(itemsPerRow - 1) * spacing + let totalHeight = CGFloat(rows) * itemSize.height + CGFloat(rows - 1) * spacing + + return CGSize(width: totalWidth, height: totalHeight) + } + + func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) { + let containerWidth = bounds.width + let itemsPerRow = max(1, Int(containerWidth / (itemSize.width + spacing))) + + for (index, subview) in subviews.enumerated() { + let row = index / itemsPerRow + let column = index % itemsPerRow + + let x = bounds.minX + CGFloat(column) * (itemSize.width + spacing) + let y = bounds.minY + CGFloat(row) * (itemSize.height + spacing) + + subview.place(at: CGPoint(x: x, y: y), proposal: ProposedViewSize(itemSize)) + } + } +} +``` + +## State Management with @Observable (iOS 17+) +Use the modern @Observable macro when available: + +```swift +// βœ… MODERN - @Observable for iOS 17+ +@available(iOS 17.0, macOS 14.0, *) +@Observable +final class ModernViewModel { + var items: [Item] = [] + var isLoading = false + var selectedItem: Item? + + private let service: FeatureServiceProtocol + + init(service: FeatureServiceProtocol) { + self.service = service + } + + @MainActor + func loadData() async { + isLoading = true + defer { isLoading = false } + + do { + items = try await service.fetchItems() + } catch { + // Handle error + } + } +} + +// Usage +struct ModernFeatureView: View { + @State private var viewModel: ModernViewModel + + init(service: FeatureServiceProtocol) { + self._viewModel = State(initialValue: ModernViewModel(service: service)) + } + + var body: some View { + List(viewModel.items, selection: $viewModel.selectedItem) { item in + ItemRow(item: item) + } + .task { + await viewModel.loadData() + } + } +} +``` + +See `swiftui-style.md` for basic SwiftUI patterns and design system integration. diff --git a/work/wiki/apple-browsers/swiftui-style.mdc b/work/wiki/apple-browsers/swiftui-style.mdc new file mode 100644 index 00000000..252c9a08 --- /dev/null +++ b/work/wiki/apple-browsers/swiftui-style.mdc @@ -0,0 +1,336 @@ +--- +source: ~/DuckDuckGo/apple-browsers.git/main/.cursor/rules/swiftui-style.mdc +confidence: 0.9 +namespace: work +last_synced: 2026-04-28 +alwaysApply: false +--- + +# SwiftUI Style Guide with Design System Integration for DuckDuckGo Browser + +## View Structure + +### View Organization +```swift +struct FeatureView: View { + // MARK: - Environment and State + @Environment(\.colorScheme) var colorScheme + @EnvironmentObject var appSettings: AppSettings + + // MARK: - State and Binding + @State private var localState = false + @Binding var externalState: Bool + + // MARK: - View Model + @StateObject private var viewModel: FeatureViewModel + + // MARK: - Body + var body: some View { + content + .onAppear { viewModel.onAppear() } + } + + // MARK: - Subviews + @ViewBuilder + private var content: some View { + // Main content here + } +} +``` + +## Design System Integration + +### REQUIRED: Use DesignResourcesKit Colors +ALWAYS use semantic colors from DesignResourcesKit instead of hardcoded or system colors: + +```swift +// βœ… CORRECT - DesignResourcesKit semantic colors +Text("Title") + .foregroundColor(Color(designSystemColor: .textPrimary)) + .background(Color(designSystemColor: .surface)) + +VStack { + Rectangle() + .fill(Color(designSystemColor: .accent)) + + Button("Action") { } + .foregroundColor(Color(designSystemColor: .controlsFillPrimary)) +} +.background(Color(designSystemColor: .background)) + +// ❌ INCORRECT - Hardcoded or system colors +Text("Title") + .foregroundColor(.black) // Don't use hardcoded colors + .background(.gray) // Don't use system colors + +// ❌ INCORRECT - Manual dark mode handling +@Environment(\.colorScheme) var colorScheme +let textColor = colorScheme == .dark ? Color.white : Color.black // Use semantic colors instead +``` + +### REQUIRED: Use DesignResourcesKit Icons +ALWAYS use icons from DesignResourcesKitIcons package: + +```swift +// βœ… CORRECT - DesignResourcesKit icons +Button(action: addAction) { + Image(uiImage: DesignSystemImages.Glyphs.Size16.add) + .foregroundColor(Color(designSystemColor: .accent)) +} + +Image(uiImage: DesignSystemImages.Color.Size24.bookmark) + .resizable() + .frame(width: 24, height: 24) + +// ❌ INCORRECT - System or custom icons +Button(action: addAction) { + Image(systemName: "plus") // Use DesignResourcesKit icons +} + +Image("custom_icon") // Use DesignResourcesKit icons instead +``` + +### Design System Color Categories +Use appropriate semantic color categories: + +```swift +// Text colors +.foregroundColor(Color(designSystemColor: .textPrimary)) +.foregroundColor(Color(designSystemColor: .textSecondary)) +.foregroundColor(Color(designSystemColor: .textLink)) + +// Background colors +.background(Color(designSystemColor: .background)) +.background(Color(designSystemColor: .surface)) +.background(Color(designSystemColor: .panel)) + +// Control colors +.foregroundColor(Color(designSystemColor: .controlsFillPrimary)) +.foregroundColor(Color(designSystemColor: .controlsFillSecondary)) + +// Button colors (use specific button color tokens) +.foregroundColor(Color(designSystemColor: .buttonPrimaryText)) +.background(Color(designSystemColor: .buttonPrimaryBackground)) +``` + +### Typography with Design System +Use semantic typography that integrates with the design system: + +```swift +// βœ… CORRECT - Design system typography +Text("Header") + .font(.title2.weight(.semibold)) + .foregroundColor(Color(designSystemColor: .textPrimary)) + +Text("Body") + .font(.body) + .foregroundColor(Color(designSystemColor: .textSecondary)) + +Text("Caption") + .font(.caption) + .foregroundColor(Color(designSystemColor: .textSecondary)) + +// Platform-specific typography (macOS) +#if os(macOS) +Text("Preference Title") + .font(Fonts.preferencePaneTitle) + .foregroundColor(Color(designSystemColor: .textPrimary)) +#endif +``` + +### Theme Integration +Use Theme protocol for complex scenarios: + +```swift +// βœ… CORRECT - Theme integration for advanced scenarios +struct ComplexView: View { + @EnvironmentObject var themeManager: ThemeManager + + var body: some View { + VStack { + Text("Content") + .foregroundColor(Color(themeManager.currentTheme.textColor)) + } + .background(Color(themeManager.currentTheme.backgroundColor)) + } +} + +// βœ… PREFERRED - Direct semantic colors for simple cases +struct SimpleView: View { + var body: some View { + Text("Content") + .foregroundColor(Color(designSystemColor: .textPrimary)) + .background(Color(designSystemColor: .background)) + } +} +``` + +## Component Patterns + +### Reusable Components +- Create small, focused components +- Use ViewModifiers for common styling +- Leverage ViewBuilder for conditional content + +```swift +struct PrimaryButton: View { + let title: String + let action: () -> Void + + var body: some View { + Button(action: action) { + Text(title) + .foregroundColor(.white) + .padding() + .background(Color.accentColor) + .cornerRadius(8) + } + } +} +``` + +### Lists and Navigation +```swift +List { + Section { + ForEach(items) { item in + NavigationLink(destination: DetailView(item: item)) { + ItemRow(item: item) + } + } + } header: { + Text("Section Title") + } +} +.listStyle(.insetGrouped) +``` + +## State Management + +### View Model Pattern +```swift +class FeatureViewModel: ObservableObject { + @Published var items: [Item] = [] + @Published var isLoading = false + @Published var error: Error? + + func loadData() async { + isLoading = true + defer { isLoading = false } + + do { + items = try await service.fetchItems() + } catch { + self.error = error + } + } +} +``` + +### Async Operations +```swift +struct ContentView: View { + @StateObject private var viewModel = ViewModel() + + var body: some View { + content + .task { + await viewModel.loadData() + } + .refreshable { + await viewModel.refresh() + } + } +} +``` + +## Animations and Transitions + +### Smooth Animations +```swift +@State private var isExpanded = false + +var body: some View { + VStack { + content + .frame(height: isExpanded ? 200 : 100) + .animation(.spring(), value: isExpanded) + } +} +``` + +### Custom Transitions +```swift +.transition(.asymmetric( + insertion: .move(edge: .trailing).combined(with: .opacity), + removal: .move(edge: .leading).combined(with: .opacity) +)) +``` + +## Accessibility + +### Always Include Accessibility +```swift +Image(systemName: "star.fill") + .accessibilityLabel("Favorite") + .accessibilityHint("Double tap to toggle favorite status") + +Button(action: action) { + Text("Submit") +} +.accessibilityIdentifier("submit_button") +``` + +## Performance Considerations + +### Lazy Loading +```swift +ScrollView { + LazyVStack { + ForEach(items) { item in + ItemView(item: item) + } + } +} +``` + +### Avoid Expensive Operations in Body +```swift +// Bad +var body: some View { + let processedData = expensiveOperation(data) // Don't do this + Text(processedData) +} + +// Good +@State private var processedData: String = "" + +var body: some View { + Text(processedData) + .onAppear { + processedData = expensiveOperation(data) + } +} +``` + +## Preview Support + +### Comprehensive Previews +```swift +struct FeatureView_Previews: PreviewProvider { + static var previews: some View { + Group { + FeatureView() + .previewDisplayName("Default") + + FeatureView() + .preferredColorScheme(.dark) + .previewDisplayName("Dark Mode") + + FeatureView() + .previewDevice("iPhone SE (3rd generation)") + .previewDisplayName("Small Device") + } + } +} +``` diff --git a/work/wiki/apple-browsers/testing.mdc b/work/wiki/apple-browsers/testing.mdc new file mode 100644 index 00000000..72d13eca --- /dev/null +++ b/work/wiki/apple-browsers/testing.mdc @@ -0,0 +1,1668 @@ +--- +source: ~/DuckDuckGo/apple-browsers.git/main/.cursor/rules/testing.mdc +confidence: 0.9 +namespace: work +last_synced: 2026-04-28 +alwaysApply: false +--- + +# Testing Guidelines & Best Practices + +*This guide covers testing practices and patterns for the DuckDuckGo browser on iOS and macOS platforms.* + +## 🚨 MANDATORY: Testing Execution Rules + +### NEVER Run Tests Without Permission +**NEVER execute any test commands without EXPLICIT user permission or unles user explicitly asked to in their prompt.** + +#### Required Testing Workflow: +1. Write or modify test code as requested +2. if user did not ask to run tests in their prompt, **STOP** before running any test commands: + - `swift test` + - `npm test` + - `xcodebuild test` + - `fastlane test` + - Any other test execution commands +3. **ASK** the user: "Should I run the tests?" +4. **WAIT** for explicit permission (e.g., "yes", "run tests", "test it") +5. Only then execute test commands + +**This rule applies to ALL test execution - unit tests, integration tests, UI tests, performance tests, etc.** + +--- + +## Future Improvements + +This guide is a living document. Consider these areas for future improvements: + +- **Tab Extensions Testing**: Expand patterns for testing complex tab extension interactions and lifecycle management +- **WebKit Integration Testing**: Add comprehensive patterns for testing WKWebView configurations, user scripts, and content blocking integration +- **Privacy Feature Testing**: Develop specialized testing approaches for tracker protection, HTTPS upgrade, and content blocking rule validation +- **Cross-Platform Testing**: Create patterns for testing SharedPackages functionality across iOS and macOS with consistent behavior validation +- **Fire Button Integration Testing**: Add comprehensive testing patterns for data clearing workflows across all browser components +- **Autofill and Credential Testing**: Expand testing approaches for AutofillCredentialProvider, password management, and form filling scenarios +- **Sync Testing**: Develop patterns for testing bookmark sync, conflict resolution, and cross-device data consistency +- **AI Chat Integration Testing**: Add testing patterns for AI chat functionality, context management, and user interaction flows +- **Feature Flag Testing**: Expand MockFeatureFlagger usage patterns and integration testing with real feature configurations + +## Unit Tests + +### What to Include + +Unit tests should focus on testing individual components, functions, or classes in isolation. They should be: + +- **Fast**: Run quickly (< 1 second per test) +- **Independent**: Not depend on external systems or other tests +- **Deterministic**: Always produce the same result given the same input +- **Focused**: Test one specific behavior or functionality + +### βœ… When to Write Unit Tests + +#### Model Logic +Testing business logic, data transformations, and model behavior: + +```swift +func testBookmarkFolderCreation() { + let folder = BookmarkFolder(title: "Test Folder") + XCTAssertEqual(folder.title, "Test Folder") + XCTAssertTrue(folder.children.isEmpty) +} +``` + +#### Algorithms/Parsers +Testing parsing logic, URL manipulation, search algorithms: + +```swift +func testURLSchemeDetection() { + let detector = URLSchemeDetector() + XCTAssertTrue(detector.isValidURL("https://duckduckgo.com")) + XCTAssertFalse(detector.isValidURL("invalid-url")) +} +``` + +#### Utility Functions +Testing helper functions, extensions, formatters: + +```swift +func testDateFormatter() { + let formatter = DateFormatter.shortDate + let date = Date(timeIntervalSince1970: 1640995200) // 2022-01-01 + XCTAssertEqual(formatter.string(from: date), "1/1/22") +} +``` + +#### State Management +Testing ViewModels, state transitions, and data flow: + +```swift +func testViewModelStateTransition() { + let viewModel = SearchViewModel() + viewModel.performSearch("test query") + XCTAssertEqual(viewModel.state, .loading) +} +``` + +### ❌ What to Avoid + +#### Simple Property Toggles +Testing trivial getters/setters: + +```swift +// ❌ DON'T test this +func testIsEnabledToggle() { + feature.isEnabled = true + XCTAssertTrue(feature.isEnabled) +} +``` + +#### Complex UI Interactions +Use Integration or UI tests instead. + +#### External Dependencies +File system, network calls, databases. + +#### State/Strategy Pattern Switching +These are better suited for integration tests: + +```swift +// ❌ DON'T test state switching in unit tests +func testStateSwitching() { + stateMachine.transition(to: .loading) + stateMachine.transition(to: .loaded) + // This is brittle and doesn't test real behavior +} +``` + +## Mocks and Test Helpers + +The DuckDuckGo browser project includes multiple mock categories for testing different components and scenarios: + +### Mock Categories + +#### Unit Tests Mocks +For testing individual components in isolation: +- **UI mocks**: MockWindow, MockTabViewItemDelegate +- **WebView mocks**: WebViewMock, WKSecurityOriginMock +- **Storage mocks**: FileStoreMock, UserDefaultsMock +- **Feature-specific mocks**: MockBookmarkManager, MockFireproofDomains, MockAIChatPreferencesStorage + +#### Integration Tests Mocks +For testing component interactions and workflows: +- Content blocking mocks +- Tab navigation mocks +- Fire integration mocks +- Onboarding flow mocks +- System integration mocks + +#### BSK Tests Mocks +For testing BrowserServicesKit functionality: +- **Feature flag mocks**: MockFeatureFlagger +- **Privacy configuration mocks**: MockPrivacyConfiguration +- **Statistics mocks**: MockStatisticsStore +- **Variant management mocks**: MockVariantManager +- **Network mocks**: MockAPIService + +### Mock Usage Examples + +#### UI Testing with MockWindow + +```swift +func testViewControllerLifecycle() { + autoreleasepool { + let mockWindow = MockWindow() + let viewController = MyViewController() + + mockWindow.contentViewController = viewController + viewController.viewDidLoad() // Safe to call with MockWindow + + XCTAssertNotNil(viewController.view) + } // Ensures proper cleanup +} +``` + +#### WebKit Testing with WebViewMock + +```swift +func testGeolocationPermission() { + autoreleasepool { + let mockWebView = WebViewMock() + let origin = WKSecurityOriginMock.new(url: URL(string: "https://example.com")!) + let frameInfo = WKFrameInfoMock(webView: mockWebView, securityOrigin: origin, + request: URLRequest(url: URL(string: "https://example.com")!), + isMainFrame: true) + + // Test permission handling without actual web content + permissionManager.requestGeolocationPermission(for: frameInfo) { granted in + XCTAssertTrue(granted) + } + } +} +``` + +#### Feature Flag Testing with MockFeatureFlagger + +**⚠️ CRITICAL**: MockFeatureFlagger is **REQUIRED** for feature tests throughout the entire codebase (iOS, macOS, BSK). Tests will crash without it due to DefaultFeatureFlagger assertions that prevent real feature flag usage in test environments. + +```swift +func testFeatureFlaggedBehavior() { + let mockFlagger = MockFeatureFlagger() + mockFlagger.enabledFeatureFlags = [.contextualOnboarding] + + let feature = SomeFeature(featureFlagger: mockFlagger) + XCTAssertTrue(feature.isOnboardingEnabled) +} + +// Alternative approach using featuresStub +func testWithFeatureStub() { + let mockFlagger = MockFeatureFlagger() + mockFlagger.featuresStub = [ + "contextualOnboarding": true, + "duckPlayer": false + ] + + let component = TestComponent(featureFlagger: mockFlagger) + XCTAssertTrue(component.hasOnboarding) + XCTAssertFalse(component.hasDuckPlayer) +} + +// Testing feature flag injection into Tab +func testTabWithFeatureFlags() { + autoreleasepool { + let mockFlagger = MockFeatureFlagger() + mockFlagger.enabledFeatureFlags = [.tabCrashDebugging] + + let tab = Tab(content: .newtab, featureFlagger: mockFlagger) + XCTAssertTrue(tab.canKillWebContentProcess) + } +} +``` + +**Why MockFeatureFlagger is Essential**: +- **Test Isolation**: Prevents real feature flag configurations from affecting test outcomes +- **Crash Prevention**: DefaultFeatureFlagger includes assertions that crash tests if not using MockFeatureFlagger +- **Controlled Environment**: Ensures predictable test behavior regardless of remote configuration +- **Performance**: Avoids network calls and complex privacy configuration setup + +### Mock Guidelines + +#### When to Use Each Mock Category +- **Unit Tests Mocks**: Testing individual classes, methods, and components in isolation +- **Integration Tests Mocks**: Testing workflows, component interactions, and system behavior +- **BSK Tests Mocks**: Testing shared functionality, feature flags, and cross-platform components + +#### Memory Management with Mocks +- Always use `autoreleasepool {}` for UI and WebKit mocks +- Use shared mock instances when testing multiple scenarios +- Reset mock state between tests to prevent pollution +- Be careful with reactive mocks that use Combine publishers + +#### Mock Selection Criteria +When creating mocks: +- **Check existing mocks first** - Use available mocks before creating new ones +- **Create what you need** - Don't hesitate to create mocks for single-use scenarios if no suitable mock exists +- **Focus on behavior** - Mock the interfaces and protocols your code depends on +- **Make them realistic** - Mocks should behave similarly to real implementations + +### Avoid "Testing Mocks" (Anti-patterns) + +**Golden Rule**: "If you can omit mocking, omit mocking" - The more you mock, the more you deviate from the real system, decreasing test confidence. + +#### Testing Implementation Details Instead of Behavior + +```swift +// ❌ BAD - Testing that internal methods are called +func testUserServiceCallsCorrectMethods() { + let mockRepository = MockUserRepository() + let userService = UserService(repository: mockRepository) + + userService.getCurrentUser() + + XCTAssertTrue(mockRepository.lookupSessionsCalled) // Testing HOW, not WHAT + XCTAssertTrue(mockRepository.getLatestSessionCalled) +} + +// βœ… GOOD - Testing the actual outcome +func testUserServiceReturnsCurrentUser() { + let mockRepository = MockUserRepository() + mockRepository.mockUser = User(id: "123", name: "John") + let userService = UserService(repository: mockRepository) + + let user = userService.getCurrentUser() + + XCTAssertEqual(user?.name, "John") // Testing WHAT happens, not HOW +} +``` + +#### Over-mocking Creates a Fake System + +```swift +// ❌ BAD - Testing mostly mock interactions +func testCompleteWorkflow() { + let mockAuth = MockAuthService() + let mockAPI = MockAPIService() + let mockStorage = MockStorage() + let mockLogger = MockLogger() + let mockAnalytics = MockAnalytics() + // ... testing interactions between mocks, not real behavior +} +``` + +#### Complex Scenarios Need Integration Testing + +```swift +// ❌ BAD - Complex mocking setup for user flows +func testBookmarkSyncFlow() { + let mockNetworkMonitor = MockNetworkMonitor() + let mockSyncService = MockSyncService() + let mockBookmarkStore = MockBookmarkStore() + let mockConflictResolver = MockConflictResolver() + // ... 50 lines of mock setup for a flow that should be integration tested +} + +// βœ… BETTER - Use Integration Test instead +// This complex scenario is better tested as an integration test +// with real components, providing higher confidence and less maintenance +``` + +When complex mocking suggests better alternatives: +- If you're setting up many interconnected mocks β†’ Consider an Integration Test with real components +- If you're mocking user interaction flows β†’ Consider a UI Test that exercises the actual user journey +- If mock setup takes longer than the test logic β†’ You're probably over-mocking + +## Memory Management - TestRunHelper + +The project includes **TestRunHelper** that provides automatic memory management and tracking during testing. + +### TestRunHelper Features (Automatically Enabled) + +- **Autorelease Tracking**: Automatically enabled for all tests, tracks specific classes (WKWebView, NSWindow, NSWindowController, NSViewController, TabBarItemCellView) +- **View Hierarchy Tracking**: Monitors NSView creations and validates deallocation with 3-second timeout +- **UI Presentation Prevention**: Fails tests that try to present real UI with `fatalError("Unit Tests should not present UI. Use MockWindow if needed.")` +- **Test Instance Variables Clearing**: Monitors test case instance variables and ensures they're cleared after test completion +- **Shared Resource Management**: Shared WKProcessPool for WebKit tests, temporary directory cleanup + +### Memory Issue Detection + +TestRunHelper will break/assert in these scenarios: +- **CI Environment**: `fatalError()` with test failure +- **Local Development**: `breakByRaisingSigInt()` with debugging guidance +- **Tab Deallocation Check**: Tab.deinit includes comprehensive WebKit object deallocation verification + +#### Override allowedNonNilVariables for Legitimate Cases + +```swift +override var allowedNonNilVariables: Set { + ["constantData", "sharedManager", "staticConfiguration"] +} +``` + +### When to Use autoreleasepool {} + +**Always Required**: + +```swift +// 1. View Controller lifecycle testing +func testViewControllerLifecycle() { + autoreleasepool { + let window = MockWindow() + let viewController = MyViewController() + window.contentViewController = viewController + // Test lifecycle methods + } // Ensures immediate cleanup +} + +// 2. Heavy object creation in loops +func testBatchOperations() { + for i in 0..<1000 { + autoreleasepool { + let object = HeavyObject(data: testData[i]) + processObject(object) + } // Releases object immediately instead of waiting for test end + } +} + +// 3. WebKit and content blocking tests +func testContentBlockingRules() { + autoreleasepool { + let rulesManager = MockContentBlockerRulesManager() + let webView = WebViewMock() + // Test content blocking behavior + } // WebKit objects can be memory intensive +} + +// 4. File system and database operations +func testDatabaseOperations() { + autoreleasepool { + let context = managedObjectContext + let objects = createTestObjects(count: 100) + context.save() + } // Ensures Core Data cleanup +} +``` + +### Memory Management Best Practices + +- Use `autoreleasepool` when creating more than a few objects in a test +- Always wrap view controller tests that involve UI hierarchy +- Use for any test involving file I/O, network operations, or large data +- Wrap tearDown operations when dealing with complex object hierarchies +- Essential for batch operations and performance tests + +### Template for Memory-Safe Testing + +```swift +func testFeature() { + autoreleasepool { + // 1. Setup mocks and dependencies + let mockWindow = MockWindow() + let mockManager = MockSomeManager() + + // 2. Create objects under test + let objectUnderTest = MyObject(dependencies: mockManager) + + // 3. Perform test operations + objectUnderTest.performAction() + + // 4. Assert results + XCTAssertEqual(objectUnderTest.state, .expected) + + // 5. Objects are automatically released at end of autoreleasepool + } +} +``` + +### AutoreleaseTracker + +**What it is**: AutoreleaseTracker creates tracker objects that are autoreleased alongside specific tracked objects, maintaining malloc stack traces for debugging memory leaks. + +**What it tracks**: WKWebView, NSWindow, NSWindowController, NSViewController, TabBarItemCellView + +**How to debug autorelease issues**: +1. Enable MallocStackLogging in Tests scheme β†’ Arguments β†’ Environment Variables +2. Run your test and wait for deallocation timeout +3. Open Memory Browser in Xcode (Debug β†’ Debug Workflow β†’ View Memory) +4. Search for "AutoreleaseTracker" in Memory Browser +5. Analyze the stack trace to see where the autorelease call was made + +## Integration Tests + +### What to Include + +Integration tests verify that multiple components work together correctly. They test: +- **Component Interactions**: How different modules communicate +- **Data Flow**: End-to-end data processing +- **System Integration**: External services, APIs, databases + +### βœ… When to Write Integration Tests + +#### Complex Feature Workflows +Testing complete user journeys: + +```swift +func testBookmarkSyncFlow() { + // Test bookmark creation -> sync -> retrieval + let bookmark = createBookmark() + syncManager.sync() + let retrievedBookmark = bookmarkStore.fetch(by: bookmark.id) + XCTAssertEqual(bookmark.title, retrievedBookmark?.title) +} +``` + +#### State Management +Testing complex state transitions in real scenarios. + +#### Cross-Module Communication +Testing how different packages interact. + +#### Database Operations +Testing Core Data models and persistence. + +### ❌ Integration Test Anti-patterns + +- **Debug Information**: Don't include debug prints, use `Logger.tests` instead +- **Real UI Dependencies**: Prefer mock windows and views +- **Heavy Resource Usage**: Avoid tests that load unnecessary resources +- **External Network Calls**: Use mocked services + +### Best Practices for Integration Tests + +```swift +class IntegrationTestCase: XCTestCase { + + override func setUp() { + super.setUp() + // Use mock window to avoid UI dependencies + let mockWindow = MockWindow() + setupTestEnvironment(window: mockWindow) + } + + override func tearDown() { + // Ensure all resources are properly released + cleanupTestEnvironment() + super.tearDown() + } + + func testFeatureIntegration() { + // Test real component interaction without UI + let coordinator = FeatureCoordinator() + let result = coordinator.performAction() + XCTAssertNotNil(result) + } +} +``` + +## BSK Tests + +BSK (BrowserServicesKit) tests are specialized for the shared browser services package. + +### Logging in BSK Tests + +#### Getting BSK Test Logs + +**From GitHub Actions**: Download the `bsk-xctest-log-stream.log` artifact from failed builds + +**Local Development**: +```bash +# Use real-time log streaming (in separate terminal) +log stream --debug --info --predicate 'process == "xctest"' --style syslog & +swift test + +# For Swift Testing framework tests (using @Test macro): +swift test --enable-swift-testing + +# For specific test target with Swift Testing: +swift test --filter PersistenceTests --enable-swift-testing +``` + +> ⚠️ **AI Assistant Note**: These commands are examples for manual execution only. Never run test commands automatically without explicit user permission. +> +> **Note**: Use `--enable-swift-testing` flag when running tests that use the Swift Testing framework (`@Test` macro) instead of XCTest. This enables better test output and proper test discovery for Swift Testing tests. + +#### Enabling Private Data in Console App + +By default, macOS Console app shows `` instead of actual values for privacy. To see private data for debugging: + +1. Install the logging plist (located at `SharedPackages/BrowserServicesKit/com.apple.system.logging.plist`): + ```bash + sudo cp com.apple.system.logging.plist /Library/Preferences/Logging/com.apple.system.logging.plist + ``` + +2. Restart Console app to apply changes +3. Private data will now be visible in system logs for debugging purposes + +**Note**: Only use this for debugging - remove the plist when done to restore privacy protection. + +## Tab Extensions Testing + +Tab Extensions are modular components that provide specific functionality to tabs. For comprehensive guidance on Tab Extensions architecture and implementation patterns, see the **Tab Extensions Knowledge Sharing**. + +### Key Files to Reference +- `TabExtensions.swift` - Extension registration and architecture patterns +- `TabExtensionsBuilder.swift` - Extension initialization and dependency injection +- `Tab+Navigation.swift` - setupNavigationDelegate for navigation handling + +### Basic Tab Extension Testing + +```swift +class MyTabExtensionTests: XCTestCase { + var extensionsBuilder: TestTabExtensionsBuilder! + + override func setUp() { + super.setUp() + // Load only the extension you want to test + extensionsBuilder = TestTabExtensionsBuilder(load: [MyTabExtension.self]) + } + + func testExtensionFunctionality() { + autoreleasepool { + let tab = Tab(content: .none, extensionsBuilder: extensionsBuilder) + let myExtension = tab.extensions.myExtension + + XCTAssertNotNil(myExtension) + myExtension?.performAction() + XCTAssertEqual(myExtension?.publicProperty, "expected_value") + } + } +} +``` + +### Advanced Testing with Mocked Dependencies + +```swift +class AdClickAttributionTabExtensionTests: XCTestCase { + func testExtensionWithMockedDependencies() { + let extensionsBuilder = TestTabExtensionsBuilder(load: [AdClickAttributionTabExtension.self]) { builder in { args, dependencies in + builder.override { + AdClickAttributionTabExtension( + // Override with mocked dependencies + userContentControllerFuture: Future { $0(.success(self.mockUserContentController)) }, + dependencies: dependencies.privacyFeatures.contentBlocking + ) { _ in (logic: self.mockLogic, detection: self.mockDetection) } + } + }} + + autoreleasepool { + let tab = Tab(content: .none, extensionsBuilder: extensionsBuilder) + // Test extension behavior with mocked dependencies + } + } +} +``` + +### Tab Extension Testing Guidelines + +#### βœ… Do: +- Use TestTabExtensionsBuilder for controlled extension loading +- Load only required extensions for isolated testing +- Test through public protocol interfaces +- Use autoreleasepool for memory management +- Mock dependencies through override patterns + +#### ❌ Don't: +- Load all extensions unless testing integration +- Access private implementation details +- Create real UI (use MockWindow) +- Ignore proper setup and teardown + +## UI Tests + +For comprehensive UI testing guidelines, patterns, and best practices specifically for the DuckDuckGo macOS browser, see **[UI Testing Guidelines & Best Practices](ui-testing.md)**. + +The UI testing documentation covers: + +- **Element Access Patterns**: Accessibility IDs, element variables, extension usage +- **Window and Tab Management**: Multi-window operations, navigation modifiers, validation patterns +- **Element Interaction**: Timing best practices, middle-click handling, context menus +- **Test Server Integration**: Local test server setup and content creation +- **Performance Optimizations**: Pasteboard usage, avoiding slow operations +- **Build Architecture**: Compatibility requirements across macOS versions +- **Advanced Patterns**: Tab extensions, settings testing, bookmark/history workflows + +## Snapshot Testing + +### Overview + +The project uses `swift-snapshot-testing` for snapshot testing, which captures and compares outputs to detect regressions. + +**Available snapshot types**: +- **Image snapshots**: Visual UI testing +- **JSON snapshots**: Data structure testing +- **Inline snapshots**: Text-based output testing + +### When to Use Snapshot Testing + +#### βœ… Excellent for: + +**Complex Data Structures**: Testing JSON responses, complex model transformations + +```swift +// Example: Testing complex suggestion results +func testSuggestionResults() { + let suggestions = suggestionLoader.getSuggestions(for: "duck") + assertInlineSnapshot(of: suggestions.encoded(), as: .lines, matches: """ + Suggestions: + - Search: "duck" (score: 100) + - Bookmark: "DuckDuckGo" (https://duckduckgo.com) + - History: "Duck typing" (https://en.wikipedia.org/wiki/Duck_typing) + """) +} +``` + +**Visual Regression Testing**: UI components, layouts, visual elements + +**Algorithm Output Validation**: Testing complex calculations, parsing results + +**Cross-Platform Consistency**: Ensuring consistent output across iOS/macOS + +### Image Snapshot Testing + +Perfect for visual regression testing: + +```swift +import SnapshotTesting + +func testButtonAppearance() { + for appearanceName: NSAppearance.Name in [.aqua, .darkAqua] { + NSApp.appearance = .init(named: appearanceName)! + + let button = createStyledButton() + assertSnapshot(of: button, as: .image(perceptualPrecision: 0.9), named: appearanceName.rawValue) + } +} +``` + +**Image snapshot guidelines**: +- Test in both light and dark modes +- Use `perceptualPrecision: 0.9` for UI components (allows minor anti-aliasing differences) +- Use `perceptualPrecision: 1.0` for pixel-perfect requirements +- Create consistent test environments (fixed window sizes, scale factors) + +### JSON Snapshot Testing + +Ideal for data structure validation: + +```swift +func testBookmarkImport() { + let importResult = bookmarkImporter.importFromHTML(testFile) + assertSnapshot(of: importResult.bookmarks, as: .json, named: "imported_bookmarks") +} +``` + +**JSON snapshot benefits**: +- Catches structural changes in data models +- Validates complex transformations +- Provides clear diffs for debugging +- Language-agnostic format + +### Inline Snapshot Testing + +Best for text-based output and algorithm results: + +```swift +func testSearchSuggestions() { + let results = searchEngine.getSuggestions(for: "privacy") + assertInlineSnapshot(of: results.encoded(), as: .lines, matches: """ + Privacy Settings + Privacy Policy + Privacy Tools + """) +} +``` + +**Inline snapshot advantages**: +- Snapshots live in the test file +- Easy to review changes in pull requests +- No external files to manage +- Great for small, predictable outputs + +### Snapshot Testing Best Practices + +#### Guidelines + +- **Make tests deterministic**: Use fixed dates, sorted collections, consistent ordering +- **Test meaningful changes**: Don't snapshot trivial variations +- **Use descriptive names**: `named: "dark_mode_large_text"` instead of `named: "test1"` +- **Review snapshot changes**: Always review generated snapshots during development +- **Keep snapshots small**: Large snapshots are hard to review and maintain + +#### Memory Management with Snapshots + +```swift +func testComplexView() { + autoreleasepool { + let window = SnapshotWindow() + let view = ComplexView(data: testData) + window.contentView = view + + assertSnapshot(of: view, as: .image()) + } // Ensures immediate cleanup +} +``` + +### ❌ When NOT to Use Snapshot Testing + +- **Highly dynamic content**: Real-time data, user-specific content +- **Performance testing**: Snapshot testing is about correctness, not speed +- **Simple value comparisons**: Use `XCTAssertEqual` for basic assertions +- **Flaky systems**: Outputs that vary between runs + +## Test Logging and Debugging + +### Using Logger.tests for Test Information + +**NEVER use `print()` in tests. ALWAYS use `Logger.tests` for debug output:** + +```swift +import os.log + +βœ… // GOOD: Use Logger.tests for test debugging +func testComplexFlow() { + Logger.tests.info("Starting complex flow test") + Logger.tests.debug("Setting up test data with \(testData.count) items") + Logger.tests.debug("DEBUG: requestCount = \(requestCount), currentState = \(service.currentState)") + + // Perform test operations + + Logger.tests.log("Test completed successfully") +} + +❌ // BAD: Using print() statements +func testComplexFlow() { + print("Starting test") // Never use print() + print("DEBUG: requestCount = \(requestCount)") // Use Logger.tests.debug() instead +} +``` + +**Benefits of Logger.tests:** +- Structured logging that integrates with Xcode and CI systems +- Proper log levels (info, debug, error) +- Automatic collection in CI artifacts +- Better performance than print() statements + +### Comprehensive Failure Logging + +For CI debugging, provide comprehensive failure logs: + +```swift +func testDataProcessing() { + Logger.tests.info("Testing data processing with \(inputData.count) items") + + do { + let result = try processor.process(inputData) + Logger.tests.debug("Processing completed with \(result.count) results") + + XCTAssertEqual(result.count, expectedCount, + "Expected \(expectedCount) results, got \(result.count). Input: \(inputData)") + } catch { + Logger.tests.error("Processing failed: \(error)") + XCTFail("Processing failed with error: \(error)") + } +} +``` + +**Best practices for failure logging**: +- Include relevant context in assertion messages +- Log the actual values that caused failures +- Use structured logging for complex data +- Include test setup information that might affect results + +## Best Practices + +### Test Naming + +#### XCTest Syntax (traditional): +```swift +func testBookmarkCreation_WhenValidData_ShouldSucceed() { } +func testUserAuthentication_WithInvalidCredentials_ShouldFail() { } +``` + +#### @Test Syntax (Swift Testing - preferred for new tests): +```swift +@Test("Bookmark creation succeeds with valid data") +func bookmark_creation_with_valid_data() { } + +@Test("User authentication fails with invalid credentials") +func user_authentication_fails_with_invalid_credentials() { } + +@Test("External URLs are validated correctly") +func external_urls_are_valid() { } +``` + +#### @Test Naming Guidelines: +- Use clear, descriptive text in `@Test("description")` +- Function names should be simple and readable (no "test" prefix needed) +- Use underscores for better readability in function names +- Focus the description on what behavior is being verified + +### Test Organization + +Group related tests using `// MARK:` comments: + +```swift +// MARK: - Bookmark Creation Tests +// MARK: - Bookmark Deletion Tests +``` + +### Async Testing + +#### Basic Async Testing: +```swift +func testAsyncOperation() async { + let result = await service.fetchData() + XCTAssertNotNil(result) +} +``` + +#### Async Testing with Timeouts: +```swift +// Using withTimeout for async operations +func testAsyncOperationWithTimeout() async throws { + let result = try await withTimeout(5.0) { + return try await service.performLongOperation() + } + XCTAssertNotNil(result) +} + +// Custom timeout error +func testAsyncOperationWithCustomError() async throws { + do { + _ = try await withTimeout(1.0, throwing: CustomTimeoutError()) { + return try await service.performSlowOperation() + } + XCTFail("Should have timed out") + } catch is CustomTimeoutError { + // Expected timeout + } +} +``` + +#### Publisher Testing with Timeouts: +```swift +// Testing publisher with timeout using extensions +func testPublisherWithTimeout() async throws { + let subject = PassthroughSubject() + + // Use timeout extension for publishers + let future = subject.timeout(2.0, "Publisher timeout").first().promise() + + DispatchQueue.main.async { + subject.send("test value") + } + + let result = try await future.get() + XCTAssertEqual(result, "test value") +} + +// For testing actual async behavior, use proper event-driven patterns: +func testPublisherWithAsyncBehavior() { + let expectation = expectation(description: "Should receive published value") + let subject = PassthroughSubject() + + let cancellable = subject + .sink { value in + XCTAssertEqual(value, "test value") + expectation.fulfill() + } + + // Trigger the real event that should cause publication + service.performActionThatPublishes() // This internally calls subject.send() + + wait(for: [expectation], timeout: 1.0) + cancellable.cancel() +} +``` + +#### Async Timeout Guidelines: +- Use `withTimeout()` for Task-based async operations - prevents indefinite hanging +- Use publisher `.timeout()` extensions for Combine workflows - handles stream timeouts gracefully +- Choose appropriate timeout values: Short for unit tests (1-5s), longer for integration tests (10s+) +- Always test timeout scenarios - ensure your code handles timeouts properly +- Use descriptive timeout messages - helps with debugging when timeouts occur + +## 🚨 CRITICAL: Async Testing Anti-Patterns + +### ❌ NEVER Use These Timing Patterns in Tests + +**NEVER use arbitrary delays in tests - they make tests flaky, slow, and unreliable:** + +```swift +❌ // BAD: Arbitrary time delays +func testBadPattern() { + // NEVER DO THIS + DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { + expectation.fulfill() + } + + // OR THIS + Timer.scheduledTimer(withTimeInterval: 2.0, repeats: false) { _ in + expectation.fulfill() + } + + // OR THIS + Thread.sleep(forTimeInterval: 0.5) + + wait(for: [expectation], timeout: 5.0) +} +``` + +### βœ… Use Event-Driven Testing Instead + +**Replace timing delays with event-driven expectations:** + +#### Pattern 1: Callback-Based Expectations +```swift +βœ… // GOOD: Event-driven testing with callbacks +class MockService { + var onEventTriggered: (() -> Void)? + + func triggerEvent() { + // Do work... + onEventTriggered?() // Signal completion + } +} + +func testGoodPattern() { + let expectation = expectation(description: "Event should be triggered") + + mockService.onEventTriggered = { + expectation.fulfill() + } + + // Trigger the actual event + mockService.triggerEvent() + + wait(for: [expectation], timeout: 1.0) // Short timeout for deterministic events +} +``` + +#### Pattern 2: Publisher-Based Expectations +```swift +βœ… // GOOD: Combine publisher testing +func testPublisherPattern() { + let expectation = expectation(description: "Publisher should emit value") + + let cancellable = service.statePublisher + .compactMap { $0 } + .first() + .sink { value in + XCTAssertEqual(value, .expectedState) + expectation.fulfill() + } + + // Trigger the state change + service.updateState(.expectedState) + + wait(for: [expectation], timeout: 1.0) + cancellable.cancel() +} +``` + +#### Pattern 3: Predicate-Based Expectations +```swift +βœ… // GOOD: Condition-based waiting +func testPredicatePattern() { + // Trigger the operation + service.startOperation() + + // Wait for specific condition to be true + let predicate = NSPredicate { _, _ in + service.isOperationComplete + } + let expectation = XCTNSPredicateExpectation(predicate: predicate, object: nil) + wait(for: [expectation], timeout: 2.0) + + XCTAssertTrue(service.isOperationComplete) +} +``` + +#### Pattern 4: Inverted Expectations for "Should Not Happen" +```swift +βœ… // GOOD: Testing that something should NOT happen +func testShouldNotHappen() { + let expectation = expectation(description: "Should not trigger unwanted behavior") + expectation.isInverted = true // Test passes if expectation is NOT fulfilled + + mockService.onUnwantedEvent = { + expectation.fulfill() // This would fail the test + } + + // Trigger some action + service.performAction() + + wait(for: [expectation], timeout: 1.0) // Short timeout for negative tests + + // Verify expected behavior occurred instead + XCTAssertTrue(service.didPerformExpectedBehavior) +} +``` + +### Why Event-Driven Testing Is Superior + +1. **Deterministic**: Tests wait for actual events, not arbitrary time +2. **Fast**: No unnecessary delays - tests complete as soon as events occur +3. **Reliable**: Eliminates race conditions and timing-dependent failures +4. **Maintainable**: Clear relationship between triggers and expectations +5. **Debuggable**: Failures point to actual logic issues, not timing problems + +### Migration Strategy + +When you see these patterns in existing tests: +1. **Identify the real event** the test is waiting for +2. **Add callback/publisher** to the mock or service to signal that event +3. **Replace arbitrary delays** with event-driven expectations +4. **Use shorter timeouts** (1-2s) since events are deterministic + +**Remember: Good tests are event-driven, not time-driven!** + +## ⏰ Time/Date Testing Patterns + +### Critical Design Principle +**ALWAYS inject time/date dependencies into your classes - NEVER use `Date()` or `Task.sleep()` directly in production code that needs testing.** + +### 🚫 **FORBIDDEN: Task.sleep() in Tests** + +**NEVER use `Task.sleep()` in any test code:** + +```swift +// ❌ NEVER DO THIS +try await Task.sleep(nanoseconds: 100_000_000) // Creates flaky tests +try await Task.sleep(for: .seconds(1)) // Unreliable timing +try? await Task.sleep(interval: 0.1) // Arbitrary delays + +// βœ… DO THIS INSTEAD +let expectation = expectation(description: "Wait for async operation") +someAsyncOperation { + expectation.fulfill() +} +await fulfillment(of: [expectation], timeout: 1.0) +``` + +**Why `Task.sleep()` is banned:** +1. **Flaky tests** - Real-world timing varies +2. **Slow tests** - Fixed delays waste time +3. **Unreliable** - May not wait long enough or wait too long +4. **Hides race conditions** - Masks real timing issues + +### Timeout Guidelines + +- Keep test timeouts reasonable: **maximum 5 seconds** for most async operations +- Use shorter timeouts (1-3 seconds) when possible to catch issues faster +- Only use longer timeouts for truly slow operations (network requests, file I/O) + +### 🚫 **NEVER Change Tests to Match Wrong Behavior** + +**When tests fail, fix the code, not the test:** + +```swift +// ❌ WRONG: Changing test to match broken behavior +XCTAssertEqual(result.count, 3, "Should return 3 items") // Changed from 5 to 3 to make test pass + +// βœ… RIGHT: Fix the actual code issue +// Fix the logic to properly return all expected items +XCTAssertEqual(result.count, 5, "Should return all 5 items as originally designed") +``` + +**Tests should verify correct behavior, not accommodate bugs. If a test fails:** +1. **First** - Check if the production code has a bug +2. **Fix the bug** in the production code +3. **Only then** update the test if the expected behavior has legitimately changed + +**Never adjust tests to hide problems in the implementation.** + +When a class needs current time or sleep functionality, inject these dependencies through the initializer: + +```swift +βœ… // GOOD: Injectable time dependencies +class MyService { + private let dateProvider: () -> Date + private let sleeper: Sleeper + + init(dateProvider: @escaping () -> Date = Date.init, + sleeper: Sleeper = .default) { + self.dateProvider = dateProvider + self.sleeper = sleeper + } + + func performOperation() async throws { + let now = dateProvider() // βœ… Testable + // ... do work ... + try await sleeper.sleep(for: 1.0) // βœ… Testable + } +} + +❌ // BAD: Hard-coded time dependencies +class MyService { + func performOperation() async throws { + let now = Date() // ❌ Not testable + // ... do work ... + try await Task.sleep(nanoseconds: 1_000_000_000) // ❌ Not testable + } +} +``` + +### Pattern 1: TestClock for Async Sleep Testing + +**Use `TestClock` for testing code that uses `Task.sleep()` or periodic operations:** + +```swift +βœ… // GOOD: TestClock pattern for async sleep testing +func testPeriodicUpdates() async throws { + let clock = TestClock() + let sleeper = Sleeper(clock: clock) + + // Inject the test sleeper + let service = MyPeriodicService(sleeper: sleeper, interval: 2.0) + + let expectation1 = expectation(description: "First update") + let expectation2 = expectation(description: "Second update") + + var updateCount = 0 + service.onUpdate = { + updateCount += 1 + if updateCount == 1 { + expectation1.fulfill() + } else if updateCount == 2 { + expectation2.fulfill() + } + } + + // Start the periodic task + let task = service.startPeriodicUpdates() + + // Wait for first update (immediate) + await fulfillment(of: [expectation1], timeout: 1.0) + XCTAssertEqual(updateCount, 1) + + // Advance clock by 2 seconds to trigger next update + await clock.advance(by: .seconds(2)) + await fulfillment(of: [expectation2], timeout: 1.0) + XCTAssertEqual(updateCount, 2) + + task.cancel() +} + +// Production service with injectable sleeper +class MyPeriodicService { + private let sleeper: Sleeper + private let interval: TimeInterval + var onUpdate: (() -> Void)? + + init(sleeper: Sleeper = .default, interval: TimeInterval) { + self.sleeper = sleeper + self.interval = interval + } + + func startPeriodicUpdates() -> Task { + Task.periodic(interval: interval, sleeper: sleeper) { + await self.performUpdate() + } + } + + private func performUpdate() async { + // Do work... + onUpdate?() + } +} +``` + +#### TestClock Best Practices + +**ALWAYS use `Task.megaYield(count: N)` after `clock.advance()` to allow async processing:** + +```swift +βœ… // GOOD: Proper megaYield usage +await clock.advance(by: .seconds(5)) +await Task.megaYield(count: 5) // Allow async tasks to process + +❌ // BAD: Multiple consecutive megaYield calls +await clock.advance(by: .seconds(5)) +await Task.megaYield() +await Task.megaYield() +await Task.megaYield() + +❌ // BAD: No megaYield after clock advance +await clock.advance(by: .seconds(5)) +// Missing yield - async tasks may not complete +``` + +**Why `megaYield` is critical:** +- TestClock advancement is synchronous, but triggered async tasks need time to process +- `megaYield(count: N)` ensures async operations complete before assertions +- Use `count: 5` as a standard (allows multiple yield cycles) + +### Pattern 2: MockDateProvider for Date Testing + +**Use `MockDateProvider` or `TimeTraveller` for testing code that needs current date:** + +```swift +βœ… // GOOD: MockDateProvider pattern +class MockDateProvider { + private var date: Date + + init(date: Date = Date()) { + self.date = date + } + + func setNowDate(_ date: Date) { + self.date = date + } + + func advanceBy(_ timeInterval: TimeInterval) { + date.addTimeInterval(timeInterval) + } + + func getDate() -> Date { + date + } +} + +func testDateBasedLogic() { + let mockDateProvider = MockDateProvider(date: Date(timeIntervalSince1970: 0)) + let service = MyService(dateProvider: mockDateProvider.getDate) + + // Test with specific date + let result1 = service.processData() + XCTAssertEqual(result1.timestamp, Date(timeIntervalSince1970: 0)) + + // Advance time and test again + mockDateProvider.advanceBy(TimeInterval.days(1)) + let result2 = service.processData() + XCTAssertEqual(result2.timestamp, Date(timeIntervalSince1970: 86400)) +} + +// Production service with injectable date provider +class MyService { + private let dateProvider: () -> Date + + init(dateProvider: @escaping () -> Date = Date.init) { + self.dateProvider = dateProvider + } + + func processData() -> DataResult { + return DataResult(timestamp: dateProvider(), data: "...") + } +} +``` + +### Pattern 3: Protocol-Based Date Injection + +**For more complex date/time scenarios, use protocol injection:** + +```swift +βœ… // GOOD: Protocol-based date injection +protocol CurrentDateProviding { + var currentDate: Date { get } +} + +extension Date: CurrentDateProviding { + public var currentDate: Date { self } +} + +class MockDateProvider: CurrentDateProviding { + var currentDate: Date + + init(currentDate: Date = Date()) { + self.currentDate = currentDate + } +} + +class MyService { + private let dateProvider: CurrentDateProviding + + init(dateProvider: CurrentDateProviding = Date()) { + self.dateProvider = dateProvider + } + + func isExpired(_ item: Item) -> Bool { + return item.expiryDate < dateProvider.currentDate + } +} + +func testExpiryLogic() { + let mockDateProvider = MockDateProvider(currentDate: Date(timeIntervalSince1970: 1000)) + let service = MyService(dateProvider: mockDateProvider) + + let expiredItem = Item(expiryDate: Date(timeIntervalSince1970: 500)) + let validItem = Item(expiryDate: Date(timeIntervalSince1970: 1500)) + + XCTAssertTrue(service.isExpired(expiredItem)) + XCTAssertFalse(service.isExpired(validItem)) +} +``` + +### Pattern 4: Combined TestClock and MockDateProvider + +**For services that need both current time and sleep capabilities:** + +```swift +βœ… // GOOD: Combined time and sleep mocking +func testServiceWithTimeAndSleep() async throws { + let mockDateProvider = MockDateProvider(date: Date(timeIntervalSince1970: 0)) + let clock = TestClock() + let sleeper = Sleeper(clock: clock) + + let service = MyTimedService( + dateProvider: mockDateProvider.getDate, + sleeper: sleeper + ) + + let expectation = expectation(description: "Operation should complete") + + service.onOperationComplete = { result in + // Verify the result includes the correct timestamp + XCTAssertEqual(result.startTime, Date(timeIntervalSince1970: 0)) + expectation.fulfill() + } + + // Start operation + let task = service.startOperation() + + // Advance mock time (affects dateProvider) + mockDateProvider.advanceBy(5.0) + + // Advance test clock (affects sleeper) + await clock.advance(by: .seconds(1)) + + await fulfillment(of: [expectation], timeout: 1.0) + task.cancel() +} +``` + +### Time Testing Best Practices + +1. **Always inject time dependencies** - Never use `Date()` or `Task.sleep()` directly in production code +2. **Use TestClock for async operations** - When testing `Task.sleep()`, `Task.periodic`, or `Sleeper` +3. **Use MockDateProvider for date logic** - When testing date comparisons, timestamps, or date-based decisions +4. **Test time progression** - Use `advance()` methods to test how your code behaves over time +5. **Test boundary conditions** - Test behavior at midnight, month boundaries, leap years, etc. +6. **Keep time control granular** - Advance time by specific amounts rather than arbitrary delays + +### Common Time Testing Mistakes + +❌ **Don't use real time in tests:** +```swift +// BAD: Unreliable and slow +func testBadTimePattern() async { + service.scheduleTask() + try await Task.sleep(nanoseconds: 2_000_000_000) // 2 seconds + XCTAssertTrue(service.taskCompleted) +} +``` + +βœ… **Use mock time instead:** +```swift +// GOOD: Fast and deterministic +func testGoodTimePattern() async { + let clock = TestClock() + let service = MyService(sleeper: Sleeper(clock: clock)) + + let expectation = expectation(description: "Task should complete") + service.onTaskComplete = { expectation.fulfill() } + + service.scheduleTask() + await clock.advance(by: .seconds(2)) + + await fulfillment(of: [expectation], timeout: 1.0) +} +``` + +**Remember: Control time in tests, don't wait for it!** + +### Error Testing +Test both success and failure cases: + +```swift +func testNetworkError_ShouldHandleGracefully() { + // Test error handling +} +``` + +### Skipping Tests + +Use `XCTSkip` to temporarily disable problematic tests: + +```swift +// XCTest syntax +func testFlakySomething() throws { + throw XCTSkip("Flaky test - temporarily disabled") +} + +// @Test syntax +@Test("Temporarily disabled flaky test") +func flaky_behavior() throws { + throw XCTSkip("Flaky test - investigating timing issues") +} +``` + +**XCTSkip Guidelines**: +- Use `throw XCTSkip("reason")` to temporarily disable flaky or problematic tests +- Function must be marked as `throws` to use XCTSkip +- Include a clear reason in the skip message for future investigation + +## CI/CD Integration + +Tests are automatically run in GitHub Actions with: +- Parallel execution for iOS and macOS +- Automatic log collection on failures +- JUnit report generation +- Crash report collection +- Asana integration for failure tracking + +## Debugging Failed Tests + +### 1. Check Logs +Download log artifacts from GitHub Actions + +### 2. Reproduce Locally +Use command line tools for consistent reproduction: + +# Run macOS tests +xcodebuild test \ + -scheme "macOS Browser" \ + -configuration "Debug" \ + -skipPackagePluginValidation \ + -skipMacroValidation \ + -allowProvisioningUpdates=NO \ + -only-testing:Unit\ Tests +``` + +```bash +# Run specific macOS Unit Test (e.g., HotspotDetectionServiceTests) +xcodebuild test \ + -scheme "macOS Browser" \ + -configuration "Debug" \ + -skipPackagePluginValidation \ + -skipMacroValidation \ + -only-testing:Unit\ Tests/HotspotDetectionServiceTests \ + -allowProvisioningUpdates=NO +``` + +### Integration Tests +```bash +# Run specific macOS Integration Test (e.g., DownloadsIntegrationTests) +xcodebuild test \ + -scheme "macOS Browser" \ + -configuration "Debug" \ + -skipPackagePluginValidation \ + -skipMacroValidation \ + -only-testing:Integration\ Tests/DownloadsIntegrationTests \ + -allowProvisioningUpdates=NO + +# Run all Integration Tests +xcodebuild test \ + -scheme "macOS Browser" \ + -configuration "Debug" \ + -skipPackagePluginValidation \ + -skipMacroValidation \ + -only-testing:Integration\ Tests \ + -allowProvisioningUpdates=NO +``` + +### UI Tests +```bash +# Run iOS UI tests +xcodebuild test \ + -scheme "iOS Browser" \ + -workspace DuckDuckGo.xcworkspace \ + -destination "platform=iOS Simulator,name=iPhone 15 Pro" \ + -only-testing:UITests + +# Run macOS UI tests +xcodebuild test \ + -scheme "macOS UI Tests" \ + -configuration "Review" \ + -skipPackagePluginValidation \ + -skipMacroValidation \ + -allowProvisioningUpdates=NO \ + -only-testing:UI\ Tests +``` + +# Run specific macOS UI test case +xcodebuild test \ + -scheme "macOS UI Tests" \ + -configuration "Review" \ + -skipPackagePluginValidation \ + -skipMacroValidation \ + -allowProvisioningUpdates=NO \ + -only-testing:UI\ Tests/DownloadsUITests +``` + +> ⚠️ **AI Assistant Note**: Never run test commands automatically without explicit user permission. + +### 3. Reproduce Flaky Tests +Reduce timeouts to increase failure rate locally: + +```swift +// Temporarily reduce timeouts to catch race conditions +let shortTimeout = 0.1 // Instead of 5.0 +XCTAssertTrue(element.waitForExistence(timeout: shortTimeout)) +``` + +### 4. Run Multiple Times +Use Xcode's repeat testing options: +- **Test Navigator**: Right-click test β†’ "Run [TestName] Repeatedly..." +- **Test Settings**: Set "Run" to repeat 10-100 times +- **Without Building**: Hold Option when clicking Run button for "Run Without Building" +- **Command Line**: Use `--repeat-count` with xcodebuild + +### 5. Memory Debugging +Use Memory Browser and allocation tracking: + +```bash +# Enable malloc stack logging for detailed memory traces +export MallocStackLogging=1 + +# Run tests with memory debugging +xcodebuild test -scheme YourScheme +``` + +**In Xcode**: +- **Memory Browser**: Debug β†’ Debug Workflow β†’ View Memory +- **Malloc Stack Logging**: Enable in scheme environment variables +- **AutoreleaseTracker**: Search for "AutoreleaseTracker" in Memory Browser to find leaked objects +- **Stack Traces**: View allocation stack traces for retained objects + +--- + +**For questions or improvements to this guide, please contribute to the documentation or reach out to the iOS/macOS team.** + +### Recovering UI Automation Mode (MANDATORY when runner fails to initialize) + +When every UI test fails immediately with errors like: + +- "The test runner failed to initialize for UI testing. (Underlying Error: Timed out while enabling automation mode.)" + +Follow these steps in order: + +1) Verify and re-grant Privacy permissions +- System Settings β†’ Privacy & Security β†’ Accessibility: enable Terminal and Xcode/Xcode-beta +- System Settings β†’ Privacy & Security β†’ Automation: under Xcode/Xcode-beta, allow controlling β€œSystem Events” and Finder + +2) Refresh the user session +- Quit Xcode and Terminal +- Log out and back in (preferred) or reboot the machine +- After login, open Xcode once to re-establish automation trust prompts + +3) Optional: Reset TCC entries (you will need to re-grant prompts) +```bash +tccutil reset Accessibility com.apple.dt.Xcode com.apple.dt.Xcode-Beta com.apple.Terminal +tccutil reset AppleEvents com.apple.dt.Xcode com.apple.dt.Xcode-Beta com.apple.Terminal +``` + +4) Sanity-check with a minimal known-green test +- Run a single previously passing UI test/class (e.g., HTTPSUpgradeUITests) before running failing classes + +Notes: +- Restarting `testmanagerd` is restricted by SIP on recent macOS versions and usually not necessary once you refresh the session. +- Record environment-related failures in `failing-ui-tests.md` and resume class-by-class once automation is restored. + +### Parsing xcresult Failures (MANDATORY) + +Always extract failures from `.xcresult` to drive fixes. Use these exact steps: + +1) Dump legacy JSON to a temp file + +```bash +xcrun xcresulttool get object --format json --legacy --path \ +"/Users/admin/Library/Developer/Xcode/DerivedData/DuckDuckGo-/Logs/Test/Test-macOS UI Tests-YYYY.MM.DD_HH-MM-SS-+ZZZZ.xcresult" \ +> /tmp/xc_root.json +``` + +2) Pull top-level failure summaries from ActionResult.issues.testFailureSummaries + +```bash +python3 - << 'PY' +import json, re +j=json.load(open('/tmp/xc_root.json')) +vals = j.get('actions',{}).get('_values') or [] +if not vals: + raise SystemExit('no actions in xcresult json') +act = vals[0] +fails = ((act.get('actionResult',{}) + .get('issues',{}) + .get('testFailureSummaries',{}) + .get('_values')) or []) + +def decode_url(url): + if not url: return ('','') + m = re.match(r'^file:\/\/(.*?)#.*StartingLineNumber=(\d+)', url) + return (m.group(1), m.group(2)) if m else (url,'') + +for f in fails: + name = (f.get('testCaseName') or {}).get('_value','') + msg = (f.get('message') or {}).get('_value','') + url = (f.get('documentLocationInCreatingWorkspace') or {}).get('url',{}).get('_value','') + filePath, line = decode_url(url) + print(f"{name}\t{' '.join(msg.split())}\t{filePath}\t{line}") +PY +``` + +Notes: +- Use `--legacy`; the non-legacy command is deprecated and returns no JSON here. +- If per-test details aren't under the top-level summaries, traverse `actions._values[].actionResult.testsRef` and follow `summaryRef` ids for each leaf test to collect `failureSummaries`. +- Never guess failures by console output; always parse `.xcresult`. + +3) Update `failing-ui-tests.md` with a flat list of ❌ tests and exact reasons (include `file:line` when available). Then fix tests one by one, marking progress as: +- ❓ fixed/unchecked +- βœ… validated +- ❌ still failing β€” reason diff --git a/work/wiki/apple-browsers/ui-testing.mdc b/work/wiki/apple-browsers/ui-testing.mdc new file mode 100644 index 00000000..c8c4620a --- /dev/null +++ b/work/wiki/apple-browsers/ui-testing.mdc @@ -0,0 +1,1785 @@ +--- +source: ~/DuckDuckGo/apple-browsers.git/main/.cursor/rules/ui-testing.mdc +confidence: 0.9 +namespace: work +last_synced: 2026-04-28 +alwaysApply: false +--- + +# UI Testing Guidelines & Best Practices + +*This guide covers UI testing practices and patterns specifically for the DuckDuckGo macOS browser.* + +## Overview + +UI Tests verify the end-to-end user experience and interface behavior. They test user workflows, navigation patterns, window management, and complex interactions across the entire application. + +## Setting Up UI Tests + +### ❗Always Use UITestCase Base Class + +```swift +class FeatureUITests: UITestCase { // βœ… Use UITestCase, not XCTestCase + private var app: XCUIApplication! + + override func setUpWithError() throws { + continueAfterFailure = false + app = XCUIApplication.setUp() // βœ… Use setUp(), never launch() directly + // app is already launched and configured by setUp() + } +} +``` + +**Why UITestCase is Required**: +- Provides proper app lifecycle management +- Handles feature flag configuration +- Sets up test server environment +- Manages window state and cleanup +- Provides debugging utilities + +### Feature Flag Configuration + +```swift +// Configure feature flags during test setup +override func setUpWithError() throws { + app = XCUIApplication.setUp(featureFlags: [ + "contextualOnboarding": true, + "visualUpdates": false, + "duckPlayer": true + ]) + // Feature flags are automatically applied via FEATURE_FLAGS environment variable +} + +// Alternative: Custom environment +app = XCUIApplication.setUp(environment: [ + "UITEST_MODE_ONBOARDING": "1" +], featureFlags: [ + "newTabPageSections": true +]) +``` + +#### Privacy Subfeature Configuration + +```swift +// Configure privacy subfeatures (separate from feature flags) +override func setUpWithError() throws { + app = XCUIApplication.setUp(privacySubfeatures: [ + "autoconsent-filterlist": true, + "tracker-allowlist": true + ]) + // Privacy subfeatures are applied via PRIVACY_SUBFEATURES environment variable +} + +// Combined feature flags and privacy subfeatures +app = XCUIApplication.setUp( + featureFlags: [ + "contextualOnboarding": true + ], + privacySubfeatures: [ + "autoconsent-filterlist": true + ] +) +``` + +**❗Why Feature Flag and Privacy Subfeature Configuration is Critical**: +- UI tests run against notarized builds - feature flags can't be changed at runtime +- MockFeatureFlagger is NOT available in UI tests (only real DefaultFeatureFlagger) +- Feature flags must be configured via FEATURE_FLAGS environment variable before app launch +- **Privacy subfeatures are controlled by PrivacyConfiguration, not feature flags** +- Privacy subfeatures must be configured via PRIVACY_SUBFEATURES environment variable +- Incorrect feature/subfeature state will cause UI tests to fail when expected UI elements don't appear + +**Key Differences**: +- **Feature Flags**: Control app features (e.g., `contextualOnboarding`, `duckPlayer`) +- **Privacy Subfeatures**: Control privacy functionality (e.g., `autoconsent-filterlist`, `tracker-allowlist`) +- Both use separate environment variables and configuration systems + +### File Management in UI Tests + +The `UITestCase` base class provides built-in file management capabilities for handling downloads, temporary files, and other file operations during testing. + +**Important**: UI tests run in a sandboxed environment and cannot directly read or delete files from user directories using standard FileManager calls. For non-temp directories, use the `filesToCleanup` pattern that's handled in the base class `tearDown()`. + +#### Automatic File Cleanup + +All UI test classes automatically clean up tracked files after test completion: + +```swift +class DownloadsUITests: UITestCase { + func testFileDownload() { + let downloadsDir = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask)[0] + let fileName = "test-file.json" + let filePath = downloadsDir.appendingPathComponent(fileName).path + + // Track file for automatic cleanup + trackForCleanup(filePath) + + // Perform download test... + // File will be automatically cleaned up after test completes + } +} +``` + +#### Reading Files via Local Server + +Use `readFileViaLocalServer()` to read files that may have permission restrictions: + +```swift +func testJSONFileContent() throws { + let filePath = "/Users/admin/Downloads/test-results.json" + + // Read file via local test server (bypasses permission issues) + let jsonData = try readFileViaLocalServer(filePath: filePath) + let results = try JSONDecoder().decode(TestResults.self, from: jsonData) + + // Validate file contents + XCTAssertFalse(results.items.isEmpty) +} +``` + +#### File Management Best Practices + +```swift +class FileBasedUITests: UITestCase { + func testCompleteFileWorkflow() throws { + // 1. Track all files that will be created + let tempDir = FileManager.default.temporaryDirectory.appendingPathComponent("test-files") + trackForCleanup(tempDir.path) + + let downloadedFile = "/Users/admin/Downloads/results.json" + trackForCleanup(downloadedFile) + + // 2. Perform file operations + try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + + // 3. Read files via server if needed + let fileData = try readFileViaLocalServer(filePath: downloadedFile) + + // 4. Files are automatically cleaned up in tearDown() + } +} +``` + +#### Available Methods + +- **`trackForCleanup(_ path: String)`**: Track a file/directory for automatic cleanup +- **`readFileViaLocalServer(filePath: String) throws -> Data`**: Read file via local test server +- **Automatic cleanup**: All tracked files are cleaned up after each test via the base class `tearDown()` + +## Element Access Patterns + +### Accessibility IDs - The Golden Standard + +**ALWAYS prefer accessibility IDs for element access**. They provide the most reliable and maintainable element identification. + +#### Finding Existing Accessibility IDs + +1. **Check the actual browser code** for assigned accessibility IDs: +```swift +// In browser code - look for patterns like: +button.accessibilityIdentifier = "AddressBarViewController.addressBarButton" +textField.identifier = "PreferencesGeneralView.switchToNewTabImmediately" +``` + +2. **Check XCUIApplication/XCUIElement extensions** for existing quick-accessor variables: +```swift +// Check Common/XCUIApplicationExtension.swift +extension XCUIApplication { + var addressBar: XCUIElement { + windows.textFields["AddressBarViewController.addressBarTextField"] + } + + var backButton: XCUIElement { + buttons["NavigationBarViewController.backButton"] + } +} +``` + +3. **Use accessibility identifiers consistently**: +```swift +// βœ… CORRECT: Using accessibility IDs +let addressBar = app.textFields["AddressBarViewController.addressBarTextField"] +let bookmarksMenu = app.menuItems["BookmarksMenu.showBookmarks"] +let downloadButton = app.buttons["DownloadsViewController.downloadButton"] + +// ❌ INCORRECT: Using text-based selectors +let addressBar = app.textFields["Enter search or URL"] // Fragile - breaks with localization +let bookmarksMenu = app.menuItems["Bookmarks"] // Fragile - breaks with text changes +``` + +#### When to Add New Accessibility IDs + +**ALWAYS validate with user before modifying main app code** to add missing accessibility IDs: + +```swift +// Before adding to main app code, ask: +// "I need to add accessibility ID 'TabBarViewController.newTabButton' +// to the new tab button in TabBarViewController.swift. Should I proceed?" + +// Then add to the main app: +newTabButton.accessibilityIdentifier = "TabBarViewController.newTabButton" +``` + +#### Element Variable Guidelines + +**Only create element variables in test cases if they are very specific to the testable area**: + +```swift +// βœ… CORRECT: Test-specific elements +func testSpecificFeatureWorkflow() { + let featureSpecificButton = app.buttons["FeatureViewController.specialActionButton"] + let uniqueDialog = app.dialogs["FeatureDialog.confirmationDialog"] + // Use directly in test +} +``` + +**If elements are generic, add accessors to XCUIApplication/XCUIElement extensions**: + +```swift +// βœ… CORRECT: Add to XCUIApplicationExtension.swift +extension XCUIApplication { + var downloadButton: XCUIElement { + buttons["DownloadsViewController.downloadButton"] + } + + var preferencesWindow: XCUIElement { + windows["PreferencesWindow"] + } +} + +// Then use in tests: +func testDownloadFlow() { + app.downloadButton.click() + XCTAssertTrue(app.downloadButton.exists) +} +``` + +### Timeout Constants Usage + +**MANDATORY**: Always use `UITests.Timeouts` constants instead of hardcoded timeout values. + +```swift +// βœ… CORRECT: Use semantic timeout constants +XCTAssertTrue(button.waitForExistence(timeout: UITests.Timeouts.elementExistence), "Button should appear") +XCTAssertTrue(pageContent.waitForExistence(timeout: UITests.Timeouts.navigation), "Page should load") +XCTAssertTrue(localContent.waitForExistence(timeout: UITests.Timeouts.localTestServer), "Local server content should load") + +// ❌ INCORRECT: Hardcoded timeout values +XCTAssertTrue(button.waitForExistence(timeout: 5.0), "Button should appear") +XCTAssertTrue(pageContent.waitForExistence(timeout: 30.0), "Page should load") +XCTAssertTrue(localContent.waitForExistence(timeout: 15.0), "Local server content should load") +``` + +**Available Timeout Constants**: +- `UITests.Timeouts.elementExistence` (5 sec) - UI elements, buttons, text fields, dialogs +- `UITests.Timeouts.navigation` (30 sec) - Page loads, network requests, external sites +- `UITests.Timeouts.localTestServer` (15 sec) - Localhost connections, test server content +- `UITests.Timeouts.fireAnimation` (30 sec) - Fire animation completion + +### Address Bar Validation Rules + +**MANDATORY**: Always use `app.addressBarValueActivatingIfNeeded()` for address bar validation and prefer exact matches over contains checks. + +```swift +// βœ… CORRECT: Use helper method with exact match for known URLs +XCTAssertEqual(app.addressBarValueActivatingIfNeeded(), "https://example.com/", "Should navigate to example.com") +XCTAssertEqual(app.addressBarValueActivatingIfNeeded(), "https://duckduckgo.com/", "Should be on DuckDuckGo") + +// ❌ INCORRECT: Manual address bar access, partial comparison +app.activateAddressBar() +let addressBarValue = addressBarTextField.value as? String ?? "" +XCTAssertTrue(addressBarValue.contains("example.com"), "Should be on example.com") + +// ❌ INCORRECT: Contains check for known exact URLs +let addressBarValue = app.addressBarValueActivatingIfNeeded() ?? "" +XCTAssertTrue(addressBarValue.contains("example.com"), "Should be on example.com") // Use XCTAssertEqual instead +``` + +**Address Bar Validation Guidelines**: +- **Use exact matches** (`XCTAssertEqual`) for known static URLs +- **Use contains checks** only for dynamic URLs (search results, localhost with ports) +- **Always use** `app.addressBarValueActivatingIfNeeded()` helper method +- **Never manually** call `app.activateAddressBar()` + `addressBarTextField.value` + +### CRITICAL: XCUIElement Queries Are Always Live + +**XCUIElement queries are always valid and re-query the UI when accessed (e.g., `exists`, `waitForExistence`). No need to create "fresh" element references:** + +```swift +// βœ… CORRECT: Reuse the same element reference +class FeatureUITests: UITestCase { + private var addressBarTextField: XCUIElement! + + override func setUpWithError() throws { + continueAfterFailure = false + app = XCUIApplication.setUp() + + // Get address bar reference once + addressBarTextField = app.addressBar + } + + func testAddressBarNavigation() throws { + // Type URL and navigate + addressBarTextField.typeText("example.com") + addressBarTextField.typeKey(.enter, modifierFlags: []) + + // Wait for navigation - validate specific content, not generic webView existence + let webView = app.webViews.firstMatch + let pageContent = webView.staticTexts.containing(NSPredicate(format: "value CONTAINS 'Example Domain'")).firstMatch + XCTAssertTrue(pageContent.waitForExistence(timeout: 30.0), "Should navigate to example.com and show page content") + + // βœ… CORRECT: Reuse original reference - XCUIElement queries are live + app.activateAddressBar() // Use helper method instead of manual Cmd+L + XCTAssertTrue(addressBarTextField.exists, "Address bar should still be accessible") + + // βœ… CORRECT: The same element reference works after navigation + addressBarTextField.typeText("another-site.com") + } +} + +// ❌ INCORRECT: Creating "fresh" element references unnecessarily +func testBadPattern() { + addressBarTextField.typeText("example.com") + + // ❌ Wrong: No need to create fresh reference + let freshAddressBar = app.textFields["AddressBarViewController.addressBarTextField"] + let currentAddressBar = app.textFields["AddressBarViewController.addressBarTextField"] + + // The original addressBarTextField reference is still valid! +} +``` + +### Element Access Hierarchy + +1. **Accessibility IDs** (most reliable) +2. **Extension-provided accessors** (for common elements) +3. **Stable attributes** (for dynamic content) +4. **Text-based selectors** (last resort, fragile) + +## Window and Tab Management + +### Essential Window/Tab Validation Patterns + +Based on our tab navigation testing improvements, always validate both UI state and browser state: + +#### Single-Window Tab Operations + +```swift +func testTabOperation() { + // Perform action that opens new tab + link.click() + + // βœ… CORRECT: Wait first, then validate counts + XCTAssertTrue(app.tabs["New Tab Page"].waitForExistence(timeout: UITests.Timeouts.elementExistence)) + XCTAssertEqual(app.windows.count, 1) // Still single window + XCTAssertEqual(app.tabs.count, 2) // Original + new tab + + // Validate both tabs exist + XCTAssertTrue(app.tabs["Original Page"].exists) + XCTAssertTrue(app.tabs["New Tab Page"].exists) + + // Validate webview state + XCTAssertTrue(app.webViews["New Tab Page"].exists) +} +``` + +#### Multi-Window Operations + +```swift +func testWindowOperation() { + // Perform action that opens new window + XCUIElement.perform(withKeyModifiers: [.command, .option]) { + link.click() + } + + // βœ… CORRECT: Wait for new window, then validate structure + let mainWindow = app.windows.firstMatch + let backgroundWindow = app.windows.element(boundBy: 1) + XCTAssertTrue(backgroundWindow.waitForExistence(timeout: UITests.Timeouts.elementExistence)) + XCTAssertEqual(app.windows.count, 2) + + // Validate content in correct windows + XCTAssertTrue(backgroundWindow.webViews["New Window Page"].exists) + XCTAssertFalse(mainWindow.webViews["New Window Page"].exists) + XCTAssertTrue(mainWindow.webViews["Original Page"].exists) + + // Validate tab counts per window + XCTAssertEqual(mainWindow.tabs.count, 1) + XCTAssertEqual(backgroundWindow.tabs.count, 1) + XCTAssertTrue(mainWindow.tabs["Original Page"].exists) + XCTAssertTrue(backgroundWindow.tabs["New Window Page"].exists) +} +``` + +#### Window Focus Behavior + +**Important**: When windows are activated/deactivated, the `firstMatch` window changes: + +```swift +func testWindowActivation() { + // Initially: Window A is active (firstMatch), Window B is background + let initialActiveWindow = app.windows.firstMatch + let backgroundWindow = app.windows.element(boundBy: 1) + + // Click on background window to activate it + backgroundWindow.click() + + // Now: Window B becomes firstMatch, Window A becomes background + let newActiveWindow = app.windows.firstMatch // This is now Window B + let newBackgroundWindow = app.windows.element(boundBy: 1) // This is now Window A + + // Validate the swap occurred + XCTAssertNotEqual(initialActiveWindow, newActiveWindow) +} +``` + +### Navigation Modifier Patterns + +Understanding modifier key behaviors for comprehensive testing: + +#### Tab Opening Modifiers +- **Command Click**: Opens in background tab +- **Command+Shift Click**: Opens in foreground tab (switches to it) +- **Middle Click**: Opens in background tab +- **Middle+Shift Click**: Opens in foreground tab + +#### Window Opening Modifiers +- **Command+Option Click**: Opens in background window +- **Command+Option+Shift Click**: Opens in foreground window (switches to it) +- **Middle+Option Click**: Opens in background window +- **Middle+Option+Shift Click**: Opens in foreground window + +#### Validation Patterns by Modifier + +```swift +// Background tab (Command click) +func testCommandClickOpensBackgroundTab() { + XCUIElement.perform(withKeyModifiers: [.command]) { + link.click() + } + + XCTAssertTrue(app.tabs["New Tab"].waitForExistence(timeout: UITests.Timeouts.elementExistence)) + XCTAssertEqual(app.windows.count, 1) + XCTAssertEqual(app.tabs.count, 2) + + // Original page still visible (background tab behavior) + XCTAssertTrue(app.webViews["Original Page"].exists) + XCTAssertTrue(app.tabs["Original Page"].exists) + XCTAssertTrue(app.tabs["New Tab"].exists) +} + +// Foreground tab (Command+Shift click) +func testCommandShiftClickOpensActiveTab() { + XCUIElement.perform(withKeyModifiers: [.command, .shift]) { + link.click() + } + + XCTAssertTrue(app.webViews["New Tab"].waitForExistence(timeout: UITests.Timeouts.elementExistence)) + XCTAssertEqual(app.windows.count, 1) + XCTAssertEqual(app.tabs.count, 2) + + // New page visible, original page in background (foreground tab behavior) + XCTAssertFalse(app.webViews["Original Page"].exists) + XCTAssertTrue(app.tabs["Original Page"].exists) // Tab still exists + XCTAssertTrue(app.tabs["New Tab"].exists) +} +``` + +## Element Interaction Patterns + +### Timing and Existence Best Practices + +**Critical**: Always wait for elements before checking counts or states: + +```swift +// ❌ BAD: Checking counts before content appears +action() +XCTAssertEqual(app.windows.count, 1) // Race condition! +XCTAssertTrue(element.waitForExistence(...)) + +// βœ… GOOD: Wait first, then validate +action() +XCTAssertTrue(element.waitForExistence(timeout: UITests.Timeouts.elementExistence)) +XCTAssertEqual(app.windows.count, 1) // Now safe to check +``` + +### Interaction Methods + +#### Safe Element Interaction + +```swift +// βœ… GOOD - Use existing helper methods +element.clickAfterExistenceTestSucceeds() +element.hoverAfterExistenceTestSucceeds() +element.typeURLAfterExistenceTestSucceeds(testURL) + +// βœ… GOOD - URL handling with colon workaround +element.typeURL(url, pressingEnter: true) // Handles colon typing issues +element.pasteURL(url, pressingEnter: true) // Faster than typing + +// βœ… GOOD - Element disappearance tracking +element.waitForNonExistence(timeout: UITests.Timeouts.elementExistence) +``` + +#### Helper Method Organization + +**CRITICAL: Move common helper methods to `XCUIApplication` extensions instead of duplicating them across test classes:** + +```swift +// ❌ BAD: Duplicating helper methods across test classes +class FeatureUITests: UITestCase { + private func setupSingleWindow() { + app.typeKey("w", modifierFlags: [.command, .option, .shift]) + app.typeKey("n", modifierFlags: .command) + } +} + +class AnotherFeatureUITests: UITestCase { + private func setupSingleWindow() { // ❌ Duplicate! + app.typeKey("w", modifierFlags: [.command, .option, .shift]) + app.typeKey("n", modifierFlags: .command) + } +} + +// βœ… GOOD: Use existing extension methods or add new ones to XCUIApplicationExtension.swift +class FeatureUITests: UITestCase { + override func setUpWithError() throws { + continueAfterFailure = false + app = XCUIApplication.setUp() + + // Use existing extension method + app.enforceSingleWindow() // βœ… Already exists in XCUIApplicationExtension.swift + } +} + +// βœ… GOOD: Add new helper methods to the extension for reuse +extension XCUIApplication { + /// Navigate to a URL and wait for page load + /// - Parameter url: The URL to navigate to + /// - Parameter timeout: Timeout for page load wait + /// - Parameter isNewTab: Whether this is happening on a new tab (affects address bar activation) + func navigateToURL(_ url: String, timeout: TimeInterval = 10.0, isNewTab: Bool = false) -> Bool { + guard addressBar.waitForExistence(timeout: 5.0) else { return false } + + // Only activate address bar if not on a new tab (new tabs have address bar pre-activated) + if !isNewTab { + activateAddressBar() + } + + addressBar.typeText(url) + addressBar.typeKey(.enter, modifierFlags: []) + + // Wait for specific content rather than generic webView existence + let webView = webViews.firstMatch + let pageContent = webView.staticTexts.firstMatch + return pageContent.waitForExistence(timeout: timeout) + } + + /// Open downloads popup and verify it appears + func openDownloadsPopup() -> Bool { + typeKey("j", modifierFlags: [.command]) + let downloadsPopup = windows.containing(.any).firstMatch + return downloadsPopup.waitForExistence(timeout: 5.0) + } +} +``` + +**Available Extension Properties and Methods:** +- `app.addressBar` β†’ Address bar text field element (replaces manual `app.textFields["AddressBarViewController.addressBarTextField"]`) +- `app.addressBarValueActivatingIfNeeded()` β†’ Activate address bar and return its current value as String? +- `app.enforceSingleWindow()` β†’ Close all windows and open new one (replaces `setupSingleWindow()`) +- `app.activateAddressBar()` β†’ Activate address bar for input (replaces direct `Cmd+L`) +- `app.openNewTab()` β†’ Open new tab via `Cmd+T` +- `app.resetBookmarks()` β†’ Reset bookmarks for testing +- `app.openBookmarksManager()` β†’ Open bookmarks manager +- `app.openBookmarksPanel()` β†’ Show bookmarks panel + +**Common patterns that should be moved to extensions:** +- `setupSingleWindow()` β†’ Use existing `enforceSingleWindow()` +- Manual address bar references β†’ Use `app.addressBar` +- Direct `Cmd+L` usage β†’ Use `app.activateAddressBar()` +- URL navigation helpers β†’ `navigateToURL(_:timeout:)` +- Downloads popup helpers β†’ `openDownloadsPopup()` +- Common assertion patterns β†’ Extension methods + +**Benefits of using extension methods:** +- βœ… **No duplication** - Write once, use everywhere +- βœ… **Consistent behavior** - Same implementation across all tests +- βœ… **Easier maintenance** - Fix bugs in one place +- βœ… **Better discoverability** - Other developers can find and reuse helpers +- βœ… **Cleaner test files** - Focus on test logic, not boilerplate + +#### Existence Checking Patterns + +```swift +// βœ… GOOD - Proper existence checking +XCTAssertTrue(mainElement.waitForExistence(timeout: UITests.Timeouts.elementExistence)) +XCTAssertTrue(relatedButton.exists) // Good after waitForExistence passed +XCTAssertTrue(anotherComponent.exists) // Good for checking multiple components + +// ❌ BAD - Avoid these patterns +XCTAssertTrue(element.exists) // Without waitForExistence first +XCTAssertTrue(element1.waitForExistence(timeout: 5)) +XCTAssertTrue(element2.waitForExistence(timeout: 5)) // Consecutive waits slow tests +Thread.sleep(forTimeInterval: 2.0) // Unreliable +Task.sleep(nanoseconds: 2_000_000_000) // Same issue +``` + +#### XCUIElement Property Waiting Extensions + +**NEW: Type-safe property waiting methods for more reliable UI tests:** + +```swift +// βœ… EXCELLENT: Wait for element properties using key paths +let addressBar = app.addressBar +let button = app.buttons["TestButton"] + +// Wait for property to contain substring (case-insensitive) +XCTAssertTrue(addressBar.wait(for: \.value, contains: "example.com", timeout: 10.0)) +XCTAssertTrue(button.wait(for: \.label, contains: "Submit")) + +// Wait for property to equal specific value +XCTAssertTrue(button.wait(for: \.isEnabled, equals: true, timeout: 5.0)) +XCTAssertTrue(addressBar.wait(for: \.value, equals: "https://duckduckgo.com")) + +// Use in assertions with descriptive failure messages +XCTAssertTrue(statusField.wait(for: \.value, equals: "1 of 4"), + "Status field should show '1 of 4', but got: \(statusField.value ?? "nil")") +``` + +**Benefits of property waiting extensions:** +- **Type-safe**: Uses Swift key paths instead of string predicates +- **Flexible**: Works with any property (\.value, \.label, \.title, \.isEnabled, etc.) +- **Reliable**: Built on XCTNSPredicateExpectation for proper waiting +- **Debuggable**: Easy to add current value to failure messages + +#### XCUIElementQuery Filtering Extensions + +**Type-safe element filtering using key paths:** + +```swift +// βœ… CORRECT: Filter elements using key paths +let webView = app.webViews.firstMatch + +// Filter by substring (case-insensitive) +let pageContent = webView.staticTexts.containing(\.value, containing: "Example Domain").firstMatch +let submitButtons = app.buttons.containing(\.label, containing: "Submit") + +// Filter by exact value +let enabledButtons = app.buttons.containing(\.isEnabled, equalTo: true) +let specificText = webView.staticTexts.containing(\.value, equalTo: "Welcome").firstMatch +let settingsWindow = app.windows.containing(\.title, equalTo: "Settings").firstMatch + +// Element matching patterns +let stopMenuItem = app.menuItems.containing(\.title, equalTo: "Stop").firstMatch +let backgroundTab = app.radioButtons.containing(\.title, equalTo: "Background Download").firstMatch + +// Replace old NSPredicate format strings +// ❌ OLD: webView.staticTexts.containing(NSPredicate(format: "value CONTAINS 'Example Domain'")) +// βœ… NEW: webView.staticTexts.containing(\.value, containing: "Example Domain") +``` + +**Available XCUIElementQuery Methods**: +- `containing(_:containing:)` - Filter elements where property contains substring +- `containing(_:equalTo:)` - Filter elements where property equals value +- `containing(_:where:)` - Filter elements containing specific element type with predicate +- `matching(_:containing:)` - Alternative filtering method for contains +- `matching(_:equalTo:)` - Alternative filtering method for equals +- `element(matching:containing:)` - Get single element matching contains criteria +- `element(matching:equalTo:)` - Get single element matching equals criteria + +#### NSPredicate KeyPath Extensions + +**Type-safe predicate construction for complex filtering:** + +```swift +// βœ… CORRECT: Using .keyPath() method for predicate construction +let webView = app.webViews.firstMatch + +// Complex element filtering with compound predicates +let pdfElement = app.groups.containing(.staticText, where: .keyPath(\.value, beginsWith: "TestPDF")).firstMatch + +// Compound predicates combining multiple conditions +let summaryGroup = webView.groups.containing(.keyPath(\.value, beginsWith: "1p navigation -")).firstMatch +let headerGroup = summaryGroup.groups.containing(.staticText, where: .keyPath(\.value, beginsWith: "Blocked")).firstMatch + +// Advanced predicate construction with chaining +let complexPredicate = NSPredicate.keyPath(\.elementType, equalTo: XCUIElement.ElementType.staticText.rawValue) + .and(.keyPath(\.value, beginsWith: "Expected")) + +let pathCell = tables.cells.containing(NSPredicate { element, _ in + guard let id = (element as? NSObject)?.value(forKey: #keyPath(XCUIElement.identifier)) as? String, + id.hasPrefix("/"), + URL(fileURLWithPath: id).standardizedFileURL.path == standardizedPath else { return false } + return true +}).firstMatch + +// Window filtering patterns +let namedWindow = app.windows.containing(NSPredicate(format: "title == %@", "Page Title")).firstMatch + +// Replace manual NSPredicate format strings +// ❌ OLD: NSPredicate(format: "value CONTAINS %@ AND isEnabled == %@", "text", true) +// βœ… NEW: .keyPath(\.value, contains: "text").and(.keyPath(\.isEnabled, equalTo: true)) +``` + +**Available NSPredicate Static Methods**: + +**Equality and Membership**: +- `.keyPath(_:equalTo:)` - Property equals specific value +- `.keyPath(_:in: [values])` - Property in collection of values +- `.keyPath(_:in: range)` - Property in numeric range + +**String Operations**: +- `.keyPath(_:contains:)` - Property contains substring (case-insensitive) +- `.keyPath(_:like:)` - Pattern matching with wildcards (* and ?) +- `.keyPath(_:beginsWith:)` - Property starts with prefix +- `.keyPath(_:endsWith:)` - Property ends with suffix +- `.keyPath(_:matchingRegex:)` - Property matches regular expression + +**Numeric Range Operations**: +- `.keyPath(_:in: 1...10)` - Closed range (inclusive) +- `.keyPath(_:in: 1..<10)` - Half-open range +- `.keyPath(_:in: 5...)` - Greater than or equal (>= 5) +- `.keyPath(_:in: ..<10)` - Less than (< 10) +- `.keyPath(_:in: ...10)` - Less than or equal (<= 10) + +**Compound Operations**: +- `predicate.and(otherPredicate)` - AND combination (instance method) +- `predicate.or(otherPredicate)` - OR combination (instance method) +- `.and(pred1, pred2, ...)` - AND multiple predicates (static) +- `.or(pred1, pred2, ...)` - OR multiple predicates (static) +- `predicate.inverted` - NOT predicate (property) + +**Benefits of NSPredicate KeyPath Extensions**: +- **Type Safety**: Compile-time KeyPath validation +- **Automatic Format Specifiers**: Handles %@, %d, %f automatically based on type +- **Composable**: Easy compound predicate construction with and/or/not +- **Reusable**: Store predicates as variables for reuse across tests + +#### XCUIElementQuery Waiting Extensions + +**Wait for conditions on element queries (e.g., count changes):** + +```swift +// βœ… CORRECT: Wait for element count conditions +let table = app.tables.firstMatch +let cells = table.cells + +// Wait for exact count +XCTAssertTrue(cells.wait(for: \.count, equals: 5, timeout: UITests.Timeouts.localTestServer), "Should have exactly 5 cells") + +// Wait for range conditions +XCTAssertTrue(cells.wait(for: \.count, in: 1...10, timeout: UITests.Timeouts.elementExistence), "Should have 1-10 cells") +XCTAssertTrue(cells.wait(for: \.count, in: 2..., timeout: UITests.Timeouts.elementExistence), "Should have at least 2 cells") +XCTAssertTrue(cells.wait(for: \.count, in: ..<10, timeout: UITests.Timeouts.elementExistence), "Should have less than 10 cells") + +// Wait with custom predicate +let countPredicate = NSPredicate.keyPath(\.count, in: 1...5) +XCTAssertTrue(cells.wait(for: countPredicate, timeout: UITests.Timeouts.elementExistence), "Should have 1-5 cells") + +// Replace old XCTNSPredicateExpectation patterns +// ❌ OLD: Manual XCTNSPredicateExpectation creation +// let expectation = XCTNSPredicateExpectation(predicate: NSPredicate(format: "count == %d", 2), object: table.cells) +// XCTAssertEqual(XCTWaiter.wait(for: [expectation], timeout: 15.0), .completed) + +// βœ… NEW: Direct query waiting methods +// XCTAssertTrue(table.cells.wait(for: \.count, equals: 2, timeout: UITests.Timeouts.localTestServer)) +``` + +**Available XCUIElementQuery Wait Methods**: +- `wait(for: NSPredicate, timeout:)` - Wait for custom NSPredicate condition +- `wait(for: \.count, equals: value, timeout:)` - Wait for count to equal specific value +- `wait(for: \.count, in: ClosedRange, timeout:)` - Wait for count in inclusive range (1...10) +- `wait(for: \.count, in: Range, timeout:)` - Wait for count in half-open range (1..<10) +- `wait(for: \.count, in: PartialRangeFrom, timeout:)` - Wait for count >= value (5...) +- `wait(for: \.count, in: PartialRangeUpTo, timeout:)` - Wait for count < value (..<10) +- `wait(for: \.count, in: PartialRangeThrough, timeout:)` - Wait for count <= value (...10) + +**Benefits of query waiting extensions:** +- **Type-safe**: Uses Swift key paths with compile-time validation +- **Range support**: Native Swift range syntax for numeric conditions +- **Simplified**: Replaces verbose XCTNSPredicateExpectation patterns +- **Consistent**: Same predicate-based API as filtering methods +- **Maintainable**: Compiler catches property and range type errors + +#### Middle Click Special Handling + +```swift +// ❌ NEVER: Use synthesized CGEvent approach +let mouseDownEvent = CGEvent(mouseEventSource: nil, + mouseType: .otherMouseDown, + mouseCursorPosition: point, + mouseButton: .center)! +let mouseUpEvent = CGEvent(mouseEventSource: nil, + mouseType: .otherMouseUp, + mouseCursorPosition: point, + mouseButton: .center)! +mouseDownEvent.post(tap: .cghidEventTap) +mouseUpEvent.post(tap: .cghidEventTap) + +// βœ… CORRECT: Use extension method directly +element.middleClick() // Extension handles middle-click properly + +// βœ… CORRECT: For middle-click with modifiers, use separate perform block +XCUIElement.perform(withKeyModifiers: [.option]) { + element.middleClick() // This doesn't work correctly +} +``` + +### Test Assertions Must Be Precise + +**CRITICAL RULE**: All test checks must be predictable and precise. Avoid OR-conditions, Thread.sleep(), and vague checks. + +```swift +// ❌ WRONG - Vague OR-conditions with sleep +Thread.sleep(forTimeInterval: 1.0) +let someUIVisible = element1.exists || element2.exists || element3.exists +XCTAssertTrue(someUIVisible, "Some UI should be accessible") + +// ❌ WRONG - Using XCTNSPredicateExpectation for simple element waiting +let webView = app.webViews.firstMatch +let pageLoaded = XCTNSPredicateExpectation( + predicate: NSPredicate(format: "exists == true"), + object: webView +) +XCTAssertEqual(XCTWaiter.wait(for: [pageLoaded], timeout: 15.0), .completed) + +// ❌ WRONG - Using 'if' statements for button waiting +if runButton.waitForExistence(timeout: 5.0) { + runButton.click() +} + +// βœ… CORRECT - Use waitForExistence with assertion for simple element waiting +XCTAssertTrue(runButton.waitForExistence(timeout: 15.0), "Run button should be available") +runButton.click() + +// βœ… BEST PRACTICE - Wait for the actual element you need, not its container +// Don't wait for webView if you need a button inside it - button existence implies page loaded +let runButton = app.webViews.buttons["run"] +XCTAssertTrue(runButton.waitForExistence(timeout: 15.0), "Run button should be available") +runButton.click() + +// βœ… CORRECT - Use XCTNSPredicateExpectation only for complex conditions +let complexCondition = XCTNSPredicateExpectation( + predicate: NSPredicate(format: "count > 2"), + object: app.tables.cells +) +XCTAssertEqual(XCTWaiter.wait(for: [complexCondition], timeout: 5.0), .completed) +``` + +**Prohibited Patterns:** +- `Thread.sleep()` - Use `waitForExistence` or `XCTNSPredicateExpectation` instead +- OR-conditions (`||`) in assertions - Test one specific state +- Vague "should be accessible" - Test specific elements and values +- Fallback checks - If primary check fails, test should fail clearly +- `if button.waitForExistence()` - Use `XCTAssertTrue(button.waitForExistence())` instead +- Complex `XCTNSPredicateExpectation` for simple existence checks - Use `waitForExistence` directly + +#### Address Bar Usage Pattern +**Use the extension property and follow activation rules:** + +```swift +func testAddressBarInteraction() { + // βœ… CORRECT: Use extension property + let addressBar = app.addressBar + + // βœ… CORRECT: On new tab page, address bar is already activated - no Cmd+L needed + addressBar.pasteURL("example.com") + + // Wait for navigation + let pageContent = webView.staticTexts.containing(NSPredicate(format: "value CONTAINS 'Example Domain'")).firstMatch + XCTAssertTrue(pageContent.waitForExistence(timeout: 30.0), "Example.com should load") + + // βœ… REQUIRED: After navigation, activate address bar before further interaction + app.activateAddressBar() // Use extension method instead of direct Cmd+L + + // Now the address bar is ready for input + addressBar.typeText("another-site.com") + addressBar.typeKey(.enter, modifierFlags: []) +} + +// ❌ INCORRECT: Don't create your own addressBar reference +func testBadPattern() { + let addressBarTextField = app.textFields["AddressBarViewController.addressBarTextField"] // ❌ Use app.addressBar instead + + // ❌ INCORRECT: Don't use Cmd+L on new tab pages + app.typeKey("l", modifierFlags: [.command]) // Address bar is already activated on new tabs + addressBarTextField.typeText("example.com") +} +``` + +**Address Bar Activation Rules**: +- βœ… **NEW TAB PAGE**: Address bar is already activated - do NOT use `Cmd+L` +- βœ… **AFTER NAVIGATION**: Address bar becomes read-only - USE `app.activateAddressBar()` before typing +- βœ… **USE EXTENSION**: Always use `app.addressBar` instead of creating your own reference +- βœ… **USE HELPER METHOD**: Use `app.activateAddressBar()` instead of direct `Cmd+L` + +#### Element Query Guidelines + +```swift +// βœ… CORRECT: Use element references efficiently +class MyUITests: UITestCase { + private var addressBar: XCUIElement! + private var webView: XCUIElement! + + override func setUpWithError() throws { + continueAfterFailure = false + app = XCUIApplication.setUp() + + // Create element references once + addressBar = app.textFields["AddressBarViewController.addressBarTextField"] + webView = app.webViews.firstMatch + } + + func testNavigation() { + // Use the same references throughout the test + XCTAssertTrue(addressBar.waitForExistence(timeout: 5.0)) + + addressBar.typeText("example.com") + addressBar.typeKey(.enter, modifierFlags: []) + + // Wait for specific page content, not generic webView existence + let pageContent = webView.staticTexts.containing(NSPredicate(format: "value CONTAINS 'Example Domain'")).firstMatch + XCTAssertTrue(pageContent.waitForExistence(timeout: 10.0), "Should show example.com page content") + + // After navigation, activate address bar for new input + app.activateAddressBar() + + // Same addressBar reference is still valid + addressBar.typeText("duckduckgo.com") + } +} + +// ❌ INCORRECT: Don't create multiple references to same element +func testBadElementHandling() { + let addressBar1 = app.textFields["AddressBarViewController.addressBarTextField"] + addressBar1.typeText("example.com") + + // ❌ Unnecessary - addressBar1 is still valid + let addressBar2 = app.textFields["AddressBarViewController.addressBarTextField"] + let freshAddressBar = app.textFields["AddressBarViewController.addressBarTextField"] + + // All three references point to the same element! +} +``` + +### Context Menu Interaction + +```swift +// Use coordinate-based context menu clicking for reliability across macOS versions +func testContextMenuAction() { + let webView = app.webViews.firstMatch + webView.rightClick() + + // βœ… Use the coordinate-based context menu hack + try app.clickContextMenuItem(matching: { $0.identifier == "PDFContextMenu.print" }) + + // This method uses coordinate-based clicking instead of direct element interaction + // because context menu detection fails on older macOS systems (13/14) in CI +} +``` + +## Local Test Server Integration + +UI tests use a local test server running on `http://localhost:8085/` for reliable page loading and content simulation. + +### Test Server Architecture + +- **Port**: 8085 (not 8080) +- **Integration**: Uses `TestsURLExtension.swift` shared with Integration Tests +- **APIs**: Provides `URL.testsServer` and `.appendingTestParameters()` methods +- **Content**: Supports dynamic content generation via query parameters + +### Creating Test Content + +#### Static Content +```swift +// Static content from test files +let url = URL.testsServer.appendingPathComponent("test-page.html") +``` + +#### Dynamic Content +```swift +// Dynamic content with custom HTML +let url = UITests.simpleServedPage(titled: "Test Page") +// Creates: http://localhost:8085/?data=...Test Page... + +// With custom body content +let url = UITests.simpleServedPage(titled: "Test Page") { + "Test Link" +} +``` + +#### Custom HTTP Responses +```swift +let url = URL.testsServer + .appendingPathComponent("test-endpoint") + .appendingTestParameters( + status: 404, + reason: "Not Found", + headers: ["Content-Type": "application/json"] + ) +``` + +### URL Parameter Options +- `status`: HTTP status code (default: 200) +- `reason`: HTTP status string (default: "OK") +- `data`: Response body (Data or String, base64 encoded if binary) +- `headers`: HTTP response headers + +### Test Server Best Practices +- Use `UITests.simpleServedPage(titled:)` for basic HTML pages +- Use `URL.testsServer.appendingTestParameters()` for custom responses +- Test various HTTP status codes and response types +- Keep test content simple and predictable +- Always validate server responses in tests + +## Performance Optimizations + +### Using Pasteboard for Speed + +```swift +func testAddressBarInput() { + // Instead of typing character by character + let testURL = "https://example.com" + UIPasteboard.general.string = testURL + + addressBar.press(forDuration: 1.0) + app.menuItems["Paste"].tap() + + // Much faster than: addressBar.typeText(testURL) +} +``` + +### Avoiding Slow Operations + +```swift +// βœ… FAST: Use paste for long URLs: it manages pasting into the pasteboard and restores its contents afterwards +addressBar.pasteURL(longURL) + +// ❌ SLOW: Character-by-character typing +addressBar.typeText(longURL) + +// βœ… FAST: Single waitForExistence +XCTAssertTrue(mainElement.waitForExistence(timeout: 5)) +XCTAssertTrue(relatedElement.exists) + +// ❌ SLOW: Multiple consecutive waits +XCTAssertTrue(element1.waitForExistence(timeout: 5)) +XCTAssertTrue(element2.waitForExistence(timeout: 5)) +``` + +## UI Test Build Architecture + +UI tests use a unique build architecture that affects compatibility: + +### Build Process +- **App Binary**: Built using notarized build action (latest Xcode/toolchain) +- **UI Test Bundle**: Built on target macOS version (macOS 13/14/15 runners) +- **Testing**: UI Test bundle tests the notarized app binary across macOS 13/14/15 + +### Code Compatibility Requirements + +```swift +// βœ… GOOD - UI Test code must compile on older Xcode versions +func testFeature() { + let app = XCUIApplication.setUp() + app.addressBar.typeText("test") + // Uses APIs available in minimum supported Xcode +} + +// ❌ BAD - Don't use newest APIs that aren't available on older Xcode +@available(macOS 14.0, *) +func testNewAPI() { + // This won't compile on macOS 13 UI test runners +} +``` + +### Compatibility Guidelines +- UI Test code must compile on oldest supported Xcode version (for macOS 13 runner) +- App code can use latest Swift/Xcode features (built with latest toolchain) +- Test only stable APIs - avoid beta/preview APIs in UI test code +- Use `@available` checks carefully - must work across all test runners + +## Advanced Testing Patterns + +### Tab Extension Testing in UI Tests + +For testing Tab Extension behavior through the UI: + +```swift +func testTabExtensionUIBehavior() { + // Load page that triggers tab extension + openTestPage("Extension Test Page") + + // Test extension UI elements appear + XCTAssertTrue(app.buttons["TabExtension.actionButton"].waitForExistence(timeout: 5)) + + // Test extension interactions + app.buttons["TabExtension.actionButton"].click() + XCTAssertTrue(app.alerts["TabExtension.confirmationAlert"].exists) +} +``` + +### Settings and Preferences Testing + +```swift +func testPreferencesImpactOnBehavior() { + // Navigate to preferences + navigateToGeneralPreferences() + + // Change setting + let toggle = app.checkBoxes["PreferencesGeneralView.switchToNewTabImmediately"] + XCTAssertTrue(toggle.waitForExistence(timeout: UITests.Timeouts.elementExistence)) + + if (toggle.value as? Bool) != true { + toggle.click() + } + + // Test behavior change + openTestPage("Test Page") + XCUIElement.perform(withKeyModifiers: [.command]) { + link.click() + } + + // Validate inverted behavior due to setting + XCTAssertTrue(app.webViews["New Page"].waitForExistence(timeout: UITests.Timeouts.elementExistence)) + + // Reset setting + if (toggle.value as? Bool) == true { + toggle.click() + } +} +``` + +### Bookmark and History Testing + +#### Bookmark Testing Best Practices + +**Always reset bookmarks before testing** to ensure a clean state: + +```swift +func testBookmarkNavigation() { + // βœ… CORRECT: Reset bookmarks for clean test state + app.resetBookmarks() + + // Add bookmark + openTestPage("Test Page") + app.mainMenuAddBookmarkMenuItem.click() + app.addBookmarkAlertAddButton.click() + + // Test bookmark interactions + app.bookmarksMenu.click() + // βœ… CORRECT: Use bookmarksMenu.menuItems for bookmark items + let bookmarkItem = app.bookmarksMenu.menuItems["Test Page"] + XCTAssertTrue(bookmarkItem.waitForExistence(timeout: UITests.Timeouts.elementExistence)) + + // Test modifier behaviors + XCUIElement.perform(withKeyModifiers: [.command]) { + bookmarkItem.click() + } + + XCTAssertTrue(app.webViews["Test Page"].waitForExistence(timeout: UITests.Timeouts.elementExistence)) + XCTAssertEqual(app.windows.count, 1) + XCTAssertEqual(app.tabs.count, 2) +} +``` + +#### Bookmark Testing Setup Patterns + +**For single bookmark tests**: Call `app.resetBookmarks()` at the beginning of each test: + +```swift +func testSingleBookmarkBehavior() { + app.resetBookmarks() // Clean state for this test + + // Test bookmark functionality... +} +``` + +**For test suites focused on bookmark management**: Reset in `setUp` if all tests involve bookmarks: + +```swift +class BookmarkManagementUITests: UITestCase { + override func setUpWithError() throws { + super.setUpWithError() + app.resetBookmarks() // Clean state for all bookmark tests + } + + func testAddBookmark() { + // No need to reset here - already done in setUp + } + + func testDeleteBookmark() { + // No need to reset here - already done in setUp + } +} +``` + +#### Bookmark Menu Item Access + +**Always use `app.bookmarksMenu.menuItems` for bookmark menu items**: + +```swift +// βœ… CORRECT: Specific bookmark menu item access +app.bookmarksMenu.click() +let bookmarkItem = app.bookmarksMenu.menuItems["My Bookmark"] + +// ❌ INCORRECT: Generic menu item access (may conflict with other menus) +let bookmarkItem = app.menuItems["My Bookmark"] +``` + +## Screenshot and Debugging + +### Automatic Screenshots +Screenshots are taken automatically on test failures for debugging. + +### Manual Screenshots +```swift +func takeScreenshot(name: String) { + let screenshot = XCUIScreen.main.screenshot() + let attachment = XCTAttachment(screenshot: screenshot) + attachment.name = name + attachment.lifetime = .keepAlways + add(attachment) +} +``` + +### UI Tests Logging + +**For UI tests, NEVER use `print()`. ALWAYS use `Logger.log()` for debug output:** + +```swift +βœ… // GOOD: Use Logger.log() for UI test debugging +class FeatureUITests: UITestCase { + func testComplexFlow() { + Logger.log("Starting complex UI flow test") + Logger.log("Setting up test data with \(testData.count) items") + Logger.log("DEBUG: currentState = \(app.addressBarValueActivatingIfNeeded())") + + // Perform UI test operations + + Logger.log("UI test completed successfully") + } +} + +❌ // BAD: Using print() statements in UI tests +func testComplexFlow() { + print("Starting test") // Never use print() + print("DEBUG: addressBar = \(addressBar.value)") // Use Logger.log() instead +} +``` + +**Benefits of Logger.log():** +- Integrates with XCTest's internal logging system +- Appears in test logs alongside other XCTest debug output +- Better performance and integration than print() statements +- Proper formatting for CI log collection +- Uses XCTest's private debug logging infrastructure for better integration + +**Usage Examples:** +```swift +class FeatureUITests: UITestCase { + func testComplexInteraction() { + Logger.log("Starting test with \(elements.count) elements") + + let currentURL = app.addressBarValueActivatingIfNeeded() + Logger.log("Current URL: \(currentURL ?? "nil")") + + if !button.waitForExistence(timeout: 5.0) { + Logger.log("Button not found, taking screenshot for debugging") + takeScreenshot("button-not-found") + } + } +} +``` + +### πŸ” Debug Operator: `???` for Optional String Conversion + +The `???` operator provides safe string conversion for debugging: + +```swift +// βœ… CORRECT: Debug string conversion with ??? operator (UI tests) +Logger.log("event received: \(event ??? "")") +XCTAssertTrue(element.exists, "Element should exist: \(optionalValue ??? "missing")") + +// βœ… CORRECT: Debug string conversion with ??? operator (unit tests) +Logger.log("event received: \(event ??? "")") +``` + +**What it does:** +- **Converts any optional to String** for debugging/logging +- **Uses `String(describing:)` if value exists** +- **Falls back to provided default string if nil** +- **Safer than force unwrapping** for debug output + +### πŸ” Debugging Pattern: UI Snapshot Logging + +When UI tests fail and you need to see the actual element hierarchy: + +```swift +// βœ… CORRECT: UI snapshot debugging for failed assertions +XCTAssertTrue(element.waitForExistence(timeout: 5.0), + "Element should exist: \((try? parentElement.snapshot().toDictionary()) ??? "snapshot failed")") +``` + +**When to use:** +- **UI tests fail unexpectedly** and you need to see what's actually there +- **Element queries don't find expected elements** +- **Debugging privacy dashboard content**, context menus, or complex UI +- **Only during debugging** - remove before committing + +**Key Points:** +- Use `(try? element.snapshot().toDictionary())` to safely get UI hierarchy +- Use `??? "fallback"` to handle snapshot failures +- Provides complete element tree structure when tests fail +- Remove debugging code before final commit + +### UI Test Debugging with View Hierarchy Snapshots + +When debugging UI test failures, use the `toDictionary()` helper method to capture and inspect the complete view hierarchy: + +```swift +// βœ… CORRECT: Debug view hierarchy when UI elements aren't found as expected +func testComplexUIInteraction() { + let webView = app.webViews.firstMatch + let runButton = webView.buttons["Start"] + + if !runButton.waitForExistence(timeout: 5.0) { + // Capture view hierarchy for debugging + let snapshot = try! webView.snapshot().toDictionary() + Logger.log("WebView hierarchy:\n\(snapshot)") + XCTFail("Start button not found in webView") + } + + runButton.click() +} + +// βœ… CORRECT: Include hierarchy in assertion failure messages +func testAddressBarBehavior() { + app.activateAddressBar() + let addressBarValue = addressBarTextField.value as? String ?? "" + + if addressBarValue.isEmpty { + // Include snapshot in failure message for debugging + let snapshot = try! app.snapshot().toDictionary() + XCTAssertFalse(addressBarValue.isEmpty, + "Address bar should have content, got: \(addressBarValue)\n\(snapshot)") + } +} +``` + +**Available snapshot properties** (customizable via `keys` parameter): +- `elementType`: UI element type (button, textField, etc.) +- `identifier`: Accessibility identifier +- `label`: Accessibility label +- `title`: Element title +- `value`: Current value +- `isEnabled`: Whether element is enabled +- `frame`: Element position and size +- `children`: Nested elements (automatically included) + +```swift +// βœ… CORRECT: Custom properties for specific debugging needs +let snapshot = try! element.snapshot().toDictionary(keys: [ + "elementType", "identifier", "label", "isEnabled" +]) + +// βœ… CORRECT: Full default properties for comprehensive debugging +let snapshot = try! element.snapshot().toDictionary() +``` + +**When to use view hierarchy debugging**: +- Element not found when expected to exist +- UI interaction failing unexpectedly +- Need to understand complex nested view structures +- Debugging test flakiness related to UI timing +- Adding detailed context to assertion failure messages + +**Best practices**: +- Use sparingly in production tests (only for debugging) +- Include snapshots in assertion failure messages for context +- Remove debug snapshots once issues are resolved +- Use custom `keys` parameter to focus on relevant properties + +### Debug Information +```swift +func debugElementHierarchy() { + // Print element hierarchy for debugging + Logger.log("Current window hierarchy: \(app.windows.debugDescription)") + Logger.log("Current tab structure: \(app.tabs.debugDescription)") +} +``` + +## Code Reading Requirements + +### Always Read Actual Code + +**Never assume how UI implementation is done**. Always read the actual browser code related to the tested area: + +1. **Find the relevant view controller** or UI component +2. **Check for existing accessibility identifiers** +3. **Understand the UI hierarchy and structure** +4. **Identify interaction patterns and state management** +5. **Verify element lifecycle and timing** + +```swift +// Example: Before testing address bar, read: +// - AddressBarViewController.swift +// - Check how textField.accessibilityIdentifier is set +// - Understand the view hierarchy +// - Look for existing test accessors in extensions +``` + +### Extension Integration + +Before creating element accessors, check existing extensions: + +```swift +// Check XCUIApplicationExtension.swift for patterns like: +extension XCUIApplication { + var addressBar: XCUIElement { + windows.textFields["AddressBarViewController.addressBarTextField"] + } +} + +// Check XCUIElementExtension.swift for helper methods like: +extension XCUIElement { + func clickAfterExistenceTestSucceeds() { + // Implementation + } +} +``` + +## Test Execution Guidelines + +### ❗Never Run Tests Yourself + +**DO NOT attempt to run UI tests unless explicitly asked by the user.** UI tests: +- Take significant time to execute +- Require specific setup and environment +- Can interfere with system state +- Should only be run when specifically requested for debugging + +### Local Testing Recommendations + +When users want to run tests locally: + +```bash +# Use xcodebuild for consistency with CI +xcodebuild -project macOS/DuckDuckGo.xcodeproj \ + -scheme 'DuckDuckGo (macOS)' \ + -configuration Debug \ + -destination 'platform=macOS' \ + test \ + -only-testing:DuckDuckGo_Privacy_BrowserTests/TabNavigationTests + +# For specific test methods +xcodebuild -project macOS/DuckDuckGo.xcodeproj \ + -scheme 'DuckDuckGo (macOS)' \ + -configuration Debug \ + -destination 'platform=macOS' \ + test \ + -only-testing:DuckDuckGo_Privacy_BrowserTests/TabNavigationTests/testCommandClickOpensBackgroundTab +``` + +## 🚫 CRITICAL: UI Testing Anti-Patterns - NEVER USE THESE + +#### ❌ Anti-Pattern #1: Thread.sleep and Arbitrary Delays +**NEVER USE:** +```swift +// ❌ FORBIDDEN: Thread.sleep() +Thread.sleep(forTimeInterval: 2.0) + +// ❌ FORBIDDEN: DispatchQueue.main.asyncAfter +DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { + // test logic +} + +// ❌ FORBIDDEN: Any fixed time delays +usleep(2000000) +``` + +**βœ… USE INSTEAD:** +```swift +// βœ… CORRECT: Use waitForExistence with appropriate timeout +XCTAssertTrue(element.waitForExistence(timeout: 10.0), "Element should appear") + +// βœ… CORRECT: Use waitForNonExistence for disappearing elements +XCTAssertTrue(element.waitForNonExistence(timeout: 5.0), "Element should disappear") + +// βœ… CORRECT: Use XCTNSPredicateExpectation for complex conditions +let webView = app.webViews.firstMatch +let pageLoaded = XCTNSPredicateExpectation( + predicate: NSPredicate(format: "exists == true"), + object: webView +) +let result = XCTWaiter.wait(for: [pageLoaded], timeout: 30.0) +``` + +#### ❌ Anti-Pattern #2: Validation Branching (if/else Logic) +**NEVER USE:** +```swift +// ❌ FORBIDDEN: if/else validation branching +if button.waitForExistence(timeout: 5.0) { + button.click() + // test logic +} else { + XCTAssertTrue(true, "Button not available") +} + +// ❌ FORBIDDEN: Combined conditional checks +if button.waitForExistence(timeout: 5.0) && button.isEnabled { + // test logic +} else { + XCTAssertTrue(true, "fallback message") +} +``` + +**βœ… USE INSTEAD:** +```swift +// βœ… CORRECT: Direct assertions that fail clearly +XCTAssertTrue(button.waitForExistence(timeout: 5.0), "Button should be available") +XCTAssertTrue(button.isEnabled, "Button should be enabled") +button.click() + +// βœ… CORRECT: Use XCTFail for impossible conditions +XCTAssertTrue(element.waitForExistence(timeout: 10.0), "Element should exist") +// If element doesn't exist, test fails clearly - no fallback needed +``` + +#### ❌ Anti-Pattern #3: Cop-Out Assertions +**NEVER USE:** +```swift +// ❌ FORBIDDEN: XCTAssertTrue(true) cop-outs +XCTAssertTrue(true, "Test completed - implementation may vary") + +// ❌ FORBIDDEN: print() instead of assertions +if condition { + // test logic +} else { + print("Feature not available in test environment") // ❌ NO! Use Logger.log() instead +} +``` + +**βœ… USE INSTEAD:** +```swift +// βœ… CORRECT: Meaningful assertions that can fail +XCTAssertEqual(actualCount, expectedCount, "Should have exact number of elements") + +// βœ… CORRECT: Use XCTFail for impossible conditions +if !element.waitForExistence(timeout: 10.0) { + XCTFail("Critical element should always be available") +} +``` + +#### ❌ Anti-Pattern #4: Generic WebView Existence Checks +**NEVER USE:** +```swift +// ❌ FORBIDDEN: Pointless webView.waitForExistence() +XCTAssertTrue(webView.waitForExistence(timeout: 30.0), "Page should load") +``` + +**βœ… USE INSTEAD:** +```swift +// βœ… CORRECT: Wait for specific content +let expectedContent = webView.staticTexts.containing(NSPredicate(format: "value CONTAINS 'expected text'")).firstMatch +XCTAssertTrue(expectedContent.waitForExistence(timeout: 30.0), "Page should show expected content") + +// βœ… CORRECT: Validate specific page elements +let pageTitle = webView.staticTexts.containing(NSPredicate(format: "value CONTAINS 'Welcome'")).firstMatch +XCTAssertTrue(pageTitle.waitForExistence(timeout: 15.0), "Welcome page should load") +``` + +#### ❌ Anti-Pattern #5: typeURL() Usage +**NEVER USE:** +```swift +// ❌ FORBIDDEN: typeURL() is unreliable +addressBarTextField.typeURL(url) +``` + +**βœ… USE INSTEAD:** +```swift +// βœ… CORRECT: Use pasteURL() with pressingEnter +addressBarTextField.pasteURL(url, pressingEnter: true) +``` + +#### ❌ Anti-Pattern #6: Manual Cmd+L for Address Bar Activation +**NEVER USE:** +```swift +// ❌ FORBIDDEN: Manual keyboard shortcuts +app.typeKey("l", modifierFlags: [.command]) +``` + +**βœ… USE INSTEAD:** +```swift +// βœ… CORRECT: Use dedicated helper method +app.activateAddressBar() +``` + +#### ❌ Anti-Pattern #7: Incorrect NSPredicate Usage +**NEVER USE:** +```swift +// ❌ FORBIDDEN: label CONTAINS in predicates +NSPredicate(format: "label CONTAINS 'search-text'") +``` + +**βœ… USE INSTEAD:** +```swift +// βœ… CORRECT: value CONTAINS for UI elements +NSPredicate(format: "value CONTAINS 'search-text'") +``` + +#### ❌ Anti-Pattern #8: Windows for Tab Counting +**NEVER USE:** +```swift +// ❌ FORBIDDEN: Using windows.count for tabs in tabbed browser +let tabCount = app.windows.count +``` + +**βœ… USE INSTEAD:** +```swift +// βœ… CORRECT: Use tabGroups for counting tabs +let tabCount = app.tabGroups.count +``` + +#### ❌ Anti-Pattern #9: Incorrect Modifier Clicks +**NEVER USE:** +```swift +// ❌ FORBIDDEN: These don't work or are unreliable +element.click(forDuration: 0.1, thenDragTo: element) +element.tap() +element.rightClick() // for modifier clicks +``` + +**βœ… USE INSTEAD:** +```swift +// βœ… CORRECT: Use perform(withKeyModifiers:) +element.perform(withKeyModifiers: [.option]) { + element.click() +} +``` + +### Privacy Button Access +```swift +// βœ… CORRECT: Use the proper accessibility identifier +let privacyButton = app.buttons.matching(identifier: "AddressBarButtonsViewController.privacyDashboardButton").firstMatch +``` + +### ⚠️ ENFORCEMENT: These Rules Are MANDATORY + +- **Every UI test MUST follow these patterns** +- **No exceptions for "quick fixes" or "temporary solutions"** +- **All existing tests MUST be refactored to follow these patterns** +- **Code reviews MUST check for these anti-patterns** + +### Common Anti-Patterns to Avoid + +#### ❌ Don't Use Generic Element Access +```swift +// ❌ BAD: Generic, fragile selectors +app.buttons.firstMatch +app.textFields["Search"] +app.menuItems.element(boundBy: 2) +``` + +#### ❌ Don't Create Redundant Element Variables +```swift +// ❌ BAD: Test-specific variables for common elements +func testSomething() { + let addressBar = app.textFields["AddressBarViewController.addressBarTextField"] + let backButton = app.buttons["NavigationBarViewController.backButton"] + // These should be in extensions +} +``` + +#### ❌ Don't Check State Before Waiting +```swift +// ❌ BAD: Race conditions +action() +XCTAssertEqual(app.tabs.count, 2) // Too early! +XCTAssertTrue(newElement.waitForExistence(...)) +``` + +#### ❌ Don't Use Text-Based Selectors for Stable Elements +```swift +// ❌ BAD: Fragile to localization/text changes +app.buttons["Download"] +app.menuItems["Open in New Tab"] + +// βœ… GOOD: Use accessibility IDs +app.buttons["DownloadsViewController.downloadButton"] +app.menuItems["ContextMenu.openInNewTab"] +``` + +#### ❌ Don't Test Bookmarks Without Clean State +```swift +// ❌ BAD: Testing without resetting bookmarks (may conflict with existing bookmarks) +func testBookmarkBehavior() { + openTestPage("Test Page") + app.mainMenuAddBookmarkMenuItem.click() + // Test may fail if bookmark already exists +} + +// ❌ BAD: Using generic menu item access for bookmarks +app.bookmarksMenu.click() +let bookmarkItem = app.menuItems["My Bookmark"] // May conflict with other menus + +// βœ… GOOD: Clean state and specific bookmark menu access +func testBookmarkBehavior() { + app.resetBookmarks() // Ensure clean state + openTestPage("Test Page") + app.mainMenuAddBookmarkMenuItem.click() + + app.bookmarksMenu.click() + let bookmarkItem = app.bookmarksMenu.menuItems["Test Page"] +} +``` + +## Running UI Tests +```bash +# Run iOS UI tests +xcodebuild test \ + -scheme "iOS Browser" \ + -workspace DuckDuckGo.xcworkspace \ + -destination "platform=iOS Simulator,name=iPhone 15 Pro" \ + -only-testing:UITests + +# Run macOS UI tests +xcodebuild test \ + -scheme "macOS UI Tests" \ + -configuration "Review" \ + -skipPackagePluginValidation \ + -skipMacroValidation \ + -allowProvisioningUpdates=NO \ + -only-testing:UI\ Tests +``` + +# Run specific macOS UI test case +xcodebuild test \ + -scheme "macOS UI Tests" \ + -configuration "Review" \ + -skipPackagePluginValidation \ + -skipMacroValidation \ + -allowProvisioningUpdates=NO \ + -only-testing:UI\ Tests/DownloadsUITests +``` + +## Best Practices Summary + +1. **Always read actual browser code** before writing tests +2. **Use accessibility IDs** for element access +3. **Wait for elements before checking counts** or states +4. **Validate both UI state and browser state** (webViews + tabs) +5. **Use existing extensions** and helper methods +6. **Test modifier key behaviors** comprehensively +7. **Handle multi-window scenarios** with proper window indexing +8. **Reset bookmarks with `app.resetBookmarks()`** before bookmark tests or in setUp for bookmark-focused test suites +9. **Use `app.bookmarksMenu.menuItems`** for accessing bookmark menu items +10. **Never run tests unless explicitly requested** +11. **Ask permission before modifying main app code** for accessibility IDs +12. **Use middle-click extension methods** properly + +--- + +**For questions about UI testing patterns or to request test execution, please reach out to the iOS/macOS team.** diff --git a/work/wiki/apple-browsers/user-defaults-storage.mdc b/work/wiki/apple-browsers/user-defaults-storage.mdc new file mode 100644 index 00000000..74ed862c --- /dev/null +++ b/work/wiki/apple-browsers/user-defaults-storage.mdc @@ -0,0 +1,217 @@ +--- +source: ~/DuckDuckGo/apple-browsers.git/main/.cursor/rules/user-defaults-storage.mdc +confidence: 0.9 +namespace: work +last_synced: 2026-04-28 +alwaysApply: false +--- + +# User Defaults Settings Storage and Reading + +## βœ… RECOMMENDED - KVO Pattern with KeyValueStore + +Use the KVO pattern with KeyValueStore for all new persistent settings: + +```swift +// βœ… CORRECT - KVO pattern with KeyValueStore +struct AppearancePreferencesUserDefaultsPersistor: AppearancePreferencesPersistor { + + enum Key: String { + case newTabPageIsOmnibarVisible = "new-tab-page.omnibar.is-visible" + case newTabPageIsProtectionsReportVisible = "new-tab-page.protections-report.is-visible" + case userPreferences = "user.preferences" + case lastUpdateCheck = "last.update.check" + } + + private let keyValueStore: KeyValueStoring + + init(keyValueStore: KeyValueStoring) { + self.keyValueStore = keyValueStore + } + + var isOmnibarVisible: Bool { + get { (try? keyValueStore.object(forKey: Key.newTabPageIsOmnibarVisible.rawValue) as? Bool) ?? true } + set { try? keyValueStore.set(newValue, forKey: Key.newTabPageIsOmnibarVisible.rawValue) } + } + + var isProtectionsReportVisible: Bool { + get { (try? keyValueStore.object(forKey: Key.newTabPageIsProtectionsReportVisible.rawValue) as? Bool) ?? false } + set { try? keyValueStore.set(newValue, forKey: Key.newTabPageIsProtectionsReportVisible.rawValue) } + } + + var userPreferences: [String: String] { + get { (try? keyValueStore.object(forKey: Key.userPreferences.rawValue) as? [String: String]) ?? [:] } + set { try? keyValueStore.set(newValue, forKey: Key.userPreferences.rawValue) } + } + + var lastUpdateCheck: Date { + get { (try? keyValueStore.object(forKey: Key.lastUpdateCheck.rawValue) as? Date) ?? Date.distantPast } + set { try? keyValueStore.set(newValue, forKey: Key.lastUpdateCheck.rawValue) } + } +} +``` + +## Key Guidelines for KVO Pattern + +1. **Use struct conforming to protocol** - Follow the persistor pattern +2. **Define keys as enum with String raw values** - Use kebab-case for key names +3. **Use KeyValueStoring protocol** - Not direct UserDefaults access +4. **Computed properties with get/set** - Handle storage operations in accessors +5. **Use try? for error handling** - KeyValueStore operations can throw +6. **Provide default values** - Use nil coalescing operator (??) for defaults +7. **Inject KeyValueStore in init** - Enable dependency injection and testing + +## Advanced Pattern for Optional Values + +```swift +// βœ… CORRECT - Optional values pattern +struct SettingsUserDefaultsPersistor: SettingsPersistor { + + enum Key: String { + case optionalUserName = "user.name" + case optionalTheme = "app.theme" + } + + private let keyValueStore: KeyValueStoring + + init(keyValueStore: KeyValueStoring) { + self.keyValueStore = keyValueStore + } + + var optionalUserName: String? { + get { try? keyValueStore.object(forKey: Key.optionalUserName.rawValue) as? String } + set { + if let value = newValue { + try? keyValueStore.set(value, forKey: Key.optionalUserName.rawValue) + } else { + try? keyValueStore.removeObject(forKey: Key.optionalUserName.rawValue) + } + } + } + + var selectedTheme: Theme? { + get { + guard let rawValue = try? keyValueStore.object(forKey: Key.optionalTheme.rawValue) as? String else { return nil } + return Theme(rawValue: rawValue) + } + set { + if let value = newValue { + try? keyValueStore.set(value.rawValue, forKey: Key.optionalTheme.rawValue) + } else { + try? keyValueStore.removeObject(forKey: Key.optionalTheme.rawValue) + } + } + } +} +``` + +## Platform-Specific Storage + +```swift +// βœ… CORRECT - Platform-specific KeyValueStore usage +struct PlatformSettingsUserDefaultsPersistor: PlatformSettingsPersistor { + + enum Key: String { + case platformSpecificSetting = "platform.specific.setting" + } + + private let keyValueStore: KeyValueStoring + + init(keyValueStore: KeyValueStoring) { + self.keyValueStore = keyValueStore + } + + var platformSpecificSetting: Bool { + get { + #if os(iOS) + return (try? keyValueStore.object(forKey: Key.platformSpecificSetting.rawValue) as? Bool) ?? false + #elseif os(macOS) + return (try? keyValueStore.object(forKey: Key.platformSpecificSetting.rawValue) as? Bool) ?? true + #endif + } + set { + try? keyValueStore.set(newValue, forKey: Key.platformSpecificSetting.rawValue) + } + } +} +``` + +## 🚫 DEPRECATED - @UserDefaultsWrapper Pattern + +The following pattern is deprecated and should not be used for new code: + +```swift +// ❌ DEPRECATED - Do not use @UserDefaultsWrapper for new code +extension AppUserDefaults { + @UserDefaultsWrapper(key: .newFeatureEnabled, defaultValue: false) + var newFeatureEnabled: Bool + + @UserDefaultsWrapper(key: .lastUpdateCheck, defaultValue: Date.distantPast) + var lastUpdateCheck: Date +} +``` + +## Migration from Property Wrappers + +When migrating from `@UserDefaultsWrapper` to the KVO pattern: + +1. **Create a new persistor struct** - Following the naming convention `*UserDefaultsPersistor` +2. **Define keys enum** - Convert string keys to enum cases +3. **Convert properties** - Transform @UserDefaultsWrapper properties to computed properties +4. **Update injection** - Pass KeyValueStore through dependency injection +5. **Preserve key names** - Ensure existing UserDefaults keys remain unchanged + +## Testing Pattern + +```swift +// βœ… CORRECT - Testing with mock KeyValueStore +class MockKeyValueStore: KeyValueStoring { + private var storage: [String: Any] = [:] + + func object(forKey key: String) throws -> Any? { + return storage[key] + } + + func set(_ value: Any, forKey key: String) throws { + storage[key] = value + } + + func removeObject(forKey key: String) throws { + storage.removeValue(forKey: key) + } +} + +// In tests +let mockStore = MockKeyValueStore() +let persistor = AppearancePreferencesUserDefaultsPersistor(keyValueStore: mockStore) +persistor.isOmnibarVisible = true +XCTAssertTrue(persistor.isOmnibarVisible) +``` + +## What NOT to Do + +```swift +// ❌ INCORRECT - Direct UserDefaults access +var newFeatureEnabled: Bool { + get { return UserDefaults.standard.bool(forKey: "newFeature") } + set { UserDefaults.standard.set(newValue, forKey: "newFeature") } +} + +// ❌ INCORRECT - Using @UserDefaultsWrapper for new code +@UserDefaultsWrapper(key: .newFeatureEnabled, defaultValue: false) +var newFeatureEnabled: Bool + +// ❌ INCORRECT - Not handling errors +var setting: Bool { + get { keyValueStore.object(forKey: "key") as? Bool ?? false } // Missing try? + set { keyValueStore.set(newValue, forKey: "key") } // Missing try? +} + +// ❌ INCORRECT - Not using enum for keys +var setting: Bool { + get { (try? keyValueStore.object(forKey: "hardcoded-key") as? Bool) ?? false } + set { try? keyValueStore.set(newValue, forKey: "hardcoded-key") } +} +``` + +The KVO pattern with KeyValueStore provides better testability, error handling, and dependency injection while maintaining type safety and consistency across the codebase. diff --git a/work/wiki/apple-browsers/webkit-browser.mdc b/work/wiki/apple-browsers/webkit-browser.mdc new file mode 100644 index 00000000..712bd766 --- /dev/null +++ b/work/wiki/apple-browsers/webkit-browser.mdc @@ -0,0 +1,387 @@ +--- +source: ~/DuckDuckGo/apple-browsers.git/main/.cursor/rules/webkit-browser.mdc +confidence: 0.9 +namespace: work +last_synced: 2026-04-28 +alwaysApply: false +--- + +# WebKit & Browser Development Guidelines + +## WebView Configuration + +### Basic WebView Setup +```swift +import WebKit + +class BrowserWebView { + private lazy var webView: WKWebView = { + let configuration = WKWebViewConfiguration() + + // Enable JavaScript + configuration.preferences.javaScriptEnabled = true + + // Set user agent + configuration.applicationNameForUserAgent = UserAgentManager.shared.userAgent + + // Configure content blockers + configuration.userContentController = makeUserContentController() + + // Enable developer extras in debug + #if DEBUG + configuration.preferences.setValue(true, forKey: "developerExtrasEnabled") + #endif + + let webView = WKWebView(frame: .zero, configuration: configuration) + webView.allowsBackForwardNavigationGestures = true + webView.allowsLinkPreview = true + + return webView + }() +} +``` + +### User Scripts Management +```swift +private func makeUserContentController() -> WKUserContentController { + let controller = WKUserContentController() + + // Add content blocking scripts + let contentBlockingScript = WKUserScript( + source: ContentBlockingUserScript.source, + injectionTime: .atDocumentStart, + forMainFrameOnly: false + ) + controller.addUserScript(contentBlockingScript) + + // Add message handlers + controller.add(self, name: "duckduckgo") + + return controller +} +``` + +## Tab Management + +### Tab Model +```swift +class Tab: NSObject { + let id = UUID() + private(set) var url: URL? + private(set) var title: String? + private(set) var favicon: UIImage? + + weak var webView: WKWebView? + weak var delegate: TabDelegate? + + private var observations: Set = [] + + init(url: URL? = nil) { + self.url = url + super.init() + setupWebView() + } + + private func setupWebView() { + let webView = WKWebView(frame: .zero, configuration: TabManager.shared.configuration) + self.webView = webView + + // Observe properties + observations.insert( + webView.observe(\.url) { [weak self] _, _ in + self?.urlDidChange() + } + ) + + observations.insert( + webView.observe(\.title) { [weak self] webView, _ in + self?.title = webView.title + self?.delegate?.tab(self!, didUpdateTitle: webView.title) + } + ) + + observations.insert( + webView.observe(\.estimatedProgress) { [weak self] webView, _ in + self?.delegate?.tab(self!, didUpdateProgress: webView.estimatedProgress) + } + ) + } +} +``` + +### Tab Lifecycle +```swift +extension Tab { + func load(url: URL) { + let request = URLRequest(url: url) + webView?.load(request) + } + + func reload() { + webView?.reload() + } + + func stop() { + webView?.stopLoading() + } + + func goBack() { + webView?.goBack() + } + + func goForward() { + webView?.goForward() + } + + func close() { + observations.forEach { $0.invalidate() } + observations.removeAll() + webView?.stopLoading() + webView?.removeFromSuperview() + webView = nil + } +} +``` + +## Navigation Handling + +### Navigation Delegate +```swift +extension BrowserViewController: WKNavigationDelegate { + func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction) async -> WKNavigationActionPolicy { + let url = navigationAction.request.url + + // Handle special URLs + if let url = url, URLSchemeHandler.shared.canHandle(url) { + URLSchemeHandler.shared.handle(url) + return .cancel + } + + // Apply content blocking + if contentBlocker.shouldBlock(url) { + return .cancel + } + + // Check for downloads + if shouldDownload(navigationAction) { + startDownload(from: navigationAction.request) + return .cancel + } + + return .allow + } + + func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) { + updateProgressBar(animated: true) + updateNavigationButtons() + } + + func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { + hideProgressBar() + captureHistory() + updateFavicon() + } + + func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) { + handleNavigationError(error) + } +} +``` + +## JavaScript Bridge + +### Message Handling +```swift +extension BrowserViewController: WKScriptMessageHandler { + func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) { + guard let dict = message.body as? [String: Any] else { return } + + switch message.name { + case "duckduckgo": + handleDuckDuckGoMessage(dict) + case "autofill": + handleAutofillMessage(dict) + case "tracker": + handleTrackerMessage(dict) + default: + break + } + } + + private func handleDuckDuckGoMessage(_ message: [String: Any]) { + guard let action = message["action"] as? String else { return } + + switch action { + case "openSettings": + presentSettings() + case "reportBrokenSite": + presentBrokenSiteReport() + default: + break + } + } +} +``` + +### JavaScript Injection +```swift +extension WKWebView { + func evaluateJavaScriptSafely(_ script: String) async throws -> Any? { + return try await withCheckedThrowingContinuation { continuation in + evaluateJavaScript(script) { result, error in + if let error = error { + continuation.resume(throwing: error) + } else { + continuation.resume(returning: result) + } + } + } + } + + func injectPrivacyProtection() async { + let script = """ + (function() { + // Override fingerprinting methods + const originalCanvas = HTMLCanvasElement.prototype.toDataURL; + HTMLCanvasElement.prototype.toDataURL = function() { + return ""; + }; + + // Block tracking pixels + const observer = new MutationObserver(function(mutations) { + mutations.forEach(function(mutation) { + mutation.addedNodes.forEach(function(node) { + if (node.tagName === 'IMG' && isTrackingPixel(node.src)) { + node.remove(); + } + }); + }); + }); + observer.observe(document.body, { childList: true, subtree: true }); + })(); + """ + + try? await evaluateJavaScriptSafely(script) + } +} +``` + +## Cookie Management + +### Cookie Handling +```swift +extension BrowserViewController { + func clearCookies(completion: @escaping () -> Void) { + let dataStore = WKWebsiteDataStore.default() + let dataTypes = Set([WKWebsiteDataTypeCookies]) + + dataStore.fetchDataRecords(ofTypes: dataTypes) { records in + let fireproofedDomains = FireproofingManager.shared.fireproofedDomains + + let recordsToDelete = records.filter { record in + !fireproofedDomains.contains(where: { record.displayName.contains($0) }) + } + + dataStore.removeData(ofTypes: dataTypes, for: recordsToDelete) { + completion() + } + } + } +} +``` + +## Download Management + +### Download Delegate +```swift +extension BrowserViewController: WKDownloadDelegate { + func download(_ download: WKDownload, decideDestinationUsing response: URLResponse, suggestedFilename: String) async -> URL? { + let documentsURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first! + let destinationURL = documentsURL.appendingPathComponent(suggestedFilename) + + // Check if file exists and generate unique name if needed + return FileManager.default.uniqueURL(for: destinationURL) + } + + func download(_ download: WKDownload, didFailWithError error: Error, resumeData: Data?) { + // Handle download failure + if let resumeData = resumeData { + // Store resume data for later + DownloadManager.shared.storeResumeData(resumeData, for: download) + } + } + + func downloadDidFinish(_ download: WKDownload) { + // Handle successful download + DownloadManager.shared.completeDownload(download) + } +} +``` + +## Performance Optimization + +### Memory Management +```swift +class TabManager { + private let maxInMemoryTabs = 5 + private var tabs: [Tab] = [] + + func optimizeMemory() { + let activeTabs = tabs.filter { $0.isActive } + let inactiveTabs = tabs.filter { !$0.isActive } + .sorted { $0.lastAccessDate < $1.lastAccessDate } + + // Suspend inactive tabs if we have too many in memory + if activeTabs.count + inactiveTabs.count > maxInMemoryTabs { + let tabsToSuspend = inactiveTabs.prefix(inactiveTabs.count - (maxInMemoryTabs - activeTabs.count)) + tabsToSuspend.forEach { $0.suspend() } + } + } +} + +extension Tab { + func suspend() { + // Take snapshot + webView?.takeSnapshot(with: nil) { [weak self] image, error in + self?.snapshot = image + self?.webView?.removeFromSuperview() + self?.webView = nil + } + } + + func resume() { + guard webView == nil else { return } + setupWebView() + if let url = url { + load(url: url) + } + } +} +``` + +## Security Considerations + +### Certificate Validation +```swift +extension BrowserViewController: WKNavigationDelegate { + func webView(_ webView: WKWebView, didReceive challenge: URLAuthenticationChallenge) async -> (URLSession.AuthChallengeDisposition, URLCredential?) { + guard challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust, + let serverTrust = challenge.protectionSpace.serverTrust else { + return (.performDefaultHandling, nil) + } + + // Perform certificate pinning for DuckDuckGo domains + if CertificatePinning.shared.shouldPin(host: challenge.protectionSpace.host) { + do { + try CertificatePinning.shared.validate(serverTrust, host: challenge.protectionSpace.host) + let credential = URLCredential(trust: serverTrust) + return (.useCredential, credential) + } catch { + return (.cancelAuthenticationChallenge, nil) + } + } + + return (.performDefaultHandling, nil) + } +} +```