Files
obsidian-vault/work/projects/vscode-migration/tech-design-spm-browser-package.md
T

21 KiB

Tech Design: macOS browser code as a Swift package

Status: DRAFT · 2026-10-07 · implementation: alex/spm-browser-package (PR #7080, draft) Related: projects/vscode-migration/vscode-migration · projects/vscode-migration/modularization-roadmap · projects/vscode-migration/findings


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 Project: Migration to fully VSCode-supported builds&debugging

Background & Requirements

  • The macOS browser's code lives in macOS/DuckDuckGo/: 1,406 Swift files, ~271k lines, 103 feature folders. Until now it was compiled by two Xcode app targets, DMG ("DuckDuckGo Privacy Browser") and App Store. Some files were also compiled by up to six helper targets: VPN agent, PIR agent and NetP extensions.

  • The previous design, Tech Design: convert all macOS modules to Swift Package (approved Oct 2025), proposed:

    • turning the app code into a static library that the app targets link;
    • a SwiftGen plugin for Core Data, storyboards and assets.

    Its main blocker was "shared code files between the app targets and #if preconditions (SPARKLE/APPSTORE/ALPHA)". That blocker has been removed step by step since then; all of these are merged or in review:

Done Result
ObjC/C code moved to local packages; missing imports fixed App code is Swift-only and compiles outside the app target
WebKit private APIs unified; #if SPARKLE / APPSTORE dropped DMG and App Store targets compile the same files
Dead target flags dropped (SANDBOX_TEST_TOOL, ALPHA, NETWORK_EXTENSION in app files) (#6983); sandbox-test-tool replaced by App Store UI tests (#6980) One set of compilation conditions for the app code
Storyboards dropped: Feedback (#6866), PasswordManager (#6950), BookmarksBar, NavigationBar No Interface Builder files bind classes to the app module
Helper-target split, stages A, B, C, G (#7029 and follow-ups) Helpers no longer compile app files, except a short, tracked list (see Notes [4])
Asset resource initializers (NSImage(resource: .foo)) instead of generated NSImage.foo symbols Asset catalogs work from a package
  • Xcode 27 changes the picture compared with the previous design. With Xcode 27, Swift packages process the following natively:

    • asset catalogs, with generated ImageResource/ColorResource;
    • Core Data models, with generated entity classes;
    • string catalogs.

    So the SwiftGen plugin and the conversion of Core Data models to code are no longer needed. Both were cancelled on 2026-10-01.

  • Requirements:

    • DMG and App Store builds, and every Xcode scheme, CI workflow and release lane, keep working unchanged. That includes the Debug, Review, CI and Release configurations, unit, integration and UI tests, notarization and App Store submission.
    • The shipped binary keeps the same structure: browser code statically linked into the app executable, no runtime loading.
    • No user-visible change: no data migration, no change to persisted class names, defaults keys or the Core Data store.

Problem Statement

The browser code is part of the Xcode app targets, so:

  • only the Xcode project can build, index or test it;
  • membership, dependencies and resources are maintained by hand in an 11k-line project.pbxproj;
  • 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 the user gets.

Make macOS/DuckDuckGo/ a Swift package in place. One library target holds all browser code and its resources. The two app targets become thin shells that link it.

macOS/DuckDuckGo/Package.swift          ← new: package "DuckDuckGo"
  target DuckDuckGo_Privacy_Browser      (path: ".", all Swift files + resources, Bundle.module)
  product DuckDuckGoBrowser (static)     → linked by both app targets
  product DuckDuckGoBrowserDynamic       → for builds outside Xcode (see Notes [1])

macOS/DuckDuckGoAppBundle/              ← former non-code app files
  main.swift                             (import DuckDuckGo_Privacy_Browser; AppMain.main())
  Info.plist, InfoPlist.xcstrings, *.entitlements, AppStore/DeveloperID.xcstrings

DMG / App Store app targets (module DuckDuckGoApp):
  sources: main.swift · bundle resources: Info.plist strings, app icons, macos-config.json
  frameworks: DuckDuckGoBrowser

Implementation steps

  1. Package manifest (macOS/DuckDuckGo/Package.swift):
    • one target with path: ".", so no source files move and git history stays intact;
    • the 82 package products the app targets already used are now declared as target dependencies, plus Punycode[2];
    • resources are declared explicitly (.process(...)): asset catalogs, the string catalog (1,980 keys), JSON/JS/HTML/bin data and the 8 .xcdatamodeld models;
    • non-code files that belong to the app or helper bundles are excluded.
  2. Module name kept: the package target is named DuckDuckGo_Privacy_Browser, the former app module name. Archived class names (NSKeyedArchiver, state restoration), @objc runtime names and @testable import DuckDuckGo_Privacy_Browser in 600 test files keep working. The app targets get a new module name, DuckDuckGoApp[3].
  3. Thin app targets:
    • the two app targets' Sources phases contain only DuckDuckGoAppBundle/main.swift;
    • AppMain.main() is a public entry point in the package;
    • the package product is linked statically;
    • project.pbxproj loses ~11k lines of file references and build files.
  4. Resources go through Bundle.module:
    • Core Data models are loaded with NSManagedObjectModel.mergedModel(from: [.module]);
    • named images and Lottie data assets are loaded from .module;
    • localized strings: a module-level NSLocalizedString(…, bundle: Bundle = #bundle, …) shadows Foundation's, so the ~275 files that use UserText don't change. SwiftUI Text("…") literals get bundle: #bundle. Xcode's string extraction still finds the calls, because it matches the function name.
    • The resource bundle is copied into the app's Contents/Resources by Xcode as usual.
  5. Compilation conditions:
    • DEBUG comes from the package configuration;
    • Xcode maps the custom CI configuration to release for packages, so CI workflows set SPM_FORCE_DEBUG=1, which Package.swift reads. This is the same mechanism AppKitExtensions/SyncUI already use;
    • the only remaining target flag, REVIEW, gated one debug menu item, which now uses a runtime check (isDebugBuild || isReviewBuild).
  6. Guard rails:
    • InputFilesChecker (Xcode build plugin) checks that every file in macOS/DuckDuckGo/ belongs to the package. It fails the build if an Xcode target bundles a package resource, which would ship a second copy, unless the file is on the explicit packageFolderResourcesBundledByXcodeTargets list. It also keeps the temporary helper-target exceptions list, where stale entries fail the build.
    • New SwiftLint rules: see "What we lose" below.
  7. Follow-ups in separate PRs:
    • drop the 82 now-duplicated product dependencies from the app targets;
    • keep macOS/DuckDuckGo/Package.resolved in sync with the project's resolved file (CI check), or generate it;
    • remove the remaining helper-target exceptions (Notes [4]).

What this brings (pros)

  • Explicit, reviewable dependencies. The browser's dependencies (82 products) are declared once in Package.swift rather than twice as XCSwiftPackageProductDependency entries in project.pbxproj.
  • No file membership management.
    • A new file in macOS/DuckDuckGo/ is compiled automatically.
    • Nothing can be "added to the wrong target" or forgotten.
    • The DMG/App Store mismatch class of bugs is gone, because both link one product.
    • project.pbxproj shrinks by ~11k lines, so there are far fewer merge conflicts.
  • Standard SwiftPM tooling works on the browser code:
    • swift build, swift test and SourceKit-LSP indexing work without the Xcode project;
    • so do any SwiftPM-aware editor or CI step, and building the code as a dynamic library for faster local iteration.
  • Unit test dependency management:
    • test targets can depend on the package product instead of only the app host;
    • modules extracted later get their own testTargets with exactly the dependencies they need;
    • logic tests can run without launching the app host (a faster, more isolated path, to adopt gradually).
    • The existing host-app unit and integration test targets keep working unchanged.
  • Foundation for modularization. New modules can be added as targets of the same Package.swift (path: "Bookmarks" + exclude), with no file moves and the compiler enforcing boundaries. Brief roadmap (details in projects/vscode-migration/modularization-roadmap):
    • foundation modules: utilities, design system, per-module strings and assets, pixels, one Core Data model per module;
    • shared contracts and constructor injection instead of the Application.appDelegate service locator (273 files);
    • settings models split from the Settings window;
    • leaf features first (CrashReports, Geolocation, Sharing, FindInPage, AppHealth, …);
    • tab core, with tab extensions owned by features;
    • mid-tier features;
    • shell (target ≤ 30k lines in the app module).
  • iOS unification:
    • about 15 areas have near-duplicate logic on iOS and macOS: Sync adapters, content-blocking providers, Autoconsent, unified feedback, TipKit, data import, Duck.ai stores, subscription web flows, statistics/ATB, and others;
    • with macOS code in a package, those parts can move into SharedPackages/BrowserServicesKit and serve both apps;
    • iOS can follow the same path to a package.
  • Previews and isolated builds are scoped to a package target rather than the whole app target.
  • Same shipped binary: static linking, one executable, the same module name for persisted names.

What we lose or change (cons), and mitigations

Already given up in earlier, merged steps of this project:

Lost Why Mitigation
Per-target compile-time flags (SPARKLE, APPSTORE, ALPHA, SANDBOX_TEST_TOOL, NETWORK_EXTENSION in app files) A package target has one set of conditions for all products that link it Runtime checks (StandardApplicationBuildType, bundle/feature checks); DMG/App Store differences live in injected services
Storyboards and xibs Interface Builder module binding (customModuleProvider="target") breaks when code leaves the app module Views built in code / SwiftUI
Generated asset symbols (NSImage.foo, NSColor.foo) Xcode doesn't generate these symbol extensions for packages (Xcode 26 and 27) NSImage(resource: .foo) / NSColor(resource:), type-checked
Sandbox test tool A separate target that compiled app sources AppStoreSandboxUITests on CI (#6980)

New with this step:

Lost / changed Impact Mitigation
Resources must be declared in Package.swift A new resource type or folder that isn't listed is not bundled. SwiftPM only warns: "found N file(s) which are unhandled" InputFilesChecker verifies package vs Xcode target ownership; treat SwiftPM's unhandled-files warning as a CI error; recommend folder-level .process("Feature/Resources") entries so new files in existing folders are picked up
Bundle.main no longer finds browser resources NSImage(named:), Bundle.main.url(forResource:), LottieAnimation.asset(_, bundle: .main) silently return nil or crash at runtime (3 such regressions were caught and fixed on the branch) SwiftLint rules in macOS/.swiftlint.yml: forbid NSImage(named:)/NSColor(named:) and resource lookups on Bundle.main in macOS/DuckDuckGo/ (use NSImage(resource:) or Bundle.module); identity uses (bundleIdentifier, bundleURL, app group) stay allowed. Plus a unit test that loads every bundled Lottie/data asset (added)
Localization needs the calling module's bundle SwiftUI Text("literal") and String(localized:) default to Bundle.main; a missing bundle: shows the English key at runtime with no build error Module-level NSLocalizedString shim with #bundle; SwiftLint rule: Text("…") / String(localized:) / LocalizedStringKey in the package must pass bundle: #bundle. The translation checks were updated to follow the catalog's move
No cross-linking of a source file into several targets Helper targets (VPN, PIR agents, extensions) can't simply add an app file to their Sources phase any more Code shared with helpers goes into a shared local package (AppKitExtensions, DataBrokerProtection-macOS, NetworkProtectionMac/VPNPixels, …). The few remaining files are still referenced by path and tracked in InputFilesChecker's temporary list (Notes [4])
Custom configurations don't reach the package Packages know only debug/release. Review and CI map by name; per-configuration xcconfig Swift settings (flags, warnings) don't apply to package targets SPM_FORCE_DEBUG for CI; REVIEW replaced by a runtime check; Swift settings (language mode, warnings) set in Package.swift to match the app's xcconfig
SwiftPM manifest cache ignores environment variables Locally switching between SPM_FORCE_DEBUG=1 and normal builds can reuse a stale evaluated manifest Documented; CI runners are clean. Same pre-existing risk as AppKitExtensions
Two Package.resolved files macOS/DuckDuckGo/Package.resolved (for standalone SwiftPM builds) can drift from the project's CI check that the pinned versions match, or generate the file from the project's
Xcode 27 required Package asset catalogs, Core Data codegen and string catalogs The project already targets Xcode 27 (Xcode 26 support is dropped)
App module renamed to DuckDuckGoApp Anything that referred to the app module by name (Bundle(for:) from app code, NSClassFromString("DuckDuckGo_Privacy_Browser.X")) All code is in the package, which keeps the old name; the app module holds only main.swift
Build time not yet measured One big package target vs the app target: clean and incremental times may change either way Benchmark clean and incremental builds (Debug, Release) before merging; see Testing

What else could be easy to miss:

  • App Store and notarization: the SwiftPM resource bundle (DuckDuckGo_DuckDuckGo_Privacy_Browser.bundle) is now nested in Contents/Resources. It must pass App Store validation and notarization, and be signed with the app. Verify with a TestFlight/App Store Connect validation build.
  • Crash symbolication: the static library is linked into the app executable, so the app's dSYM covers it. Check that the uploaded dSYMs still symbolicate.
  • Code coverage: check that coverage reports include the package target's files.
  • Release-lane scripts (Fastfile, prepare_sparkle_test_builds.sh) referenced the old Info.plist path and were updated. Sparkle test builds need a pass.
  • Localization export/import (loc_export.sh, the translation vendor pipeline) with the catalog in the package: one full round trip has to be verified.

Notes

  • [1] The dynamic product is not used by Xcode or release builds. It lets the browser code be built and run as a dylib loaded by a small host app outside Xcode. That workflow is covered by the separate developer-tooling work and doesn't affect shipped builds.

  • [2] Punycode was only reached through a string extension from another package. In release configurations the linker didn't pull it in transitively, so it's declared explicitly.

  • [3] PRODUCT_MODULE_NAME = DuckDuckGoApp is set in DuckDuckGo.xcconfig / DuckDuckGoAppStore.xcconfig, not in AppTargetsBase.xcconfig. The base file is shared with the VPN and PIR agents, which would otherwise produce the same module twice.

  • [4] Files still compiled by helper targets from the package folder (stages D, E1, E2/H, F):

    • subscription pixels and environment;
    • TipKit;
    • VPN IPC, error recorder and country labels;
    • shared VPN strings;
    • UserDefaultsWrapper.

    The package excludes nothing for them: both the package and the helper targets compile them. They move to shared packages as part of modularization (UserDefaultsWrapper is replaced by KeyedStoring).

  • [5] Alternatives considered:

    • Keep the app target and add a parallel Package.swift for tooling only: two sources of truth for membership and dependencies. Rejected.
    • SwiftGen plugin for assets, Core Data and storyboards (previous design): unnecessary with Xcode 27 and with storyboards gone.
    • Core Data models in code: cancelled for the same reason.
    • Split into many packages right away: too big a change at once and blocked by the dependency cycles. It follows incrementally as targets of the same package.
  • [6] This design is reversible: deleting Package.swift and restoring the app targets' Sources phases from git brings back the previous structure. No source files moved.

Testing

  • Builds, all succeeded locally on Xcode 27.2:

    • every product scheme (DMG, App Store, VPN agents, PIR agents, extensions) in Debug;
    • macOS Browser in Review;
    • unit and integration build-for-testing in Debug and CI (SPM_FORCE_DEBUG=1).

    CI runs all existing PR checks, including the App Store sandbox UI tests.

  • Existing tests: the unit, integration and UI suites run unchanged; their test utilities now load Core Data models from the package bundle.

  • Resource smoke tests (added):

    • every bundled Lottie/data asset loads from Bundle.module;
    • extending this to named images, JSON resources and the Core Data models catches a missed Bundle.main lookup at test time.
  • Manual pass on DMG and App Store builds:

    • launch;
    • Fire animation and onboarding animations;
    • tracker icons in the address bar;
    • Duck.ai sidebar;
    • app icon switching (Debug/Alpha/Review);
    • one localized UI (e.g. German) to confirm the strings come from the package catalog;
    • bookmarks, history, permissions and downloads persisted across a relaunch, to confirm the Core Data stores open with the package models.
  • Release validation:

    • notarized DMG;
    • App Store Connect validation build;
    • Sparkle update from the previous version;
    • dSYM symbolication of a test crash.
  • Build-time benchmark: clean and incremental (one-file change) builds in Debug and Release, before and after, with the results attached here.

Additional Considerations

  • Privacy: No change to data collection, use or retention. Persisted data, store files and defaults keys stay the same.
  • Security:
    • No new entitlements or network access.
    • The app keeps the hardened runtime and library validation.
    • Browser code is statically linked into the signed executable, and nothing is loaded at runtime.
    • The dynamic product (Notes [1]) is never shipped.
  • Site Breakage: None expected: no WebKit, content-blocking or user-script behavior changes. Content-blocking resources (trackerData.json, macos-config.json, the HTTPS bloom filter) are still bundled; the resource smoke tests cover loading them.
  • Experimentation: Not applicable: a build-structure change with no user-facing behavior to compare.
  • Operational:
    • CI: workflows that build with the CI configuration set SPM_FORCE_DEBUG=1. Add a check that macOS/DuckDuckGo/Package.resolved matches the project's resolved file, and turn SwiftPM's unhandled-resource warning into an error.
    • No new hardware or deployment processes.
    • Scaling: adding code is cheaper, since there is no project file editing. Future modules are targets in the same manifest.
    • Failure modes: a missed resource lookup shows up at runtime as a nil image or an untranslated string. That's why the lint rules and resource smoke tests above matter more than for an app target.
  • Localization / Internationalization:
    • The string catalog (Localizable.xcstrings, 1,980 keys) moves into the package and is read through #bundle; translations are unchanged.
    • InfoPlist.xcstrings, AppStore.xcstrings and DeveloperID.xcstrings stay with the app bundle.
    • Verify the export/import round trip (loc_export.sh, the vendor pipeline) and the translation PR checks, which now follow file renames.
    • Testing must include at least one non-English locale.