Driving a locked iPhone from a coding session
I’ve been building Deep Cut Atlas, an iOS app that leans on MusicKit, Apple’s framework for talking to Apple Music. The simulator can’t touch MusicKit at all - it throws or silently no-ops depending on the call. Every real check of a MusicKit feature meant a physical phone, and if I wasn’t standing next to it, work stalled until I was.
The fix I landed on this session: hand a spare iPhone 13 over to a coding agent full time, so it can tap, screenshot, and verify against the real device without me in the room.
The path I didn’t take
The obvious route is WebDriverAgent, the same private XCTest driver Appium and most mobile automation tools sit on. I actually started down it a couple sessions back: go-ios for the tunnel, a shallow WDA clone, mobile-mcp wired up as an MCP server. Then I stopped to think about what I was actually signing up for.
WDA needs a signed build that can expire, a tunnel process, a port forward, and (per its own GitHub issues) it leaks memory and dies on long sessions. None of that is a dealbreaker on its own. What killed it was the lock paradox: to keep the phone reachable, I’d have needed Auto-Lock set to Never, meaning an always-on, always-unlocked iPhone sitting on a desk. That’s a real security hole for the sake of test automation.
What I used instead
Apple ships something that solves the exact opposite problem: iPhone Mirroring lets you drive an iPhone from a Mac while the phone itself stays locked. The trick was finding a way to send taps and screenshots into that mirrored window programmatically. mirroir-mcp does exactly that: it drives the iPhone Mirroring window with CGEvent (macOS’s low-level input API) and reads the screen with Vision OCR. No WebDriverAgent, no code signing, no tunnel. devicectl still handles install, launch, and logs, unchanged.
The dual-account setup is the other half of this. Mirroring requires the phone’s iCloud account to match the Mac’s, but I didn’t want test data (library adds, playlists, play history) landing in my real Apple Music account. So the phone’s iCloud is my primary account (for the mirroring pairing), while Media & Purchases is switched to a secondary family account. Apple Music itself follows Media & Purchases, so anything the app writes during a test session goes into an isolated library.
Five gate tests, in order
Before trusting any of this for real remote work, I wrote five gate tests, and told myself to stop and reassess if any of them failed:
- G1 - the account split actually isolates Apple Music data. Passed, with one region-mismatch snag along the way that needed fixing.
- G2 -
devicectl installand launch work on a phone that’s locked with mirroring active. This was the one true unknown going in. It passed, and it corrected my mental model too: mirroring doesn’t show the phone’s lock screen, it shows the live, fully interactive app while the physical device stays dark. That’s the intended behavior, not a bug. - G3 - reconnect after an idle timeout, hands-free. The mirroring session had already gone idle by the time I sat down to test this.
open -a "iPhone Mirroring"brought it back with nobody touching the phone. - G4 - reachable for hours with nobody around. I decided to skip a dedicated multi-hour test for this one. If it’s actually a problem, it’ll show up during a real remote session and I’ll fix it then.
- G5 - the full loop against the real app: screenshot, read the screen, tap, verify. This worked cleanly, navigating between tabs and into a detail sheet, phone locked the entire time.
Two things that surprised me
The first was a red herring in my own plan. I’d written cursor_mode: "preserving" into the setup notes as a global config setting to stop the mouse cursor from jumping around during automation. It doesn’t exist as a global setting. It’s actually a parameter on the individual tap call, and even then it only restores where your mouse pointer sits afterward. It does nothing about the deeper issue.
That deeper issue is the second surprise, and I found it the hard way: mid-session, a tap yanked focus away from what I was doing. mirroir-mcp’s own FAQ says as much: macOS only routes input to the frontmost app, so every tap has to bring iPhone Mirroring forward first. There’s no API to send events to a window that isn’t in focus. The documented workaround is to give iPhone Mirroring its own macOS Space, which keeps your cursor position and text selection intact in whatever Space you were actually working in. I set that up and tried again. It stopped losing my place, but it still swiped me over to that Space every time a tap fired. Better, not solved. I’ve filed a follow-up spike to see if there’s a way to avoid the visible switch entirely.
Also, minor but the kind of thing that costs you twenty minutes if you don’t catch it: npx -y mirroir-mcp mirroir doctor looks like it works. It exits cleanly, prints some startup logging, and gives no error. It also never runs the actual doctor checks, because npx resolves to the package’s default binary (the MCP server itself) instead of the mirroir CLI tool bundled alongside it. The fix is npx -y -p mirroir-mcp -- mirroir doctor, which tells npx explicitly which binary inside the package to run.
Where it landed
Four of the five gates passed with real evidence, and I made a deliberate call to skip the fifth rather than burn hours proving something I’ll notice anyway if it breaks. The reconnect flow is a five-line shell script I run by hand before a session, on purpose, not a login item or a watchdog. I’ve been burned before by building recovery infrastructure for failures I hadn’t actually observed yet, and I didn’t want to repeat that here.
The result is that I can now hand the agent a locked phone and a coding session, and it can go check whether a MusicKit flow actually works, without waiting for me to be free.
Related reading
The mirrored window lied about the phone being unlocked
A one-line grouping fix that was already written, and three walls between it and proof: actor isolation, log stream's Mac-only scope, and a mirrored session that looks unlocked when the device isn't.
Pre-release albums, and the fix I couldn't fully verify
Apple's 'Track N' placeholders rendered as real data. The one detection signal I couldn't confirm became the one signal I stopped depending on.
A subscribe button, a MusicKit API I'd never used, and a sheet that dismisses into silence
musicSubscriptionOffer presents Apple's native sheet - and nothing in the app would ever notice it closing. The correctly-coded action that was still a functional dead end.