Squirreleye LLC Squirreleye Service Notes Naked Labs Mirror · Revival Series Index Tools About
09
Section 09 · follows 08

Versioned logs and a streaming pipeline: closing the iteration loop

Written
2026-05-16
Log
nakedmirror-debug-20260516-110455.txt

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.”

§ 9.1

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.

§ 9.2

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:

Fig. 9-1
swift
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) starting

The export header gained:

=== NakedMirror Debug Log ===App Version: 0.2.0 (build 1)Generated: 2026-05-16T17:04:55.024Z

And 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.

§ 9.3

Step two: the slow loop nobody named

With versioning solved, the second-order question shifted. Every test cycle now looked like this:

  1. I change the code on the Mac.
  2. I build in Xcode and install it on the iPhone.
  3. I run the diagnostic.
  4. I tap Export and AirDrop the .txt back to the Mac.
  5. 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:

Fig. 9-2
swift
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.

Fig. 9-3
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.

§ 9.4

What this changes

The new loop is:

  1. I change the code on the Mac.
  2. I build in Xcode and install it on the iPhone.
  3. I run the diagnostic.
  4. I read NakedMirrorDebug/logs/current.txt at 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.

§ 9.5

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 a current.txt with 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=N plus 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?)
§ 9.6

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.main plumbing 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 /logs to 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.
§ 9.7
§ 9.8
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.

1
Read app version from Bundle.main at runtime
Add static helpers to DiagnosticEngine: `static let appVersion` reading `Bundle.main.infoDictionary?["CFBundleShortVersionString"]` and `static let buildNumber` reading `CFBundleVersion`, falling back to "?" if missing. Confirm MARKETING_VERSION in project.yml already feeds the Info.plist via xcodegen.
2
Surface version in log, export, and UI
Using DiagnosticEngine.appVersion/buildNumber, write "NakedMirrorDebug v{appVersion} (build {buildNumber}) starting" as the first line of the global log from init(), add "App Version: {appVersion} (build {buildNumber})" to the export header built in buildLogExport(), and set the navigation title to "Mirror Diagnostics v{appVersion}".
3
Document a version-bump convention
Write down a semver bump rule in the project's notes (e.g. a CONTRIBUTING or STATUS doc): bump the minor version for every instrumentation or behavior change you intend to test against hardware, bump patch for comment/log-wording-only changes, and reserve major for the first end-to-end successful scan. Update project.yml's MARKETING_VERSION now and commit the convention text alongside it.
4
Add GET /logs to the existing listener
Factor exportLogFile()'s string-building into a `buildLogExport()` function reusable by both the file-writer and a new HTTP handler. In the existing port-7777 listener's request router, add `if method == "GET" && path == "/logs" { return httpResponse("200 OK", body: buildLogExport(), contentType: "text/plain; charset=utf-8") }`. Since buildLogExport() reads globalLog on the main actor, make processHTTP non-nonisolated and have the connection's receive-completion handler hop to @MainActor via a Task before calling it.
5
Write the Mac-side log-tailing script
Write a stdlib-only Python script, NakedMirrorDebug/tools/tail-debug-log.py, using urllib.request and pathlib, that polls http://<iphone-ip>:7777/logs every 2 seconds and writes the response to NakedMirrorDebug/logs/current.txt, streaming new lines to the terminal. When the polled snapshot's "v… starting" line count increases (app relaunch/reset), archive the previous current.txt under NakedMirrorDebug/logs/sessions/v{version}-{timestamp}.txt before overwriting. Add NakedMirrorDebug/logs/ to .gitignore.