diff --git a/.gitignore b/.gitignore index 26d977acaa..911dc81e6e 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,8 @@ build/ /release-*/ .idea/ .gradle/ +.cache/ +.vscode/ /x/ local.properties /scrcpy-server diff --git a/app/data/bash-completion/scrcpy b/app/data/bash-completion/scrcpy index a49da8ca2d..cf878d9765 100644 --- a/app/data/bash-completion/scrcpy +++ b/app/data/bash-completion/scrcpy @@ -83,6 +83,7 @@ _scrcpy() { --screen-off-timeout= --shortcut-mod= --start-app= + --exit-on-close -t --show-touches --tcpip --tcpip= diff --git a/app/scrcpy.1 b/app/scrcpy.1 index d72fda13f8..1bf56995f0 100644 --- a/app/scrcpy.1 +++ b/app/scrcpy.1 @@ -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 diff --git a/app/src/cli.c b/app/src/cli.c index b2e3e30a53..19854e42a7 100644 --- a/app/src/cli.c +++ b/app/src/cli.c @@ -7,6 +7,9 @@ #include #include #include +#include + +#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, diff --git a/app/src/options.c b/app/src/options.c index 0fe82d291b..4587ce5a54 100644 --- a/app/src/options.c +++ b/app/src/options.c @@ -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, diff --git a/app/src/options.h b/app/src/options.h index 03b4291344..cf47814e33 100644 --- a/app/src/options.h +++ b/app/src/options.h @@ -325,6 +325,8 @@ struct scrcpy_options { bool audio_dup; const char *new_display; // [x][/] parsed by the server const char *start_app; + bool exit_on_app_close; + bool stop_app; bool vd_destroy_content; bool vd_system_decorations; }; diff --git a/app/src/scrcpy.c b/app/src/scrcpy.c index aedfdf9cf8..566d0e45ce 100644 --- a/app/src/scrcpy.c +++ b/app/src/scrcpy.c @@ -7,6 +7,7 @@ #include #include #include +#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, ¶ms, &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 @@ scrcpy(struct scrcpy_options *options) { 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 @@ scrcpy(struct scrcpy_options *options) { 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 @@ scrcpy(struct scrcpy_options *options) { 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 @@ scrcpy(struct scrcpy_options *options) { 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 @@ scrcpy(struct scrcpy_options *options) { 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 @@ scrcpy(struct scrcpy_options *options) { 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 @@ scrcpy(struct scrcpy_options *options) { } } - 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 @@ scrcpy(struct scrcpy_options *options) { sc_screen_hide_window(&s->screen); } -end: + end: if (timeout_started) { sc_timeout_stop(&s->timeout); } diff --git a/app/src/server.c b/app/src/server.c index 153219c3f4..1fa41f1139 100644 --- a/app/src/server.c +++ b/app/src/server.c @@ -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"); } diff --git a/app/src/server.h b/app/src/server.h index 5f4592de90..238e2b0301 100644 --- a/app/src/server.h +++ b/app/src/server.h @@ -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; }; diff --git a/doc/device.md b/doc/device.md index ab1e6ba486..f5765a942d 100644 --- a/doc/device.md +++ b/doc/device.md @@ -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= --exit-on-close +``` + +- This is useful for kiosk, automation, or demo scenarios. +- `--exit-on-close` only works when used with `--start-app`. diff --git a/server/src/main/java/com/genymobile/scrcpy/Options.java b/server/src/main/java/com/genymobile/scrcpy/Options.java index 66bb68e8a9..baa78ef410 100644 --- a/server/src/main/java/com/genymobile/scrcpy/Options.java +++ b/server/src/main/java/com/genymobile/scrcpy/Options.java @@ -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 boolean getVDSystemDecorations() { return vdSystemDecorations; } + public boolean getExitOnAppClose() { + return exitOnAppClose; + } + public boolean getList() { return listEncoders || listDisplays || listCameras || listCameraSizes || listApps; } @@ -483,6 +488,9 @@ public static Options parse(String... args) { 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 pair = parseCaptureOrientation(value); options.captureOrientationLock = pair.first; diff --git a/server/src/main/java/com/genymobile/scrcpy/control/AppMonitor.java b/server/src/main/java/com/genymobile/scrcpy/control/AppMonitor.java new file mode 100644 index 0000000000..635e2ef876 --- /dev/null +++ b/server/src/main/java/com/genymobile/scrcpy/control/AppMonitor.java @@ -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; + } + } +} diff --git a/server/src/main/java/com/genymobile/scrcpy/control/Controller.java b/server/src/main/java/com/genymobile/scrcpy/control/Controller.java index b4a8e3ca84..cfceb5cf4e 100644 --- a/server/src/main/java/com/genymobile/scrcpy/control/Controller.java +++ b/server/src/main/java/com/genymobile/scrcpy/control/Controller.java @@ -70,6 +70,7 @@ private DisplayData(int virtualDisplayId, PositionMapper positionMapper) { private static final ScheduledExecutorService EXECUTOR = Executors.newSingleThreadScheduledExecutor(); private ExecutorService startAppExecutor; + private AppMonitor appMonitor; private Thread thread; @@ -82,6 +83,7 @@ private DisplayData(int virtualDisplayId, PositionMapper positionMapper) { 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 Controller(ControlChannel controlChannel, CleanUp cleanUp, Options option this.cleanUp = cleanUp; this.clipboardAutosync = options.getClipboardAutosync(); this.powerOn = options.getPowerOn(); + this.exitOnAppClose = options.getExitOnAppClose(); initPointers(); sender = new DeviceMessageSender(controlChannel); @@ -691,6 +694,33 @@ private void startApp(String name) { 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() { diff --git a/server/src/main/java/com/genymobile/scrcpy/control/TaskStackMonitor.java b/server/src/main/java/com/genymobile/scrcpy/control/TaskStackMonitor.java new file mode 100644 index 0000000000..88586b0daf --- /dev/null +++ b/server/src/main/java/com/genymobile/scrcpy/control/TaskStackMonitor.java @@ -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; + } +} + +