[2026-10-07] eagle: work/projects/vscode-migration/tech-design-spm-browser-package.md work/projects/vscode-migration/vscode-migration.md
This commit is contained in:
1 parent
f0e1e932e2
commit
8224dbb53e
2 files changed
+231
No files matched your search
@@ -0,0 +1,230 @@
|
||||
# Tech Design: macOS browser code as a Swift package
|
||||
|
||||
Status: **DRAFT** · 2026-10-07 · implementation: `alex/spm-browser-package` ([PR #7080](https://github.com/duckduckgo/apple-browsers/pull/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](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)
|
||||
|
||||
## 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](https://app.asana.com/1/137249556945/project/1202406491309510/task/1211728521308688) (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.
|
||||
|
||||
## Recommended Approach
|
||||
|
||||
**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 `exclude`d.
|
||||
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 `testTarget`s 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.
|
||||
@@ -5,6 +5,7 @@ Status: **IN PROGRESS** · updated 2026-10-04
|
||||
**Asana:** [Migration to fully VSCode-supported builds&debugging](https://app.asana.com/1/137249556945/project/1203301625297703/task/1209808486380252)
|
||||
**Repo:** `~/DuckDuckGo/apple-browsers.git` (bare + worktrees)
|
||||
**Findings:** [[projects/vscode-migration/findings]]
|
||||
**Tech design (package step):** [[projects/vscode-migration/tech-design-spm-browser-package]]
|
||||
**Next:** [[projects/vscode-migration/modularization-roadmap]] — modularization after the browser package (incl. iOS/macOS sharing)
|
||||
|
||||
## Goal
|
||||
|
||||
Reference in new issue
Block a user