Skip to content
C.W.K.
Lesson 01 of 05 · published

One Package, Three Targets

~13 min · swiftpm, architecture, targets, modules

Level 0New Tab
0 XP0/44 lessons0/13 achievements
0/100 XP to next level100 XP to go0% complete

Three folders, three rules

Open Voyager's repository and the first file worth reading is not a view or a model. It is Package.swift, and its header comment is the whole architecture in four lines: a parent, an engine child, and a Mac shell. One package called Voyager builds three targets and exports three products: two libraries, VoyagerCore and VoyagerWebKit, and the executable Voyager.

Each target is a folder under Sources/, and each folder holds one kind of knowledge.

TargetRoleDepends onSwift files
VoyagerCoreThe parent: tabs, sessions, identities, trust, the departure log, the family catalog, the Pippa hostCwkKitCore26
VoyagerWebKitThe engine child: the system's WebKit behind the root contractVoyagerCore6
VoyagerAppThe Mac shell: window, tab strip, address field, menusboth, plus CwkKitCore, CwkKitMac, CwkKitMedia26

The arrows point one way

Read the dependency lists as arrows. The child depends on the parent. The shell depends on both. Nothing points back at the child, and SwiftPM refuses a cycle, so the parent cannot even name WebKitEngine: that type lives in a module VoyagerCore never imports. This half of the rule is enforced by the compiler.

The other half is not. WebKit, AppKit and UIKit are system frameworks, and any target on an Apple platform can import them without asking SwiftPM. The manifest's comment promises "No WebKit, AppKit or UIKit import" for the parent, and on the current main branch the promise holds: the parent's 26 files import Foundation, Observation, CwkKitCore and a few system libraries (Security, SQLite3, CommonCrypto), and not one of them contains an #if os branch. No test checks it, though. It holds because architecture invariant 2 says "No parent code imports WebKit or branches on the platform", and every change is read against that line.

Principle: Know which of your boundaries the toolchain enforces and which are promises. Write the promises where every change will meet them, or turn them into a check.

Why the libraries are products

An executable target alone would have been enough for a Mac app. The two libraries are products for a reader that did not exist yet. The very first commit with code in it (a73d23c, 2026-10-03) already says the package is "consumed by the Mac app here and by the iPhone and iPad app". The phone target was started 23 days later (22dc017, 2026-10-26). When it came, its XcodeGen project pointed at the same package by a relative path and linked the two products. Nothing was copied and nothing was ported. The phone app became a second shell on the same parent and the same child, which is the subject of lesson 5.

Two test targets, two kinds of truth

VoyagerCoreTests depends on VoyagerCore and the kit's CwkKitTesting, and on nothing that can draw a page. It holds 242 tests in 48 suites, and each one checks a rule of the parent against a fake engine or against no engine at all. VoyagerWebKitTests depends on the parent and the child, and drives real WKWebViews inside the test process: 63 tests in 8 suites. One xcrun swift test runs both. Lessons 3 and 4 open each of them.

The split is the point. A failure in the first target means a rule of the browser is broken. A failure in the second means WebKit does something the child did not expect. You never have to guess which of the two you are looking at, because the target that failed already says.

What else the manifest carries

  • The platform floor. .macOS("15.4"), .iOS("18.4"), the first releases with WKWebExtension. The first brick said .macOS(.v15), .iOS(.v18); the commit that brought the extension host (1360d8d) raised it. Because the manifest names the floor, no @available check rides the code.
  • Swift 6 language mode for every target, so strict concurrency checking is on everywhere. Swift Quest covers what that buys and what it costs.
  • The family's shared Swift, by path. .package(path: "../cwkSiblingKit/CwkKit"), the way every other family app consumes it. Sibling Kit Quest explains why the family shares code like this and never forks it.

Code

cwkVoyager, Package.swift: the three targets and their dependency arrows (verbatim, lines 5-33)·swift
// VoyagerKit: one Swift parent and one engine child, consumed by the Mac app
// here and by the iPhone and iPad app (docs/VOYAGER-ARCHITECTURE.md).
//
//   VoyagerCore     the parent. Tabs, sessions, identities, trust, the
//                   departure log. No WebKit, AppKit or UIKit import.
//   VoyagerWebKit   the engine child: the system's WebKit behind the root
//                   contract VoyagerCore declares.
//   VoyagerApp      the Mac shell: window, tabs, address field, menus.
let package = Package(
    name: "Voyager",
    platforms: [.macOS("15.4"), .iOS("18.4")],
    products: [
        .library(name: "VoyagerCore", targets: ["VoyagerCore"]),
        .library(name: "VoyagerWebKit", targets: ["VoyagerWebKit"]),
        .executable(name: "Voyager", targets: ["VoyagerApp"]),
    ],
    dependencies: [
        // The family's shared Swift, by path like every other consumer.
        .package(path: "../cwkSiblingKit/CwkKit"),
    ],
    targets: [
        .target(
            name: "VoyagerCore",
            dependencies: [.product(name: "CwkKitCore", package: "CwkKit")]
        ),
        .target(
            name: "VoyagerWebKit",
            dependencies: ["VoyagerCore"]
        ),
cwkVoyager, clients/apple/VoyagerMobile/project.yml: the phone project links the package's two libraries (verbatim, lines 31-52)·text
packages:
  Voyager:
    path: ../../..
  CwkKit:
    path: ../../../../cwkSiblingKit/CwkKit

targets:
  VoyagerMobile:
    type: application
    platform: iOS
    sources:
      - path: Sources
      - path: Shared
      - path: Resources/Assets.xcassets
    dependencies:
      - package: Voyager
        product: VoyagerCore
      - package: Voyager
        product: VoyagerWebKit
      - package: CwkKit
        product: CwkKitCore
      - target: VoyagerWidgets

External links

Exercise

Take an app you are building, or start a small Swift package, and split it into three targets: a core library that holds the rules, a library for the one framework that is hardest to test (a web view, a map, a database driver), and the app itself. Write the dependency arrows down. Then add one check that fails if the core target imports that framework, and make it fail once on purpose to see that it works.
Hint
The check can be a dozen lines: a test that lists the files under Sources/<YourCore>, reads each one, and records an issue for any line that starts with the framework's import. It is crude, and that is fine, because the rule it guards is crude too: the name never appears in the core.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign in — Please sign in to comment.

No comments yet — be the first.