From 80409b026bdf651fdedbf08dd0aae9ab6d087c7a Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Sat, 13 Sep 2025 04:19:13 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20A=C3=B1adir=20la=20opci=C3=B3n=20--ssh-?= =?UTF-8?q?tunnel=20para=20simplificar=20la=20conexi=C3=B3n=20remota?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Este commit introduce una nueva opción de línea de comandos `--ssh-tunnel` para simplificar el proceso de conexión a un dispositivo Android remoto a través de Internet. Anteriormente, los usuarios tenían que crear manualmente un túnel SSH con reglas específicas de reenvío de puertos y establecer la variable de entorno ADB_SERVER_SOCKET. Esta nueva opción automatiza todo el proceso. Cuando se utiliza `--ssh-tunnel=usuario@host`, scrcpy hará lo siguiente: 1. Iniciará automáticamente un proceso `ssh` en segundo plano. 2. Configurará los reenvíos de puertos locales y remotos necesarios tanto para el servidor adb como para los sockets de datos de scrcpy. 3. Establecerá las variables de entorno necesarias para que adb utilice el túnel. 4. Terminará el proceso ssh limpiamente cuando scrcpy se cierre. Esto mejora significativamente la experiencia del usuario para las conexiones remotas. Para dar soporte a esta característica, se han añadido pruebas unitarias y de integración: - Una prueba unitaria verifica el análisis de la nueva opción de línea de comandos. - Una prueba de integración simulada utilizando un script `ssh` simulado verifica que el comando ssh se construye y se ejecuta correctamente. Además, el sistema de construcción se ha configurado para soportar informes de cobertura de pruebas tanto para el cliente C (gcovr) como para el servidor Java (Jacoco) para facilitar futuras mejoras en las pruebas. --- app/meson.build | 6 ++- app/src/cli.c | 10 +++++ app/src/options.h | 1 + app/src/scrcpy.c | 46 ++++++++++++++++++++++ app/tests/mock_ssh.sh | 14 +++++++ app/tests/test_cli.c | 17 ++++++++ app/tests/test_ssh_tunnel_integration.sh | 50 ++++++++++++++++++++++++ release/test_client.sh | 3 +- server/build.gradle | 28 +++++++++++++ 9 files changed, 173 insertions(+), 2 deletions(-) create mode 100755 app/tests/mock_ssh.sh create mode 100755 app/tests/test_ssh_tunnel_integration.sh diff --git a/app/meson.build b/app/meson.build index f7df69eb22..5429069d65 100644 --- a/app/meson.build +++ b/app/meson.build @@ -180,7 +180,7 @@ configure_file(configuration: conf, output: 'config.h') src_dir = include_directories('src') -executable('scrcpy', src, +scrcpy_exe = executable('scrcpy', src, dependencies: dependencies, include_directories: src_dir, install: true, @@ -278,6 +278,10 @@ if get_option('buildtype') == 'debug' c_args: ['-DSDL_MAIN_HANDLED', '-DSC_TEST']) test(t[0], exe) endforeach + + test('ssh_tunnel_integration', + find_program('tests/test_ssh_tunnel_integration.sh'), + depends: scrcpy_exe) endif if meson.version().version_compare('>= 0.58.0') diff --git a/app/src/cli.c b/app/src/cli.c index b2e3e30a53..6f17add2d7 100644 --- a/app/src/cli.c +++ b/app/src/cli.c @@ -54,6 +54,7 @@ enum { OPT_DISPLAY_BUFFER, OPT_VIDEO_BUFFER, OPT_V4L2_BUFFER, + OPT_SSH_TUNNEL, OPT_TUNNEL_HOST, OPT_TUNNEL_PORT, OPT_NO_CLIPBOARD_AUTOSYNC, @@ -879,6 +880,12 @@ static const struct sc_option options[] = { "shortcuts, pass \"lctrl,lsuper\".\n" "Default is \"lalt,lsuper\" (left-Alt or left-Super).", }, + { + .longopt_id = OPT_SSH_TUNNEL, + .longopt = "ssh-tunnel", + .argdesc = "user@host", + .text = "Spin up an SSH tunnel for remote scrcpy access.", + }, { .longopt_id = OPT_START_APP, .longopt = "start-app", @@ -2620,6 +2627,9 @@ parse_args_with_getopt(struct scrcpy_cli_args *args, int argc, char *argv[], return false; } break; + case OPT_SSH_TUNNEL: + opts->ssh_tunnel_host = optarg; + break; case OPT_FORWARD_ALL_CLICKS: LOGE("--forward-all-clicks has been removed, " "use --mouse-bind=++++ instead."); diff --git a/app/src/options.h b/app/src/options.h index 03b4291344..2621504519 100644 --- a/app/src/options.h +++ b/app/src/options.h @@ -254,6 +254,7 @@ struct scrcpy_options { struct sc_mouse_bindings mouse_bindings; enum sc_camera_facing camera_facing; struct sc_port_range port_range; + const char *ssh_tunnel_host; uint32_t tunnel_host; uint16_t tunnel_port; uint8_t shortcut_mods; // OR of enum sc_shortcut_mod values diff --git a/app/src/scrcpy.c b/app/src/scrcpy.c index a4c8c340dc..0f28292f4b 100644 --- a/app/src/scrcpy.c +++ b/app/src/scrcpy.c @@ -37,7 +37,9 @@ # include "usb/usb.h" #endif #include "util/acksync.h" +#include "util/env.h" #include "util/log.h" +#include "util/process.h" #include "util/rand.h" #include "util/timeout.h" #include "util/tick.h" @@ -386,6 +388,42 @@ scrcpy(struct scrcpy_options *options) { #endif struct scrcpy *s = &scrcpy; + sc_pid ssh_tunnel_pid = SC_PROCESS_NONE; + + if (options->ssh_tunnel_host) { + LOGI("Starting SSH tunnel to %s", options->ssh_tunnel_host); + +#ifdef _WIN32 + if (_putenv_s("ADB_SERVER_SOCKET", "tcp:localhost:5038")) { + LOGE("Failed to set ADB_SERVER_SOCKET"); + return SCRCPY_EXIT_FAILURE; + } +#else + if (setenv("ADB_SERVER_SOCKET", "tcp:localhost:5038", 1)) { + LOGE("Failed to set ADB_SERVER_SOCKET"); + return SCRCPY_EXIT_FAILURE; + } +#endif + const char *mock_ssh_path = sc_get_env("SCRCPY_MOCK_SSH_PATH"); + const char *ssh_executable = mock_ssh_path ? mock_ssh_path : "ssh"; + const char *cmd[] = {ssh_executable, "-CN", "-L5038:localhost:5037", + "-R27183:localhost:27183", + options->ssh_tunnel_host, NULL}; + enum sc_process_result res = + sc_process_execute(cmd, &ssh_tunnel_pid, + SC_PROCESS_NO_STDOUT | SC_PROCESS_NO_STDERR); + free((void *)mock_ssh_path); + + if (res != SC_PROCESS_SUCCESS) { + LOGE("Failed to execute ssh"); + return SCRCPY_EXIT_FAILURE; + } + + options->tunnel_host = 0x7f000001; // 127.0.0.1 + options->tunnel_port = 27183; + options->force_adb_forward = true; + } + // Minimal SDL initialization if (SDL_Init(SDL_INIT_EVENTS)) { LOGE("Could not initialize SDL: %s", SDL_GetError()); @@ -960,6 +998,14 @@ scrcpy(struct scrcpy_options *options) { sc_timeout_stop(&s->timeout); } + if (ssh_tunnel_pid != SC_PROCESS_NONE) { + LOGI("Terminating SSH tunnel"); + if (!sc_process_terminate(ssh_tunnel_pid)) { + LOGW("Could not terminate SSH tunnel"); + } + sc_process_wait(ssh_tunnel_pid, true); + } + // The demuxer is not stopped explicitly, because it will stop by itself on // end-of-stream #ifdef HAVE_USB diff --git a/app/tests/mock_ssh.sh b/app/tests/mock_ssh.sh new file mode 100755 index 0000000000..a305b67c93 --- /dev/null +++ b/app/tests/mock_ssh.sh @@ -0,0 +1,14 @@ +#!/bin/sh +# Mock ssh command for integration tests + +if [ -z "$MOCK_SSH_OUTPUT_FILE" ]; then + echo "MOCK_SSH_OUTPUT_FILE env var not set" >&2 + exit 1 +fi + +# Record all arguments to the file specified by the env var +echo "$@" > "$MOCK_SSH_OUTPUT_FILE" + +# Block forever to simulate an active connection, until killed by the test +# script. +sleep 9999 diff --git a/app/tests/test_cli.c b/app/tests/test_cli.c index de605cb964..31e389df40 100644 --- a/app/tests/test_cli.c +++ b/app/tests/test_cli.c @@ -96,6 +96,22 @@ static void test_options(void) { assert(opts->window_borderless); } +static void test_parse_ssh_tunnel(void) { + struct scrcpy_cli_args args = { + .opts = scrcpy_options_default, + .help = false, + .version = false, + }; + + char *argv[] = {"scrcpy", "--ssh-tunnel=user@host"}; + + bool ok = scrcpy_parse_args(&args, 2, argv); + assert(ok); + + const struct scrcpy_options *opts = &args.opts; + assert(!strcmp(opts->ssh_tunnel_host, "user@host")); +} + static void test_options2(void) { struct scrcpy_cli_args args = { .opts = scrcpy_options_default, @@ -158,5 +174,6 @@ int main(int argc, char *argv[]) { test_options(); test_options2(); test_parse_shortcut_mods(); + test_parse_ssh_tunnel(); return 0; } diff --git a/app/tests/test_ssh_tunnel_integration.sh b/app/tests/test_ssh_tunnel_integration.sh new file mode 100755 index 0000000000..9f6e52776e --- /dev/null +++ b/app/tests/test_ssh_tunnel_integration.sh @@ -0,0 +1,50 @@ +#!/bin/sh + +set -e + +# The scrcpy binary is in the app/ subdirectory in the meson test env +SCRCPY_BIN=./app/scrcpy + +# Provide the absolute path to the mock ssh script via an envvar +MOCK_SSH_SCRIPT=$(readlink -f "$(dirname "$0")/mock_ssh.sh") +export SCRCPY_MOCK_SSH_PATH="$MOCK_SSH_SCRIPT" + +TMP_FILE=$(mktemp) +export MOCK_SSH_OUTPUT_FILE="$TMP_FILE" +trap 'rm -f "$TMP_FILE"' EXIT + +# Run scrcpy in the background. It is expected to fail because there is no +# device, so ignore the error code. +set +e +"$SCRCPY_BIN" --ssh-tunnel=test@mockhost & +SCRCPY_PID=$! +set -e + +# Give scrcpy time to start the ssh process +sleep 2 + +# Kill the scrcpy process, which should also trigger cleanup of the ssh process +# It may already be terminated, so ignore the error code. +kill "$SCRCPY_PID" 2>/dev/null || true +# Wait for the process to be fully terminated +wait "$SCRCPY_PID" 2>/dev/null || true + +# Check if the mock ssh was called with the correct arguments +if [ ! -f "$TMP_FILE" ]; then + echo "ERROR: Mock ssh was not called (output file not found)" + exit 1 +fi + +EXPECTED_ARGS="-CN -L5038:localhost:5037 -R27183:localhost:27183 test@mockhost" +ACTUAL_ARGS=$(cat "$TMP_FILE") + +if [ "$EXPECTED_ARGS" != "$ACTUAL_ARGS" ]; then + echo "ERROR: Mock ssh called with wrong arguments" + echo " Expected: $EXPECTED_ARGS" + echo " Actual: $ACTUAL_ARGS" + exit 1 +fi + +echo "SUCCESS: Mock ssh called with correct arguments" + +exit 0 diff --git a/release/test_client.sh b/release/test_client.sh index 6059541d0c..9ca329a3ea 100755 --- a/release/test_client.sh +++ b/release/test_client.sh @@ -8,5 +8,6 @@ TEST_BUILD_DIR="$WORK_DIR/build-test" rm -rf "$TEST_BUILD_DIR" meson setup "$TEST_BUILD_DIR" -Dcompile_server=false \ - -Db_sanitize=address,undefined + -Db_sanitize=address,undefined -Db_coverage=true ninja -C "$TEST_BUILD_DIR" test +ninja -C "$TEST_BUILD_DIR" coverage-html diff --git a/server/build.gradle b/server/build.gradle index 31092b1233..e024c54575 100644 --- a/server/build.gradle +++ b/server/build.gradle @@ -1,4 +1,5 @@ apply plugin: 'com.android.application' +apply plugin: 'jacoco' android { namespace 'com.genymobile.scrcpy' @@ -28,3 +29,30 @@ dependencies { } apply from: "$project.rootDir/config/android-checkstyle.gradle" + +jacoco { + toolVersion = "0.8.11" +} + +tasks.withType(Test) { + jacoco.includeNoLocationClasses = true + jacoco.excludes = ['jdk.internal.*'] +} + +task jacocoTestReport(type: JacocoReport, dependsOn: 'test') { + reports { + xml.required = true + html.required = true + } + + def fileFilter = ['**/R.class', '**/R$*.class', '**/BuildConfig.*', '**/Manifest*.*', '**/*Test*.*'] + def debugTree = fileTree(dir: "$project.buildDir/intermediates/javac/debug/classes", excludes: fileFilter) + def mainSrc = "$project.projectDir/src/main/java" + + sourceDirectories.setFrom(files([mainSrc])) + classDirectories.setFrom(files([debugTree])) + executionData.setFrom(fileTree(dir: project.buildDir, includes: [ + 'jacoco/testDebugUnitTest.exec', + 'outputs/code_coverage/debugAndroidTest/connected/*coverage.ec' + ])) +}