# Tech Design: macOS browser code as a Swift package **Author:** Alex M **Reviewer:** TBD (reviewer of the previous design: Dominik) **Stakeholders:** macOS team, plus the stakeholders of [Tech Design: convert all macOS modules to Swift Package](https://app.asana.com/1/137249556945/project/1202406491309510/task/1211728521308688) **Project:** [Migration to fully VSCode-supported builds&debugging](https://app.asana.com/1/137249556945/project/1203301625297703/task/1209808486380252) · implementation: [PR #7080](https://github.com/duckduckgo/apple-browsers/pull/7080) (draft) ## Background & Requirements - The macOS browser code lives in `macOS/DuckDuckGo/`: 1,406 Swift files, ~271k lines. - Today the DMG and App Store app targets compile it. - Some files are also compiled into the helper targets (VPN agent, PIR agent, NetP extensions). - The [previous design](https://app.asana.com/1/137249556945/project/1202406491309510/task/1211728521308688) (approved Oct 2025) proposed: - a static library linked by the app targets; - a SwiftGen plugin for Core Data, storyboards and assets. Its blocker was files shared between targets and per-target `#if` flags. That blocker has since been removed: - ObjC/C code moved to packages; - `SPARKLE`/`APPSTORE`/`ALPHA` and the other per-target flags dropped (#6983); - storyboards dropped; - helper targets no longer compile app files, apart from a short tracked list (#7029, see Notes [2]); - asset lookups use `NSImage(resource:)`. - Packages built with Xcode 27 process asset catalogs, Core Data models and string catalogs natively. So the SwiftGen plugin and the code-defined Core Data models are no longer needed; both were cancelled. - **Requirements:** - All existing schemes, configurations, tests and release lanes keep working unchanged. - The shipped binary keeps the same structure: browser code statically linked into the app executable, nothing loaded at runtime. - No user-visible change and no data migration. ## Problem Statement The browser code is part of the Xcode app targets, so: - only the Xcode project can build, index or test it; - file membership, dependencies and resources are maintained by hand in `project.pbxproj` (23k lines); - the code can't be split into modules or shared with iOS without first leaving the app target. We want the browser code to be a regular Swift package target that the app targets link, with no change to the app users get. ## Recommended Approach Make `macOS/DuckDuckGo/` a Swift package in place. One library target holds all browser code and its resources. The DMG and App Store targets become thin shells that link it. **Layout** - `macOS/DuckDuckGo/Package.swift` (new): - target `DuckDuckGo_Privacy_Browser` with `path: "."`, so files don't move and history is kept; - product `DuckDuckGoBrowser` (static), linked by both app targets; - product `DuckDuckGoBrowserDynamic`, for builds outside Xcode only (Notes [1]). - `macOS/DuckDuckGoAppBundle/` is a folder, not a target. It holds what used to sit next to the code: - `main.swift`; - `Info.plist`; - entitlements; - Info.plist and App Store string catalogs. - DMG and App Store app targets, both using that folder: - sources: `main.swift` only (`AppMain.main()`); - bundle resources: app icons, `macos-config.json`, plist strings; - link: `DuckDuckGoBrowser`. **Steps** 1. **Manifest.** - Dependencies: the 82 package products the app targets already use. - Resources declared with `.process(...)`: - asset catalogs; - `Localizable.xcstrings` (1,980 keys); - JSON/JS/HTML data; - the 8 Core Data models. - Files that belong to the app or helper bundles are `exclude`d. 2. **Keep the module name.** The package target keeps the old app module name, `DuckDuckGo_Privacy_Browser`. So these keep working: - archived class names; - `@objc` runtime names; - `@testable import` in ~600 test files. The app targets' own module becomes `DuckDuckGoApp`, which contains only `main.swift`. 3. **Thin app targets.** Their Sources phase is `main.swift` only. ~11k lines of file references leave `project.pbxproj`. 4. **Resources through `Bundle.module`.** - Core Data models, named images and Lottie assets are loaded from `.module`. - Strings: a module-level `NSLocalizedString(…, bundle: Bundle = #bundle, …)` shadows Foundation's, so `UserText` (275 files) doesn't change. - SwiftUI `Text("…")` literals get `bundle: #bundle`. 5. **Compilation conditions.** - `DEBUG` comes from the package configuration. - Xcode builds packages in release for the custom CI configuration, so CI sets `SPM_FORCE_DEBUG=1`, which `Package.swift` reads. `AppKitExtensions`/`SyncUI` already work this way. - `REVIEW`, the last target flag, gated one debug menu item; it's now a runtime check. 6. **Guard rails.** These are part of this change; see Risks. - `InputFilesChecker` fails the build if an Xcode target bundles a file from the package folder, except a short allowlist (app icons, `macos-config.json`, NetP strings). - It also gets a new check (to add): every resource in the package folder is either declared in `Package.swift` or excluded. - SwiftLint rules (to add), applied to `macOS/DuckDuckGo/`: - no `NSImage(named:)`/`NSColor(named:)`; - no resource lookups on `Bundle.main`. Identity uses such as `bundleIdentifier` and app group stay allowed. - `Text("…")`, `String(localized:)` and `LocalizedStringKey` must pass `bundle: #bundle`. 7. **Follow-ups (separate PRs).** - Drop the now-duplicated 82 product dependencies from the app targets, and check that framework embedding is unchanged. - Keep `macOS/DuckDuckGo/Package.resolved` in sync with the project's, with a CI check. The change is reversible: deleting `Package.swift` and restoring the app targets' Sources phases brings back the old structure, since no source files moved. **Further splitting.** The details are in the modularization roadmap. New modules become targets of the same `Package.swift` (`path: "Bookmarks"` plus an `exclude` in the main target), so files still don't move. Order: 1. Foundation modules: utilities, design system, pixels, per-module string catalogs and assets, and one Core Data model set per module. 2. Shared contracts and constructor injection in place of the `Application.appDelegate` service locator (273 files). Settings models split from the Settings window. 3. Leaf features first: CrashReports, Geolocation, Sharing, FindInPage, AppHealth, … 4. Tab core, with tab extensions owned by their features. 5. Mid-tier features: Bookmarks, History, Permissions, Downloads, Fire, Autofill, Sync, VPN UI, … 6. Shell: what's left in the app module, target ≤ 30k lines. Along the way, logic duplicated with iOS moves into `SharedPackages`/BrowserServicesKit instead of a macOS-only module. About 15 areas qualify: Sync adapters, content-blocking providers, Autoconsent, unified feedback, TipKit, data import, Duck.ai stores, subscription web flows, statistics/ATB, … ### Pros - **Dependencies declared once.** They live in `Package.swift`, not twice as product dependencies in `project.pbxproj`. - **No file membership management.** - New files are compiled automatically, so they can't be added to the wrong target or forgotten. - DMG and App Store can't drift apart, since both link one product. - `project.pbxproj` loses ~11k lines, so there are far fewer merge conflicts. - **Standard SwiftPM tooling.** `swift build`, `swift test` and SourceKit-LSP work on the browser code without the Xcode project, so any SwiftPM-aware editor or CI step can use them. - **Test dependencies.** - Test targets can depend on the package product instead of only the app host. - Extracted modules get their own `testTarget`s with exactly the dependencies they need. - Logic tests can run without launching the app. Existing host-app tests keep working unchanged. - **Foundation for modularization and for sharing code with iOS** (see above). - **Same shipped binary.** Static linking, one executable, the same module name for persisted names. ### Cons: what we lose Already given up in earlier steps of this project: - **Per-target compile-time flags** (`SPARKLE`, `APPSTORE`, `ALPHA`, `NETWORK_EXTENSION` in app files). Differences are now runtime checks and injected services. - **Storyboards and xibs.** Their module binding breaks outside the app module. Views are built in code or SwiftUI. - **Generated asset symbols** (`NSImage.foo`). Xcode doesn't generate them for packages; we use `NSImage(resource: .foo)`, which is still type-checked. - **ObjC/C in the app code and the bridging header.** A Swift package target can't mix languages, so ObjC/C code lives in separate packages. New with this step: - **Cross-linking a file into several targets.** Code shared with helper targets has to go into a shared package. The few remaining files are referenced by path and tracked by `InputFilesChecker` (Notes [2]). - **Implicit resource membership.** Every resource must be declared in `Package.swift`; SwiftPM only warns about unhandled files. Mitigation: the `InputFilesChecker` check from step 6. - **`Bundle.main` for browser resources.** A lookup that's missed returns nil or shows an untranslated string at runtime, with no build error. Three such cases were found and fixed on the branch. Mitigation: SwiftLint rules and resource smoke tests. - **Per-configuration build settings.** - Packages only know debug and release, so the custom Review and CI configurations don't reach the package. - Swift flags from the xcconfigs don't apply to package targets either. That includes testability in CI and the slow type-check warnings (`DDG_SLOW_COMPILE_CHECK`). - Mitigation: `SPM_FORCE_DEBUG` for CI. Set the same flags in `Package.swift` (to do; allowed for local packages). - SwiftPM caches the evaluated manifest regardless of environment variables, so switching `SPM_FORCE_DEBUG` locally may need a manifest change. CI runners are clean. - **Building the app requires Xcode 27,** for package asset catalogs, Core Data code generation and string catalogs. The project already targets Xcode 27. - **UI test runners don't need Xcode 27.** They download the prebuilt app and build only the UI test bundle, which doesn't link the browser package. The macOS 14 and 15 legs keep Xcode 16.2 and 26.2. - **A second `Package.resolved`,** used for standalone SwiftPM builds. CI checks that it matches the project's. - **Build time unknown.** One big package target replaces the app target, so clean and incremental times may change either way. We benchmark before merging. ### Risks - **Resources missing from the app.** - A new resource that isn't declared in `Package.swift` isn't bundled. - A resource bundled by an Xcode target instead of the package ends up in the wrong bundle. - Mitigation: the `InputFilesChecker` build phase fails on any file in the package folder that isn't a Swift source, isn't declared as a package resource, isn't excluded and isn't on the Xcode-target allowlist. Resource smoke tests load every bundled asset, JSON file and Core Data model from `Bundle.module`. - **User data loss.** - The data models are unchanged: same `.xcdatamodeld` files and versions, same store file, same encryption key. Only the bundle they load from changes. - Still, a **manual update test of a release build is mandatory**: install the current release, create data, update to the new build, and check every model: - `Bookmark` (legacy bookmarks, migration only); - `Favicons`; - `Downloads`; - `FireproofDomains`; - `History`; - `Permissions`; - `PixelDataModel` (pixel/ATB state); - `UnprotectedDomains`; - `HTTPSUpgrade` (BrowserServicesKit, part of the same merged store); - the BrowserServicesKit stores the app opens: bookmarks (`BookmarksModel`), Sync metadata, remote messaging, privacy stats. - Also check **keychain access** after the update: - the Core Data encryption key — if it can't be read, the stores can't be opened; - SecureVault (passwords, cards, identities); - Sync account; - subscription tokens; - the VPN and PIR auth that the helpers share. Entitlements and access groups are unchanged, so we expect no change. - **Localization.** Strings now come from the package's catalog through `#bundle`. A missed `bundle:` silently shows English. - Mitigation: the SwiftLint rule. - Test a **release build with a non-English locale**. - Test the **localization flow manually**: export, translation import and the translation PR checks. - **Framework embedding** when the duplicated product dependencies are dropped (follow-up). Mitigation: compare the app bundle's `Frameworks/` before and after. ## Notes - [1] The dynamic product is not used by Xcode or release builds. It lets the browser code be built as a dylib and run from a small host app outside Xcode. That developer tooling is covered separately and never ships. - [2] Files still compiled by both the package and the helper targets: - subscription pixels and environment; - TipKit; - VPN IPC, error recorder and country labels; - shared VPN strings; - `UserDefaultsWrapper`. They move to shared packages during modularization; `UserDefaultsWrapper` is replaced by `KeyedStoring`. - [3] Alternatives considered: - **A parallel `Package.swift` for tooling only, keeping the app target:** two sources of truth for membership and dependencies. - **SwiftGen plugin or code-defined Core Data models** (previous design): not needed with Xcode 27. - **Many packages right away:** blocked by dependency cycles inside the code; this follows incrementally as targets of the same package. ## Testing - **Builds** (local, Xcode 27.2, all passing): - every product scheme in Debug; - `macOS Browser` in Review; - unit and integration `build-for-testing` in Debug and CI. CI runs all existing PR checks, including the App Store sandbox UI tests and the UI test legs on older macOS. - **Existing tests** run unchanged; the test utilities now load Core Data models from the package bundle. - **Resource smoke tests:** Lottie/data assets (added). Named images, JSON resources and Core Data models (to add). - **Manual, DMG and App Store builds:** - Fire and onboarding animations; - tracker icons; - Duck.ai sidebar; - app icon switching; - the update and keychain checks from Risks; - a non-English locale. - **Release:** - notarized DMG; - App Store Connect validation (the nested resource bundle must pass); - Sparkle update from the previous version; - symbolication of a test crash; - code coverage includes the package files. - **Benchmark:** clean and incremental (one-file change) builds in Debug and Release, before and after. ## Additional Considerations - **Privacy:** No change. Persisted data, store files and defaults keys stay the same. - **Security:** No change. No new entitlements; nothing is loaded at runtime; the dynamic product isn't shipped. - **Site Breakage:** None expected. No change to WebKit, content blocking or user scripts, and content-blocking data is still bundled (covered by the smoke tests). - **Experimentation:** Not applicable. - **Operational:** - CI sets `SPM_FORCE_DEBUG=1` for the CI configuration and checks `Package.resolved` sync. No new infrastructure. - A missed resource lookup fails at runtime, not at build time, hence the lint rules, `InputFilesChecker` and the smoke tests. - **Localization / Internationalization:** - `Localizable.xcstrings` moves into the package; translations are unchanged. - `InfoPlist`, `AppStore` and `DeveloperID` string catalogs stay with the app bundle. - Testing covers a non-English release build and the full export/import flow.