The previous post ended with the audit pivot: stop iterating per-step, sit down and read the APK end to end, write the master plan. That work landed. The plan is now eight phases long and lives at the root of the repo. But the very first action it spawned (instrumenting the silent discoverWifiNetworks so I could see what the WiFi sheet was actually doing) surfaced a second-order problem that had been quietly stealing time the whole project.
This post is about that problem, and about the two-part fix that closes the loop between “I changed something” and “I can read the result.”
The first hardware run after the pivot
The day after the audit, I ran the freshly-instrumented build on the iPhone. I’d added eight or nine new globalLog.append(...) calls inside discoverWifiNetworks: the start of the operation, the 0x31 write attempt, the write result, the wifiNumNetworks read attempt, the raw bytes, the decoded count, every SSID read, every parse. The plan was to tap the WiFi Setup button, watch the log fill in, and finally see whether the count was zero because the mirror hadn’t populated or because I was reading the wrong characteristic.
The log came back. It ended at step 8.
Steps 9 through 15 were all [PENDING]. The toolbar WiFi button never got tapped. There was no WiFi discovery line anywhere in the global log. I had exported the file before getting to the part I’d instrumented.
That alone wasn’t the surprise. The surprise was the second-order question: was this the build with my new instrumentation, or the build before it? The log file gave no way to tell. The filename was nakedmirror-debug-20260516-110455.txt, a date and a wall-clock time, nothing about the source state. The first line was === NakedMirror Debug Log ===. The header had Device IP (en0) and Listener Port and Listener Ready. Nothing about which version of the binary produced it.
Without a way to ask “did this run contain the new code?”, every log was potentially stale. I could only trust the latest one as long as I remembered, with confidence, whether I’d built since my last install on the phone. That confidence decays in any session longer than half an hour.
Step one: semver in the log
The fix here is short. project.yml already declared MARKETING_VERSION: "0.1.0". The Info.plist was generated from it by xcodegen. Nothing was reading it at runtime.
I added two static helpers on DiagnosticEngine:
static let appVersion: String = (Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String) ?? "?"static let buildNumber: String = (Bundle.main.infoDictionary?["CFBundleVersion"] as? String) ?? "?"…and I surfaced them in three places. The first line of the global log, written from init(), became:
INFO NakedMirrorDebug v0.2.0 (build 1) startingThe export header gained:
=== NakedMirror Debug Log ===App Version: 0.2.0 (build 1)Generated: 2026-05-16T17:04:55.024ZAnd the navigation title in the running app became Mirror Diagnostics v0.2.0, so I can glance at the top of the screen before a test and know what’s installed.
The convention for bumping is the part that matters more than the implementation. I picked semver with one rule:
- Minor (
0.X.0 → 0.X+1.0) for every instrumentation change or behavior tweak that I plan to test against hardware. The point is that the log from before the change and the log from after the change must be unambiguously distinguishable. - Patch for comment or log-message wording changes that don’t affect correlation.
- Major reserved for the first end-to-end successful scan against real hardware.
The instrumentation cycle that prompted all this (the one that added the silent-discoverWifiNetworks log calls) became v0.2.0. The version got committed to project.yml and written down in the project’s notes so the convention sticks.
Step two: the slow loop nobody named
With versioning solved, the second-order question shifted. Every test cycle now looked like this:
- I change the code on the Mac.
- I build in Xcode and install it on the iPhone.
- I run the diagnostic.
- I tap Export and AirDrop the
.txtback to the Mac. - I open the file, read it, and figure out the next change.
Step 4 is the one that quietly steals time. It’s not just the AirDrop click: it’s the discontinuity. You can’t read a log mid-run; you have to wait until something interesting (or boring) happens, then export, then transfer. If the interesting moment was three seconds long, you saw zero of it. If you wanted to confirm a hypothesis before a step finished, you had to run the test all the way through, export, read, then re-run.
The fix is obvious once you name it: the iPhone is already running an HTTP listener on port 7777 for the mirror’s eventual scan POST. There’s a GET /health endpoint that returns {"status":"ok"}. There was no reason I couldn’t add a GET /logs endpoint that returns the same string that the AirDrop export writes to disk.
The implementation is fifteen lines. exportLogFile() got factored: the string-building moved into buildLogExport(), and the file-writing kept the original name. The new /logs handler is:
if method == "GET" && path == "/logs" { return httpResponse("200 OK", body: buildLogExport(), contentType: "text/plain; charset=utf-8")}The only non-trivial part was that processHTTP was nonisolated and synchronous: it ran on the listener queue. To read globalLog, it now needs to be on the main actor. The change is small: drop nonisolated, and have receiveRequest’s completion handler hop to @MainActor via a Task before calling processHTTP. NWConnection.send is thread-safe, so the response can still be dispatched from there.
On the Mac side, NakedMirrorDebug/tools/tail-debug-log.py is the companion piece. It’s stdlib-only: urllib.request for the GET, pathlib for files, that’s it. It polls http://<iphone>:7777/logs every two seconds and writes the response to NakedMirrorDebug/logs/current.txt. New lines also stream to the terminal so I can see them as they happen. When the polled snapshot’s NakedMirrorDebug v… starting line count goes up (which happens whenever the app is relaunched or Reset), the previous current.txt is archived under logs/sessions/v{version}-{timestamp}.txt, so I keep history without having to think about it.
sequenceDiagram
participant Mac as tail-debug-log.py
participant Phone as iPhone (NakedMirrorDebug)
loop every 2 seconds
Mac->>Phone: GET /logs
Phone-->>Mac: buildLogExport() text
Mac->>Mac: write to logs/current.txt
end
Note over Mac: if the "v… starting" line count increases,<br/>archive the previous current.txt under logs/sessions/NakedMirrorDebug/logs/ is gitignored. The streamed mirror is an ephemeral working file; the version stamps and archive filenames cover correlation.
What this changes
The new loop is:
- I change the code on the Mac.
- I build in Xcode and install it on the iPhone.
- I run the diagnostic.
- I read
NakedMirrorDebug/logs/current.txtat any moment.
Step 4 is the part that compounds. I don’t have to wait until a run feels “done” before I look. I can poll the file directly without touching the phone again. And if something fires unexpectedly in the middle of a 30-second sequence, I can read it within two seconds.
It also means the AirDrop pathway is no longer load-bearing: it still works, but it’s now the fallback, not the primary. The export button kept its name; the filename now reads nakedmirror-debug-v0.3.0-20260516-110455.txt, so even fallback exports carry the version forward.
The wider point, the one this post is really about, is that you can spend a long time iterating against a feedback loop without noticing how slow the loop itself is. The audit pivot in the previous post fixed what I was iterating on. This cycle fixed how often I could iterate. Both kinds of slowness are easy to swim in.
What I’m testing in the next build
Two unchanged questions, now in a faster harness:
- The WiFi sheet still shows
Discovered networks (0). v0.2.0 added the byte-level instrumentation. v0.3.0 (this post) didn’t change that: it just made the result visible without an AirDrop. The next hardware run should produce acurrent.txtwith one of:WiFi discovery: 0x31 write failed→ BLE-layer issue.WiFi discovery: wifiNumNetworks raw=[00] count=0→ mirror hasn’t populated; fix is poll-after-write.WiFi discovery: wifiNumNetworks raw=[…] count=Nplus empty SSID reads → format/encoding wrong.
- Scale battery jumped from 7% to 85%. The session before this one was at 7% and still USB-C-cabled to the mirror for charging. The 2026-05-16 log shows it at 85%, which removes one of the three environmental gates entirely if the scale is now off-cable. (Sub-question: is the scale on the floor in the camera FOV, or just charged but still cabled?)
Takeaways
- Version-stamp your runtime artifacts. If a log file can’t tell you which build produced it, every log is potentially stale. Read it from the bundle, write it into the first log line, write it into the export filename, surface it in the UI. Three places, one minute of code, saves you from a class of “is this even the right run?” doubt that compounds across hours.
- A version-bump convention is a contract, not a fact. The
Bundle.mainplumbing is mechanical. The discipline of bumping the version when you change instrumentation is what makes it useful. Document the rule somewhere you’ll actually re-read it (a project notes file, a contributing guide). - Watch for AirDrop-class transitions in your feedback loop. Any step that requires you to manually move artifacts between machines is a place where iteration slows by 30 seconds and concentration leaks. Some of those steps are essential (the human is the one running the hardware). Some of them, like log retrieval, can be replaced by an HTTP GET.
- Existing infrastructure compounds. The iPhone was already running an HTTP listener for the mirror’s scan POST. Adding
/logsto it cost fifteen lines. If I’d had no listener at all, the streaming pipeline would have been a much bigger project. Re-using an open port is one of the cheapest changes a debug tool can absorb.
Follow along
Procedure 9-AIf you want the same fast “change it, see it” loop on your own debug build:
- 1Read your app’s own marketing version and build number from
Bundle.mainat runtime, don’t just declare it inproject.ymland forget it. - 2Print the version in the first line of your log, in the export’s header, and somewhere visible in the running UI (a nav title is enough).
- 3Pick one version-bump rule and write it down where your next session will see it: bump the minor version for every instrumentation or behavior change you intend to test on hardware, so two log files can never be mistaken for the same build.
- 4If your app already has an HTTP listener for any other purpose, add a
GET /logsendpoint that returns the same string your manual export writes to disk. Fifteen lines is a realistic budget. - 5On the other machine, write a small stdlib-only script that polls that endpoint every couple of seconds, writes the result to a local file, and archives the previous snapshot whenever the “app starting” line count increases. Keep the AirDrop export as a fallback, not the primary path.
Agent prompts Procedure 9-A as prompts for a coding agent · 5 prompts
Paste into Claude Code or a similar agent, one at a time, from the root of your iOS project. Each prompt maps to the step with the same number above.