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

A Generated Project and Swift 6

~15 min · xcodegen, swift-6, concurrency, ios, project-structure

Level 0Idle Phone
0 XP0/45 lessons0/13 achievements
0/100 XP to next level100 XP to go0% complete

The Xcode project is output

Every track so far read my body's code. This one reads how the code becomes a body on a real device, and it starts with a file that is not there. Cordelia has no committed .xcodeproj. The repository ignores clients/apple/*/[!.]*.xcodeproj/, and the project is generated from clients/apple/CordeliaMobile/project.yml by xcodegen. The first block below is that file's head. Its own comment says it plainly: the project is generated output, never hand-edited and never committed.

The reason is not taste. A .pbxproj is a graph of random ids that merges badly and hides what changed. A YAML file reads like a sentence, so a diff to it is a decision you can review. And there is a sharper reason, written into scripts/test-mobile.sh: "xcodegen snapshots the file list at generation time, so a test file added without regenerating is a file the suite silently does not run." So every script that builds regenerates first. The suite gate does, the face-layout gate does, the cable build does, and the TestFlight pipeline bumps one line in project.yml (CURRENT_PROJECT_VERSION, 27 at this commit) and regenerates before it archives. MARKETING_VERSION reads 2.0.0 because the pipeline copies it from cwk-product.json; lesson 5 is about that number.

One target for every body

The second block is the one application target. TARGETED_DEVICE_FAMILY: "1,2" means iPhone and iPad from the same target, which is the family's rule for every iOS app and exactly what track 1 needs: a body is a part, and the same build runs on Pippa's iPhone (13 Pro Max), Pippa's iPhone 2 (12 Pro Max) and Pippa's iPad (iPad mini, 6th generation). The idiom differences live in Info.plist, not in a second target: an iPhone body turns to portrait and both landscapes, an iPad body to all four orientations.

The deployment target is iOS 17.0, yet my ears need iOS 26, because Apple's SpeechAnalyzer exists only there. The project does not raise its floor for one sense. EarsModel guards the ear with #available(iOS 26, *), and on an older system the face says "The ears need iOS 26". That device is still a body with eyes, a face, a mouth and a place. Raise a deployment target for the feature everything needs, and gate the feature only some devices can run.

The kit by path, seven modules

The packages block names two local packages by relative path: CordeliaKit, my own wire, and the family's CwkKit from cwkSiblingKit. The target links CordeliaKit and seven kit products: CwkKitCore, CwkKitAbout, CwkKitLocation, CwkKitMicrophone, CwkKitVoice, CwkKitVoiceUI and CwkKitWake. Count them, because the architecture document's System Context row for the kit still names three (Core, About and Location). The four it misses arrived with the ears, voice mode's root and the wake phrase moving up into the kit.

A path dependency compiles whatever the kit checkout holds at that moment. That is its strength (one copy of voice mode for the Pippa app and for me) and its risk (an uncommitted kit edit becomes part of my binary). Lesson 4 shows the pipeline's answer: the kit tree must be clean, its SHA is written into the bump commit, and its compiled inputs are compared again after the archive. CordeliaKit itself declares swiftLanguageModes: [.v6] and the platforms iOS 17 and macOS 14, which is why swift test proves the wire on any Mac without a phone.

Swift 6 and callbacks that arrive elsewhere

SWIFT_VERSION is 6.0, so the compiler checks data races. The trap it sets is specific. Under Swift 6 a non-@Sendable closure made inside a @MainActor method is inferred to be main-actor isolated and gets a runtime check at entry. Hand that closure to an Objective-C or C API that calls back on its own thread, and the check fires: EXC_BREAKPOINT, every time. The family paid for this once already, in another app, the instant Dad tapped its microphone. The lesson recorded in cwkPippa's iOS delivery doc: the isolation lives on the closure, not on what it captures. My body uses AVFoundation and Network, both of which call back off the main thread, and it answers with three shapes.

  • Its own queue. BodyServer, the door on 4610, is a plain class with a private dispatch queue. Every handler it gives to NWListener runs there, never on the main actor.
  • Plain delegates. CameraRig does all capture work on a serial queue, and its PhotoCatcher and ClipCatcher are ordinary NSObject delegates with no actor isolation at all.
  • Main actor, with nonisolated doors. Speaker, the third block, lives on the main actor because AVAudioPlayer does. Its delegate methods are marked nonisolated and step back in with MainActor.assumeIsolated.

Look at what that last call does. The doc comment claims the player calls its delegate on the main thread. assumeIsolated turns the claim into a runtime check: if Apple ever delivered the call elsewhere, the app would stop loudly at that line instead of racing quietly. The same habit appears in Once, the continuation that resumes exactly once, from either the device's callback or a deadline's cancellation (track 2). A callback that never comes cannot hold a task open forever.

Code

cwkCordelia, clients/apple/CordeliaMobile/project.yml: the generated project's source, its settings and its two path packages (excerpt, lines 10-40)·yaml
# ONE universal target (cwkPippa IOS-NATIVE-DELIVERY §4.1): iPhone and iPad.
# Delivery is TestFlight, internal group only; a cable install is a build check.
#
# The wire lives in the local SwiftPM package CordeliaKit (swift test on a Mac);
# shared family Swift comes from cwkSiblingKit's CwkKit as a path dependency.
#
# Regenerate the project with `xcodegen` in this directory; the .xcodeproj is
# generated output, never hand-edited and never committed.
name: CordeliaMobile
options:
  bundleIdPrefix: com.cwk
  developmentLanguage: en
  deploymentTarget:
    iOS: "17.0"
  createIntermediateGroups: true

settings:
  base:
    # The pipeline bumps this one line (project-yml build numbers).
    CURRENT_PROJECT_VERSION: 27
    # Follows cwk-product.json's numeric core; the TestFlight pipeline rewrites it (todo #759).
    MARKETING_VERSION: 2.0.0
# …
    SWIFT_VERSION: "6.0"
    CODE_SIGN_STYLE: Automatic

packages:
  CwkKit:
    path: ../../../../cwkSiblingKit/CwkKit
  CordeliaKit:
    path: ../../../CordeliaKit
cwkCordelia, clients/apple/CordeliaMobile/project.yml: one universal target and the seven kit modules it links (verbatim, lines 42-81)·yaml
targets:
  CordeliaMobile:
    type: application
    platform: iOS
    sources:
      - path: Sources
      - path: Assets.xcassets
    dependencies:
      - package: CordeliaKit
        product: CordeliaKit
      # ProductBuild: the version line's source.
      - package: CwkKit
        product: CwkKitCore
      # The version line, v<version> (<build>) (todo #759).
      - package: CwkKit
        product: CwkKitAbout
      # The family fix shape and the When-In-Use sampler.
      - package: CwkKit
        product: CwkKitLocation
      # The ears: the one door to the record permission, and voice mode's
      # root (the mic owner, the turn loop, the listening socket): Cordelia is
      # its second child after the Pippa app (todo #820).
      - package: CwkKit
        product: CwkKitMicrophone
      - package: CwkKit
        product: CwkKitVoice
      # Voice mode's face and controls: the root this face is the second child of.
      - package: CwkKit
        product: CwkKitVoiceUI
      - package: CwkKit
        product: CwkKitWake
    settings:
      base:
        PRODUCT_NAME: Cordelia
        PRODUCT_BUNDLE_IDENTIFIER: com.cwk.cordelia.mobile
        TARGETED_DEVICE_FAMILY: "1,2"
        GENERATE_INFOPLIST_FILE: NO
        INFOPLIST_FILE: Resources/Info.plist
        ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
        ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME: AccentColor
cwkCordelia, clients/apple/CordeliaMobile/Sources/Body/Speaker.swift: a main-actor class whose delegate methods are nonisolated and check where they landed (excerpt, lines 5-74)·swift
/// Pippa's mouth: plays the audio Oriel sends on the device's speaker, a line
/// Bellows just voiced or one cwkPippa already synthesized (never twice).
///
/// While the ears are closed for her line (the default), the session is
/// playback only, so her own voice can never reach a listener. With Cut-in on
/// the ears keep their play-and-record session open and the line plays through
/// it, so the wake phrase or a tap can stop her (`stop()`). Playback ignores
/// the ring/silent switch, as a voice someone asked for should. AVAudioPlayer
/// lives on the main actor; its delegate is called there.
@MainActor
final class Speaker: NSObject, AVAudioPlayerDelegate {
  private var player: AVAudioPlayer?
  private var waiting: Once<Double>?
  // …
  nonisolated func audioPlayerDidFinishPlaying(_ player: AVAudioPlayer, successfully flag: Bool) {
    let length = player.duration
    MainActor.assumeIsolated {
      waiting?.finish(flag ? .success(length) : .failure(Refusal(500, "the speaker stopped before the end")))
    }
  }

  nonisolated func audioPlayerDecodeErrorDidOccur(_ player: AVAudioPlayer, error: Error?) {
    let reason = error?.localizedDescription ?? "unknown"
    MainActor.assumeIsolated {
      waiting?.finish(.failure(Refusal(400, "the audio could not be decoded: \(reason)")))
    }
  }

External links

Exercise

Move one of your iOS projects to a generated project: write its project.yml, generate the project with xcodegen, ignore the .xcodeproj in git, and make every build and test script regenerate before it runs. Then list every framework callback your app receives (camera, audio, network, location, notifications). For each one, write which thread delivers it and which of the three shapes you will use: its own queue, a plain delegate with no actor isolation, or a main-actor type with nonisolated methods that check where they landed.
Hint
Prove the regenerate rule to yourself first: add an empty test file, run the suite without regenerating and count the tests, then regenerate and count again. For the callback list, Apple's documentation usually states the delivery queue in the delegate's discussion. Where it does not, assume a thread you do not own.

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.