The previous post closed with a feedback loop that was supposed to be faster: the iPhone exposes /logs, a Mac-side Python script polls it, and I read the file directly. No more AirDrop. The first real test of that loop happened the same afternoon, and it produced two findings: one a parser bug I’d been blind to for sessions, the other a piece of friction that only got noticed because the loop was finally fast enough to expose it.
The first WiFi-discovery log I’d ever read
I tapped the toolbar 📶 button and tapped Refresh. v0.2.0’s instrumentation kicked in, the iPhone wrote to its global log, the Mac-side tail mirrored the file, and within two seconds I was reading lines I’d never seen before.
The good news first: every step of the protocol worked. The mirror accepted the 0x31 command, the count read returned [0c] (twelve in decimal, base trap acknowledged), and twelve SSID reads followed. Each one came back as 81 bytes of data. The hex dumps were clean. The format string was exactly what the APK said: index/SSID/security/signal.
The bad news, on the very next line: every one of those twelve SSID parses failed. The log told me twelve times in a row:
WARN WiFi discovery: SSID[0] parse failed (expected index/SSID/security/signal)WARN WiFi discovery: SSID[1] parse failed (expected index/SSID/security/signal)…INFO WiFi discovery: parsed 0 network(s) from 12 advertisedThat last line is the punchline. The mirror told me about 12 networks. I rejected all 12.
sequenceDiagram
participant Phone
participant Mirror
Phone->>Mirror: write 0x31 to scannerAction
Phone->>Mirror: read wifiNumNetworks
Mirror-->>Phone: count = 12 (0x0c)
loop 12 times
Phone->>Mirror: read wifiNetworkSSIDEnum
Mirror-->>Phone: 81-byte buffer (C-string + zero padding)
end
Note over Phone: each buffer should parse as "index/SSID/security/signal\0\0\0..."Where the parser tripped
The instrumentation had logged each SSID three ways: raw byte hex, a decoded string, and the parse-failure warning. Reading the three together made the bug instantly visible:
WiFi discovery: SSID[0] (81 bytes) raw=[31 30 2f 69 52 6f 62 6f 74 2d 46 38 34 45 2f 31 2f 35 39 00 00 00 00 00 00 …] str="10/iRobot-F84E/1/59 "WARN WiFi discovery: SSID[0] parse failedThe mirror sends each SSID as an 81-byte fixed-width buffer holding a C-string. The format payload is followed by zero-padding: twenty bytes of 10/iRobot-F84E/1/59, then sixty-two bytes of 0x00. Standard C convention. The parser:
let raw = String(data: ssidData, encoding: .utf8) ?? ""let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)let parts = trimmed.split(separator: "/", maxSplits: 3, omittingEmptySubsequences: false).map(String.init)guard parts.count >= 4, let idx = Int(parts[0]…), let signal = Int(parts[3]…) else { continue }…handled this almost right. The split works, because / is unambiguous. parts[0] parses cleanly as "10". parts[1] and parts[2] are the SSID and security fields and are never parsed numerically. parts[3] is where it dies, because parts[3] isn’t "59". It’s "59\0\0\0\0…".
String.trimmingCharacters(in: .whitespacesAndNewlines) does not strip null bytes. The Unicode whitespace category covers spaces, tabs, newlines, form feeds, vertical tabs, the various exotic Unicode spaces, but not U+0000. Null isn’t whitespace in any Unicode sense; it’s its own thing. So trimmed carries the null padding all the way through the parse, and Int("59\0\0\0…") returns nil, because Int rejects non-digit characters.
The fix is one line at the wire boundary. Strip the null padding from the Data before converting to String:
let cString = Data(ssidData.prefix(while: { $0 != 0 }))let raw = String(data: cString, encoding: .utf8) ?? ""Data.prefix(while:) walks the bytes and stops at the first 0x00, exactly matching C-string semantics. The resulting cString is the payload only, "10/iRobot-F84E/1/59", nineteen bytes, no padding, ready to split.
I also widened the log line slightly so the next run shows both the raw buffer size and the payload size: (81 bytes, 19 before NUL padding). The hex dump stays full-width (useful to confirm I’m still reading the same wire format). The str= field becomes much shorter and easier to scan.
The other thing the streaming loop made obvious
Before the WiFi-sheet fix went anywhere, there was a smaller annoyance I hit twice: mirror-tail <my-ip> couldn’t connect until step 11 had run. The HTTP listener doesn’t bind at app launch: it binds inside stepStartListener, which is only called when Run All reaches that step or I tap Step 11 individually. With the old AirDrop flow, this didn’t matter (you only exported once the whole run was done anyway). With the streaming flow, it matters every session: open the app, the script is already trying to poll, “connection refused” until you remember to tap Run.
That ordering is a side effect of an earlier design assumption: that the listener is a diagnostic step whose binding success is itself part of what I’m testing. It is. But there’s no reason that test can’t be “the listener started at app launch and was still reachable when I asked.” So:
override init() { … bootstrapListener()} private func bootstrapListener() { for candidate in [7777, 7778, 7779] { do { try startListener(port: candidate) listenerPort = candidate globalLog.append(…"Listener auto-started at app launch on port \(candidate)…") return } catch { globalLog.append(.warn, …"port \(candidate) failed: …") } } globalLog.append(.error, "could not bind any of 7777/7778/7779: step 11 will retry")}stepStartListener then becomes mostly a verifier: if listener != nil, wait a few hundred ms for listenerReady, mark step 11 PASS with detail (auto-started). The bind-loop survives as a fallback for the rare case where auto-start failed (e.g., another process already on 7777 at app launch but freed by step-11 time).
Cosmetically, the global log on a fresh launch now reads, before I do anything:
INFO NakedMirrorDebug v0.4.0 (build 1) startingINFO Listener auto-started at app launch on port 7777 (Device IP: <my-ip>)…which means mirror-tail connects instantly the next time you open the app. No more “connection refused [1 consecutive]” while you remember to tap Run All.
What I learned
- String trimming and binary-buffer trimming are different problems.
trimmingCharacters(in: .whitespacesAndNewlines)is for human-typed strings with stray spaces. When the wire format is a C string in a fixed-width buffer, the first thing to do is treat the bytes as bytes and find the null terminator. Convert toStringonly over the payload range. Going the other way (convert first, then try to strip) is a recipe for the exact bug I hit, because Unicode whitespace and C-string padding are different sets of characters, and the language’s standard trimmer only knows about one of them. - The streaming pipeline paid for itself in its first session. This specific bug had been latent since commit
2280a9ctwo weeks ago, when the WiFi sheet shipped and I first saw “Discovered networks (0)” with no log evidence. v0.2.0 added the byte-level instrumentation; v0.3.0 made it streamable; v0.4.0 closed the bug. Each version is an obvious follow-on once the previous one ships, but without the pipeline I’d still be guessing. The “you can’t iterate faster than your slowest feedback step” line gets quoted a lot for a reason. - Watch for diagnostic-only side effects. “Listener binds at step 11” was a perfectly reasonable design when the listener’s only consumer was the mirror itself (which only POSTs after a successful scan). The moment a second consumer appeared (a Mac script polling
/logs), that ordering became wrong. Adding a feature can change the right answer for an unrelated bit of architecture.
What I’m testing in the next build
One thing: tap WiFi Setup, tap Refresh, watch the streamed log. Expect to see twelve clean parsed N network(s) lines, no parse failed warnings, twelve rows in the SSID picker. With <my-home-wifi> and 1140842 at 92% and 85% signal respectively, I have good candidates to actually connect to.
Once that works, the real next test is what happens when I pick a network, enter the password, and tap Connect, which exercises the wifiSetSSID write path and the wifiConnectionStatus poll loop. That code is already instrumented (v0.2.0). One step at a time.
Follow along
Procedure 10-AIf you’re parsing fixed-width binary buffers from a BLE peripheral and getting inexplicable parse failures:
- 1Log the raw bytes, the decoded string, and the parse result side by side. Three views of the same failure make the bug visible in seconds instead of requiring a guess.
- 2Before converting a fixed-width buffer to a
String, strip trailing null bytes at the byte level with something likeData.prefix(while: { $0 != 0 }). Don’t rely ontrimmingCharacters(in: .whitespacesAndNewlines); Unicode whitespace and C-string null padding are not the same set of characters. - 3If your app has any other process polling an HTTP endpoint it exposes, start that listener at app launch rather than lazily inside a diagnostic step. A lazily-bound listener is invisible with a manual export-and-transfer workflow, but becomes a recurring “connection refused” the moment something else depends on it being up immediately.
- 4When you do bind lazily as a fallback, try a small range of ports (not just one) and log which one succeeded.
Agent prompts Procedure 10-A as prompts for a coding agent · 4 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.