The Grey Square: A Month of Shipping a Tour App to iPhones

An August on the Loadout Advance iOS app: a Dynamic Island that drew a grey square for reasons that had nothing to do with the image, a project file that quietly broke TestFlight, and a search built for thumbs instead of databases.

Most of August went into the iOS companion for Loadout Advance, the tool touring crews use to advance shows. Twenty-one commits over about three weeks. Some were features: a Travel tab, an advance checklist on the phone, Drive folders, backing up a show's files from the phone. But the parts worth writing up are the ones where the platform did something I didn't expect.

The grey square

The app puts the run of show on the lock screen and in the Dynamic Island as a Live Activity: what's happening now, what's next, a countdown. The compact island showed the artist's picture on the left. Or it was supposed to. What it actually showed, build after build, was an empty grey square.

The first theory was bad artwork. It turns out UIImage(contentsOfFile:) can hand back an image that isn't nil and has zero size. So the obvious Swift guard takes the success branch, draws nothing, and the fallback never runs:

private static func usable(_ image: UIImage?) -> UIImage? {
    guard let image, image.size.width > 0, image.size.height > 0 else { return nil }
    return image
}

The downloader was also re-encoding every image: decode, redraw, re-JPEG. Most show artwork is already small (the typical avatar is 300px and around 20KB), so that step bought nothing and was one more place a bad file could come from. Small images now get written byte-for-byte, then read straight back and decoded to prove they work. If one doesn't decode, it gets deleted instead of cached forever.

Then there were the files already on devices. The old re-encode path could leave behind a JPEG that decodes perfectly and is visually blank. No validity check can catch that, because nothing about the file is invalid. The only way out was to walk away from those files, so the cache key got a version number:

private static let cacheVersion = 2

static func filename(eventID: Int) -> String {
    "ros-artwork-v\(cacheVersion)-\(eventID).jpg"
}

If you can't tell a bad cached file from a good one, version the key.

None of that fixed the square. For one build I made the fallback bright red with a warning icon, so a single glance would tell me which branch was running. A photo meant fixed. Red meant the cache write was at fault. Grey meant the extension on the device wasn't the one I was building.

A fresh install with an empty cache still showed grey. That should have been impossible: with no file on disk, my view falls back to red. Grey meant my view wasn't rendering at all. iOS was drawing its own placeholder because the view missed its render budget. The compact island re-renders constantly and gets very little time, and reading a file off disk inside a view body is exactly how you miss that deadline.

The fix was to stop touching the disk there. The app mark now ships in the widget extension's own asset catalog. It's already in memory and can't miss the deadline. Before shipping I checked that Assets.car was actually embedded in the .appex with the glyph in it, since a missing asset would have failed exactly like the original bug.

A Live Activity that's too slow doesn't show an error. It shows you something that isn't yours.

Live Activities fail silently

The same morning had two smaller lessons in the same vein.

To get the countdown flush against the right edge, I tried .frame(maxWidth: .infinity) on a child inside a .fixedSize(horizontal: true) parent. That's a contradiction: the child wants all the width and the parent wants its ideal width. In a normal app SwiftUI lets it slide. In a Live Activity, a layout that fails renders as nothing, and the whole lock screen card went blank. The fix was to delete the clever bit. Trailing alignment plus the Spacer already in the HStack was all it ever needed. One thing did need saying explicitly: Text(_, style: .timer) lays itself out as centred multiline text, so trailing alignment on the stack does nothing to it. It needs .multilineTextAlignment(.trailing) on the text itself.

The compact island also gives its trailing slot about 50pt, and a live countdown can be anything from "42" to "10:22:15", so it truncated. The island now shows the clock time of the next call instead. That's always four or five characters, never stale, and honestly more useful at a glance. The full countdown stays on the lock screen and in the expanded island, where there's room.

The last Live Activity lesson was about editing. Timings live in ActivityAttributes, and those are frozen once an activity starts. There's no API to change them. So when someone moves doors back fifteen minutes, the app can't push that into the running activity. It tears it down and starts a new one:

func reflect(event: EventFull) async -> Bool {
    guard runningEventID == event.id else { return false }
    let fresh = RunOfShowTimeline.milestones(for: event)
    guard fresh != runningMilestones else { return false }
    try? await start(event: event)
    return true
}

It runs on every event reload, so an edit made on another device gets picked up too.

TestFlight, killed by a project file

For a stretch, TestFlight uploads just stopped working. The cause was one line in project.pbxproj:

objectVersion = 110;

Format 110 can only be opened by the Xcode 27 beta. App Store Connect refuses any binary built with a beta SDK (error 90534, "Unsupported SDK or Xcode version"). Worse, it fails at the very end of the run, after the archive and export have finished, so every attempt cost the full build time before telling me no. Trying a newer beta failed exactly the same way.

The fix was dropping to objectVersion = 77, the Xcode 16+ format. It still supports the PBXFileSystemSynchronizedRootGroup setup this project depends on, so nothing was lost. The release Xcode listed all three schemes, built clean, and build 30 went up to TestFlight. The upload script now defaults to the release toolchain, with a comment saying why in capital letters.

There's a related trap in the version number. MARKETING_VERSION in the project still says 1.3, left over from the baseline commit. Every release passes VERSION= explicitly instead, because Apple compares versions component by component, and 1.3 would read as a downgrade from 1.27.

You can build with the beta. Just don't let it save the project file.

Search for a thumb, not a database

Assigning an inbound email to a show used to offer a list of recent events: fifty of them, newest first. The show you wanted was usually not in it. The server's search wants every word of the query to show up somewhere, which is right for a database and wrong for someone typing with one thumb on a loading dock. A missed letter or a swapped pair and you get nothing.

The app now loads every show the account can see, once, and searches on the device. FuzzyMatch ranks results instead of filtering them. The whole query as a substring wins outright, with earlier matches ranked higher. After that, each word is scored on its own: exact match, prefix, substring, then edit distance. If a word matches nothing, it gets one more chance as letters in order across the whole string, so an abbreviation can still find a long venue name.

The rule I like best is how many typos it allows:

private static func allowedEdits(_ token: String) -> Int {
    switch token.count {
    case 0...3: 0
    case 4...6: 1
    default: 2
    }
}

Short words get no typo allowance at all, because one edit turns "MTL" into "ATL". In this business, that's a different country. The Levenshtein check also gives up as soon as it can't come in under the limit, because it runs for every word on every row on every keystroke.

The same list powers filing attachments. A rider belongs to the act, a parking map belongs to the building, and a stage plot belongs to that night. So you tick the attachments, pick a show, venue or artist from the same fuzzy list, and they go where they belong.

Small doors

Two features were mostly about what the phone shouldn't show.

The venue's "nearby" panel (food, pharmacy and whatever else is configured) came to the phone behind an unlabelled pin on the show header. What crew look up around a venue at midnight is their business, and it shouldn't be readable over their shoulder. The results are cached on the server, so opening the screen never costs a Places lookup, and refreshing stays admin-only because forcing a lookup costs money.

The Travel tab puts the tour book on the phone: the days between shows, flights, ground transport, hotels, passports. It's read-only on purpose. The phone's job is answering "what am I doing now, what's my room number, who do I call" at six in the morning in a hotel lobby. The editor stays on the web. A day lists flights, ground and hotel check-in by the clock instead of as three separate blocks, because a time column down the left promises order. Anything without a time it can read, "TBD" included, drops to the end of the day instead of sorting to the top. Passports carry their own expiry warning, since plenty of countries turn away a passport with less than six months left on it.

The tab also hides for accounts that can't open a tour book, and the Inbox hides for non-admins. The server would refuse both anyway. Showing someone a door they can't open isn't a feature.