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.
Tests, counted
| Suite | Count at the snapshot | What it guards |
|---|---|---|
| Engine pytest | 128 | The corpus, turns, the interpreter, links, and the kit drift check |
| Web vitest | 67, in 13 files | The browser client's parsing and state |
| iOS unit tests (XCTest) | 70, in 7 files | The send order, uploads, waiting captures, offline copies, shares, addresses, one moment screen's width |
| iOS UI tests | 2 | A 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.