Skip to content

feat: add --screencap and --control options for powerful CLI automation#6749

Draft
dapangyu-fish wants to merge 32 commits into
Genymobile:masterfrom
fish-dapangyu-dev:feat/control
Draft

feat: add --screencap and --control options for powerful CLI automation#6749
dapangyu-fish wants to merge 32 commits into
Genymobile:masterfrom
fish-dapangyu-dev:feat/control

Conversation

@dapangyu-fish

@dapangyu-fish dapangyu-fish commented Apr 1, 2026

Copy link
Copy Markdown

Motivation

scrcpy is an excellent mirroring tool. However, many users (including myself) need to perform automated operations on the device directly from the command line — for UI automation, testing, scripting, bot development, etc.

I understand that the scrcpy maintainers may intentionally want to keep scrcpy as a pure display/mirroring tool. Implementing full control features might go beyond the original vision. That said, these features are not very complex to implement and are extremely useful for many automation users.

Therefore, I have implemented a complete CLI control system.

PR Description

What does this PR do?

This PR adds two new powerful options for CLI automation:

  1. --screencap=FILE - Take a screenshot and save it locally (can be combined with --no-playback and -m resolution options)
  2. --control=COMMAND - Execute control commands (click, swipe, input text, etc.)

Multiple --control flags can be used simultaneously for multi-touch gestures.

Supported control commands

  • input "text" : Send text input (full Unicode/emoji support)
  • click x y [duration_ms] : Click or long-press
  • swipe x1 y1 x2 y2 duration_ms : Swipe gesture
  • sleep ms : Delay between actions

Commands can be chained with &&. Multi touch can use multiple --control=COMMAND.

Examples

Attached is a demo video of the 10 point touch limit test.
output
https://github.com/user-attachments/assets/acd72a86-8b3c-4a3b-a852-528e209efa61

Notes

  • This is a feature addition. I understand if it doesn't match the project's core philosophy of staying a pure mirroring tool.
  • If not merged, the code is still available for anyone who needs advanced CLI control.

How to build & test

https://github.com/fish-dapangyu-dev/scrcpy.git
cd scrcpy
rm -rf build
meson setup build --buildtype=release

ninja -C build app/scrcpy
rm /opt/homebrew/bin/scrcpy
cp app/scrcpy /opt/homebrew/bin/

@dapangyu-fish

Copy link
Copy Markdown
Author

Note: This PR mainly modifies the client-side code. I have only done basic testing and have not fully tested all features yet. I'm not actively seeking a merge at this time. If anyone needs this automation capability, feel free to build it separately from my branch. Thank you!

@rom1v

rom1v commented Apr 1, 2026

Copy link
Copy Markdown
Collaborator

Please mention when the code is AI generated.

@dapangyu-fish dapangyu-fish marked this pull request as draft April 1, 2026 09:12
@dapangyu-fish

Copy link
Copy Markdown
Author

Please mention when the code is AI generated.

Thank you for the reminder.
To be honest, all code in this PR was initially generated by Claude Opus 4.6.
The PR already change to Draft status. I am actively reviewing the code line by line, testing it thoroughly, and refactoring where necessary to match scrcpy's style and standards.
If you have any concerns or suggestions, please let me know.

@zfp19961018

Copy link
Copy Markdown

👍

ci and others added 20 commits July 3, 2026 10:33
Resolve conflicts with upstream scrcpy 4.0 (SDL3, FFmpeg 8):
 - meson.build: keep libswscale (needed by screencap) alongside sdl3
 - cli.c/events.h/options.h: keep both feature options (--screencap,
   --control) and new upstream options; drop the deprecated --rotation
   entry removed upstream
 - screencap.c: adapt packet sink open() to the new signature taking
   a struct sc_stream_session parameter

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Avoid any conflict with an official scrcpy installation on the same
machine or device:
 - client executable: scrcpy -> scrcpy-auto
 - server binary: scrcpy-server -> scrcpy-auto-server, installed in
   share/scrcpy-auto/ and pushed to the device as
   /data/local/tmp/scrcpy-auto-server.jar
 - renamed installed data accordingly (manpage, icons, desktop
   entries, bash/zsh completions) and the SDL app name

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Feasibility analysis and implementation specification for
--daemon-port/--client-port: a long-running per-device daemon that
keeps the device session alive and serves screenshot/control requests
from thin client invocations over localhost TCP, with reconnection
supervision, an on-disk registry for discovery, and full backward
compatibility with the one-shot CLI.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Add --daemon-port: run a persistent per-device daemon that keeps the
device session alive (server + video demuxer + decoder + controller),
retains the latest decoded frame, and serves client requests over a
length-prefixed JSON protocol on 127.0.0.1, with automatic reconnection
(1s..30s backoff, --daemon-reconnect policy) and an advisory on-disk
registry.

Add --client-port: thin client executing --control, --screencap,
--daemon-status and --daemon-stop against a running daemon in
milliseconds, without starting a new device session.

 - app/src/control_exec.{c,h}: --control executors moved out of
   scrcpy.c, parameterized by touch pointer-id base so concurrent
   daemon requests use disjoint ranges
 - app/src/screencap.{c,h}: split PNG encoding into sc_frame_to_png()
 - app/src/daemon/frame_keeper.{c,h}: re-readable latest-frame sink;
   screencap requests wait up to 2s for a frame, optional max_age_ms
   freshness via RESET_VIDEO
 - app/src/daemon/protocol.{c,h}: 4-byte BE length framing + minimal
   JSON (vendored jsmn, MIT) with escape/unescape
 - app/src/daemon/registry.{c,h}: <runtime dir>/scrcpy-auto/<port>.json
 - app/src/daemon/daemon.c: listener, per-connection threads (16 max),
   request dispatch, session supervisor state machine
 - app/src/daemon/client.c: hello/protocol check, ops in order
   control -> screencap -> status -> shutdown, exit code 2 when the
   daemon is unreachable
 - cli.c: new options + validation matrix (daemon requires --no-window;
   client mode excludes session options)

All sources compile warning-free (clang 18 via zig cc) against SDL3
3.2.0 and FFmpeg 8.0 headers; protocol framing/JSON verified by a
standalone smoke test. Not yet built with meson or tested against a
real device (no toolchain on this machine).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
 - cli.c: client mode no longer rejects the default window=true; it
   forces --no-window semantics itself, so the documented
   "scrcpy-auto --client-port PORT --screencap out.png" works
 - daemon.c: enlarge send_error() header buffer (malformed JSON for
   request ids >= 4 digits); retry on transient accept() failures
   instead of permanently exiting the accept loop; derive touch
   pointer-id ranges from the connection slot (the wrapping allocator
   could hand out overlapping ranges); clamp screencap max_age_ms
   (int64 overflow / negative min_tick bypassed the first-frame wait);
   record stop requests while draining in-flight requests; add the
   missing audio_codec_options/audio_encoder server params
 - frame_keeper: add sc_frame_keeper_reset(), called on session stop,
   so a screencap after reconnect cannot serve a stale frame from the
   previous session
 - control_exec.c: fix use-after-free (text logged after ownership
   moved to the controller queue, which may free it concurrently);
   deduplicate the quoted/unquoted branches; cap step durations at
   60 s so one command cannot block daemon teardown for hours

All 77 sources still compile warning-free (clang 18 via zig cc,
-Wall -Wextra) against SDL3 3.2.0 and FFmpeg 8.0 headers.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Add doc/fork.md: authoritative inventory of every difference from
upstream scrcpy (rename, --screencap/--control, daemon mode), the full
modified-file list, known conflict hotspots with resolution rules,
semantic coupling points that break without textual conflicts, and a
post-merge verification checklist.

Add CLAUDE.md so AI-assisted sessions automatically load the merge
invariants and are pointed at doc/fork.md before resolving conflicts.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Touch events were silently ignored by the device when video capture is
enabled: the server-side PositionMapper drops any event whose
screen_size does not match the video size, and the executor hardcoded a
{UINT16_MAX, UINT16_MAX} placeholder (which only works when video is
disabled, where the device falls back to raw coordinates -- the one-shot
--control path).

 - sc_control_exec_run() now takes the reference screen_size; the
   daemon passes the actual video size (waiting for the first frame if
   needed), so click/swipe coordinates are interpreted in video-frame
   space -- the same pixel space as the screenshots returned by the
   daemon. --no-video daemons and one-shot mode keep raw coordinates.
 - one-shot control mode now explicitly disables video, and rejects the
   --control + --screencap combination (it exited before the screenshot
   completed and would have hit the same position-mapping issue).
 - --control now takes a required argument, so the space-separated form
   (--control "click 500 800") works, not only --control=...;
   --control help prints the command reference.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
"click 540 1500 540 5000" silently parsed as a click at (540,1500)
with a 540 ms duration, ignoring the trailing token -- the user
intended a long press and got a short one with no error. Fail with the
usage message when a touch command has trailing arguments.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Rejected commands (e.g. "click 540 1500 540 5000", one argument too
many) previously surfaced only as a generic "control commands failed"
on the client, with the usage message hidden in the daemon log. Add
sc_control_exec_check() and validate the request up front in the
daemon, so the client receives E_BAD_REQUEST with the expected syntax.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A bare DOWN followed by a late UP is less reliable for holds than a
gesture with a continuous event stream: some gesture detectors drop or
mis-handle a pointer that stays silent. Send MOVE events at the click
position every 10ms while held (identical to "swipe x y x y ms"), and
raise the default click duration from 100ms to 300ms.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Expose the interfaces a web control tool (scrcpy-auto-web) needs:

 - subscribe_video: turns an IPC connection into a one-way push stream of
   the ENCODED device video (Annex-B, forwarded verbatim, no re-encode) so
   a browser can decode it with WebCodecs. Implemented as a second packet
   sink on the video demuxer (app/src/daemon/broadcaster.c) alongside the
   existing decoder-for-screenshots; sends codec metadata + config (SPS/PPS)
   + frames, re-announced to subscribers on reconnect/resolution change.
 - inject_touch / inject_key / inject_text / inject_scroll: single,
   non-blocking realtime input events (unlike the batch "control" op),
   pushing one sc_control_msg each. Touch/scroll coordinates are in
   video-pixel space, stamped with the current video size.

Additive protocol changes only (no version bump). Documented in
doc/daemon.md §8.3/§8.7.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Force RESET_VIDEO when a client subscribes so a mid-stream subscriber
starts decoding at the next frame instead of waiting for the device's
periodic I-frame interval (seconds of blank screen otherwise).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Add --auto-test-report=DIR (daemon) and --action=TEXT (client) for the
scrcpy-auto test-report platform (scrcpy-auto-web/DESIGN-test-report.md):

 - app/src/daemon/report.{c,h}: creates DIR, records the screen to
   DIR/recording.mp4 (sc_recorder as a third packet sink on the video
   demuxer), and appends every deliberate client operation to
   DIR/events.jsonl with a frame-accurate, drift-free timestamp; writes
   and finalizes DIR/manifest.json.
 - Frame-accurate clock: frame_keeper tracks first/last video PTS (us)
   and exposes sc_frame_keeper_video_time_ms() (decision: eliminate
   drift by deriving op times from the video PTS clock, not wall time).
 - Logging: control and screencap ops are logged (control carries the
   raw cmds + video size so the web renderer can rebuild finger paths;
   screencap logs timestamp + dimensions, no image copy). Realtime
   inject_* (web live-control) is not logged (decision: manual web
   gestures are excluded from the report).
 - --action threads through the client control/screencap requests.
 - Report mode records one session and ends on device disconnect
   (finalizes the mp4). MP4 format; orientation locked implicitly.
 - CLI: --auto-test-report requires --daemon-port and video.

All 79 sources compile warning-free (clang via zig cc). Report JSON
output verified by a unit harness (manifest + events.jsonl parse
cleanly, escaping correct). Not yet run against a real device.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Add a client --note="title: description" option and a daemon "note" op
for report annotations that are not tied to a control command — marking
test-case boundaries, assertions, reasoning, and milestones.

The daemon splits the note on the first ':' into a structured
{title, text} and logs a "note" event with the video-correlated
timestamp. --note is a standalone client operation (no --control needed).

Compiles warning-free; note title/text parsing unit-tested. Documented
in doc/daemon.md §8.3.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Let external tools provide custom --<command> options without touching
the C code — intended for AI/Python workflows that reuse scrcpy-auto for
screenshots and input (doc/addons.md).

 - Daemon --add-on=PATH (repeatable): app/src/daemon/addon.c queries each
   entrypoint (SC_ADDON_MODE=register) for its command name, then runs it
   (SC_ADDON_MODE=run) with the command value as $1 and runtime info in
   the environment (SC_DAEMON_PORT, SC_DEVICE_SERIAL, SC_REPORT_DIR,
   SC_VIDEO_WIDTH/HEIGHT, SC_ADDON_NAME, SCRCPY_AUTO). New "plugin" op.
 - Client: any unrecognized --name=value is pre-scanned out of argv and
   forwarded as {op:"plugin",name,args}; the daemon runs the registered
   add-on (unknown -> error). Requires --client-port.
 - Report: the daemon auto-logs a "plugin" start event; the add-on's own
   screenshot/note/click callbacks are logged in real time (no batching),
   giving the timeline: plugin start -> screenshot -> note -> click.
 - Lifecycle: client call blocks until the script exits; a running add-on
   child is terminated on daemon shutdown. Unix/macOS; localhost trust.

All 80 sources compile warning-free (clang via zig cc). Add-on
register/run mechanics verified with a shell entrypoint (name query,
$1 + env passing, exit code). Documented in doc/addons.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Fix tap/action markers landing seconds too early (and the real action
showing up as the later note): the report timestamped each op with the
last decoded frame's PTS, but Android's encoder emits frames only when
the screen changes, so during a static period (e.g. a ~5s AI add-on
think) the PTS clock freezes. An op logged then got a stale, too-early
time, while an op logged just after the screen changed got the correct
later time.

Timestamp by wall-clock elapsed since the first recorded frame
(frame_keeper first_frame_tick) instead — that matches the mp4's
real-time playback timeline, which holds static frames for their true
duration. The web-side rescale to video.duration still corrects any
residual linear rate difference.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Extend the add-on framework (doc/addons.md) into one protocol every plugin
shares, so new AI capabilities never add bespoke CLI flags or wire ops:

- Register metadata gains result=/env=/meta= and arg types path/pathlist
  (in addition to name=/arg=); advertised in the daemon hello as keyed
  strings, discovered by both the C client and the web UI.
- --json (global): the client prints only the plugin's result object, or a
  JSON error object on failure; banner/logs are suppressed so stdout stays
  pure JSON.
- --daemon-host (default 127.0.0.1) + adaptive reference-image transport: a
  loopback daemon reads path/pathlist args directly (no copy); a remote
  daemon receives the bytes via a new "upload" op (per-connection temp
  file). Plugins are locality-oblivious.
- Result channel: SC_RESULT_FILE (the plugin writes {result,reason,
  thinking,...}); the daemon reads it back into the plugin response.
  Required env-var validation. Protocol bumped to v3.

The AI plugins themselves (exec_prompt/assert_prompt) are intentionally not
committed; .gitignore excludes plugin implementations and their example
assets.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
After an add-on writes SC_RESULT_FILE, log its structured result
({result,reason,thinking,...}) as a "result" event in events.jsonl (only
when --auto-test-report is active), so the web report can display the full
plugin result JSON on the timeline.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
When a report is recording and a plugin is invoked with path/pathlist
arguments (e.g. --ref-images), copy those files into <report>/assets/ and
record their relative paths on the plugin event, so the report (and its
offline export) can render the reference images. Best-effort; works for
local paths and daemon-side uploaded temp files alike.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
ci and others added 11 commits July 6, 2026 20:03
Two add-ons declaring the same argument name (e.g. both --exec_prompt and
--assert_prompt accepting --ref-images) previously collided: the client
attributed the bare flag to whichever plugin declared it first, so only that
one could ever receive it.

The client now resolves each raw flag as (1) a command, (2) a qualified
--<command>.<arg>, or (3) a bare arg routed to the *invoked* declaring
command. A bare name is unambiguous whenever a single declaring plugin is
invoked; when several are, it is rejected in favour of the qualified form.
The <command>. prefix is stripped before the call is sent, so the daemon and
entrypoint scripts still see the plain SC_ARG_<NAME> and need no changes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
New client operation: --clip-start/--clip-end (seconds, decimals allowed)
and --output save that segment of the daemon's session as a standalone MP4,
while the session and its test-report recording continue untouched.

The daemon gains a clip buffer (app/src/daemon/clip_buffer.c), a fourth,
fully independent packet sink on the video demuxer: every encoded packet is
appended to an unlinked spool file and indexed in memory {pts, offset, size,
keyframe}. The new "clip" op selects [keyframe <= start, last packet <= end],
muxes the slice into an in-memory MP4 outside the buffer lock (timestamps
rebased to 0, extradata from the config packet like the recorder) and returns
the bytes as the response payload; the CLIENT writes the output file, so
remote clients work and the daemon needs no write access to the path.

Contract: times are relative to the first video packet (the report timeline
origin); the start snaps back to the nearest keyframe and the response
reports the actual bounds; an end beyond the last recorded packet fails with
E_RANGE. Spool write errors disable clips but never fail the pipeline.

SC_PACKET_SOURCE_MAX_SINKS is bumped to 4 (decoder, web broadcaster, clip
buffer, report recorder); fork invariants updated accordingly.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
handle_status now also emits the loaded add-on schemas (the same array the
hello advertises), a "report" object (enabled/dir/recording/video path), and
a "config" object with the referenced capture parameters (port, control,
video, and when video is on codec/bit_rate/max_size/max_fps/encoder), so
--daemon-status is a one-stop readout of how the daemon was launched.

Also fixes a latent bug in the new code: sc_strbuf_append_staticstr() uses
sizeof(S)-1, so passing a ternary of two string literals (a const char*, not
an array) computed the pointer size as the length — harmless under assertions
but an out-of-bounds read under NDEBUG. Booleans now use sc_strbuf_append_str.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add-ons now receive the daemon's capture parameters as SC_* environment
variables at launch, so a plugin reads them from its environment instead of
shelling back into the client to introspect: SC_VIDEO_CODEC, SC_VIDEO_BIT_RATE,
SC_VIDEO_MAX_FPS, SC_VIDEO_MAX_SIZE, SC_VIDEO_ENCODER and SC_CONTROL, next to
the existing SC_DEVICE_SERIAL / SC_REPORT_DIR / SC_VIDEO_WIDTH/HEIGHT. Empty
string / 0 mirrors "unset / server default", as with SC_REPORT_DIR.

doc/addons.md lists the new variables.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
sc_report_init did a single-level mkdir, so a nested report target such as
"./v/test1" failed with "could not create directory" when "./v" did not exist
yet. make_dir now creates every missing parent component before the leaf
(EEXIST treated as success, leading '/' or '.' preserved, empty path handled
without reading past the buffer).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Install the client with `brew install <raw formula URL>` (or `--HEAD`, or by
tapping the repo). The formula builds scrcpy-auto from source against Homebrew
SDL3/FFmpeg(+libswscale)/libusb, and downloads the matching upstream
scrcpy-server-v4.0 release artifact (sha256-pinned) as the prebuilt server so
no Android SDK is needed — meson installs it to
share/scrcpy-auto/scrcpy-auto-server, where the client resolves it. adb comes
from the android-platform-tools cask. README documents install, the
server-handling rationale, device setup, and how to keep the source revision
and server resource in lock-step across releases.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…n by name

A management layer over the add-on system (doc/plugins.md):

- `scrcpy-auto plugins-install <git-url> --add-on-name=<name>|ALL` and
  `plugins-upgrade` shallow-clone the repo and copy the plugin directory(ies)
  into ~/.scrcpy-auto/plugins/<name>/. ALL installs every plugin dir; upgrade
  ALL refreshes only the ones already installed. (app/src/plugins.{c,h})
- `--add-on=<name>` now accepts a bare plugin name, resolved to
  ~/.scrcpy-auto/plugins/<name>/entrypoint.sh; a value that looks like a path
  is still used as-is. (daemon/addon.c: sc_addons_load)
- main.c dispatches the two subcommands (Unix only, like add-ons); the plugin
  code is SDL/log-free and #ifndef _WIN32 guarded (Windows no-op).

Docs: doc/plugins.md (new) + pointer in doc/addons.md + doc/fork.md inventory.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The caller's cwd may be a deleted directory (e.g. after brew reinstall), which
made git abort with 'cannot read current working directory'. Plugin paths are
absolute, so move to HOME before spawning git/cp/rm.
access(spec) matched a same-named directory in the current dir (e.g. running
from ~/.scrcpy-auto/plugins), so bare names were treated as local paths and
never resolved. Now: '/'-containing specs are used as-is; a bare name resolves
to $HOME/.scrcpy-auto/plugins/<name>/entrypoint.sh only if it exists.
The hicolor PNG icons are useless on macOS and are a common `brew link`
conflict: a prior manual `sudo ninja install` leaves
share/icons/hicolor/.../apps root-owned, which Homebrew (correctly refusing to
run as root) cannot overwrite. Remove them from the keg after meson install;
the man page and shell completions are unaffected.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants