Skip to content

feat: add ios-device screenshot backed by devicectl - #118

Merged
onevcat merged 4 commits into
mainfrom
feat/ios-device-screenshot
Aug 27, 2026
Merged

onevcat merged 4 commits into
mainfrom
feat/ios-device-screenshot

Conversation

@onevcat

@onevcat onevcat commented Aug 27, 2026 •

Copy link
Copy Markdown
Contributor

Summary

sim-use ios-device screenshot captures a PNG of a connected iPhone or iPad display. Contributes to #115: the issue's constraints section records screenshot as unsupportable on the physical-device channel — true for the accessibility audit channel, but CoreDevice (xcrun devicectl device capture screenshot) provides it, and with fewer restrictions than ui/tap: whatever is on screen is captured, SpringBoard and system apps included, with no development-signing requirement. This removes the largest entry from the physical-device exception list ahead of the capability-model work.

Design

  • Device selection stays on the shared surface. The verb resolves the target through the same DeviceSession discovery as ui/tap (new DeviceSession.resolveDevice — discovery + selection without opening the audit service), so every ios-device verb sees the same device set and errors, and --device remains optional with exactly one attached. Only the capture itself runs over CoreDevice.
  • Capture shells out to xcrun devicectl device capture screenshot --quiet --timeout 30. Pipe drain and error mapping follow the SimctlDeviceLister.runSimctl / Adb.run pattern; a non-zero exit surfaces devicectl's stderr. Non-.png output paths are rejected up front (devicectl writes PNG only).
  • --output path resolution is now shared. The identical file/directory/default-naming logic previously duplicated in IOSSimScreenshotCommand and VideoOutputFile is hoisted into one SimUseCore.OutputFilePath; both keep their public API as thin delegates, and the new verb reuses it. Default name mirrors the simulator convention: Device Screenshot - <device name> - <timestamp>.png.
  • Drive-by: fixed the unused-binding warning in ios-device tap's validate() (if let alias, id != nil).

Not included (deliberately)

Verification

  • make build — passed, 0 warnings. make test — 1343 passed, 0 warnings; new offline coverage pins the devicectl argument vector, exit-code/stderr error mapping, drain success, and the default-filename convention.
  • Live, iPhone 15 Pro Max (iOS 26.6) over USB: default name, --device <UDID>, --output file.png, and --output <dir> all produce a valid 1290×2796 PNG in ~2.8 s end-to-end; a non-.png path fails with exit 1 and a clear message; the home screen (SpringBoard) captures fine, confirming the no-signing-requirement claim. ui/tap re-checked after the DeviceSession refactor.
  • Live, iPhone 17 Pro Max simulator (iOS 27.0): sim-use screenshot default and explicit paths unchanged after the OutputFilePath refactor.
  • devicectl device capture verified present on both installed Xcodes (26.6.0 and 27.0.0 Beta 5 — both ship devicectl 642.9.1).

Physical iOS devices could observe (ui) and act (tap) but not capture
the screen — issue #115 even records screenshot as unsupportable on
this channel. That is true for the accessibility audit channel, but
CoreDevice offers it: `xcrun devicectl device capture screenshot`
captures any screen, with no development-signing requirement.

- `sim-use ios-device screenshot`: resolves the device like the other
  ios-device verbs (same DeviceSession discovery and errors, --device
  optional with exactly one attached), then shells out to devicectl for
  the capture. Output mirrors the simulator verb: path on stdout,
  confirmation on stderr. Non-.png output paths are rejected up front
  (devicectl writes PNG only).
- --output path resolution (file / directory / default naming) is
  hoisted into a shared SimUseCore OutputFilePath, replacing the two
  existing per-target copies in IOSSimScreenshotCommand and
  VideoOutputFile; both keep their public API as thin delegates.
- DeviceSession.resolveDevice exposes discovery + selection without
  opening the audit service, for verbs that hand the work to another
  channel.

Screen recording exists on the same channel (capture screen-record) but
CoreDevice reports the capability unsupported on the available test
device, so it is not exposed yet.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

Signed-off-by: onevcat <onevcat@gmail.com>
Review findings on the initial revision:

- OutputFilePath.resolve() removed an existing file at the target before
  ios-device screenshot's .png validation ran, so a rejected
  `--output important.jpg` destroyed the user's important.jpg and then
  errored. Resolution and preparation are now separate steps: resolve()
  is read-only, and the new prepare() carries the destructive work
  (mkdir parent, replace existing file). The verb validates between the
  two; the simulator/video wrappers recombine them, so their replace-on-
  success behaviour (which predates this PR) is unchanged. Regression
  tests pin both: a rejected path leaves the existing file intact, an
  accepted path still replaces.
- README example showed `./…` for the saved-path output; the command
  prints an absolute path (stdout: the path, stderr: confirmation).
  Example updated to match.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

Signed-off-by: onevcat <onevcat@gmail.com>
Round-2 review finding: for a valid .png target the previous flow was
validate -> prepare (removes existing file) -> devicectl capture, so a
capture failure (device unplugged, devicectl timeout, CoreDevice error)
destroyed the old file with nothing written. The prior regression test
even pinned that early deletion as expected.

ios-device screenshot now resolves and validates without touching the
target (parent directories are still created up front), captures into a
temporary sibling that keeps the .png suffix devicectl requires, and
moves it over the target only on success; on failure the temporary is
cleaned up and the existing file is untouched. Regression tests cover
the injected-failure branch (existing content intact, no temporary left
behind), atomic replacement, and fresh-file creation.

OutputFilePath gains a non-destructive createParentDirectory(for:);
prepare() (parent dirs + remove existing) remains for the simulator and
video wrappers, whose replace-before-write behaviour predates this PR.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

Signed-off-by: onevcat <onevcat@gmail.com>
…tract

Two round-3 review findings on ios-device screenshot:

- The temporary capture name embedded the full target basename plus a
  ~50-byte suffix, so a valid NAME_MAX-length (255-byte) *.png target
  failed with ENAMETOOLONG when devicectl created the temporary. The
  temporary basename is now fixed and short
  (.sim-use-screenshot-partial-<UUID>.png) — same directory and the
  UUID already guarantee uniqueness; nothing needed the target name.
- The default filename interpolated the user-editable device name
  verbatim, so a name like "My iPhone/Work" turned the default output
  into a directory hierarchy (and ".." segments could walk out of the
  current directory), breaking the documented "single file in the
  current directory" default. Device names now pass through
  OutputFilePath.safeFilenameComponent ("/" -> "-"); with separators
  gone, a ".." can never form a standalone path component, which
  closes the traversal case too.

Regression tests: 255-byte target captures (also verified live on
device), slash names stay one component, and a traversal-shaped device
name resolves into the current directory.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

Signed-off-by: onevcat <onevcat@gmail.com>
@onevcat
onevcat merged commit dd97622 into main Aug 27, 2026
4 checks passed
@onevcat
onevcat deleted the feat/ios-device-screenshot branch August 27, 2026 01:40
angelmic pushed a commit to angelmic/sim-use that referenced this pull request Sep 1, 2026
Follow-up to lycorp-jp#118, which fixed this for ios-device screenshot: the
simulator default filename interpolated the FBSimulator name verbatim,
and simctl accepts any free text as a name — "My iPhone/Work" turned
the default output into a directory hierarchy, and ".." segments could
walk out of the current directory. The name now passes through
OutputFilePath.safeFilenameComponent, same as the physical-device verb.

The Android default filename embeds the adb serial, whose accepted
router charset already excludes separators; it is sanitised too as
defence in depth. Video default filenames (sim-use-video-<ISO8601>)
embed no user-controlled text and need no change.

Regression tests pin the slash and traversal cases; verified live with
a simulator actually named "Slash/Name Probe".

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

Signed-off-by: onevcat <onevcat@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant