385 lines
37 KiB
Markdown
385 lines
37 KiB
Markdown
# macOS Browser Modularization Roadmap
|
||
|
||
Status: **DRAFT** · 2026-10-06 · analysis of `alex/spm-browser-package` (6a2217fbf3)
|
||
Related: [[projects/vscode-migration/vscode-migration]] · [[projects/vscode-migration/findings]]
|
||
|
||
## TL;DR
|
||
|
||
- The macOS browser code is now one SwiftPM target, `DuckDuckGo_Privacy_Browser`: **1,406 Swift files, ~271k lines, 103 feature folders**. It builds from Xcode and VSCode. **We are here.**
|
||
- Inside that target almost everything depends on everything. 93 of the 103 folders form **one dependency cycle**. A module can't be split off until the cycle is broken. So the next work is mostly **removing a few hubs**, not moving files.
|
||
- Six hubs cause most of the coupling:
|
||
- the `AppDelegate` service locator: 273 files, ~45 services;
|
||
- `UserText`: 275 files;
|
||
- feature flags reached through the app delegate: ~200 files;
|
||
- `GeneralPixel`: 115 files;
|
||
- `Preferences`: it holds both the settings models and the Settings UI;
|
||
- `Tab` with `TabExtensions`: every feature's tab extension is registered in one place.
|
||
- Proposed order (detailed in [Part 4](#part-4-roadmap)):
|
||
1. Foundation modules.
|
||
2. Contracts and dependency injection, including the settings split.
|
||
3. Leaf features (can start right after the first foundation module).
|
||
4. Tab core.
|
||
5. Mid-tier features.
|
||
6. Shell.
|
||
|
||
Each step is a series of small PRs, and the app keeps building at every step.
|
||
- iOS has the same problem: 1,450 files and ~273k lines in its app target, almost all of it outside packages. About **15 areas** have near-duplicate logic on both platforms; several files are 90–99% identical. Sharing these now removes duplication and makes the macOS extractions cheaper. See [Part 5](#part-5-cross-platform-packages-ios--macos).
|
||
|
||
---
|
||
|
||
## Part 1: The road so far (VSCode migration)
|
||
|
||
| # | Step | Result | Status |
|
||
|---|---|---|---|
|
||
| 1 | Move non-Swift code (ObjC/C) to local packages | App target is Swift-only | ✅ Jun 2025 |
|
||
| 2 | Missing imports, unused files cleanup | Files compile outside the app target | ✅ Jun 2025 |
|
||
| 3 | Unify WebKit private methods; drop `#if SPARKLE/APPSTORE` | DMG and App Store targets compile the same files | ✅ Mar 2026 |
|
||
| 4 | Drop dead target flags (`SANDBOX_TEST_TOOL`, `ALPHA`, `NETWORK_EXTENSION` in shared files) | One set of compilation conditions | ✅ #6983 |
|
||
| 5 | Replace sandbox-test-tool with App Store UI tests | No extra target compiles app sources | ✅ #6980 |
|
||
| 6 | Drop storyboards (Feedback, PasswordManager, BookmarksBar, NavigationBar) | No IB module bindings to the app module | ✅ / last one in review |
|
||
| 7 | Asset resource initializers (`NSImage(resource:)`) instead of generated symbol extensions | Asset catalogs work from a package | ✅ committed |
|
||
| 8 | **Target file split**: stages A–X below | No app file is compiled into a helper target | 🔄 A+B in review, C ready, G done; the rest is shared by path |
|
||
| 9 | **Browser code as a SwiftPM package** (static lib for the app, dylib for VSCode; strings, assets, Core Data models and JSON in `Bundle.module`) | `swift build` + SourceKit-LSP; run and debug from VSCode | 🔄 **← we are here** (PR #7080, draft) |
|
||
|
||
### Target split stages (step 8)
|
||
|
||
The goal was a clean app directory: no file in `macOS/DuckDuckGo/` is compiled into the VPN agent, the PIR agent or the NetP extensions. Without that, the directory can't be one package target. We didn't move everything. Files that helpers still need stay **shared by path**: the helper targets compile them from the package folder.
|
||
|
||
| Stage | What | Where it goes | Status |
|
||
|---|---|---|---|
|
||
| A | `ApplicationBuildType`, `BuildFlags`, `DuckDuckGoUserAgent` | `AppKitExtensions` | ✅ in review (#7029) |
|
||
| B | DBP feature flagger, auth manager builder, settings environment, WebKit/Safari version providers | `DataBrokerProtection-macOS` | ✅ in review (#7029) |
|
||
| C | `NetworkProtectionPixelEvent` | `NetworkProtectionMac` → `VPNPixels` product | ✅ ready (`move-vpn-pixel-event`) |
|
||
| G | 10 helper-only sources | `DuckDuckGoVPN/`, `HelperTargetsShared/` | ✅ done |
|
||
| **— browser package split off here (we are here) —** | | | |
|
||
| D | Subscription pixels, environment, wide-event flag adapter, funnel origin | new shared subscription-support package | ⏸ later (still shared by path) |
|
||
| E1 | TipKit ×4, VPN IPC resources, VPN error recorder, VPN country labels, `VPNSettings+Environment` | `NetworkProtectionMac` | ⏸ later |
|
||
| E2 + H | Shared VPN strings, VPN location formatter; app icons, `macos-config.json`, VPN Lottie files, NetP string catalogs | `NetworkProtectionUI` resources | ⏸ after the per-module strings pattern (Phase 1) |
|
||
| F | `UserDefaultsWrapper`, `UserDefaults+NetworkProtectionShared` | replaced by `KeyedStoring` | ⏸ blocked on the KeyedStoring migration |
|
||
| X | `NETP_SYSTEM_EXTENSION` → runtime check | — | optional, needs VPN team |
|
||
|
||
D, E1, E2+H and F reappear below as part of Phase 1 and Phase 6. Once the strings and storage patterns exist, they are ordinary module extractions.
|
||
|
||
---
|
||
|
||
## Part 2: What the browser package looks like today
|
||
|
||
### Size
|
||
|
||
1,406 Swift files and ~271k lines in one module. The largest folders:
|
||
|
||
| Folder | Lines | Files | | Folder | Lines | Files |
|
||
|---|---:|---:|---|---|---:|---:|
|
||
| AIChat | 24.7k | 95 | | NetworkProtection (app side) | 8.6k | 54 |
|
||
| Tab (+ extensions, user scripts) | 22.2k | 87 | | Statistics | 7.1k | 34 |
|
||
| Common | 17.8k | 148 | | NewTabPage | 6.8k | 41 |
|
||
| NavigationBar | 16.4k | 37 | | Permissions | 6.6k | 32 |
|
||
| Bookmarks | 14.5k | 78 | | Fire | 6.4k | 17 |
|
||
| Preferences | 13.7k | 63 | | Application | 6.3k | 19 |
|
||
| DataImport | 11.8k | 58 | | Menus | 4.8k | 8 |
|
||
| TabBar | 10.1k | 29 | | FileDownload | 4.8k | 23 |
|
||
| SecureVault | 9.5k | 36 | | Onboarding | 4.5k | 31 |
|
||
|
||
Tests: `UnitTests` has 599 files, and 570 of them use `@testable import DuckDuckGo_Privacy_Browser`. `IntegrationTests` has 30 files, 28 of them with that import.
|
||
|
||
### Method
|
||
|
||
I took each top-level folder as a component, with second-level folders for `Common`, `Tab` and `NetworkProtection`. For each top-level type or function, I found the folder that declares it. Then I counted, for each folder, the files that reference symbols declared in another folder. Generic names (`Constants`, `Row`, `assert`, …) are excluded. The numbers are approximate. Extension members such as `UserText.foo` are attributed to the folder that declares the type.
|
||
|
||
### Finding 1: one big cycle
|
||
|
||
- 93 of 103 folders (~270k lines) are in **one strongly connected component**: every folder reaches every other one.
|
||
- Even with an ideal foundation layer moved out and the app shell (Application, MainWindow, Windows, Menus, StateRestoration) moved on top, **58 feature folders (~210k lines) still form a cycle**.
|
||
- The foundation folders themselves form a smaller cycle: Utilities ↔ Statistics ↔ VisualRefresh ↔ View ↔ Favicons ↔ Fireproofing ↔ Database ↔ FileSystem ↔ OSVersion.
|
||
|
||
So extracting a "big" feature now (Bookmarks, AIChat, Tab) means going through every hub first. The hubs come first.
|
||
|
||
### Finding 2: the hubs
|
||
|
||
| Hub | Where | Used by | What it is | Why it blocks |
|
||
|---|---|---|---|---|
|
||
| **App delegate service locator** | `Application/AppDelegate.swift` (2,810 lines) | 273 files | `Application.appDelegate.x` / `NSApp.delegateTyped.x` for ~45 services. Top: `windowControllersManager` 220, `featureFlagger` 103, `themeManager` 70, `subscriptionManager` 39, `webExtensionManager` 31, `privacyFeatures` 30, `faviconManager` 27 | Any file that uses it depends on the whole app |
|
||
| **Strings** | `Common/Localizables/UserText*.swift`, `Localizable.xcstrings` (1,980 keys) | 275 files | One `UserText` namespace for all features | A module needs its own strings |
|
||
| **Pixels** | `Statistics/GeneralPixel.swift` and friends | `GeneralPixel` in 115 files | One pixel enum for unrelated features; `Statistics` also refers back to `MainWindow`, `Menus`, `Permissions`, `Preferences`, `Onboarding` | Statistics can't go to the foundation layer |
|
||
| **Preferences** | `Preferences/Model` + `Preferences/View` | 54 folders depend on it, and it depends on 36 | Holds every feature's settings model (`AppearancePreferences` 37 files, `TabsPreferences` 16, `WebTrackingProtectionPreferences` 16, `AutofillPreferences` 15, …) **and** the Settings window that shows every feature's pane | Settings models must sit below features, the Settings UI above them |
|
||
| **Tab** | `Tab/Model`, `Tab/TabExtensions`, `TabBar/TabCollectionViewModel` | `Tab` 98 files, `TabCollectionViewModel` 48 | `TabExtensions.swift` registers all 30 feature tab extensions (AIChat, Autofill, Downloads, DuckPlayer, History, Privacy Dashboard, …) in one builder | Tab depends on every feature, and every feature depends on Tab |
|
||
| **Window management** | `Windows/WindowControllersManager*` | `WindowControllersManagerProtocol` in 58 files | Used to "open a tab", "show settings", "find the key window" | Features need a small navigation API, not the window manager |
|
||
| **Theme** | `VisualRefresh/Theme*` | 31 folders | `ThemeManaging`, `ThemeStyleProviding` | Fine as foundation, but depends on `AppearancePreferences` |
|
||
| **Promotions** | `Promotions/PromoTypes` + `Promotions/Promos` | 19 folders | Promo framework (types) + registration of every feature's promo | The types are foundation; the registration belongs in the shell |
|
||
| **Common/Extensions** | 71 files | 9 folders | Mostly generic, but some refer to `AppDelegate`, `Bookmarks`, `Preferences`, `MainViewController` | Has to be cleaned before it can go to the foundation layer |
|
||
| **Custom `assert`** | `Common/Utilities/Assertions.swift` | 178 + 68 files | Module-wide replacement of `assert`/`assertionFailure` for tests | Every extracted module needs it, so it goes in a foundation module |
|
||
|
||
### Finding 3: some features are already loose
|
||
|
||
Features ranked by how many symbols they use from other features or the shell (ignoring the foundation candidates). These can be extracted almost right away:
|
||
|
||
| Feature | Lines | Uses from other features / shell |
|
||
|---|---:|---|
|
||
| CrashReports, Geolocation, Sharing, NetP ExcludedApps | 0.4–0.5k each | nothing |
|
||
| AttributedMetric, DeviceAuthentication, FindInPage, UnifiedFeedbackForm, UserNotifications, Waitlist | 0.4–1.7k | 1 symbol (e.g. `Application`, `AutofillPreferences`, `MainWindow`) |
|
||
| AppHealth | 3.2k | `WindowControllersManager` only |
|
||
| MaliciousSiteProtection, TabPreview, JSAlert, PasswordManager, DBP, Updates | 0.3–2k | 3–5 symbols |
|
||
| AdBlocking, Suggestions, YoutubePlayer (Duck Player), Freemium, Autoconsent | 1–2.5k | 6–9 symbols |
|
||
|
||
At the other end, these need 40 or more symbols from other folders and come last:
|
||
- NavigationBar: 115;
|
||
- Tab/TabExtensions: 83;
|
||
- Preferences: 72;
|
||
- NewTabPage: 71;
|
||
- Tab/Model and Tab/View: 64 each;
|
||
- TabBar: 47;
|
||
- AIChat: 41;
|
||
- Tab/UserScripts: 40.
|
||
|
||
### Finding 4: some of this is already done in local packages
|
||
|
||
Some features are already half-extracted: most of the logic and UI live in a local package, and the app keeps the glue.
|
||
|
||
| Feature | Package | Left in the app |
|
||
|---|---|---|
|
||
| History | `HistoryView` | `History/` |
|
||
| New Tab Page | `NewTabPage` | `NewTabPage/`, `HomePage/` |
|
||
| Updates | `AppUpdater` | `Updates/` |
|
||
| Crash reports | `CrashReporting` | `CrashReports/` |
|
||
| VPN | `NetworkProtectionMac` | `NetworkProtection/AppTargets` |
|
||
| PIR | `DataBrokerProtection-macOS` | `DBP/` |
|
||
| Freemium | `Freemium` | `Freemium/` |
|
||
| VPN waitlist | `Waitlist-macOS` | `Waitlist/` |
|
||
| Sync | `SyncUI-macOS` | `Sync/` |
|
||
| Subscription | `SubscriptionUI` | `Subscription/` |
|
||
| Settings | `PreferencesUI-macOS` | `Preferences/` |
|
||
|
||
That's the existing pattern: the package holds the logic and UI, and the app holds the glue. Merging the glue in is the cheapest kind of extraction.
|
||
|
||
---
|
||
|
||
## Part 3: Target architecture
|
||
|
||
```
|
||
┌──────────────────────────────────────────────────────────────┐
|
||
│ App shell (DuckDuckGoApp + DuckDuckGoBrowserShell) │ composition root: AppDelegate builds
|
||
│ Application, MainWindow, Windows, Menus, StateRestoration, │ services and passes them to features;
|
||
│ NavigationBar & TabBar composition, Settings window, promo │ TabExtensionsBuilder; promo registry;
|
||
│ registration │ menus
|
||
├──────────────────────────────────────────────────────────────┤
|
||
│ Feature modules (one per feature; UI + logic, macOS only) │ depend only on Contracts, Foundation,
|
||
│ Bookmarks, History, Downloads, Permissions, Fire, Autofill, │ and shared packages; never on each
|
||
│ Sync, DuckPlayer, AIChat, VPN app UI, Onboarding, … │ other
|
||
├──────────────────────────────────────────────────────────────┤
|
||
│ BrowserContracts (macOS) │ protocols & value types features use
|
||
│ TabOpening / BrowserNavigating, TabContent, BurnerMode, │ to talk to each other and to the shell
|
||
│ PreferencePaneIdentifier, Promo types, settings models' │
|
||
│ protocols, TabExtension protocol │
|
||
├──────────────────────────────────────────────────────────────┤
|
||
│ Browser foundation (macOS) │ no knowledge of features
|
||
│ BrowserFoundation (Utilities, Extensions, FileSystem, │
|
||
│ Database, OSVersion, Assertions, KeyedStoring adapters), │
|
||
│ BrowserDesignSystem (Common/View, Theme, shared assets), │
|
||
│ BrowserStatistics (ATB, pixel infrastructure) │
|
||
├──────────────────────────────────────────────────────────────┤
|
||
│ Shared packages (iOS + macOS): BrowserServicesKit, Common, │
|
||
│ PixelKit, Persistence, Networking, AIChat, VPN, … │
|
||
└──────────────────────────────────────────────────────────────┘
|
||
```
|
||
|
||
Rules:
|
||
1. **Features never import other features.** When they must talk, a protocol in `BrowserContracts` defines the call (e.g. `BookmarkStoring` for Suggestions) and the shell passes in the implementation.
|
||
2. **No service locator below the shell.** Dependencies arrive through initializers. `Application.appDelegate` is visible only in the shell module.
|
||
3. **Each module owns its resources:** string catalog (the `#bundle` `NSLocalizedString` shim makes this automatic), assets, Core Data model, JS/JSON.
|
||
4. **Each module owns its tests**, as a package test target that runs with `swift test` and from VSCode.
|
||
5. **Start with targets in the same `Package.swift`.** New targets in `macOS/DuckDuckGo/Package.swift` use `path: "Bookmarks"` and so on, and the main target adds that folder to `exclude`. The files don't move and the git history stays intact. Splitting into separate packages, or moving to `SharedPackages`, can come later.
|
||
6. **Measure build time.** More modules don't automatically mean a faster clean build: each module adds scan, emit-module and compile tasks. The gains are incremental-build scope, parallel compilation of independent modules, SwiftUI previews, ownership and sharing. Run a benchmark before and after the first two or three extractions.
|
||
|
||
---
|
||
|
||
## Part 4: Roadmap
|
||
|
||
Each phase is a set of small PRs, and the app builds and the tests pass after each one. Phases 1, 2 and 4 mostly break dependencies; files move into new targets once a folder is clean. Phase 3 (leaf features) and the "share now" items from Part 5 can run in parallel with Phases 1–2.
|
||
|
||
### Phase 1: Foundation modules (break the lowest cycle)
|
||
|
||
| Step | Module | Contents | Work needed | Size |
|
||
|---|---|---|---|---|
|
||
| 1.1 | `BrowserFoundation` | `Common/Utilities` (incl. `Assertions`, `PublishedAfter`, `UserDefaultsKeys`), `Common/Extensions`, `Common/FileSystem`, `Common/Database`, `Common/OSVersion`, `InternalUserDecider`, `UserAgent` | Move ~12 feature-aware extensions out of `Common/Extensions` to their owners (the ones that use `AppDelegate`, `MainViewController`, `BookmarkPasteboardWriter`, `DataClearingPreferences`, `DuckURLSchemeHandler`, …). Make `assert`/`assertionFailure` `public` and import it in every module | ~9k lines, M |
|
||
| 1.2 | Storage: `KeyedStoring` instead of `UserDefaultsWrapper` | 53 files use `UserDefaultsWrapper` | This is stage **F**. Until it's done, `UserDefaultsWrapper` lives in `BrowserFoundation` | ongoing, per feature |
|
||
| 1.3 | `BrowserDesignSystem` | `Common/View` (AppKit/SwiftUI/Lottie views), `VisualRefresh` (theme), `MessageViews`, `InfoViews`, shared colors and icons from `Assets.xcassets` | Move `ThemeAppearance` out of `Preferences` and put the theme API behind `ThemeManaging`. Split `Assets.xcassets`: shared design tokens go here, feature icons go to features (572 image sets, 230 color sets) | ~7k lines, M |
|
||
| 1.4 | Strings | `UserText` (1,980 keys) | Set the pattern: each module has its own `Localizable.xcstrings` and a `UserText` extension. A script moves keys to the module that uses them; common ones (OK, Cancel, …) go to `BrowserDesignSystem`. Check `loc_export.sh` and the translation import with several catalogs. This unblocks **E2 + H** | tooling S, then per feature |
|
||
| 1.5 | `BrowserStatistics` | `Statistics/ATB`, `StatisticsLoader`, `LocalStatisticsStore`, pixel helpers | Split `GeneralPixel` per feature; per-feature pixel enums already exist for History, NTP, Settings, Permissions and others. Remove the back references to `MainWindow`, `Menus`, `Permissions`, `Preferences`, `Onboarding` | ~7k lines, M |
|
||
| 1.6 | Favicons, Fireproofing, UnprotectedDomains | Data services that many features need | `Favicons` → `BookmarkManager` goes through a protocol. Each keeps its Core Data model | ~4k lines, S |
|
||
| 1.7 | Database per module | `Common/Database/Database.swift` builds **one encrypted store** from every model in `Bundle.module` (7 app models + `HTTPSUpgrade`) and registers the shared value transformers | Make `Database` take the list of models from its callers, so each module can bring its own `.xcdatamodeld` in its own bundle. Keep one store file, so no data migration is needed. The encryption transformers go to `BrowserFoundation`. This is a prerequisite for moving any feature that owns a model: Bookmarks (legacy), History, Favicons, Fireproofing, Permissions, Downloads, pixel state, unprotected domains | S–M |
|
||
|
||
Exit criterion: the foundation modules have no cycle and import no feature code.
|
||
|
||
### Phase 2: Contracts and dependency injection (break the hubs)
|
||
|
||
| Step | What | How |
|
||
|---|---|---|
|
||
| 2.1 | `BrowserContracts` module | Value types and protocols that features share today: `TabContent`, `BurnerMode`, `LinkOpenBehavior`, `NewWindowPolicy`, `PreferencePaneIdentifier`, `Promotions/PromoTypes` (already a separate folder), `TabIdentifier`, `SubscriptionFunnelOrigin`, the `TabExtension` protocol |
|
||
| 2.2 | Narrow navigation API | Replace `WindowControllersManagerProtocol` in features (58 files) with small protocols: `TabOpening` (open URL / tab content in the current or a new window), `SettingsPresenting`, `KeyWindowProviding`. The shell implements them on top of `WindowControllersManager` |
|
||
| 2.3 | Remove the service locator, feature by feature | For each feature, constructor-inject what it reads from `Application.appDelegate`: `featureFlagger` (103 uses), `themeManager` (70), `subscriptionManager` (39), `privacyFeatures`, `faviconManager`, … Track the count: 273 files → 0 outside the shell. Most of the work is per feature, so it happens in Phases 4–6 |
|
||
| 2.4 | Settings split | Move each settings model from `Preferences/Model` into its feature or contract (`AppearancePreferences` → design system/theme; `TabsPreferences` → tab core; `DownloadsPreferences` → Downloads; `AutofillPreferences` → Autofill; `DuckPlayerPreferences` → Duck Player; `WebTrackingProtectionPreferences`, `CookiePopupProtectionPreferences` → Privacy Protections; …). The Settings window (`Preferences/View` + `PreferencesUI-macOS`) stays at the top. Each feature provides its pane through a `SettingsPaneProviding` protocol and the shell assembles the sidebar |
|
||
| 2.5 | Promotions split | `PromoTypes` + `PromoService` → contracts/foundation. `Promotions/Promos/PromoServiceFactory+*.swift` (19 files, one per feature) stays in the shell |
|
||
|
||
Exit criterion: no feature folder refers to `Application`, `AppDelegate`, `MainWindowController` or `WindowControllersManager`. Check it with a lint rule or a dependency script in CI.
|
||
|
||
### Phase 3: Leaf feature modules (can start right after 1.1)
|
||
|
||
These can be extracted with almost no refactoring. Use them to test the pattern (package target, strings, tests, VSCode).
|
||
|
||
| Order | Module | Merge into | Notes |
|
||
|---|---|---|---|
|
||
| 1 | CrashReports | `CrashReporting` package | 0 cross-feature deps |
|
||
| 2 | Geolocation, Sharing, FindInPage, DeviceAuthentication, UserNotifications | new targets | 0–1 deps each |
|
||
| 3 | AppHealth | new target | Needs only `KeyWindowProviding` (2.2) |
|
||
| 4 | UnifiedFeedbackForm, Feedback (the core, not `QuitSurvey`) | new `Feedback` target | Feedback needs `Tab`/`Fire` → contracts |
|
||
| 5 | Updates | `AppUpdater` | Promo types from 2.5 |
|
||
| 6 | Waitlist, VPN ExcludedApps/ExcludedDomains | `Waitlist-macOS`, `NetworkProtectionMac` | |
|
||
| 7 | MaliciousSiteProtection, AttributedMetric | new targets / `AttributedMetric` shared package | |
|
||
| 8 | DBP, Freemium | `DataBrokerProtection-macOS`, `Freemium` | Freemium ↔ DBP dependency goes through a protocol |
|
||
| 9 | PasswordManager (Bitwarden) | `BWIntegration` | Needs `AutofillPreferences` from 2.4 |
|
||
| 10 | Suggestions | new target | Needs `BookmarkStoring` and `HistoryProviding` contracts |
|
||
| 11 | Duck Player (`YoutubePlayer`) + AdBlocking | new target | Settings model from 2.4 |
|
||
|
||
### Phase 4: Tab core
|
||
|
||
This is the biggest single refactor, because the tab is where all features meet.
|
||
|
||
| Step | What |
|
||
|---|---|
|
||
| 4.1 | `BrowserTabCore` module: `Tab`, `TabContent`, `TabCollection`, `TabCollectionViewModel`, `TabViewModel`, lazy loading, `TabExtension` registry, navigation responders, base user scripts. `Tab.swift` is 1,686 lines |
|
||
| 4.2 | Invert the tab extensions: each of the 30 `*TabExtension.swift` files moves into its feature module (`HistoryTabExtension` → History, `DownloadsTabExtension` → Downloads, `AIChatTabExtension` → AIChat, …). `TabExtensionsBuilder.registerExtensions` stays in the shell and registers each feature's extension factory. The typed accessors (`tab.extensions.history`) become extensions declared in each feature |
|
||
| 4.3 | Split `Tab/UserScripts` the same way: each feature provides its user scripts, and `UserScripts` builds the list from the registry |
|
||
| 4.4 | `Tab/View` (`BrowserTabViewController`, `WebViewContainerView`) stays in the shell or a `BrowserTabUI` module. Content pages (NTP, Settings, Bookmarks manager, History view, DBP, Duck.ai) are provided through a `TabContentViewProviding` registry, not a switch over every feature |
|
||
|
||
### Phase 5: Mid-tier feature modules
|
||
|
||
After Phases 1–4, these depend only on contracts and the foundation. Suggested order: least coupled first, and features with an iOS counterpart early (see Part 5).
|
||
|
||
| Module | Folders | Merge into / new | Main blockers removed earlier |
|
||
|---|---|---|---|
|
||
| Permissions | `Permissions`, `Geolocation` | new | Settings models (2.4), `UserDialogRequest` → contracts |
|
||
| Downloads | `FileDownload` | new | `FireWindowSessionRef`, `MainMenu` → contracts; termination via `ApplicationTerminationDecider` protocol |
|
||
| History | `History`, `RecentlyClosed` model | `HistoryView` | `FireProtocol`, `TabCollection` → contracts |
|
||
| Bookmarks | `Bookmarks`, `BookmarksBar` | new (14.5k + 3.8k lines) | Tab/TabBar types, `DataImport` types → contracts |
|
||
| Fire (data clearing) | `Fire`, `Fireproofing` UI, `Tab/Services` | new | Needs History, Tab core and AIChat history cleaning behind protocols |
|
||
| Privacy protections | `ContentBlocker`, `Autoconsent`, `PrivacyDashboard`, `SmarterEncryption`, `UnprotectedDomains`, `PrivacyStats`, `BrokenSitePrompt` | new | `Onboarding` coupling of `ContentBlocker` goes through a protocol |
|
||
| Autofill & passwords | `SecureVault`, `Autofill`, `PasswordManager`, `DeviceAuthentication` | new + `BWIntegration` | `PinningManager`, `PopoverPresenter`, Sync promo → contracts |
|
||
| Data import | `DataImport`, `DataExport` | new | `SecureVault`, `Bookmarks`, Sync promo → contracts |
|
||
| Sync (app side) | `Sync` | `SyncUI-macOS` | Bookmarks/SecureVault adapters stay in the shell |
|
||
| Subscription (app side) | `Subscription`, subscription user scripts, stage **D** | `SubscriptionUI` / new `SubscriptionSupport` | Stage D package is shared with the helpers |
|
||
| VPN (app side) | `NetworkProtection/AppTargets`, stages **E1/E2/H** | `NetworkProtectionMac` | Navigation, pinning and `Waitlist` → contracts |
|
||
| Onboarding, Default browser & dock prompts, Remote messaging, Promotions UI | | `Onboarding` shared package / new | Promo types (2.5) |
|
||
| New Tab Page | `NewTabPage`, `HomePage` | `NewTabPage` | Each feature provides its NTP section through a protocol (AIChat, Subscription, Next Steps, Remote messages) |
|
||
| Web extensions, Dark Reader | | `WebExtensions` shared package / new | Tab core |
|
||
|
||
### Phase 6: Top layer
|
||
|
||
| Module | Notes |
|
||
|---|---|
|
||
| AIChat (Duck.ai): 24.7k lines | The largest feature, with 41 symbols from Tab, TabBar, Windows and NavigationBar. The logic and the omnibar/sidebar controllers go to a macOS module next to the shared `AIChat` package. Integration with the tab, sidebar and address bar goes through contracts. `PromptBar` comes along |
|
||
| NavigationBar (address bar): 16.4k lines | Uses 115 symbols from other folders: it shows buttons for every feature. Split it into `AddressBar` (text field, suggestions binding, editing) and the shell part that builds the button set from feature-provided `NavigationBarButtonProviding` items. `PinningManager` (38 files) → contracts |
|
||
| TabBar, PinnedTabs, TabPreview | Into `BrowserTabUI`, after the tab core |
|
||
| Shell | `Application`, `MainWindow`, `Windows`, `Menus`, `StateRestoration`, `Automation`, `LogExporter`, the Settings window, promo registration, tab extension registration. This is what's left of `DuckDuckGo_Privacy_Browser`, target ≈ 20–30k lines |
|
||
|
||
### Tracking progress
|
||
|
||
Add these checks to a script and run it in CI:
|
||
|
||
| Metric | Today | Target |
|
||
|---|---:|---:|
|
||
| Lines in `DuckDuckGo_Privacy_Browser` | 271k | ≤ 30k (shell) |
|
||
| Folders in the biggest dependency cycle | 93 / 103 | 0 |
|
||
| Files using `Application.appDelegate` / `NSApp.delegateTyped` | 273 | shell only |
|
||
| Files using `WindowControllersManager(Protocol)` | 78 | shell only |
|
||
| Unit test files with `@testable import DuckDuckGo_Privacy_Browser` | 570 | shell tests only |
|
||
| Keys in the app's `Localizable.xcstrings` | 1,980 | shell strings only |
|
||
|
||
---
|
||
|
||
## Part 5: Cross-platform packages (iOS + macOS)
|
||
|
||
### Where iOS stands
|
||
|
||
- `iOS/DuckDuckGo` has **1,450 Swift files and ~273k lines** in the app target, 294 of them flat in the root folder. Another 62 files (~11k lines) are in `iOS/Core`, which is an Xcode framework, not SwiftPM.
|
||
- The 10 iOS local packages are small (~21k lines in total) and declared iOS-only. Two of them already have a clean Core/UI split: `SitePermissions` (5.1k) and `SetDefaultBrowser` (2.4k).
|
||
- So both apps are in the same place. A shared package removes code from **two** monoliths at once.
|
||
|
||
### What is already shared
|
||
|
||
- **BrowserServicesKit** products, used by both apps:
|
||
- Bookmarks (+ `BookmarksModel`), History (+ `BrowsingHistory`), Suggestions;
|
||
- Subscription, DDGSync / SyncDataProviders, RemoteMessaging;
|
||
- PrivacyConfig, PrivacyDashboard, ContentBlocking, MaliciousSiteProtection;
|
||
- Autofill/SecureVault, DataImport (CSV, cards), DuckPlayer (0.3k), Statistics protocols, Configuration, UserScript.
|
||
- **Standalone shared packages:** AIChat, Onboarding, WebExtensions, VPN, DataBrokerProtectionCore, EventHub, UIComponents, AttributedMetric, PixelKit, Persistence (incl. `KeyedStoring`), Networking, Common, WideEvent, SERPSettings.
|
||
- **The pattern to follow:** `SharedPackages/AIChat` keeps `Shared/` (91 files), `iOS/` (16) and `macOS/` (2) in one package. `WebExtensions` uses `+iOS` / `+macOS` adapters.
|
||
|
||
### What is duplicated
|
||
|
||
Similarity = 1 − (differing lines / total lines of both files), with license headers stripped and whitespace ignored. Spot-checked: `TipKitController` differs by 1 line, `UnifiedMetadataCollector` by 3, `SyncMetricsEventsHandler` by 6, `ImportArchiveReader` by 14 of 138.
|
||
|
||
| Area | Near-duplicate files on both platforms | Destination | Verdict |
|
||
|---|---|---|---|
|
||
| **Sync app integration** | `SyncDataProviders` 70%, `SyncErrorHandler` 57%, `SyncBookmarksAdapter` 67%, `SyncCredentialsAdapter` 77%, `SyncCreditCardsAdapter` 83%, `SyncSettingsAdapter` 81%, `SyncMetadataDatabase` 72%, `SyncMetricsEventsHandler` 92%, cleanup error handlers 90–92%, `UnifiedDeviceListPixelHandler` 92%, `AIChatDeleteOperation` 95%, `FavoritesDisplayModeSyncHandler` 76%, `SyncPromoViewModel` 76% | new shared `SyncAppIntegration` package (or a BSK product) | **Share now.** ~3k duplicated lines; the biggest win |
|
||
| **Content blocking providers + ad blocking** | `ContentBlockerRulesLists` 81%, `AppTrackerDataSetProvider` 82%, `AppPrivacyConfigurationDataProvider` 75%, `AdBlockingAvailability` 92%, `AdBlockingNavigationHandler` 81% | BSK `ContentBlocking` / `PrivacyConfig` | **Share now.** The embedded config file is a parameter. The `ContentBlocking` composition root is shared after refactor |
|
||
| **Autoconsent (cookie popups)** | `AutoconsentUserScript` (779 / 746 lines, 80%), `AutoconsentPixels` 81%, `AutoconsentManagement` 37% | `WebExtensions/Autoconsent` or a BSK product | **Share now.** Settings and feature flags go behind protocols |
|
||
| **Feedback** | `UnifiedMetadataCollector` 97%, `UnifiedFeedbackFormViewModel` 72%, `FeedbackCategoryProviding` 72%, `UnifiedFeedbackSender` 47%, VPN/DBP metadata collectors 61–65% | new shared `Feedback` package (model + sender), UI per platform | **Share now** |
|
||
| **TipKit** | `TipKitController` 99% + 4 files at 89–99% | `UIComponents` or a new `TipKitSupport` | **Share now.** Also resolves part of stage E1 |
|
||
| **Data import** | `ImportArchiveReader` 94% (copy-paste), `AutofillLoginImportState` 60%; bookmark HTML parsing implemented twice (XMLParser on macOS, SwiftSoup on iOS) | BSK `DataImport` | **Share now** for the archive reader, HTML parser and import state. Browser-profile readers stay on macOS |
|
||
| **Duck.ai** | `DuckAiUsageWarningPixel` 90%, `AIChatSuggestionsReader` 84%, `AIChatFeatureFlagProvider` 83%, `PageContextExtractionPixelHandler` 80%, `PageContextUserScript` 63%, `DuckAiUsageLimitsStore` 51%; user-script handlers 35% | `AIChat/Shared` | **Share now** for stores, pixels and the suggestions reader. The user-script handler is shared after refactor |
|
||
| **Data clearing telemetry** | `DataClearingWideEventService` 64%, `DataClearingPixelsReporter` 50% | BSK `DataClearing` | **Share now.** The Fire engine is shared after refactor; iOS's worker-based `FireExecutor` is the better base than macOS's 1,324-line `Fire.swift` |
|
||
| **Remote messaging glue** | `DefaultRemoteMessagingSurfacesProvider` 92%, `OSUpgradeCapabilityOverride` 95%, store error handling 75%, matcher provider 55% | BSK `RemoteMessaging` | **Share now** (small) |
|
||
| **Small managers** | `AutofillNeverPromptWebsitesManager` 91%, `EmailManagerRequestDelegate` 76%, `SecureVaultReporter` 67%, `PrivacyStatsDatabase` 53%, `CrashReportSenderExtensions` 83%, `UserScriptError+Pixel` 87% | the matching BSK products | **Share now** (tiny PRs) |
|
||
| **Subscription web flows** | `SubscriptionPagesUserScript` 81%, `SubscriptionPagesUseSubscriptionFeature` (~1k lines each side, 40%), feature-flag adapters 83–95%, `SubscriptionPixelHandler` 76%, `FreeTrialPixelHandler` 85%, `SubscriptionEnvironment+Default` 80%, `VPNSettings+Environment` 92% | BSK `Subscription` with a `SubscriptionUIHandling` protocol | **Share after refactor.** The small adapters can be shared now. They are also stage **D**, so a shared home solves D for the helpers too |
|
||
| **Statistics / ATB** | `StatisticsLoader` 52%, `AtbParser` 85%, `AtbAndVariantCleanup` 86%, `StatisticsStore` 68%, `VariantManager` ~53% | BSK `Statistics` | **Share after refactor.** Storage goes through `KeyedStoring`. Pixel enums stay per platform |
|
||
| **Bookmarks** | DB setup, `BookmarksCleanupErrorHandling` 93%, `BookmarksExporter` 68%, legacy migration 52%. macOS has its own `LocalBookmarkStore` (1,314 lines) + `BookmarkManager` (841) on top of the shared model; iOS uses the BSK view models directly | BSK `Bookmarks` | **Share after refactor.** Move the store logic down, keep the tree/outline/pasteboard UI on macOS. `Bookmark` must stop holding `NSImage` and calling `faviconManager` |
|
||
| **History** | macOS `EncryptedHistoryStore` (463 lines) re-implements BSK `HistoryStore` (428) on its own encrypted `History.xcdatamodeld`; the schemas match except for encryption and entity names | BSK `History` with a pluggable encryption transformer | **Share after refactor.** Needs a macOS data migration |
|
||
| **Permissions** | Same domain, different designs: macOS `PermissionManager` + Core Data store (7 permission types); iOS `SitePermissions` package (KeyedStoring, 3 types) | make `SitePermissions` multi-platform, add the macOS types | **Share after refactor.** Needs a migration of macOS data |
|
||
| **Fireproofing** | Same API (`isAllowed(cookieDomain:)`, eTLD+1 migration); macOS stores in Core Data, iOS in UserDefaults | new shared `Fireproofing` with injected storage | **Share after refactor** (small, low risk) |
|
||
| **Duck Player** | `YoutubeOverlayUserScript` 53%, `YoutubePlayerUserScript` 51%; macOS code has no AppKit | BSK `DuckPlayer` | **Share after refactor** |
|
||
| **Default browser prompts** | prompt type deciders 45–60%, `UserActivity` model ~80% | make `SetDefaultBrowserCore` multi-platform, add the macOS dock prompt | **Share after refactor** |
|
||
| **Configuration, MaliciousSiteProtection** | `ConfigurationManager` 55%, `AppConfigurationURLProvider` 67%; MSP manager 28% | BSK `Configuration` / `MaliciousSiteProtection` | **Share after refactor** (small) |
|
||
|
||
**Keep platform-specific:**
|
||
- Tab model: ~10% similar, different designs.
|
||
- Downloads: Core Data + `WebKitDownloadTask` on macOS, filesystem + QuickLook on iOS.
|
||
- Favicons: Core Data on macOS, Kingfisher on iOS.
|
||
- New Tab Page: web NTP on macOS, native on iOS.
|
||
- VPN app glue: system extension, IPC, login items.
|
||
- Freemium and Updates/Sparkle: macOS only.
|
||
- Feature flag enums: 175 macOS cases vs 189 iOS cases, 58 names on both. Share a provider protocol, not the enum.
|
||
- `UserDefaultsWrapper` (each app has its own): migrate both to `KeyedStoring`; don't share it.
|
||
|
||
### How sharing fits the macOS roadmap
|
||
|
||
- **Do the "share now" items before or during Phase 3.** They are small PRs, mostly into existing BSK products, and they shrink both monoliths. Each one also removes a macOS file that would otherwise need a new home later.
|
||
- **"Share after refactor" items replace a Phase 5 extraction.** When Bookmarks, History, Permissions or Statistics reach Phase 5, the logic goes into the shared package instead of a new macOS module. The macOS module keeps only UI and glue, which is the pattern `SyncUI-macOS`, `HistoryView` and `NewTabPage` already follow.
|
||
- **Shared prerequisites, needed on both platforms:**
|
||
- `KeyedStoring` instead of `UserDefaultsWrapper`;
|
||
- settings and feature-flag access through protocols, not app singletons;
|
||
- per-module string catalogs;
|
||
- no `NSImage`/`UIImage` in model types;
|
||
- on macOS, the per-module database (step 1.7).
|
||
- **Package layout:** one package per domain with `Shared/`, `macOS/` and `iOS/` folders (the AIChat pattern), or BSK products for model and logic only. UI never goes into `Shared/`.
|
||
|
||
### Suggested first cross-platform PRs (each small and independent)
|
||
|
||
1. TipKit → shared (5 files, ~99% identical).
|
||
2. `UnifiedMetadataCollector` + unified feedback model and sender → shared `Feedback`.
|
||
3. `ImportArchiveReader` → BSK `DataImport`.
|
||
4. Sync adapters, metrics handler and cleanup error handlers → `SyncAppIntegration`.
|
||
5. Content-blocking data providers + `AdBlockingAvailability` → BSK.
|
||
6. `AutoconsentUserScript` + pixels → shared.
|
||
7. Duck.ai stores and pixels → `AIChat/Shared`.
|
||
8. Subscription environment and pixel adapters → shared subscription support. This also closes stage **D**.
|
||
|
||
---
|
||
|
||
## Open questions
|
||
|
||
1. **Package layout:** keep all macOS feature modules as targets of `macOS/DuckDuckGo/Package.swift` (files stay in place, one manifest), or create one package per feature in `LocalPackages`? Recommendation: targets in the same package first. Move a module to `SharedPackages` when iOS starts using it.
|
||
2. **Strings tooling:** `loc_export.sh` / the translation pipeline with one catalog per module. This has to be verified before step 1.4 scales.
|
||
3. **KeyedStoring timeline** (stage F): who owns it and when.
|
||
4. **Code ownership:** module boundaries are a good moment to add CODEOWNERS per feature module.
|
||
5. **Build benchmarks:** measure clean and incremental builds after Phase 1 and the first leaf modules, with Xcode and `swift build`.
|