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' + ])) +}