How it works

One verified pipeline. There is no other way to write.

The device's acknowledgement for a home-screen write carries no information. You cannot know a write worked unless you read the device back. So ScreenWright treats every write, from either face, as a five-step transaction.

01 The five steps

Snapshot, validate, dry-run, write, verify.

snapshot
…/Snapshots/<udid>/20260901-011349.680-mcp-apply.plist

1 · Snapshot

The current layout is saved to your Mac before anything is sent, under Application Support, per device. At least the last 20 are kept.

validate
ok · 0 errors

2 · Validate

The plan is compiled against the device's real installed-app list and real grid metrics. A typo'd bundle ID, an app placed twice, a fifth dock item, a folder without a name, a page over capacity: any error refuses the write. Validation happens before sending, never after. There is no override.

dry-run
diff: 6 lines · nothing written

3 · Dry-run

The exact diff the write would make, with nothing written yet. In the app, that's the Review sheet. For an agent, it's preview_layout, and the diff it returns is the diff apply_layout will make.

write · settle
USB · ~3 s

4 · Write, then settle

The layout is pushed in one operation, then ScreenWright waits about three seconds for SpringBoard to settle before trusting anything.

verify
read back · 0 unexpected

5 · Verify

The device is read back and diffed against the predicted state, ignoring the two fields the device owns (iconModDate, bundleVersion). “Verified clean” is the only success signal that means anything. Anything else is reported as UNCLEAN, with the snapshot path and recovery advice.

02 Invariants and refusals

Seven rules the code cannot break.

The same rules bind the app and the server, because both are calls into one library. There is no unplanned write API anywhere in the code: writes go through one verified path, or they don't happen.

The seven invariants

  1. Never write without snapshotting first.
  2. Always verify after a write: settle, read back, diff.
  3. Diffs ignore device-owned fields.
  4. Validate before sending, never after.
  5. Never invent a bundle identifier; a typo'd ID is a silently vanished app.
  6. Widget entries are preserved verbatim unless explicitly moved or resized; their UUIDs are the device's.
  7. Dry-run on every mutating path, GUI and agent alike.

Refusals stale_plan validation_failed

If the device changed between planning and writing, the write is refused as stale_plan: no push, no settle, and no snapshot file left behind; re-plan and try again. A plan with validation errors is refused as validation_failed before anything is sent.

Broken mid-flight write_unverified

If the pipeline breaks after the snapshot landed (while sending, reading back, or decoding), the result says so, names the phase, and carries the snapshot path and recovery advice. The write may be on the device: re-read before deciding anything.

The ultimate rollback lives on the device, and every unclean report names it:

SettingsGeneralTransfer or Reset iPhoneResetReset Home Screen Layout

What a refusal looks like, as JSON

03 Limits

What it can't do. Stated up front.

These are limits of what iOS exposes to a paired computer, not features we haven't gotten to. The server states the four hard ones (wallpaper, icon appearance, removing apps, creating widgets) to agents in its instructions. Here is the full list for you.

CapabilityStatusWhy
WallpaperCan't be setNo command exists; MDM's wallpaper command is supervised-only. On iOS 27, even reading it breaks the service channel, so the app doesn't try.
Icon appearance (dark, tinted, large)Can't be setNot in the service.
Blank slots, free placement, hidden apps, Today View widgets, App Library orderNot expressibleNot in the wire format.
Removing an app from the home screenNot possibleSpringBoard re-inserts anything omitted. ScreenWright warns rather than pretends. Delete or hide the app on the phone instead.
Creating a widgetNot possibleExisting widgets can be moved, resized, or removed, by UUID. A synthesized widget renders as a dead blank tile, so the compiler refuses to invent one. Add new ones on the phone; ScreenWright will place them.
PagesCappedAt the device's own ceiling, 15 on current iOS. The app reads the number from the device.
Every SeptemberExpect maintenanceApple can change this service in any iOS release. Plan on updates after major iOS versions.

04 Connecting

USB, Wi-Fi, and a locked phone.

USB

Plug in once and tap Trust This Computer. That's the whole pairing. ScreenWright lists the device the instant the system sees it; name, model, and iOS version fill in a moment later.

Wi-Fi

With the cable in, turn on “Show this iPhone when on Wi-Fi” in Finder. After that: same Wi-Fi network, no cable. A Wi-Fi-visible device appears twice in the picker (a USB row and a Wi-Fi row, same identity) and you choose the link. Writes over Wi-Fi are flagged everywhere they appear: “wireless write” on the Review sheet, “Wi-Fi (wireless)” on the Report, and wireless: true in every MCP write result.

Locked is fine

The phone can stay locked. Reads and writes work on a locked device over USB and Wi-Fi. Unplugged, a locked iPhone naps its Wi-Fi between uses and the app reconnects in place; the transport is always visible in the toolbar.

01Plug in. Tap Trust This Computer.
02In Finder, turn on Show this iPhone when on Wi-Fi.
03Unplug. Same network from here on.

What's flaky, and what isn't

Writes: verified through lock/wake cyclesWi-Fi writes have held up: repeated pushes while the phone locked and woke, every one verified clean.
Connecting to a locked, unplugged phone: retries ~30 sIts Wi-Fi listing comes and goes, so ScreenWright retries for about 30 seconds, and the picker warns that the listing comes and goes. The app reconnects in place; the server retries at connect.

tip  If Wi-Fi discovery stops working after it worked once, check that the iPhone's Private Wi-Fi Address is off for your network. Wi-Fi pairing is matched by hardware address.

05 How it's built

Pure Swift, and why it isn't on the App Store.

Transport chain: usbmux, lockdown, StartSession over TLS with SwiftNIO and NIOSSL, springboardservices, installation proxy.

280+tests, no phone required
10MCP tools
7invariants
1shared core

Pure Swift. SwiftNIO and NIOSSL carry the TLS session with the device, using the pairing record the system already holds. No libimobiledevice, no Python, no third-party protocol libraries.

The path is the classic one: usbmux → lockdown → StartSession (TLS) → com.apple.springboardservices for the layout, metrics, and icons, plus the installation proxy for the installed-app list.

Everything the app can do is a call into HomeScreenKit, a Swift library. The MCP server links the same library and runs with the app closed. The app doesn't drive the server; the server doesn't script the app. When you drag an icon and click Apply, and when an agent sends apply_layout, the same planner produces the same plan and the same verified writer carries it out.

A hand-rolled property-list codec round-trips losslessly (dates stay dates, unknown keys are preserved), so a field Apple adds next year passes through untouched instead of being dropped on the floor.

Layouts compile through one pure function: (spec, device context) → (layout, report). Validate and preview are that same function with the write withheld. Over 280 tests run against recorded device fixtures; none of them needs a phone.

Why not the Mac App Store researched

Talking to an iPhone means connecting to usbmuxd's UNIX socket. The App Sandbox forbids that, and no entitlement unlocks it; this was researched specifically, not assumed. The third-party tools that do real device communication (iMazing, AnyTrans, iExplorer, 3uTools) ship outside the App Store for the same reason. So does ScreenWright: Developer ID signed, notarized and stapled, Hardened Runtime on, App Sandbox off. Nothing else about the app needs the sandbox off; the transport does.

Developer IDNotarizedHardened RuntimeSandbox off

Download ScreenWright 0.1.3