[2026-10-07] eagle: work/projects/vscode-migration/tech-design-spm-browser-package.md
This commit is contained in:
1 parent
f35e128788
commit
c23808e802
1 file changed
+170
-173
@@ -1,230 +1,227 @@
|
||||
# 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)
|
||||
**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'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;
|
||||
- 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 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.
|
||||
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:**
|
||||
- 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.
|
||||
- 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;
|
||||
- membership, dependencies and resources are maintained by hand in an 11k-line `project.pbxproj`;
|
||||
- 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 the user gets.
|
||||
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 two app targets become thin shells that link it.
|
||||
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.
|
||||
|
||||
```
|
||||
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])
|
||||
**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`.
|
||||
|
||||
macOS/DuckDuckGoAppBundle/ ← former non-code app files
|
||||
main.swift (import DuckDuckGo_Privacy_Browser; AppMain.main())
|
||||
Info.plist, InfoPlist.xcstrings, *.entitlements, AppStore/DeveloperID.xcstrings
|
||||
**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.
|
||||
|
||||
DMG / App Store app targets (module DuckDuckGoApp):
|
||||
sources: main.swift · bundle resources: Info.plist strings, app icons, macos-config.json
|
||||
frameworks: DuckDuckGoBrowser
|
||||
```
|
||||
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.
|
||||
|
||||
### Implementation steps
|
||||
The change is reversible: deleting `Package.swift` and restoring the app targets' Sources phases brings back the old structure, since no source files moved.
|
||||
|
||||
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]).
|
||||
**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.
|
||||
|
||||
### What this brings (pros)
|
||||
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, …
|
||||
|
||||
- **Explicit, reviewable dependencies.** The browser's dependencies (82 products) are declared once in `Package.swift` rather than twice as `XCSwiftPackageProductDependency` entries in `project.pbxproj`.
|
||||
### Pros
|
||||
|
||||
- **Dependencies declared once.** They live in `Package.swift`, not twice as product dependencies 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.
|
||||
- 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.
|
||||
|
||||
### What we lose or change (cons), and mitigations
|
||||
### Cons: what we lose
|
||||
|
||||
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) |
|
||||
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.
|
||||
|
||||
| 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 |
|
||||
### Risks
|
||||
|
||||
**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.
|
||||
- **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 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):
|
||||
- [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`.
|
||||
|
||||
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.
|
||||
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**, all succeeded locally on Xcode 27.2:
|
||||
- every product scheme (DMG, App Store, VPN agents, PIR agents, extensions) in Debug;
|
||||
- **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 (`SPM_FORCE_DEBUG=1`).
|
||||
- unit and integration `build-for-testing` in Debug and CI.
|
||||
|
||||
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;
|
||||
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 (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:**
|
||||
- app icon switching;
|
||||
- the update and keychain checks from Risks;
|
||||
- a non-English locale.
|
||||
- **Release:**
|
||||
- notarized DMG;
|
||||
- App Store Connect validation build;
|
||||
- App Store Connect validation (the nested resource bundle must pass);
|
||||
- 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.
|
||||
- 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 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.
|
||||
- **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:** 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.
|
||||
- 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:**
|
||||
- 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.
|
||||
- `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.
|
||||
Reference in new issue
Block a user