This commit is contained in:
San Juan 2026-04-20 13:44:40 +00:00 committed by GitHub
commit a94a6142d4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 581 additions and 117 deletions

2
.gitignore vendored
View file

@ -5,6 +5,8 @@ build/
/release-*/
.idea/
.gradle/
.cache/
.vscode/
/x/
local.properties
/scrcpy-server

View file

@ -83,6 +83,7 @@ _scrcpy() {
--screen-off-timeout=
--shortcut-mod=
--start-app=
--exit-on-close
-t --show-touches
--tcpip
--tcpip=

View file

@ -536,9 +536,15 @@ Add a '+' prefix to force-stop before starting the app:
scrcpy --new-display --start-app=+org.mozilla.firefox
Both prefixes can be used, in that order:
All prefixes can be combined, in that order.
scrcpy --start-app=+?firefox
See also \fB\-\-exit\-on\-close\fR.
.TP
.B \-\-exit\-on\-close
Exit scrcpy when the app started with \fB\-\-start\-app\fR closes.
This option is only available with \fB\-\-start\-app\fR.
.TP
.B \-t, \-\-show\-touches

View file

@ -7,6 +7,9 @@
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <SDL2/SDL.h>
#include "config.h"
#include "options.h"
#include "util/log.h"
@ -108,6 +111,7 @@ enum {
OPT_NEW_DISPLAY,
OPT_LIST_APPS,
OPT_START_APP,
OPT_EXIT_ON_CLOSE,
OPT_SCREEN_OFF_TIMEOUT,
OPT_CAPTURE_ORIENTATION,
OPT_ANGLE,
@ -890,8 +894,14 @@ static const struct sc_option options[] = {
" scrcpy --start-app=?firefox\n"
"Add a '+' prefix to force-stop before starting the app:\n"
" scrcpy --new-display --start-app=+org.mozilla.firefox\n"
"Both prefixes can be used, in that order:\n"
" scrcpy --start-app=+?firefox",
"All prefixes can be combined, in that order.\n"
"See also --exit-on-close.",
},
{
.longopt_id = OPT_EXIT_ON_CLOSE,
.longopt = "exit-on-close",
.text = "Exit scrcpy when the app started with --start-app closes.\n"
"This option is only available with --start-app.",
},
{
.shortopt = 't',
@ -2799,6 +2809,23 @@ parse_args_with_getopt(struct scrcpy_cli_args *args, int argc, char *argv[],
break;
case OPT_START_APP:
opts->start_app = optarg;
// Check if the app name ends with '-' to enable end execution
if (optarg && strlen(optarg) > 0 && optarg[strlen(optarg) - 1] == '-') {
opts->stop_app = true;
// Remove the trailing '-' from the app name
char *app_name = strdup(optarg);
if (app_name) {
app_name[strlen(app_name) - 1] = '\0';
opts->start_app = app_name;
}
}
break;
case OPT_EXIT_ON_CLOSE:
if (!opts->start_app) {
LOGE("--exit-on-close is only available with --start-app");
return false;
}
opts->exit_on_app_close = true;
break;
case OPT_SCREEN_OFF_TIMEOUT:
if (!parse_screen_off_timeout(optarg,

View file

@ -110,6 +110,8 @@ const struct scrcpy_options scrcpy_options_default = {
.audio_dup = false,
.new_display = NULL,
.start_app = NULL,
.exit_on_app_close = false,
.stop_app = false,
.angle = NULL,
.vd_destroy_content = true,
.vd_system_decorations = true,

View file

@ -325,6 +325,8 @@ struct scrcpy_options {
bool audio_dup;
const char *new_display; // [<width>x<height>][/<dpi>] parsed by the server
const char *start_app;
bool exit_on_app_close;
bool stop_app;
bool vd_destroy_content;
bool vd_system_decorations;
};

View file

@ -7,6 +7,7 @@
#include <stdlib.h>
#include <string.h>
#include <SDL2/SDL.h>
#include "adb/adb.h"
#ifdef _WIN32
// not needed here, but winsock2.h must never be included AFTER windows.h
@ -29,6 +30,7 @@
#include "uhid/gamepad_uhid.h"
#include "uhid/keyboard_uhid.h"
#include "uhid/mouse_uhid.h"
#ifdef HAVE_USB
# include "usb/aoa_hid.h"
# include "usb/gamepad_aoa.h"
@ -36,11 +38,13 @@
# include "usb/mouse_aoa.h"
# include "usb/usb.h"
#endif
#include "util/acksync.h"
#include "util/log.h"
#include "util/rand.h"
#include "util/timeout.h"
#include "util/tick.h"
#ifdef HAVE_V4L2
# include "v4l2_sink.h"
#endif
@ -176,11 +180,15 @@ sdl_configure(bool video_playback, bool disable_screensaver) {
}
static enum scrcpy_exit_code
event_loop(struct scrcpy *s, bool has_screen) {
event_loop(struct scrcpy *s, bool has_screen, const struct scrcpy_options *options) {
SDL_Event event;
while (SDL_WaitEvent(&event)) {
switch (event.type) {
case SC_EVENT_DEVICE_DISCONNECTED:
if (options && options->exit_on_app_close) {
LOGI("Target app closed; exiting");
return SCRCPY_EXIT_SUCCESS;
}
LOGW("Device disconnected");
return SCRCPY_EXIT_DISCONNECTED;
case SC_EVENT_DEMUXER_ERROR:
@ -261,7 +269,7 @@ await_for_server(bool *connected) {
static void
sc_recorder_on_ended(struct sc_recorder *recorder, bool success,
void *userdata) {
void *userdata) {
(void) recorder;
(void) userdata;
@ -272,7 +280,7 @@ sc_recorder_on_ended(struct sc_recorder *recorder, bool success,
static void
sc_video_demuxer_on_ended(struct sc_demuxer *demuxer,
enum sc_demuxer_status status, void *userdata) {
enum sc_demuxer_status status, void *userdata) {
(void) demuxer;
(void) userdata;
@ -288,7 +296,7 @@ sc_video_demuxer_on_ended(struct sc_demuxer *demuxer,
static void
sc_audio_demuxer_on_ended(struct sc_demuxer *demuxer,
enum sc_demuxer_status status, void *userdata) {
enum sc_demuxer_status status, void *userdata) {
(void) demuxer;
const struct scrcpy_options *options = userdata;
@ -299,14 +307,14 @@ sc_audio_demuxer_on_ended(struct sc_demuxer *demuxer,
sc_push_event(SC_EVENT_DEVICE_DISCONNECTED);
} else if (status == SC_DEMUXER_STATUS_ERROR
|| (status == SC_DEMUXER_STATUS_DISABLED
&& options->require_audio)) {
&& options->require_audio)) {
sc_push_event(SC_EVENT_DEMUXER_ERROR);
}
}
static void
sc_controller_on_ended(struct sc_controller *controller, bool error,
void *userdata) {
void *userdata) {
// Note: this function may be called twice, once from the controller thread
// and once from the receiver thread
(void) controller;
@ -422,64 +430,65 @@ scrcpy(struct scrcpy_options *options) {
uint32_t scid = scrcpy_generate_scid();
struct sc_server_params params = {
.scid = scid,
.req_serial = options->serial,
.select_usb = options->select_usb,
.select_tcpip = options->select_tcpip,
.log_level = options->log_level,
.video_codec = options->video_codec,
.audio_codec = options->audio_codec,
.video_source = options->video_source,
.audio_source = options->audio_source,
.camera_facing = options->camera_facing,
.crop = options->crop,
.port_range = options->port_range,
.tunnel_host = options->tunnel_host,
.tunnel_port = options->tunnel_port,
.max_size = options->max_size,
.video_bit_rate = options->video_bit_rate,
.audio_bit_rate = options->audio_bit_rate,
.max_fps = options->max_fps,
.angle = options->angle,
.screen_off_timeout = options->screen_off_timeout,
.capture_orientation = options->capture_orientation,
.capture_orientation_lock = options->capture_orientation_lock,
.control = options->control,
.display_id = options->display_id,
.new_display = options->new_display,
.display_ime_policy = options->display_ime_policy,
.video = options->video,
.audio = options->audio,
.audio_dup = options->audio_dup,
.show_touches = options->show_touches,
.stay_awake = options->stay_awake,
.video_codec_options = options->video_codec_options,
.audio_codec_options = options->audio_codec_options,
.video_encoder = options->video_encoder,
.audio_encoder = options->audio_encoder,
.camera_id = options->camera_id,
.camera_size = options->camera_size,
.camera_ar = options->camera_ar,
.camera_fps = options->camera_fps,
.force_adb_forward = options->force_adb_forward,
.power_off_on_close = options->power_off_on_close,
.clipboard_autosync = options->clipboard_autosync,
.downsize_on_error = options->downsize_on_error,
.tcpip = options->tcpip,
.tcpip_dst = options->tcpip_dst,
.cleanup = options->cleanup,
.power_on = options->power_on,
.kill_adb_on_close = options->kill_adb_on_close,
.camera_high_speed = options->camera_high_speed,
.vd_destroy_content = options->vd_destroy_content,
.vd_system_decorations = options->vd_system_decorations,
.list = options->list,
.scid = scid,
.req_serial = options->serial,
.select_usb = options->select_usb,
.select_tcpip = options->select_tcpip,
.log_level = options->log_level,
.video_codec = options->video_codec,
.audio_codec = options->audio_codec,
.video_source = options->video_source,
.audio_source = options->audio_source,
.camera_facing = options->camera_facing,
.crop = options->crop,
.port_range = options->port_range,
.tunnel_host = options->tunnel_host,
.tunnel_port = options->tunnel_port,
.max_size = options->max_size,
.video_bit_rate = options->video_bit_rate,
.audio_bit_rate = options->audio_bit_rate,
.max_fps = options->max_fps,
.angle = options->angle,
.screen_off_timeout = options->screen_off_timeout,
.capture_orientation = options->capture_orientation,
.capture_orientation_lock = options->capture_orientation_lock,
.control = options->control,
.display_id = options->display_id,
.new_display = options->new_display,
.display_ime_policy = options->display_ime_policy,
.video = options->video,
.audio = options->audio,
.audio_dup = options->audio_dup,
.show_touches = options->show_touches,
.stay_awake = options->stay_awake,
.video_codec_options = options->video_codec_options,
.audio_codec_options = options->audio_codec_options,
.video_encoder = options->video_encoder,
.audio_encoder = options->audio_encoder,
.camera_id = options->camera_id,
.camera_size = options->camera_size,
.camera_ar = options->camera_ar,
.camera_fps = options->camera_fps,
.force_adb_forward = options->force_adb_forward,
.power_off_on_close = options->power_off_on_close,
.clipboard_autosync = options->clipboard_autosync,
.downsize_on_error = options->downsize_on_error,
.tcpip = options->tcpip,
.tcpip_dst = options->tcpip_dst,
.cleanup = options->cleanup,
.power_on = options->power_on,
.kill_adb_on_close = options->kill_adb_on_close,
.camera_high_speed = options->camera_high_speed,
.vd_destroy_content = options->vd_destroy_content,
.vd_system_decorations = options->vd_system_decorations,
.exit_on_app_close = options->exit_on_app_close,
.list = options->list,
};
static const struct sc_server_callbacks cbs = {
.on_connection_failed = sc_server_on_connection_failed,
.on_connected = sc_server_on_connected,
.on_disconnected = sc_server_on_disconnected,
.on_connection_failed = sc_server_on_connection_failed,
.on_connected = sc_server_on_connected,
.on_disconnected = sc_server_on_disconnected,
};
if (!sc_server_init(&s->server, &params, &cbs, NULL)) {
return SCRCPY_EXIT_FAILURE;
@ -566,7 +575,7 @@ scrcpy(struct scrcpy_options *options) {
if (options->video_playback && options->control) {
if (!sc_file_pusher_init(&s->file_pusher, serial,
options->push_target)) {
options->push_target)) {
goto end;
}
fp = &s->file_pusher;
@ -575,18 +584,18 @@ scrcpy(struct scrcpy_options *options) {
if (options->video) {
static const struct sc_demuxer_callbacks video_demuxer_cbs = {
.on_ended = sc_video_demuxer_on_ended,
.on_ended = sc_video_demuxer_on_ended,
};
sc_demuxer_init(&s->video_demuxer, "video", s->server.video_socket,
&video_demuxer_cbs, NULL);
&video_demuxer_cbs, NULL);
}
if (options->audio) {
static const struct sc_demuxer_callbacks audio_demuxer_cbs = {
.on_ended = sc_audio_demuxer_on_ended,
.on_ended = sc_audio_demuxer_on_ended,
};
sc_demuxer_init(&s->audio_demuxer, "audio", s->server.audio_socket,
&audio_demuxer_cbs, options);
&audio_demuxer_cbs, options);
}
bool needs_video_decoder = options->video_playback;
@ -597,22 +606,22 @@ scrcpy(struct scrcpy_options *options) {
if (needs_video_decoder) {
sc_decoder_init(&s->video_decoder, "video");
sc_packet_source_add_sink(&s->video_demuxer.packet_source,
&s->video_decoder.packet_sink);
&s->video_decoder.packet_sink);
}
if (needs_audio_decoder) {
sc_decoder_init(&s->audio_decoder, "audio");
sc_packet_source_add_sink(&s->audio_demuxer.packet_source,
&s->audio_decoder.packet_sink);
&s->audio_decoder.packet_sink);
}
if (options->record_filename) {
static const struct sc_recorder_callbacks recorder_cbs = {
.on_ended = sc_recorder_on_ended,
.on_ended = sc_recorder_on_ended,
};
if (!sc_recorder_init(&s->recorder, options->record_filename,
options->record_format, options->video,
options->audio, options->record_orientation,
&recorder_cbs, NULL)) {
options->record_format, options->video,
options->audio, options->record_orientation,
&recorder_cbs, NULL)) {
goto end;
}
recorder_initialized = true;
@ -624,11 +633,11 @@ scrcpy(struct scrcpy_options *options) {
if (options->video) {
sc_packet_source_add_sink(&s->video_demuxer.packet_source,
&s->recorder.video_packet_sink);
&s->recorder.video_packet_sink);
}
if (options->audio) {
sc_packet_source_add_sink(&s->audio_demuxer.packet_source,
&s->recorder.audio_packet_sink);
&s->recorder.audio_packet_sink);
}
}
@ -639,11 +648,11 @@ scrcpy(struct scrcpy_options *options) {
if (options->control) {
static const struct sc_controller_callbacks controller_cbs = {
.on_ended = sc_controller_on_ended,
.on_ended = sc_controller_on_ended,
};
if (!sc_controller_init(&s->controller, s->server.control_socket,
&controller_cbs, NULL)) {
&controller_cbs, NULL)) {
goto end;
}
controller_initialized = true;
@ -751,8 +760,8 @@ aoa_complete:
if (options->keyboard_input_mode == SC_KEYBOARD_INPUT_MODE_SDK) {
sc_keyboard_sdk_init(&s->keyboard_sdk, &s->controller,
options->key_inject_mode,
options->forward_key_repeat);
options->key_inject_mode,
options->forward_key_repeat);
kp = &s->keyboard_sdk.key_processor;
} else if (options->keyboard_input_mode
== SC_KEYBOARD_INPUT_MODE_UHID) {
@ -766,7 +775,7 @@ aoa_complete:
if (options->mouse_input_mode == SC_MOUSE_INPUT_MODE_SDK) {
sc_mouse_sdk_init(&s->mouse_sdk, &s->controller,
options->mouse_hover);
options->mouse_hover);
mp = &s->mouse_sdk.mouse_processor;
} else if (options->mouse_input_mode == SC_MOUSE_INPUT_MODE_UHID) {
bool ok = sc_mouse_uhid_init(&s->mouse_uhid, &s->controller);
@ -800,30 +809,30 @@ aoa_complete:
if (options->window) {
const char *window_title =
options->window_title ? options->window_title : info->device_name;
options->window_title ? options->window_title : info->device_name;
struct sc_screen_params screen_params = {
.video = options->video_playback,
.controller = controller,
.fp = fp,
.kp = kp,
.mp = mp,
.gp = gp,
.mouse_bindings = options->mouse_bindings,
.legacy_paste = options->legacy_paste,
.clipboard_autosync = options->clipboard_autosync,
.shortcut_mods = options->shortcut_mods,
.window_title = window_title,
.always_on_top = options->always_on_top,
.window_x = options->window_x,
.window_y = options->window_y,
.window_width = options->window_width,
.window_height = options->window_height,
.window_borderless = options->window_borderless,
.orientation = options->display_orientation,
.mipmaps = options->mipmaps,
.fullscreen = options->fullscreen,
.start_fps_counter = options->start_fps_counter,
.video = options->video_playback,
.controller = controller,
.fp = fp,
.kp = kp,
.mp = mp,
.gp = gp,
.mouse_bindings = options->mouse_bindings,
.legacy_paste = options->legacy_paste,
.clipboard_autosync = options->clipboard_autosync,
.shortcut_mods = options->shortcut_mods,
.window_title = window_title,
.always_on_top = options->always_on_top,
.window_x = options->window_x,
.window_y = options->window_y,
.window_width = options->window_width,
.window_height = options->window_height,
.window_borderless = options->window_borderless,
.orientation = options->display_orientation,
.mipmaps = options->mipmaps,
.fullscreen = options->fullscreen,
.start_fps_counter = options->start_fps_counter,
};
if (!sc_screen_init(&s->screen, &screen_params)) {
@ -835,7 +844,7 @@ aoa_complete:
struct sc_frame_source *src = &s->video_decoder.frame_source;
if (options->video_buffer) {
sc_delay_buffer_init(&s->video_buffer,
options->video_buffer, true);
options->video_buffer, true);
sc_frame_source_add_sink(src, &s->video_buffer.frame_sink);
src = &s->video_buffer.frame_source;
}
@ -846,9 +855,9 @@ aoa_complete:
if (options->audio_playback) {
sc_audio_player_init(&s->audio_player, options->audio_buffer,
options->audio_output_buffer);
options->audio_output_buffer);
sc_frame_source_add_sink(&s->audio_decoder.frame_source,
&s->audio_player.frame_sink);
&s->audio_player.frame_sink);
}
#ifdef HAVE_V4L2
@ -909,7 +918,7 @@ aoa_complete:
sc_tick deadline = sc_tick_now() + options->time_limit;
static const struct sc_timeout_callbacks cbs = {
.on_timeout = sc_timeout_on_timeout,
.on_timeout = sc_timeout_on_timeout,
};
ok = sc_timeout_start(&s->timeout, deadline, &cbs, NULL);
@ -944,8 +953,28 @@ aoa_complete:
}
}
ret = event_loop(s, options->window);
ret = event_loop(s, options->window, options);
terminate_event_loop();
if (options->stop_app) {
LOGI("Stopping app [%s]", options->start_app);
const char *cmd[512];
cmd[0] = sc_adb_get_executable();
cmd[1] = "shell";
int idx = 0;
if (options->serial) {
idx = 2;
cmd[2] = "-s";
cmd[3] = options->serial;
}
cmd[idx + 2] = "am";
cmd[idx + 3] = "force-stop";
cmd[idx + 4] = options->start_app;
cmd[idx + 5] = NULL;
sc_adb_execute(cmd, 0);
}
LOGD("quit...");
if (options->video_playback) {
@ -955,7 +984,7 @@ aoa_complete:
sc_screen_hide_window(&s->screen);
}
end:
end:
if (timeout_started) {
sc_timeout_stop(&s->timeout);
}

View file

@ -417,6 +417,9 @@ execute_server(struct sc_server *server,
if (!params->vd_system_decorations) {
ADD_PARAM("vd_system_decorations=false");
}
if (params->exit_on_app_close) {
ADD_PARAM("exit_on_app_close=true");
}
if (params->list & SC_OPTION_LIST_ENCODERS) {
ADD_PARAM("list_encoders=true");
}
@ -752,7 +755,6 @@ sc_server_on_terminated(void *userdata) {
sc_intr_interrupt(&server->intr);
server->cbs->on_disconnected(server, server->cbs_userdata);
LOGD("Server terminated");
}

View file

@ -70,6 +70,7 @@ struct sc_server_params {
bool camera_high_speed;
bool vd_destroy_content;
bool vd_system_decorations;
bool exit_on_app_close;
uint8_t list;
};

View file

@ -149,28 +149,28 @@ scrcpy --list-apps
An app, selected by its package name, can be launched on start:
```
```bash
scrcpy --start-app=org.mozilla.firefox
```
This feature can be used to run an app in a [virtual
display](virtual_display.md):
```
```bash
scrcpy --new-display=1920x1080 --start-app=org.videolan.vlc
```
The app can be optionally forced-stop before being started, by adding a `+`
prefix:
```
```bash
scrcpy --start-app=+org.mozilla.firefox
```
For convenience, it is also possible to select an app by its name, by adding a
`?` prefix:
```
```bash
scrcpy --start-app=?firefox
```
@ -179,6 +179,23 @@ passing the package name is recommended.
The `+` and `?` prefixes can be combined (in that order):
```
```bash
scrcpy --start-app=+?firefox
```
Also, if you add a '-' as a suffix, scrcpy will exit when the app is closed, alike `--exit-on-close` (see below):
```bash
scrcpy --start-app=org.mozilla.firefox-
```
### Exit when started app closes
If you want scrcpy to exit automatically when the started app closes, use the `--exit-on-close` option together with `--start-app`:
```bash
scrcpy --start-app=<package> --exit-on-close
```
- This is useful for kiosk, automation, or demo scenarios.
- `--exit-on-close` only works when used with `--start-app`.

View file

@ -64,6 +64,7 @@ public class Options {
private NewDisplay newDisplay;
private boolean vdDestroyContent = true;
private boolean vdSystemDecorations = true;
private boolean exitOnAppClose = false;
private Orientation.Lock captureOrientationLock = Orientation.Lock.Unlocked;
private Orientation captureOrientation = Orientation.Orient0;
@ -248,6 +249,10 @@ public class Options {
return vdSystemDecorations;
}
public boolean getExitOnAppClose() {
return exitOnAppClose;
}
public boolean getList() {
return listEncoders || listDisplays || listCameras || listCameraSizes || listApps;
}
@ -483,6 +488,9 @@ public class Options {
case "vd_system_decorations":
options.vdSystemDecorations = Boolean.parseBoolean(value);
break;
case "exit_on_app_close":
options.exitOnAppClose = Boolean.parseBoolean(value);
break;
case "capture_orientation":
Pair<Orientation.Lock, Orientation> pair = parseCaptureOrientation(value);
options.captureOrientationLock = pair.first;

View file

@ -0,0 +1,238 @@
package com.genymobile.scrcpy.control;
import com.genymobile.scrcpy.util.Ln;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.TimeUnit;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* Monitor the state of a running Android application.
* This class monitors whether a specific app is still running on a specific display
* and notifies when the app is no longer active on that display.
*/
public class AppMonitor {
private final String packageName;
private final Runnable onAppClosed;
private final ScheduledExecutorService executor;
private ScheduledFuture<?> scheduledTask;
private volatile boolean running;
private int targetDisplayId = -1; // -1 means any display
private TaskStackMonitor taskStackMonitor;
private static final Pattern DISPLAY_PATTERN = Pattern.compile("Display #(\\d+)");
// Match visible activities/tasks lines within a display block
// Example: "* Task{73b87a7 #325 type=standard A=10150:com.android.chrome U=0 visible=true ...}"
private static final Pattern VISIBLE_TASK_PATTERN = Pattern.compile(
"(?:\\*\\s+)?Task\\{[^}]*A=\\d+:([^\\s]+)\\s+U=\\d+[^}]*\\bvisible=true\\b");
public AppMonitor(String packageName, Runnable onAppClosed) {
this.packageName = packageName;
this.onAppClosed = onAppClosed;
this.executor = Executors.newSingleThreadScheduledExecutor();
this.running = false;
}
// Adaptive scheduling
private static final long DELAY_FOUND_MS = 4000;
private static final long DELAY_FAST_MS = 1000;
private static final int MISSES_TO_CONFIRM = 3;
private int consecutiveMisses = 0;
public AppMonitor(String packageName, int displayId, Runnable onAppClosed) {
this.packageName = packageName;
this.targetDisplayId = displayId;
this.onAppClosed = onAppClosed;
this.executor = Executors.newSingleThreadScheduledExecutor();
this.running = false;
}
/**
* Start monitoring the application.
*/
public void start() {
if (running) {
return;
}
running = true;
Ln.i("AppMonitor started for " + packageName + (targetDisplayId >= 0 ? (" on display " + targetDisplayId) : ""));
boolean listenerOk = registerTaskStackListener();
if (listenerOk) {
// Kick an immediate check; subsequent checks happen on task changes or backoff
scheduleNext(0);
} else {
// Fallback: start polling immediately
scheduleNext(DELAY_FAST_MS);
}
}
/**
* Stop monitoring the application.
*/
public void stop() {
if (!running) {
return;
}
running = false;
Ln.i("AppMonitor stopped for " + packageName);
executor.shutdown();
if (taskStackMonitor != null) {
taskStackMonitor.stop();
taskStackMonitor = null;
}
}
/**
* Check if the monitored app is still running on the target display.
*/
private void checkAppStatus() {
if (!running) {
return;
}
try {
// Reduce I/O: keep display headers and task lines containing the package
String grepPkg = packageName.replace("'", "'\\''");
String result = execCommand(
"dumpsys activity activities | grep -E '^(Display #)|(Task).*" + grepPkg + "'"
);
if (result == null || result.trim().isEmpty()) {
// Fallback without grep (in case grep is unavailable)
result = execCommand("dumpsys activity activities");
}
int status = -1; // 1 found, 0 not found but target seen, -1 target not seen
if (result != null && !result.trim().isEmpty()) {
status = isAppRunningOnDisplay(result, packageName, targetDisplayId);
}
if (status == 1) {
consecutiveMisses = 0;
scheduleNext(DELAY_FOUND_MS);
} else if (status == 0) {
if (++consecutiveMisses >= MISSES_TO_CONFIRM) {
Ln.i("Target app no longer visible on display " + targetDisplayId + ", exiting");
running = false;
executor.shutdown();
onAppClosed.run();
} else {
scheduleNext(DELAY_FAST_MS);
}
} else { // target display block not found; do not count as miss, retry fast
scheduleNext(DELAY_FAST_MS);
}
} catch (Exception e) {
Ln.w("Error checking app status: " + e.getMessage());
// On error, retry fast to recover
scheduleNext(DELAY_FAST_MS);
}
}
private void scheduleNext(long delayMs) {
if (!running) {
return;
}
if (scheduledTask != null && !scheduledTask.isDone()) {
scheduledTask.cancel(false);
}
scheduledTask = executor.schedule(this::checkAppStatus, delayMs, TimeUnit.MILLISECONDS);
}
private boolean registerTaskStackListener() {
try {
taskStackMonitor = new TaskStackMonitor(() -> {
// On task stack changes, reschedule a fast check (debounced by our own logic)
scheduleNext(DELAY_FAST_MS);
});
return taskStackMonitor.start();
} catch (Throwable t) {
Ln.w("TaskStackMonitor unavailable: " + t.getMessage());
taskStackMonitor = null;
return false;
}
}
/**
* Parse dumpsys activity activities output to check if the app is running on the target display.
*/
private int isAppRunningOnDisplay(String dumpsysOutput, String packageName, int targetDisplayId) {
String[] lines = dumpsysOutput.split("\n");
int currentDisplayId = -1;
boolean inTargetBlock = targetDisplayId < 0; // if any display, become true on first display
boolean seenTargetDisplay = (targetDisplayId < 0);
for (String line : lines) {
line = line.trim();
// Check for display information
Matcher displayMatcher = DISPLAY_PATTERN.matcher(line);
if (displayMatcher.find()) {
try {
currentDisplayId = Integer.parseInt(displayMatcher.group(1));
} catch (NumberFormatException e) {
Ln.w("Could not parse display ID: " + displayMatcher.group(1));
}
inTargetBlock = (targetDisplayId < 0) || (currentDisplayId == targetDisplayId);
if (currentDisplayId == targetDisplayId) {
seenTargetDisplay = true;
}
continue;
}
// Within the relevant display section, check visible tasks
if (currentDisplayId != -1 && inTargetBlock) {
Matcher taskMatcher = VISIBLE_TASK_PATTERN.matcher(line);
if (taskMatcher.find()) {
String taskPackage = taskMatcher.group(1);
if (packageName.equals(taskPackage)) {
return 1;
}
}
}
}
// If a specific display is targeted but its block is not present,
// consider the app not visible there (counts as a miss, debounced).
if (targetDisplayId >= 0 && !seenTargetDisplay) {
return 0;
}
return 0;
}
/**
* Execute a shell command and return the output.
*/
private String execCommand(String command) {
try {
Process process = new ProcessBuilder("sh", "-c", command)
.redirectErrorStream(true)
.start();
int exitCode = process.waitFor();
java.io.BufferedReader reader = new java.io.BufferedReader(
new java.io.InputStreamReader(process.getInputStream()));
StringBuilder output = new StringBuilder();
String line;
while ((line = reader.readLine()) != null) {
output.append(line).append("\n");
}
String result = output.toString();
if (exitCode != 0) {
Ln.w("Command '" + command + "' exited with code " + exitCode);
}
return result;
} catch (Exception e) {
Ln.w("Failed to execute command: " + command + ", error: " + e.getMessage());
return null;
}
}
}

View file

@ -70,6 +70,7 @@ public class Controller implements AsyncProcessor, VirtualDisplayListener {
private static final ScheduledExecutorService EXECUTOR = Executors.newSingleThreadScheduledExecutor();
private ExecutorService startAppExecutor;
private AppMonitor appMonitor;
private Thread thread;
@ -82,6 +83,7 @@ public class Controller implements AsyncProcessor, VirtualDisplayListener {
private final DeviceMessageSender sender;
private final boolean clipboardAutosync;
private final boolean powerOn;
private final boolean exitOnAppClose;
private final KeyCharacterMap charMap = KeyCharacterMap.load(KeyCharacterMap.VIRTUAL_KEYBOARD);
@ -106,6 +108,7 @@ public class Controller implements AsyncProcessor, VirtualDisplayListener {
this.cleanUp = cleanUp;
this.clipboardAutosync = options.getClipboardAutosync();
this.powerOn = options.getPowerOn();
this.exitOnAppClose = options.getExitOnAppClose();
initPointers();
sender = new DeviceMessageSender(controlChannel);
@ -691,6 +694,33 @@ public class Controller implements AsyncProcessor, VirtualDisplayListener {
Ln.i("Starting app \"" + app.getName() + "\" [" + app.getPackageName() + "] on display " + startAppDisplayId + "...");
Device.startApp(app.getPackageName(), startAppDisplayId, forceStopBeforeStart);
// Start monitoring the app if exit_on_app_close is enabled
if (exitOnAppClose) {
if (appMonitor != null) {
appMonitor.stop();
}
// Pass the display ID to monitor the app on the specific display
appMonitor = new AppMonitor(app.getPackageName(), startAppDisplayId, this::onAppClosed);
appMonitor.start();
}
}
private void onAppClosed() {
Ln.i("Monitored app closed, requesting scrcpy exit");
// Stop the app monitor
if (appMonitor != null) {
appMonitor.stop();
appMonitor = null;
}
// Terminate the server process to close the connection
// This will cause the client to detect the disconnection and exit
try {
System.exit(0);
} catch (Exception e) {
Ln.w("Error handling app close: " + e.getMessage());
}
}
private int getStartAppDisplayId() {

View file

@ -0,0 +1,99 @@
package com.genymobile.scrcpy.control;
import com.genymobile.scrcpy.util.Ln;
import android.os.IBinder;
import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Method;
import java.lang.reflect.Proxy;
/**
* Registers a TaskStackListener via reflection (hidden API) to be notified when the
* task stack changes. Falls back to polling if registration fails.
*/
public final class TaskStackMonitor {
public interface Listener {
void onTaskStackChanged();
}
private final Listener listener;
private Object activityTaskManager; // IActivityTaskManager
private Object taskStackListenerProxy; // ITaskStackListener
private Method registerMethod;
private Method unregisterMethod;
public TaskStackMonitor(Listener listener) {
this.listener = listener;
}
public boolean start() {
try {
// android.app.ActivityTaskManager.getService()
Class<?> atmClazz = Class.forName("android.app.ActivityTaskManager");
Method getService = atmClazz.getDeclaredMethod("getService");
activityTaskManager = getService.invoke(null);
if (activityTaskManager == null) {
Ln.w("ActivityTaskManager.getService() returned null");
return false;
}
// Interface android.app.ITaskStackListener
Class<?> listenerInterface = Class.forName("android.app.ITaskStackListener");
// Create dynamic proxy for ITaskStackListener
taskStackListenerProxy = Proxy.newProxyInstance(
ClassLoader.getSystemClassLoader(),
new Class<?>[] { listenerInterface },
new InvocationHandler() {
@Override
public Object invoke(Object proxy, Method method, Object[] args) {
String name = method.getName();
if ("onTaskStackChanged".equals(name)
|| "onTaskMovedToFront".equals(name)
|| "onTaskFocusChanged".equals(name)
|| "onTaskStackChangedBackground".equals(name)) {
try {
listener.onTaskStackChanged();
} catch (Throwable t) {
Ln.w("TaskStack listener callback error: " + t.getMessage());
}
}
return null;
}
}
);
// Methods: register/unregisterTaskStackListener(ITaskStackListener)
registerMethod = activityTaskManager.getClass().getMethod(
"registerTaskStackListener", listenerInterface);
unregisterMethod = activityTaskManager.getClass().getMethod(
"unregisterTaskStackListener", listenerInterface);
registerMethod.invoke(activityTaskManager, taskStackListenerProxy);
Ln.i("TaskStackMonitor registered");
return true;
} catch (Throwable e) {
Ln.w("Could not register TaskStackListener: " + e.getMessage());
return false;
}
}
public void stop() {
try {
if (activityTaskManager != null && taskStackListenerProxy != null && unregisterMethod != null) {
unregisterMethod.invoke(activityTaskManager, taskStackListenerProxy);
Ln.i("TaskStackMonitor unregistered");
}
} catch (Throwable e) {
Ln.w("Could not unregister TaskStackListener: " + e.getMessage());
}
activityTaskManager = null;
taskStackListenerProxy = null;
registerMethod = null;
unregisterMethod = null;
}
}