Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion app/meson.build
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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')
Expand Down
10 changes: 10 additions & 0 deletions app/src/cli.c
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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.");
Expand Down
1 change: 1 addition & 0 deletions app/src/options.h
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
46 changes: 46 additions & 0 deletions app/src/scrcpy.c
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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());
Expand Down Expand Up @@ -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
Expand Down
14 changes: 14 additions & 0 deletions app/tests/mock_ssh.sh
Original file line number Diff line number Diff line change
@@ -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
17 changes: 17 additions & 0 deletions app/tests/test_cli.c
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -158,5 +174,6 @@ int main(int argc, char *argv[]) {
test_options();
test_options2();
test_parse_shortcut_mods();
test_parse_ssh_tunnel();
return 0;
}
50 changes: 50 additions & 0 deletions app/tests/test_ssh_tunnel_integration.sh
Original file line number Diff line number Diff line change
@@ -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
3 changes: 2 additions & 1 deletion release/test_client.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
28 changes: 28 additions & 0 deletions server/build.gradle
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
apply plugin: 'com.android.application'
apply plugin: 'jacoco'

android {
namespace 'com.genymobile.scrcpy'
Expand Down Expand Up @@ -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'
]))
}