Why I made my iOS app's core testable on Linux

Why I made my iOS app's core testable on Linux
Photo by Vadym Kudriavtsev / Unsplash

Last year, I started working on a simple yet exciting project: an app that runs a one-car chauffeur service the way an airline runs a flight. A passenger gets a boarding pass via email. At the designated pickup time and location, the driver scans it. As the vehicle starts to depart, the app reads out a safety briefing derived from the vehicle and live map estimates, then hands the destination to Apple Maps.

My goal, aside from just building a fun app as part of my aviation hobby, was to learn more about tools I wouldn't otherwise use. It's what lies below the surface of the app that contains the most intrigue to me, and I learned how to run this by refusing to put any of the logic where it's easiest to write.

Crosscheck: Trip hospitality at its finest

Since the inspiration for this app stems entirely from aviation, I wanted to make sure the name made reference to the industry. There are many different terms available, and "Crosscheck" is certainly a popular term for software, but this also embodies what the app's purpose is, and is instantly recognizable to the target audience.

The thesis

The app, on its own, is a demonstration of technology. The thesis is that low coupling and high cohesion make software more fun to build, not less, and that the payoff shows up as capability rather than as architecture diagrams. Two concrete tests exist for whether the thesis holds:

  1. The entire domain compiles and tests on Linux with no MapKit, AVFoundation, PassKit, SwiftUI, or CoreLocation anywhere in the core package.
  2. The iOS app and the AWS Lambda import the same package so that pass construction, record locators, seat maps, and briefing text implementations conform to the "write once, use everywhere" paradigm.

What the app does

The app is a personal chauffeur-service app. Passengers receive an Apple/Google Wallet boarding pass by email ahead of the trip. At pickup, the driver scans it with the phone camera. Before departure, the app speaks a safety briefing tailored to the actual vehicle doors, seat rows, and a trip summary built from live MapKit estimates. After all of this, the destination information is sent to Apple Maps for guidance so navigation can start.

The airline feel comes from the pass layout, station codes, seat assignments, and boarding groups. The transit type is generic, allowing for greater flexibility to use this in a car.

App architecture

The core package, called TripKit, sits in the middle. It imports Foundation, swift-crypto, and nothing else. Everything else is ported.

  • TripKit serves as the domain. Value types, scope classification, vehicle mapping, the briefing composer, and the ticketing layer (record locators, pass documents, the barcode codec, HMAC). No Apple frameworks are included here.
  • TripKitApple contains all the adapters. MapKit provides estimates, AVFoundation reads out speech, VisionKit/DataScanner powers the barcode scanning feature, and CoreImage handles the PDF417 barcode rendering. Each of these adapters implements a protocol from TripKit and contains no business logic. This allows for maximum testability independent of Apple's platforms.
  • IssuerLambda is a Rust Lambda function built on AWS. It signs the pass, zips it, and sends it via AWS SES. It imports no Swift at all, and instead the device builds the pass document and hits the POST endpoint.

Twelve ports, each with a real adapter and a scaffold for testing. Every protocol conforms to Sendable, which is what makes the Linux job the load-bearing one. The app is built to be run on iOS 26 running Swift 6 with strict concurrency.

Building the app

CarPlay

When developing the CarPlay functionality of the app, the initial idea was to put a QR code up in a "Now Playing" view with some soft boarding music, switch to the pre-flight briefing, then show navigation as the briefing finished. I knew that some challenges would come up while developing the app, but what ended up happening instead is much better.

The app, in its current form, is a CarPlay Driving Task app, meaning the app's primary function is an app that enables tasks people need to do while driving and help with the drive. A safety briefing that keep's the driver's attention on the road while also going over the details of the drive is an important function for this app.

All of this is meant to keep the focus of in-car features on the driver's execution of the drive, not issuing passes.

Using the vPIC API to pull automotive data into the app

While I really enjoy driving my car, I realize that not everyone drives the same kind of car, so I need some way to accurately determine vehicle exits while accounting for the many types of vehicles on the market. Thankfully, the NHTSA vPIC API is free, keyless, and authoritative for a vehicle's structure. Think "there are four exits on this vehicle, two in the front, and two in the back", and it's all set up when the driver first opens the app.

This means the pre-trip briefing can be generated on-device in a way that is also testable on a Linux machine. When building a briefing, the system only needs the saved operator profile, vehicle information, and trip manifest.

The issuer Lambda is Rust

My original plan when building the pass issuance Lambda was to build in Swift and import TripKit. However, the invocations are infrequent, so it would be largely dominated by cold starts. Swift, as fun as it is to work with, has a slower cold start than Rust, so I went with the lowest latency option for cold starts. This could change to Swift in the future as I deploy and iterate on the architecture, but wanted to start with low latency to reduce time and cost.

As a result, the Lambda is unable to import TripKit, but as the Lambda's only purpose is pass signing and sending to users, this keeps the app secure, prevents signing keys from being distributed or read by any critical paths, and allows for safe, secure pass issuance.

What I learned

There are a few different things I learned while working on this project.

The biggest lesson learned is that decoupling is a capability, not a tax. The ports look like overhead until you realize they are necessary to run the core tests on Linux, the Lambda in Rust, and the briefings to run without a synthesizer. The architecture isn't overhead on the way to the point, but instead, the architectureis the point, and it pays off in its testability everywhere.

Another great lesson from this project is that small rules can have compounding effects. For instance "Spoken and display strings are different strings" is one line in a decision record. It recurs in destination naming, duration rendering, distance rendering, state names, and arrival times. Adhering to rules can be defined in the code written in conformance to those rules. The rule is inexpensive to establish upfront and expensive to retrofit after the fact.

Perhaps the most underrated lesson learned is that boring decisions are the ones that matter. File protection class, audio session timing, template depth, and transit type aren't glamorous to talk about, but they're the ones most likely to cause problems when used, such as in CarPlay with the phone locked or FM radio playing during a briefing. I spent more time on these than on any clever algorithm to make building the app easy and fun.

The app isn't production-ready yet, but I look forward to that day I can make this available on the App Store.

Closing

I built this because I have a passion for aviation, but I also wanted to use my skills to build something I could use. While my wife finds scanning a boarding pass to be cumbersome for a drive to the grocery store, it's everything that makes this possible that I find the most interesting. I have found that macOS CI/CD runners aren't cheap, and this kind of system could be an incredible opportunity to learn how to find a middle ground where the app's core logic could be tested independent of the app itself, on Linux, which reduces development costs, gives me greater flexibility for where this can be built, and I only have to rely on a Mac when deploying the app to TestFlight, which can be done as part of a deployment pipeline.