Files
obsidian-vault/work/projects/vscode-migration/modularization-roadmap.md
T

27 KiB
Executable File
Raw Blame History

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:

    1. Foundation modules.
    2. Contracts and dependency injection.
    3. Settings split.
    4. Leaf features (can start at once).
    5. Tab core.
    6. Mid-tier features.
    7. Shell.

    Each step is a series of small PRs, and the app keeps building at every step.

  • The sharing candidates with iOS are listed in Cross-platform packages.


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–3 mostly break dependencies, and files move into new targets as each folder gets clean. Phase 4 can run in parallel with Phase 1.

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

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)

(see below)


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.