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

Addresses From the Registry, Builds Through TestFlight

~14 min · configuration, testing, testflight, delivery

Level 0First Word
0 XP0/47 lessons0/13 achievements
0/100 XP to next level100 XP to go0% complete

No address typed by hand

The phone needs exactly one fact from the outside world before it can send anything: where the engine lives. For a while that fact was a literal in the source, a host and a port for the service host's HTTPS door. It had the right shape. It was also how the family had drifted before, app by app, into raw tailnet IPs and plain http that App Transport Security refuses.

Now the default is a lookup (the first code block). MobileSettings.defaultEndpoint asks the kit's FamilyEndpoints for the HTTPS twin of cwkSpark, a table rendered from the family's port and network ledgers. The phone never dials a name that only works on the home network, because a phone abroad is never on it. Three smaller rules sit around the lookup:

  • A saved address that names the service host by an older spelling (its raw tailnet IP, plain http, the engine's own port) is read once as Spark's HTTPS twin and saved that way. Any other host stays exactly as typed.
  • A typed address goes through the same translation first. Plain http to any other tailnet host is then refused on iPhone, with a message that asks for the https address.
  • Changing the address retires the offline copies, stops every watcher and builds a fresh client. The outbox is not keyed by address, so captures still waiting simply go to the new engine.

Following the Mac the engine is on

The family can move its services to another Mac, called Pippa's Ark, with a switch in the Pippa app. A phone that only knew the ledgers would keep dialing the old placement for everything except its own engine. So SparkMobile reads the host profile that cwkPippa serves on the engine's host and adopts it: pointed at the Ark, the phone dials the whole family there, and pointed back, it returns to the ledgers. It does this in a detached task at utility priority, never on the launch path and never while the app is hosting tests, and again whenever a new address is saved.

The switch itself lives in the keychain. The app lists two keychain access groups: its own first, so items it already wrote stay where they were, then the shared com.cwk.family group where the kit keeps the Ark setting. Flip the switch in the Pippa app and Spark moves with the family. The commit explains why a keychain group was chosen over a new App Group: a new App Group needs an Xcode account session to register, and a keychain group does not.

Principle: An address is a fact with an owner. Ask the owner on every launch, and keep a literal only as a spelling you recognize and translate, never as a place you dial.

Tests, counted

SuiteCount at the snapshotWhat it guards
Engine pytest128The corpus, turns, the interpreter, links, and the kit drift check
Web vitest67, in 13 filesThe browser client's parsing and state
iOS unit tests (XCTest)70, in 7 filesThe send order, uploads, waiting captures, offline copies, shares, addresses, one moment screen's width
iOS UI tests2A capture the engine never hears, and the throttled bench

The phone suite has its own runner (the third code block). It requires a dedicated simulator id, because several app sessions share a Mac's simulator list and a run on whatever was last open takes another app's screen. It regenerates the Xcode project first, because a test file added without regenerating is a file the suite silently does not run. And it judges by the log's own TEST SUCCEEDED banner, never by a filtered pipe. The Simulator has no camera, so the UI tests use a seeded camera: a launch argument hands the app synthetic 4032 x 3024 stills through the same preparation path a real shutter takes. The one expected skip is the throttled bench, which runs only when an engine behind the throttling proxy is named. The real camera and the live scanner are checked on the device.

TestFlight, and four separate facts

SparkMobile reaches devices only through TestFlight. The pipeline script is vendored from the kit, one program for every family iOS app, so a guard learned once runs everywhere. Its order: refuse a half-configured run, check the App Store Connect key, require the dedicated simulator, require committed inputs and a clean kit, check the icon, run the suite, run the visual gate, bump the build number, archive, confirm nothing moved during the archive, then export or upload, and commit and push the bump. The visual gate photographs every first screen and tab on the kit's 375 pt iPhone at the default and the largest Dynamic Type, and refuses a label cut short or text off the edge. Four layout bugs had shipped through a green suite, because a suite mostly checks that code runs, not what each screen looks like. The last of the four, a moment screen laid out wider than the phone, left a hosted layout test behind: it renders one moment's screen at four iPhone widths and fails if anything is drawn past the edge. One screen pinned is not every screen at every text size, so the gate still photographs them all.

Spark's own settings are a few lines: the build number lives in project.yml, there is no watch app, and bump commits start with spark-mobile. The history is 31 bump commits, builds 2 through 32. Build 2 was exported as an .ipa; builds 3 through 32 were uploaded, each naming the commit it was built from and the kit commit it compiled against. That is everything git can say. The script's header (the second code block) names four facts: uploaded, processed by Apple, given to the internal group, installed on the device. Only the last is delivery, and the repository records only the first. The honest sentence is "uploaded through build 32".

Two quests cover the ground around this one. Swift Quest teaches share extensions, intents, widgets and TestFlight in general; its running example is also called Spark, but that Spark is a different, invented app with a watch app of its own, and cwkSpark has no watch target. Firebrand Quest follows another universal phone and tablet app that reaches its engine only over the tailnet, from the side of the engine's gateway.

Code

cwkSpark, clients/apple/SparkMobile/Sources/AppModel.swift: the default engine from the registry, legacy spellings canonicalized, the host profile adopted (verbatim, lines 11-54)·swift
struct MobileSettings: @unchecked Sendable {  // UserDefaults is thread-safe; Foundation just does not say so
    /// Spark's name in the port ledger.
    static let member = "cwkSpark"
    static var defaultEndpoint: EngineEndpoint { FamilyEndpoints.twin(member)! }
    static var defaultLocation: String { FamilyEndpoints.twinURL(member)!.absoluteString }
    private static let locationKey = "spark.engineLocation"
    private static let situationKey = "spark.situation"
    private static let sceneLanguageKey = "spark.sceneLanguage"

    let defaults: UserDefaults

    /// The saved location, with the service host named by an older spelling (its raw
    /// tailnet IP, plain http, the engine's own port) read as Spark's HTTPS
    /// twin and saved that way once (todo #442). Any other host stays.
    var engineLocation: String {
        get {
            guard let saved = defaults.string(forKey: Self.locationKey)?.nonEmpty else { return Self.defaultLocation }
            let canonical = Self.canonicalLocation(saved)
            if canonical != saved { defaults.set(canonical, forKey: Self.locationKey) }
            return canonical
        }
        nonmutating set { defaults.set(newValue, forKey: Self.locationKey) }
    }

    var endpoint: EngineEndpoint {
        EngineEndpoint(location: engineLocation) ?? Self.defaultEndpoint
    }

    /// Follow the Mac this phone is pointed at (cwkPippa todo #549): read the
    /// host profile cwkPippa serves on the engine's host, so a phone pointed at
    /// Pippa's Ark dials the whole family there, and one pointed back at the service host
    /// returns to the ledgers. Best effort. Returns whether the kept profile changed.
    func adoptHostProfile(fetch: @escaping FamilyEndpoints.HostProfileFetch = FamilyEndpoints.fetchHostProfile) async -> Bool {
        guard let base = try? endpoint.baseURL() else { return false }
        return await FamilyEndpoints.adoptHostProfile(near: base, fetch: fetch)
    }

    /// A recognized legacy host spelling becomes Spark's twin; anything else is returned as given.
    static func canonicalLocation(_ raw: String) -> String {
        let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
        guard !trimmed.isEmpty, let twin = FamilyEndpoints.canonical(host: trimmed, for: member),
              let url = try? twin.baseURL() else { return raw }
        return url.absoluteString
    }
cwkSpark, scripts/mobile-testflight.sh: the vendored pipeline's usage and its four facts (verbatim, lines 10-16)·shell
#   scripts/mobile-testflight.sh            # guards → tests → build number → archive → .ipa
#   scripts/mobile-testflight.sh --upload   # …and hand it to App Store Connect
#   scripts/mobile-testflight.sh --plan     # resolve and check every fact; build nothing
#
# Upload is not delivery. Four separate facts: uploaded, Apple processed it, the internal
# group has it, the build is INSTALLED on the device. Only the last one is delivery — say
# which one is true (cwkPippa docs/IOS-NATIVE-DELIVERY.md §1).
cwkSpark, scripts/test-mobile.sh: regenerate first, then let the log's verdict decide (verbatim, lines 35-58)·shell
# project.yml is the source; the .xcodeproj is generated output. A test file
# added without regenerating is a file the suite silently does not run — so
# regenerate before trusting a green result, never after.
(cd "$mobile" && xcodegen >/dev/null)

# xcodebuild refuses to overwrite a result bundle.
results="$mobile/build/mobile-tests.xcresult"
/bin/mkdir -p "$mobile/build"
/bin/rm -rf "$results"

xcodebuild test \
  -project "$mobile/SparkMobile.xcodeproj" \
  -scheme SparkMobile \
  -destination "platform=iOS Simulator,id=$SPARK_MOBILE_SIMULATOR_ID" \
  -resultBundlePath "$results" \
  > "$mobile/build/mobile-tests.log" 2>&1 || true

# The log's verdict decides, not a filtered pipe (learning #370).
if ! /usr/bin/grep -q "\*\* TEST SUCCEEDED \*\*" "$mobile/build/mobile-tests.log"; then
  /usr/bin/grep -E "error:|failed" "$mobile/build/mobile-tests.log" | /usr/bin/head -30 >&2 || true
  print -u2 "HARD_FAIL the iOS suite did not pass — see $mobile/build/mobile-tests.log"
  exit 1
fi
/usr/bin/grep -E "Executed [0-9]+ tests" "$mobile/build/mobile-tests.log" | /usr/bin/tail -1

External links

Exercise

Find every server address your mobile app knows and move it behind one lookup that reads a single table you own. Add a migration that recognizes the old spellings a user may have saved and rewrites them once. Then write down your release in four lines (uploaded, processed, assigned to testers, installed) and mark which ones your repository can actually prove.
Hint
Test the lookup with a fresh UserDefaults suite so saved values never leak between tests. For the release, the build commit and the upload log prove the first line. The other three live in App Store Connect and on the device, so the honest record says where you checked each one, or that you did not.

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.