diff --git a/.github/workflows/publish.js.yml b/.github/workflows/publish.js.yml index bcce0282c0..23b3f368ed 100644 --- a/.github/workflows/publish.js.yml +++ b/.github/workflows/publish.js.yml @@ -88,6 +88,12 @@ jobs: runs-on: macos-15 steps: - uses: actions/checkout@v7 + with: + # The published npm package ships ThirdParty/hev-socks5-tunnel (see the + # 'files' list in package.json) so consumers can build the opt-in + # WebDriverAgentRunnerTunnel scheme; without the submodule the pack + # would silently omit the engine sources. + submodules: recursive - name: Use Node.js uses: actions/setup-node@v7 with: diff --git a/.github/workflows/wda-tests.yml b/.github/workflows/wda-tests.yml index a41bc0367d..f1a4970975 100644 --- a/.github/workflows/wda-tests.yml +++ b/.github/workflows/wda-tests.yml @@ -13,7 +13,12 @@ on: - 'PrivateHeaders/**' - 'Configurations/**' - 'WebDriverAgent.xcodeproj/**' + - 'WebDriverAgentTunnel/**' + - 'ThirdParty/**' + - '.gitmodules' - 'Scripts/build.sh' + - 'Scripts/build-hev-socks5-tunnel.sh' + - 'Scripts/embed-tunnel-extension.sh' - 'Fastlane/**' - 'Gemfile*' - '.github/workflows/wda-tests.yml' @@ -57,6 +62,8 @@ jobs: {"name": "Generic_tvOS_Build_Min_Xcode", "vm_image": "${{ env.MIN_VM_IMAGE }}", "xcode_version": "${{ env.MIN_XCODE_VERSION }}", "action": "build", "target": "tv_runner", "sdk": "tv_sim", "dest": "tv_generic", "code_sign": "no"}, {"name": "iOS_Build_Max_Xcode", "vm_image": "${{ env.MAX_VM_IMAGE }}", "xcode_version": "${{ env.MAX_XCODE_VERSION }}", "action": "build", "target": "runner", "sdk": "sim", "iphone_model": "${{ env.MAX_IPHONE_DEVICE_NAME }}", "ipad_model": "${{ env.MAX_IPAD_DEVICE_NAME }}", "ios_version": "${{ env.MAX_PLATFORM_VERSION }}"}, {"name": "iOS_Build_Min_Xcode", "vm_image": "${{ env.MIN_VM_IMAGE }}", "xcode_version": "${{ env.MIN_XCODE_VERSION }}", "action": "build", "target": "runner", "sdk": "sim", "iphone_model": "${{ env.MIN_IPHONE_DEVICE_NAME }}", "ipad_model": "${{ env.MIN_IPAD_DEVICE_NAME }}", "ios_version": "${{ env.MIN_PLATFORM_VERSION }}"}, + {"name": "iOS_Tunnel_Build_Max_Xcode", "vm_image": "${{ env.MAX_VM_IMAGE }}", "xcode_version": "${{ env.MAX_XCODE_VERSION }}", "action": "build", "target": "tunnel_runner", "sdk": "sim", "iphone_model": "${{ env.MAX_IPHONE_DEVICE_NAME }}", "ipad_model": "${{ env.MAX_IPAD_DEVICE_NAME }}", "ios_version": "${{ env.MAX_PLATFORM_VERSION }}"}, + {"name": "iOS_Tunnel_Build_Min_Xcode", "vm_image": "${{ env.MIN_VM_IMAGE }}", "xcode_version": "${{ env.MIN_XCODE_VERSION }}", "action": "build", "target": "tunnel_runner", "sdk": "sim", "iphone_model": "${{ env.MIN_IPHONE_DEVICE_NAME }}", "ipad_model": "${{ env.MIN_IPAD_DEVICE_NAME }}", "ios_version": "${{ env.MIN_PLATFORM_VERSION }}"}, {"name": "tvOS_Build_Max_Xcode", "vm_image": "${{ env.MAX_VM_IMAGE }}", "xcode_version": "${{ env.MAX_XCODE_VERSION }}", "action": "build", "target": "tv_runner", "sdk": "tv_sim", "tv_model": "${{ env.MAX_TV_DEVICE_NAME }}", "tv_version": "${{ env.MAX_TV_PLATFORM_VERSION }}"}, {"name": "tvOS_Build_Min_Xcode", "vm_image": "${{ env.MIN_VM_IMAGE }}", "xcode_version": "${{ env.MIN_XCODE_VERSION }}", "action": "build", "target": "tv_runner", "sdk": "tv_sim", "dest": "tv", "tv_model": "${{ env.MIN_TV_DEVICE_NAME }}", "tv_version": "${{ env.MIN_TV_PLATFORM_VERSION }}"} ] @@ -138,6 +145,11 @@ jobs: config: ${{ fromJSON(needs.build_matrix.outputs.matrix) }} steps: - uses: actions/checkout@v7 + with: + # Only the tunnel_runner matrix entries need ThirdParty/hev-socks5-tunnel + # (and its nested submodules); checking it out unconditionally keeps the + # shared build job simple. + submodules: recursive - uses: ./.github/actions/xcode-setup with: xcode_version: ${{ matrix.config.xcode_version }} diff --git a/.gitignore b/.gitignore index 75359860fc..dd3fdf2fd0 100644 --- a/.gitignore +++ b/.gitignore @@ -54,3 +54,8 @@ WebDriverAgentRunner-Runner.app/ # Ruby Gemfile.lock + +# Built from the hev-socks5-tunnel submodule (Scripts/build-hev-socks5-tunnel.sh) +ThirdParty/HevSocks5Tunnel.xcframework/ +ThirdParty/.hev-socks5-tunnel.stamp +ThirdParty/.hev-socks5-tunnel.lock diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000000..87f5d8e765 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "ThirdParty/hev-socks5-tunnel"] + path = ThirdParty/hev-socks5-tunnel + url = https://github.com/heiher/hev-socks5-tunnel diff --git a/Configurations/IOSSettings.xcconfig b/Configurations/IOSSettings.xcconfig index 3bde5a21f2..a0fc8a1f11 100644 --- a/Configurations/IOSSettings.xcconfig +++ b/Configurations/IOSSettings.xcconfig @@ -1,4 +1,5 @@ EXCLUDED_ARCHS = i386 +WDA_PRODUCT_BUNDLE_IDENTIFIER = com.facebook.WebDriverAgentRunner GCC_TREAT_WARNINGS_AS_ERRORS = YES GCC_WARN_PEDANTIC = YES diff --git a/Configurations/TVOSSettings.xcconfig b/Configurations/TVOSSettings.xcconfig index 3bde5a21f2..a0fc8a1f11 100644 --- a/Configurations/TVOSSettings.xcconfig +++ b/Configurations/TVOSSettings.xcconfig @@ -1,4 +1,5 @@ EXCLUDED_ARCHS = i386 +WDA_PRODUCT_BUNDLE_IDENTIFIER = com.facebook.WebDriverAgentRunner GCC_TREAT_WARNINGS_AS_ERRORS = YES GCC_WARN_PEDANTIC = YES diff --git a/Scripts/build-hev-socks5-tunnel.sh b/Scripts/build-hev-socks5-tunnel.sh new file mode 100755 index 0000000000..b36b60f565 --- /dev/null +++ b/Scripts/build-hev-socks5-tunnel.sh @@ -0,0 +1,150 @@ +#!/bin/bash +# Copyright (c) 2026-present, Droidrun. +# All rights reserved. +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. + +# Build ThirdParty/HevSocks5Tunnel.xcframework from the hev-socks5-tunnel +# git submodule (ThirdParty/hev-socks5-tunnel, pinned by SHA). +# +# Trimmed from upstream build-apple.sh: only the slices WebDriverAgent needs +# (iphoneos-arm64, iphonesimulator-arm64+x86_64). The result is consumed by +# the WebDriverAgentTunnel packet-tunnel appex; see docs/socks5-tunnel.md. +# +# The output is gitignored. Skips the build when the xcframework already +# matches the current submodule SHA + script contents (stamp file). +# +# Usage: Scripts/build-hev-socks5-tunnel.sh [--force] + +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)" +SUBMODULE_DIR="$ROOT_DIR/ThirdParty/hev-socks5-tunnel" +OUTPUT="$ROOT_DIR/ThirdParty/HevSocks5Tunnel.xcframework" +STAMP_FILE="$ROOT_DIR/ThirdParty/.hev-socks5-tunnel.stamp" +MIN_IOS="15.0" +JOBS="$(sysctl -n hw.ncpu)" + +if [ ! -f "$SUBMODULE_DIR/src/hev-main.h" ]; then + echo "error: hev-socks5-tunnel submodule is missing or not initialized." >&2 + echo " run: git submodule update --init --recursive" >&2 + exit 1 +fi + +SCRIPT_SHA=$(shasum -a 256 "$0" | cut -d' ' -f1) + +# build_static runs `make clean` in the shared submodule checkout, so two concurrent +# preparations (e.g. two WDA sessions starting at once) would delete each other's archives +# mid-compile or mid-libtool and produce nondeterministic failures or a corrupt xcframework. +# Serialize across processes, and hold the lock across the stamp check too: the loser then +# re-reads the stamp the winner just wrote and exits early instead of rebuilding. +LOCK_FILE="$ROOT_DIR/ThirdParty/.hev-socks5-tunnel.lock" +LOCK_WAIT_SECONDS="${HEV_SOCKS5_LOCK_WAIT_SECONDS:-900}" +BUILD_DIR="" + +cleanup() +{ + [ -n "$BUILD_DIR" ] && rm -rf "$BUILD_DIR" + return 0 +} +trap cleanup EXIT + +# Keep one inode permanently and let the kernel release its advisory lock when this process exits. +# Unlike stale-directory reclamation, a waiter can never rename or remove another builder's live +# lock between observing and acquiring it. Descriptor 9 remains open for the rest of the script. +exec 9>"$LOCK_FILE" +if ! /usr/bin/lockf -t "$LOCK_WAIT_SECONDS" 9; then + echo "error: timed out after ${LOCK_WAIT_SECONDS}s waiting for $LOCK_FILE" >&2 + exit 1 +fi + +# The npm tarball ships the engine sources without Git metadata. `git -C` must not be allowed to +# walk up into the consuming application's repository, where every application commit would look +# like a new hev revision. Only trust Git when its top-level is the vendored engine itself; +# otherwise hash every shipped input while excluding the build products made by this script. +SUBMODULE_REALPATH="$(cd "$SUBMODULE_DIR" && pwd -P)" +SUBMODULE_GIT_TOPLEVEL="$(git -C "$SUBMODULE_DIR" rev-parse --show-toplevel 2>/dev/null || true)" +if [ -n "$SUBMODULE_GIT_TOPLEVEL" ] \ + && [ "$(cd "$SUBMODULE_GIT_TOPLEVEL" && pwd -P)" = "$SUBMODULE_REALPATH" ]; then + SUBMODULE_SHA=$(git -C "$SUBMODULE_DIR" rev-parse HEAD) + SOURCE_ID="$SUBMODULE_SHA" +else + SOURCE_ID=$( + cd "$SUBMODULE_DIR" + find . -name .git -prune -o -type f \ + ! -path './bin/*' ! -path './build/*' \ + ! -path './third-part/hev-task-system/bin/*' \ + ! -path './third-part/hev-task-system/build/*' \ + ! -path './third-part/lwip/bin/*' ! -path './third-part/lwip/build/*' \ + ! -path './third-part/yaml/bin/*' ! -path './third-part/yaml/build/*' \ + -print0 \ + | LC_ALL=C sort -z \ + | xargs -0 shasum -a 256 \ + | shasum -a 256 | cut -d' ' -f1 + ) + SUBMODULE_SHA="tree-$SOURCE_ID" +fi +STAMP="${SOURCE_ID}-${SCRIPT_SHA}-${MIN_IOS}" + +if [ "${1:-}" != "--force" ] && [ -d "$OUTPUT" ] && [ -f "$STAMP_FILE" ] \ + && [ "$(cat "$STAMP_FILE")" = "$STAMP" ]; then + echo "HevSocks5Tunnel.xcframework is up to date ($SUBMODULE_SHA); skipping build" + exit 0 +fi + +BUILD_DIR=$(mktemp -d -t hev-socks5-tunnel-build) + +# Other-platform sources (linux/windows/jni/...) compile to empty objects on +# iOS, producing harmless 'has no symbols' archive warnings; drop just those. +filter_no_symbols() +{ + grep -v 'has no symbols' >&2 || true +} + +# build_static +build_static() +{ + local SDK="$1" ARCH="$2" MIN_FLAG="$3" + echo "building libhev-socks5-tunnel for $SDK/$ARCH" + make -C "$SUBMODULE_DIR" clean >/dev/null + make -C "$SUBMODULE_DIR" -j"$JOBS" \ + PP="xcrun --sdk $SDK --toolchain $SDK clang" \ + CC="xcrun --sdk $SDK --toolchain $SDK clang" \ + CFLAGS="-arch $ARCH $MIN_FLAG" \ + LFLAGS="-arch $ARCH $MIN_FLAG -Wl,-Bsymbolic-functions" static \ + >/dev/null 2> >(filter_no_symbols) + mkdir -p "$BUILD_DIR/$SDK-$ARCH" + # The 'static' target leaves one archive per component; merge them. + libtool -static -o "$BUILD_DIR/$SDK-$ARCH/libhev-socks5-tunnel.a" \ + "$SUBMODULE_DIR/bin/libhev-socks5-tunnel.a" \ + "$SUBMODULE_DIR/third-part/lwip/bin/liblwip.a" \ + "$SUBMODULE_DIR/third-part/yaml/bin/libyaml.a" \ + "$SUBMODULE_DIR/third-part/hev-task-system/bin/libhev-task-system.a" \ + 2> >(filter_no_symbols) + make -C "$SUBMODULE_DIR" clean >/dev/null +} + +build_static iphoneos arm64 "-miphoneos-version-min=$MIN_IOS" +build_static iphonesimulator arm64 "-mios-simulator-version-min=$MIN_IOS" +build_static iphonesimulator x86_64 "-mios-simulator-version-min=$MIN_IOS" + +mkdir -p "$BUILD_DIR/iphonesimulator-universal" +lipo -create \ + "$BUILD_DIR/iphonesimulator-arm64/libhev-socks5-tunnel.a" \ + "$BUILD_DIR/iphonesimulator-x86_64/libhev-socks5-tunnel.a" \ + -output "$BUILD_DIR/iphonesimulator-universal/libhev-socks5-tunnel.a" + +INCLUDE_DIR="$BUILD_DIR/include" +mkdir -p "$INCLUDE_DIR/HevSocks5Tunnel" +cp "$SUBMODULE_DIR/src/hev-main.h" "$INCLUDE_DIR/HevSocks5Tunnel/" +cp "$SUBMODULE_DIR/module.modulemap" "$INCLUDE_DIR/HevSocks5Tunnel/" + +rm -rf "$OUTPUT" +xcodebuild -create-xcframework \ + -library "$BUILD_DIR/iphoneos-arm64/libhev-socks5-tunnel.a" -headers "$INCLUDE_DIR" \ + -library "$BUILD_DIR/iphonesimulator-universal/libhev-socks5-tunnel.a" -headers "$INCLUDE_DIR" \ + -output "$OUTPUT" + +echo "$STAMP" > "$STAMP_FILE" +echo "built $OUTPUT from hev-socks5-tunnel $SUBMODULE_SHA" diff --git a/Scripts/build.sh b/Scripts/build.sh index 281ba3fd59..f4bf07f9fe 100755 --- a/Scripts/build.sh +++ b/Scripts/build.sh @@ -15,6 +15,7 @@ function define_xc_macros() { case "$TARGET" in "lib" ) XC_TARGET="WebDriverAgentLib";; "runner" ) XC_TARGET="WebDriverAgentRunner";; + "tunnel_runner" ) XC_TARGET="WebDriverAgentRunnerTunnel";; "tv_lib" ) XC_TARGET="WebDriverAgentLib_tvOS";; "tv_runner" ) XC_TARGET="WebDriverAgentRunner_tvOS";; "watch_lib" ) XC_TARGET="WebDriverAgentLib_watchOS";; @@ -123,7 +124,26 @@ function fastlane_test() { SDK="$XC_SDK" DEVICE="$FASTLANE_DEVICE" SCHEME="$1" bundle exec fastlane test } +function prepare_socks5_engine() { + # Only the WebDriverAgentRunnerTunnel scheme builds the WebDriverAgentTunnel appex, which + # links HevSocks5Tunnel.xcframework built from the hev-socks5-tunnel submodule (no-op when + # the build stamp is current). Plain runner builds need neither the submodule nor the + # engine. See docs/socks5-tunnel.md. + if [[ "$TARGET" != "tunnel_runner" ]]; then + return + fi + local script_dir + script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) + if [ ! -f "$script_dir/../ThirdParty/hev-socks5-tunnel/src/hev-main.h" ]; then + echo "error: the hev-socks5-tunnel submodule is not initialized;" \ + "run 'git submodule update --init --recursive' first" + exit 1 + fi + "$script_dir/build-hev-socks5-tunnel.sh" +} + define_xc_macros +prepare_socks5_engine case "$ACTION" in "analyze" ) analyze ;; "int_test_1" ) fastlane_test IntegrationTests_1 ;; diff --git a/Scripts/embed-tunnel-extension.sh b/Scripts/embed-tunnel-extension.sh new file mode 100755 index 0000000000..d2cbd64c0b --- /dev/null +++ b/Scripts/embed-tunnel-extension.sh @@ -0,0 +1,118 @@ +#!/bin/bash +# Copyright (c) 2026-present, Droidrun. +# All rights reserved. +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. + +# Embed the WebDriverAgentTunnel NetworkExtension packet tunnel extension into +# the wrapping XCTRunner host app. +# +# Apple's USES_XCTRUNNER auto-generates a Runner.app around UI-testing +# .xctest bundles after all target build phases have run, so an appex +# cannot reach Runner.app/PlugIns through a regular embed build phase. +# The extension target is built into BUILT_PRODUCTS_DIR via a target +# dependency of WebDriverAgentRunner; this scheme post-action copies it +# into Runner.app/PlugIns, validates that its bundle id matches the host app +# (extensions must be prefixed by the host's CFBundleIdentifier, which +# Xcode suffixes with '.xctrunner') and re-signs inner-first. +# +# Mirrors Scripts/embed-broadcast-extension.sh; kept separate because the +# tunnel additionally needs the NetworkExtension entitlement repaired on +# the host app (see below and docs/socks5-tunnel.md). +# +# Limitations: +# - Touches XCTRunner internals; may need updates across Xcode versions. +# - iOS only; the extension is not built for tvOS. +# - Loading the tunnel on a device needs paid-team signing with the +# packet-tunnel-provider entitlement (WDA_TUNNEL_ENTITLEMENTS / +# WDA_RUNNER_ENTITLEMENTS build settings); see docs/socks5-tunnel.md. + +set -euo pipefail + +RUNNER_APP="${BUILT_PRODUCTS_DIR}/${PRODUCT_NAME}-Runner.app" +APPEX_NAME="WebDriverAgentTunnel.appex" +APPEX_SRC="${BUILT_PRODUCTS_DIR}/${APPEX_NAME}" +NE_ENTITLEMENT="com.apple.developer.networking.networkextension" + +if [ ! -d "$RUNNER_APP" ]; then + echo "warning: ${PRODUCT_NAME}-Runner.app not found at $RUNNER_APP; skipping tunnel extension embed" + exit 0 +fi + +if [ ! -d "$APPEX_SRC" ]; then + echo "warning: $APPEX_NAME not found at $APPEX_SRC; skipping tunnel extension embed" + exit 0 +fi + +APPEX_DST="$RUNNER_APP/PlugIns/$APPEX_NAME" +rm -rf "$APPEX_DST" +mkdir -p "$RUNNER_APP/PlugIns" +cp -R "$APPEX_SRC" "$APPEX_DST" + +# Extensions must be provisioned with their final bundle id. Rewriting it after Xcode signs the +# appex cannot update the embedded provisioning profile's application identifier, so fail the +# build instead of producing a bundle that installd will reject. +HOST_ID=$(/usr/libexec/PlistBuddy -c "Print :CFBundleIdentifier" "$RUNNER_APP/Info.plist") +WANT_ID="${HOST_ID}.tunnel" +CURRENT_ID=$(/usr/libexec/PlistBuddy -c "Print :CFBundleIdentifier" "$APPEX_DST/Info.plist") +if [ "$CURRENT_ID" != "$WANT_ID" ]; then + echo "error: $APPEX_NAME was built as '$CURRENT_ID', expected '$WANT_ID'." >&2 + echo "error: set WDA_PRODUCT_BUNDLE_IDENTIFIER to the runner's base bundle id before building." >&2 + exit 1 +fi + +# Re-codesign the copied appex first, then the app so its seal covers the new nested code. In a +# scheme post-action context Xcode's CODE_SIGN_* env vars are not exposed, so discover the +# existing signing identity from the already-signed bundle. +if [ -d "$RUNNER_APP/_CodeSignature" ]; then + # Capture the signature info once. Piping codesign straight into + # `awk ... exit` makes awk close the pipe early, killing codesign with + # SIGPIPE -- which `set -o pipefail` turns into a fatal error. That trips + # only when an Authority line exists, i.e. on every real-device build. + SIGN_INFO=$(codesign -dvv "$RUNNER_APP" 2>&1 || true) + EXISTING_IDENT="${EXPANDED_CODE_SIGN_IDENTITY:-}" + if [ -z "$EXISTING_IDENT" ]; then + EXISTING_IDENT=$(awk -F'=' '/^Authority/ {print $2; exit}' <<< "$SIGN_INFO") + fi + # Simulator builds are ad-hoc signed: there is no Authority line, but the + # bundle can still be re-signed ad-hoc with an identity of "-". + if [ -z "$EXISTING_IDENT" ] && grep -q '^Signature=adhoc' <<< "$SIGN_INFO"; then + EXISTING_IDENT="-" + fi + if [ -n "$EXISTING_IDENT" ]; then + APPEX_ENTITLEMENTS_PLIST=$(mktemp -t tunnel-appex-entitlements).plist + if ! codesign -d --entitlements - --xml "$APPEX_DST" > "$APPEX_ENTITLEMENTS_PLIST" 2>/dev/null; then + : > "$APPEX_ENTITLEMENTS_PLIST" + fi + codesign --force --sign "$EXISTING_IDENT" \ + --preserve-metadata=identifier,entitlements "$APPEX_DST" + + # Xcode is not documented to propagate the UI-test target's CODE_SIGN_ENTITLEMENTS onto + # the generated Runner.app. The host must hold the NetworkExtension entitlement for + # NETunnelProviderManager to accept the configuration, so when the appex carries it but + # the host does not, inject it into the host's entitlements while re-sealing. + RUNNER_ENTITLEMENTS_PLIST=$(mktemp -t tunnel-runner-entitlements).plist + if ! codesign -d --entitlements - --xml "$RUNNER_APP" > "$RUNNER_ENTITLEMENTS_PLIST" 2>/dev/null; then + : > "$RUNNER_ENTITLEMENTS_PLIST" + fi + APPEX_HAS_NE=$(/usr/libexec/PlistBuddy -c "Print :${NE_ENTITLEMENT}" "$APPEX_ENTITLEMENTS_PLIST" 2>/dev/null || true) + RUNNER_HAS_NE=$(/usr/libexec/PlistBuddy -c "Print :${NE_ENTITLEMENT}" "$RUNNER_ENTITLEMENTS_PLIST" 2>/dev/null || true) + if [ -n "$APPEX_HAS_NE" ] && [ -z "$RUNNER_HAS_NE" ] && [ -s "$RUNNER_ENTITLEMENTS_PLIST" ]; then + /usr/libexec/PlistBuddy -c "Add :${NE_ENTITLEMENT} array" "$RUNNER_ENTITLEMENTS_PLIST" + /usr/libexec/PlistBuddy -c "Add :${NE_ENTITLEMENT}:0 string packet-tunnel-provider" "$RUNNER_ENTITLEMENTS_PLIST" + codesign --force --sign "$EXISTING_IDENT" \ + --preserve-metadata=identifier \ + --entitlements "$RUNNER_ENTITLEMENTS_PLIST" "$RUNNER_APP" + echo "injected $NE_ENTITLEMENT into Runner.app entitlements during re-sign" + else + codesign --force --sign "$EXISTING_IDENT" \ + --preserve-metadata=identifier,entitlements "$RUNNER_APP" + fi + rm -f "$APPEX_ENTITLEMENTS_PLIST" "$RUNNER_ENTITLEMENTS_PLIST" + else + echo "warning: bundle is signed but no identity discovered; signature will be invalid" + fi +fi + +echo "embedded $APPEX_NAME into $RUNNER_APP (bundle id $WANT_ID)" diff --git a/Scripts/remove-tunnel-extension.sh b/Scripts/remove-tunnel-extension.sh new file mode 100755 index 0000000000..0f5ac26ff6 --- /dev/null +++ b/Scripts/remove-tunnel-extension.sh @@ -0,0 +1,78 @@ +#!/bin/bash +# Copyright (c) 2026-present, Droidrun. +# All rights reserved. +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. + +# Remove tunnel-only artifacts from the generated XCTRunner host app. Xcode reuses the Runner.app +# directory across schemes, but the tunnel appex is copied by a scheme post-action and is therefore +# not tracked or removed by the default target build. + +set -euo pipefail + +RUNNER_APP="${BUILT_PRODUCTS_DIR}/${PRODUCT_NAME}-Runner.app" +APPEX_NAME="WebDriverAgentTunnel.appex" +APPEX_DST="$RUNNER_APP/PlugIns/$APPEX_NAME" +NE_ENTITLEMENT="com.apple.developer.networking.networkextension" + +if [ ! -d "$RUNNER_APP" ]; then + echo "warning: ${PRODUCT_NAME}-Runner.app not found at $RUNNER_APP; skipping tunnel extension cleanup" + exit 0 +fi + +SIGNED=0 +SIGN_INFO="" +EXISTING_IDENT="" +RUNNER_ENTITLEMENTS_PLIST="" +if [ -d "$RUNNER_APP/_CodeSignature" ]; then + SIGNED=1 + SIGN_INFO=$(codesign -dvv "$RUNNER_APP" 2>&1 || true) + EXISTING_IDENT="${EXPANDED_CODE_SIGN_IDENTITY:-}" + if [ -z "$EXISTING_IDENT" ]; then + EXISTING_IDENT=$(awk -F'=' '/^Authority/ {print $2; exit}' <<< "$SIGN_INFO") + fi + if [ -z "$EXISTING_IDENT" ] && grep -q '^Signature=adhoc' <<< "$SIGN_INFO"; then + EXISTING_IDENT="-" + fi + RUNNER_ENTITLEMENTS_PLIST=$(mktemp -t default-runner-entitlements) + if ! codesign -d --entitlements - --xml "$RUNNER_APP" > "$RUNNER_ENTITLEMENTS_PLIST" 2>/dev/null; then + : > "$RUNNER_ENTITLEMENTS_PLIST" + fi +fi + +REMOVED_APPEX=0 +if [ -e "$APPEX_DST" ]; then + rm -rf "$APPEX_DST" + rmdir "$RUNNER_APP/PlugIns" 2>/dev/null || true + REMOVED_APPEX=1 +fi + +REMOVED_ENTITLEMENT=0 +if [ -n "$RUNNER_ENTITLEMENTS_PLIST" ] && [ -s "$RUNNER_ENTITLEMENTS_PLIST" ]; then + if /usr/libexec/PlistBuddy -c "Print :${NE_ENTITLEMENT}" "$RUNNER_ENTITLEMENTS_PLIST" >/dev/null 2>&1; then + /usr/libexec/PlistBuddy -c "Delete :${NE_ENTITLEMENT}" "$RUNNER_ENTITLEMENTS_PLIST" + REMOVED_ENTITLEMENT=1 + fi +fi + +if [ "$SIGNED" = "1" ] && { [ "$REMOVED_APPEX" = "1" ] || [ "$REMOVED_ENTITLEMENT" = "1" ]; }; then + if [ -z "$EXISTING_IDENT" ]; then + echo "warning: Runner.app is signed but no identity was discovered; signature is invalid after tunnel cleanup" + elif [ "$REMOVED_ENTITLEMENT" = "1" ]; then + codesign --force --sign "$EXISTING_IDENT" \ + --preserve-metadata=identifier \ + --entitlements "$RUNNER_ENTITLEMENTS_PLIST" "$RUNNER_APP" + else + codesign --force --sign "$EXISTING_IDENT" \ + --preserve-metadata=identifier,entitlements "$RUNNER_APP" + fi +fi + +if [ -n "$RUNNER_ENTITLEMENTS_PLIST" ]; then + rm -f "$RUNNER_ENTITLEMENTS_PLIST" +fi + +if [ "$REMOVED_APPEX" = "1" ] || [ "$REMOVED_ENTITLEMENT" = "1" ]; then + echo "removed stale tunnel artifacts from $RUNNER_APP" +fi diff --git a/ThirdParty/README.md b/ThirdParty/README.md new file mode 100644 index 0000000000..3c61de8e30 --- /dev/null +++ b/ThirdParty/README.md @@ -0,0 +1,20 @@ +# ThirdParty + +## hev-socks5-tunnel + +- `hev-socks5-tunnel/` is a git submodule of + [heiher/hev-socks5-tunnel](https://github.com/heiher/hev-socks5-tunnel) + (MIT license), pinned at a release tag. It carries four nested submodules + (hev-task-system, yaml, lwip, hev-socks5-core), so clone/update with + `git submodule update --init --recursive`. +- `HevSocks5Tunnel.xcframework` (gitignored) is built from that source by + `Scripts/build-hev-socks5-tunnel.sh` — a trimmed variant of upstream's + `build-apple.sh` producing only the iphoneos-arm64 and + iphonesimulator-arm64/x86_64 static-library slices. The script skips the + build when a stamp file matches the current submodule SHA; pass `--force` + to rebuild. +- The xcframework is linked only by the `WebDriverAgentTunnel` packet-tunnel + app extension; see `docs/socks5-tunnel.md`. + +To bump the engine: check out a new tag inside the submodule, update the +nested submodules, commit the new SHA, and rerun the build script. diff --git a/ThirdParty/hev-socks5-tunnel b/ThirdParty/hev-socks5-tunnel new file mode 160000 index 0000000000..00c7eb9ad7 --- /dev/null +++ b/ThirdParty/hev-socks5-tunnel @@ -0,0 +1 @@ +Subproject commit 00c7eb9ad7ca381b0f1fee880abc1077fe9b93be diff --git a/WebDriverAgent.xcodeproj/project.pbxproj b/WebDriverAgent.xcodeproj/project.pbxproj index b0f1f1a558..fe3c81ab0d 100644 --- a/WebDriverAgent.xcodeproj/project.pbxproj +++ b/WebDriverAgent.xcodeproj/project.pbxproj @@ -7,84 +7,6 @@ objects = { /* Begin PBXBuildFile section */ - 34AB13EFF1F673084C910195 /* GCDAsyncSocket.h in Headers */ = {isa = PBXBuildFile; fileRef = 718226C72587443600661B83 /* GCDAsyncSocket.h */; }; - 718226CC2587443700661B83 /* GCDAsyncSocket.h in Headers */ = {isa = PBXBuildFile; fileRef = 718226C72587443600661B83 /* GCDAsyncSocket.h */; }; - 718226CD2587443700661B83 /* GCDAsyncSocket.h in Headers */ = {isa = PBXBuildFile; fileRef = 718226C72587443600661B83 /* GCDAsyncSocket.h */; }; - 718226CE2587443700661B83 /* GCDAsyncSocket.m in Sources */ = {isa = PBXBuildFile; fileRef = 718226C82587443600661B83 /* GCDAsyncSocket.m */; }; - 718226CF2587443700661B83 /* GCDAsyncSocket.m in Sources */ = {isa = PBXBuildFile; fileRef = 718226C82587443600661B83 /* GCDAsyncSocket.m */; }; - FBCAFE000000000000002002 /* FBImageUtilsTests.m in Sources */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000002001 /* FBImageUtilsTests.m */; }; - FBCAFE000000000000001002 /* FBVideoStreamSession.h in Headers */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000001001 /* FBVideoStreamSession.h */; }; - FBCAFE000000000000001003 /* FBVideoStreamSession.h in Headers */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000001001 /* FBVideoStreamSession.h */; }; - FBCAFE000000000000001005 /* FBVideoStreamSession.m in Sources */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000001004 /* FBVideoStreamSession.m */; }; - FBCAFE000000000000001006 /* FBVideoStreamSession.m in Sources */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000001004 /* FBVideoStreamSession.m */; }; - FBCAFE000000000000000002 /* FBScreenCaptureCommands.h in Headers */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000000001 /* FBScreenCaptureCommands.h */; }; - FBCAFE000000000000000003 /* FBScreenCaptureCommands.h in Headers */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000000001 /* FBScreenCaptureCommands.h */; }; - FBCAFE000000000000000005 /* FBScreenCaptureCommands.m in Sources */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000000004 /* FBScreenCaptureCommands.m */; }; - FBCAFE000000000000000006 /* FBScreenCaptureCommands.m in Sources */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000000004 /* FBScreenCaptureCommands.m */; }; - FBCAFE000000000000003002 /* FBMobilerunA11yCommands.h in Headers */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000003001 /* FBMobilerunA11yCommands.h */; }; - FBCAFE000000000000003102 /* FBMobilerunActionsCommands.h in Headers */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000003101 /* FBMobilerunActionsCommands.h */; }; - FBCAFE000000000000003003 /* FBMobilerunA11yCommands.h in Headers */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000003001 /* FBMobilerunA11yCommands.h */; }; - FBCAFE000000000000003103 /* FBMobilerunActionsCommands.h in Headers */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000003101 /* FBMobilerunActionsCommands.h */; }; - FBCAFE000000000000003005 /* FBMobilerunA11yCommands.m in Sources */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000003004 /* FBMobilerunA11yCommands.m */; }; - FBCAFE000000000000003105 /* FBMobilerunActionsCommands.m in Sources */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000003104 /* FBMobilerunActionsCommands.m */; }; - FBCAFE000000000000003006 /* FBMobilerunA11yCommands.m in Sources */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000003004 /* FBMobilerunA11yCommands.m */; }; - FBCAFE000000000000003106 /* FBMobilerunActionsCommands.m in Sources */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000003104 /* FBMobilerunActionsCommands.m */; }; - FBCAFE000000000000000008 /* FBPixelBufferConverter.h in Headers */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000000007 /* FBPixelBufferConverter.h */; }; - FBCAFE000000000000000009 /* FBPixelBufferConverter.h in Headers */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000000007 /* FBPixelBufferConverter.h */; }; - FBCAFE00000000000000000B /* FBPixelBufferConverter.m in Sources */ = {isa = PBXBuildFile; fileRef = FBCAFE00000000000000000A /* FBPixelBufferConverter.m */; }; - FBCAFE00000000000000000C /* FBPixelBufferConverter.m in Sources */ = {isa = PBXBuildFile; fileRef = FBCAFE00000000000000000A /* FBPixelBufferConverter.m */; }; - FBCAFE00000000000000000E /* FBVideoEncoder.h in Headers */ = {isa = PBXBuildFile; fileRef = FBCAFE00000000000000000D /* FBVideoEncoder.h */; }; - FBCAFE00000000000000000F /* FBVideoEncoder.h in Headers */ = {isa = PBXBuildFile; fileRef = FBCAFE00000000000000000D /* FBVideoEncoder.h */; }; - FBCAFE000000000000000011 /* FBVideoEncoder.m in Sources */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000000010 /* FBVideoEncoder.m */; }; - FBCAFE000000000000000012 /* FBVideoEncoder.m in Sources */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000000010 /* FBVideoEncoder.m */; }; - FBCAFE000000000000000014 /* FBVideoStreamManager.h in Headers */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000000013 /* FBVideoStreamManager.h */; }; - FBCAFE000000000000000015 /* FBVideoStreamManager.h in Headers */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000000013 /* FBVideoStreamManager.h */; }; - FBCAFE000000000000000017 /* FBVideoStreamManager.m in Sources */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000000016 /* FBVideoStreamManager.m */; }; - FBCAFE000000000000000018 /* FBVideoStreamManager.m in Sources */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000000016 /* FBVideoStreamManager.m */; }; - FBCAFE00000000000000001A /* FBPixelBufferConverterTests.m in Sources */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000000019 /* FBPixelBufferConverterTests.m */; }; - FBCAFE00000000000000001C /* FBVideoEncoderTests.m in Sources */ = {isa = PBXBuildFile; fileRef = FBCAFE00000000000000001B /* FBVideoEncoderTests.m */; }; - FBCAFE000000000000004002 /* FBVideoStreamSessionTests.m in Sources */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000004001 /* FBVideoStreamSessionTests.m */; }; - FBCAFE000000000000005103 /* FBBroadcastSampleHandler.m in Sources */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000005102 /* FBBroadcastSampleHandler.m */; }; - FBCAFE000000000000005106 /* FBExtBroadcastClient.m in Sources */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000005105 /* FBExtBroadcastClient.m */; }; - FBCAFE000000000000005109 /* FBExtSessionPipeline.m in Sources */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000005108 /* FBExtSessionPipeline.m */; }; - FBCAFE000000000000005201 /* FBVideoEncoder.m in Sources */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000000010 /* FBVideoEncoder.m */; }; - FBCAFE000000000000005202 /* GCDAsyncSocket.m in Sources */ = {isa = PBXBuildFile; fileRef = 718226C82587443600661B83 /* GCDAsyncSocket.m */; }; - FBCAFE000000000000005203 /* FBBroadcastProtocol.m in Sources */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000005301 /* FBBroadcastProtocol.m */; }; - FBCAFE000000000000005302 /* FBBroadcastProtocol.h in Headers */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000005300 /* FBBroadcastProtocol.h */; }; - FBCAFE000000000000005303 /* FBBroadcastProtocol.h in Headers */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000005300 /* FBBroadcastProtocol.h */; }; - FBCAFE000000000000005304 /* FBBroadcastProtocol.m in Sources */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000005301 /* FBBroadcastProtocol.m */; }; - FBCAFE000000000000005305 /* FBBroadcastProtocol.m in Sources */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000005301 /* FBBroadcastProtocol.m */; }; - FBCAFE000000000000005306 /* FBBroadcastProtocol.m in Sources */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000005301 /* FBBroadcastProtocol.m */; }; - FBCAFE000000000000005312 /* FBBroadcastControlServer.h in Headers */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000005310 /* FBBroadcastControlServer.h */; }; - FBCAFE000000000000005313 /* FBBroadcastControlServer.h in Headers */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000005310 /* FBBroadcastControlServer.h */; }; - FBCAFE000000000000005314 /* FBBroadcastControlServer.m in Sources */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000005311 /* FBBroadcastControlServer.m */; }; - FBCAFE000000000000005315 /* FBBroadcastControlServer.m in Sources */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000005311 /* FBBroadcastControlServer.m */; }; - FBCAFE000000000000005322 /* FBBroadcastManager.h in Headers */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000005320 /* FBBroadcastManager.h */; }; - FBCAFE000000000000005323 /* FBBroadcastManager.h in Headers */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000005320 /* FBBroadcastManager.h */; }; - FBCAFE000000000000005324 /* FBBroadcastManager.m in Sources */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000005321 /* FBBroadcastManager.m */; }; - FBCAFE000000000000005325 /* FBBroadcastManager.m in Sources */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000005321 /* FBBroadcastManager.m */; }; - FBCAFE000000000000005332 /* FBBroadcastPickerHost.h in Headers */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000005330 /* FBBroadcastPickerHost.h */; }; - FBCAFE000000000000005333 /* FBBroadcastPickerHost.h in Headers */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000005330 /* FBBroadcastPickerHost.h */; }; - FBCAFE000000000000005334 /* FBBroadcastPickerHost.m in Sources */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000005331 /* FBBroadcastPickerHost.m */; }; - FBCAFE000000000000005335 /* FBBroadcastPickerHost.m in Sources */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000005331 /* FBBroadcastPickerHost.m */; }; - FBCAFE000000000000006002 /* FBScrcpyPacket.h in Headers */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000006000 /* FBScrcpyPacket.h */; }; - FBCAFE000000000000006003 /* FBScrcpyPacket.h in Headers */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000006000 /* FBScrcpyPacket.h */; }; - FBCAFE000000000000006004 /* FBScrcpyPacket.m in Sources */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000006001 /* FBScrcpyPacket.m */; }; - FBCAFE000000000000006005 /* FBScrcpyPacket.m in Sources */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000006001 /* FBScrcpyPacket.m */; }; - FBCAFE000000000000006012 /* FBAudioStreamSession.h in Headers */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000006010 /* FBAudioStreamSession.h */; }; - FBCAFE000000000000006013 /* FBAudioStreamSession.h in Headers */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000006010 /* FBAudioStreamSession.h */; }; - FBCAFE000000000000006014 /* FBAudioStreamSession.m in Sources */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000006011 /* FBAudioStreamSession.m */; }; - FBCAFE000000000000006015 /* FBAudioStreamSession.m in Sources */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000006011 /* FBAudioStreamSession.m */; }; - FBCAFE000000000000006022 /* FBAudioStreamManager.h in Headers */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000006020 /* FBAudioStreamManager.h */; }; - FBCAFE000000000000006023 /* FBAudioStreamManager.h in Headers */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000006020 /* FBAudioStreamManager.h */; }; - FBCAFE000000000000006024 /* FBAudioStreamManager.m in Sources */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000006021 /* FBAudioStreamManager.m */; }; - FBCAFE000000000000006025 /* FBAudioStreamManager.m in Sources */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000006021 /* FBAudioStreamManager.m */; }; - FBCAFE000000000000006032 /* FBAudioCaptureCommands.h in Headers */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000006030 /* FBAudioCaptureCommands.h */; }; - FBCAFE000000000000006033 /* FBAudioCaptureCommands.h in Headers */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000006030 /* FBAudioCaptureCommands.h */; }; - FBCAFE000000000000006034 /* FBAudioCaptureCommands.m in Sources */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000006031 /* FBAudioCaptureCommands.m */; }; - FBCAFE000000000000006035 /* FBAudioCaptureCommands.m in Sources */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000006031 /* FBAudioCaptureCommands.m */; }; - FBCAFE000000000000006042 /* FBExtAudioPipeline.m in Sources */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000006041 /* FBExtAudioPipeline.m */; }; - FBCAFE000000000000006051 /* FBAudioStreamTests.m in Sources */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000006050 /* FBAudioStreamTests.m */; }; 005B327C702EF47820887884 /* FBErrorBuilder.h in Headers */ = {isa = PBXBuildFile; fileRef = EE3A18601CDE618F00DE4205 /* FBErrorBuilder.h */; }; 00ABDDC324B060006EA182F7 /* FBScreen.m in Sources */ = {isa = PBXBuildFile; fileRef = 715AFAC01FFA29180053896D /* FBScreen.m */; }; 0161F45997A981E47729DB25 /* RouteResponse.h in Headers */ = {isa = PBXBuildFile; fileRef = 01E7CFEBAD717FCF4BCDD383 /* RouteResponse.h */; }; @@ -242,6 +164,7 @@ 3400BE6CBA163DC9A58D60E6 /* XCUIApplicationProcessTracker-Protocol.h in Headers */ = {isa = PBXBuildFile; fileRef = 3238E68F292452D8234153F1 /* XCUIApplicationProcessTracker-Protocol.h */; }; 348B7FFB742C26599E44DB67 /* XCTMessagingRole_ProcessMonitoring-Protocol.h in Headers */ = {isa = PBXBuildFile; fileRef = B43D656732067585371ADD31 /* XCTMessagingRole_ProcessMonitoring-Protocol.h */; settings = {ATTRIBUTES = (Public, ); }; }; 34A32D91F0C8B6A31A9E8F9A /* FBSettingsHandler.m in Sources */ = {isa = PBXBuildFile; fileRef = 71F3E7D725417FF400E0C22C /* FBSettingsHandler.m */; }; + 34AB13EFF1F673084C910195 /* GCDAsyncSocket.h in Headers */ = {isa = PBXBuildFile; fileRef = 718226C72587443600661B83 /* GCDAsyncSocket.h */; }; 34E3403E0FE0E93C74E8FB2D /* XCTScreenCapturePolicy.h in Headers */ = {isa = PBXBuildFile; fileRef = FDB15E393EA0850C004D26B2 /* XCTScreenCapturePolicy.h */; }; 35924251B4B5D0A486A6A0BB /* FBHTTPServer.m in Sources */ = {isa = PBXBuildFile; fileRef = AADFEA2ED9E61A8C1A99B2D7 /* FBHTTPServer.m */; }; 35C087E005C82F9642F8A76C /* XCUIDeviceDelayedAttachmentTransferSupportInterface-Protocol.h in Headers */ = {isa = PBXBuildFile; fileRef = 4C19CEEEC9858A106061D1F8 /* XCUIDeviceDelayedAttachmentTransferSupportInterface-Protocol.h */; settings = {ATTRIBUTES = (Public, ); }; }; @@ -599,7 +522,6 @@ 71241D7B1FAE3D2500B9559F /* FBTouchActionCommands.h in Headers */ = {isa = PBXBuildFile; fileRef = 71241D791FAE3D2500B9559F /* FBTouchActionCommands.h */; }; 71241D7C1FAE3D2500B9559F /* FBTouchActionCommands.m in Sources */ = {isa = PBXBuildFile; fileRef = 71241D7A1FAE3D2500B9559F /* FBTouchActionCommands.m */; }; 71241D7E1FAF084E00B9559F /* FBW3CTouchActionsIntegrationTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 71241D7D1FAF084E00B9559F /* FBW3CTouchActionsIntegrationTests.m */; }; - FBCAFE000000000000003202 /* FBMobilerunActionsIntegrationTests.m in Sources */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000003201 /* FBMobilerunActionsIntegrationTests.m */; }; 71241D801FAF087500B9559F /* FBW3CMultiTouchActionsIntegrationTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 71241D7F1FAF087500B9559F /* FBW3CMultiTouchActionsIntegrationTests.m */; }; 712A0C851DA3E459007D02E5 /* FBXPathTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 712A0C841DA3E459007D02E5 /* FBXPathTests.m */; }; 712A0C871DA3E55D007D02E5 /* FBXPath-Private.h in Headers */ = {isa = PBXBuildFile; fileRef = 712A0C861DA3E55D007D02E5 /* FBXPath-Private.h */; }; @@ -683,6 +605,10 @@ 716F0DA12A16CA1000CDD977 /* NSDictionary+FBUtf8SafeDictionary.h in Headers */ = {isa = PBXBuildFile; fileRef = 716F0D9F2A16CA1000CDD977 /* NSDictionary+FBUtf8SafeDictionary.h */; }; 716F0DA32A16CA1000CDD977 /* NSDictionary+FBUtf8SafeDictionary.m in Sources */ = {isa = PBXBuildFile; fileRef = 716F0DA02A16CA1000CDD977 /* NSDictionary+FBUtf8SafeDictionary.m */; }; 716F0DA62A17323300CDD977 /* NSDictionaryFBUtf8SafeTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 716F0DA52A17323300CDD977 /* NSDictionaryFBUtf8SafeTests.m */; }; + 718226CC2587443700661B83 /* GCDAsyncSocket.h in Headers */ = {isa = PBXBuildFile; fileRef = 718226C72587443600661B83 /* GCDAsyncSocket.h */; }; + 718226CD2587443700661B83 /* GCDAsyncSocket.h in Headers */ = {isa = PBXBuildFile; fileRef = 718226C72587443600661B83 /* GCDAsyncSocket.h */; }; + 718226CE2587443700661B83 /* GCDAsyncSocket.m in Sources */ = {isa = PBXBuildFile; fileRef = 718226C82587443600661B83 /* GCDAsyncSocket.m */; }; + 718226CF2587443700661B83 /* GCDAsyncSocket.m in Sources */ = {isa = PBXBuildFile; fileRef = 718226C82587443600661B83 /* GCDAsyncSocket.m */; }; 7182A87F3CAA27F71B624AD2 /* XCTRunnerAutomationSession-Protocol.h in Headers */ = {isa = PBXBuildFile; fileRef = 01AF8E73DD47455B4854E470 /* XCTRunnerAutomationSession-Protocol.h */; settings = {ATTRIBUTES = (Public, ); }; }; 718F49C8230844330045FE8B /* FBProtocolHelpersTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 718F49C7230844330045FE8B /* FBProtocolHelpersTests.m */; }; 718F49C923087ACF0045FE8B /* FBProtocolHelpers.h in Headers */ = {isa = PBXBuildFile; fileRef = 71B155DD23080CA600646AFB /* FBProtocolHelpers.h */; }; @@ -1268,7 +1194,6 @@ EE8DDD7F20C5733C004D4925 /* XCUIElement+FBForceTouch.h in Headers */ = {isa = PBXBuildFile; fileRef = EE8DDD7D20C5733C004D4925 /* XCUIElement+FBForceTouch.h */; settings = {ATTRIBUTES = (Public, ); }; }; EE9AB8011CAEE048008C271F /* UITestingUITests.m in Sources */ = {isa = PBXBuildFile; fileRef = EE9AB7FD1CAEE048008C271F /* UITestingUITests.m */; }; EE9B76591CF7987800275851 /* FBRouteTests.m in Sources */ = {isa = PBXBuildFile; fileRef = EE9B76571CF7987300275851 /* FBRouteTests.m */; }; - FBCAFE000000000000003011 /* FBTestInterruptionSuppressionTests.m in Sources */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000003010 /* FBTestInterruptionSuppressionTests.m */; }; EE9B768E1CF7997600275851 /* AppDelegate.m in Sources */ = {isa = PBXBuildFile; fileRef = EE9B76831CF7997600275851 /* AppDelegate.m */; }; EE9B768F1CF7997600275851 /* ViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = EE9B76851CF7997600275851 /* ViewController.m */; }; EE9B76911CF7997600275851 /* main.m in Sources */ = {isa = PBXBuildFile; fileRef = EE9B76871CF7997600275851 /* main.m */; }; @@ -1332,6 +1257,105 @@ FAC85D261CAD26DB5F9D804C /* XCUIPlatformApplicationServicesProviding-Protocol.h in Headers */ = {isa = PBXBuildFile; fileRef = 3BA71BCF1FDA482419CA8596 /* XCUIPlatformApplicationServicesProviding-Protocol.h */; settings = {ATTRIBUTES = (Public, ); }; }; FBAFC553152CE132D6CB98E5 /* XCUIDeviceDelayedAttachmentTransferSupportInterface-Protocol.h in Headers */ = {isa = PBXBuildFile; fileRef = 4C19CEEEC9858A106061D1F8 /* XCUIDeviceDelayedAttachmentTransferSupportInterface-Protocol.h */; }; FBB82323D9D57F069440BF94 /* XCTMessagingRole_SystemConfiguration-Protocol.h in Headers */ = {isa = PBXBuildFile; fileRef = EA24F4BE52B2520874E09BEF /* XCTMessagingRole_SystemConfiguration-Protocol.h */; settings = {ATTRIBUTES = (Public, ); }; }; + FBCAFE000000000000000002 /* FBScreenCaptureCommands.h in Headers */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000000001 /* FBScreenCaptureCommands.h */; }; + FBCAFE000000000000000003 /* FBScreenCaptureCommands.h in Headers */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000000001 /* FBScreenCaptureCommands.h */; }; + FBCAFE000000000000000005 /* FBScreenCaptureCommands.m in Sources */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000000004 /* FBScreenCaptureCommands.m */; }; + FBCAFE000000000000000006 /* FBScreenCaptureCommands.m in Sources */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000000004 /* FBScreenCaptureCommands.m */; }; + FBCAFE000000000000000008 /* FBPixelBufferConverter.h in Headers */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000000007 /* FBPixelBufferConverter.h */; }; + FBCAFE000000000000000009 /* FBPixelBufferConverter.h in Headers */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000000007 /* FBPixelBufferConverter.h */; }; + FBCAFE00000000000000000B /* FBPixelBufferConverter.m in Sources */ = {isa = PBXBuildFile; fileRef = FBCAFE00000000000000000A /* FBPixelBufferConverter.m */; }; + FBCAFE00000000000000000C /* FBPixelBufferConverter.m in Sources */ = {isa = PBXBuildFile; fileRef = FBCAFE00000000000000000A /* FBPixelBufferConverter.m */; }; + FBCAFE00000000000000000E /* FBVideoEncoder.h in Headers */ = {isa = PBXBuildFile; fileRef = FBCAFE00000000000000000D /* FBVideoEncoder.h */; }; + FBCAFE00000000000000000F /* FBVideoEncoder.h in Headers */ = {isa = PBXBuildFile; fileRef = FBCAFE00000000000000000D /* FBVideoEncoder.h */; }; + FBCAFE000000000000000011 /* FBVideoEncoder.m in Sources */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000000010 /* FBVideoEncoder.m */; }; + FBCAFE000000000000000012 /* FBVideoEncoder.m in Sources */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000000010 /* FBVideoEncoder.m */; }; + FBCAFE000000000000000014 /* FBVideoStreamManager.h in Headers */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000000013 /* FBVideoStreamManager.h */; }; + FBCAFE000000000000000015 /* FBVideoStreamManager.h in Headers */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000000013 /* FBVideoStreamManager.h */; }; + FBCAFE000000000000000017 /* FBVideoStreamManager.m in Sources */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000000016 /* FBVideoStreamManager.m */; }; + FBCAFE000000000000000018 /* FBVideoStreamManager.m in Sources */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000000016 /* FBVideoStreamManager.m */; }; + FBCAFE00000000000000001A /* FBPixelBufferConverterTests.m in Sources */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000000019 /* FBPixelBufferConverterTests.m */; }; + FBCAFE00000000000000001C /* FBVideoEncoderTests.m in Sources */ = {isa = PBXBuildFile; fileRef = FBCAFE00000000000000001B /* FBVideoEncoderTests.m */; }; + FBCAFE000000000000001002 /* FBVideoStreamSession.h in Headers */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000001001 /* FBVideoStreamSession.h */; }; + FBCAFE000000000000001003 /* FBVideoStreamSession.h in Headers */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000001001 /* FBVideoStreamSession.h */; }; + FBCAFE000000000000001005 /* FBVideoStreamSession.m in Sources */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000001004 /* FBVideoStreamSession.m */; }; + FBCAFE000000000000001006 /* FBVideoStreamSession.m in Sources */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000001004 /* FBVideoStreamSession.m */; }; + FBCAFE000000000000002002 /* FBImageUtilsTests.m in Sources */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000002001 /* FBImageUtilsTests.m */; }; + FBCAFE000000000000003002 /* FBMobilerunA11yCommands.h in Headers */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000003001 /* FBMobilerunA11yCommands.h */; }; + FBCAFE000000000000003003 /* FBMobilerunA11yCommands.h in Headers */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000003001 /* FBMobilerunA11yCommands.h */; }; + FBCAFE000000000000003005 /* FBMobilerunA11yCommands.m in Sources */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000003004 /* FBMobilerunA11yCommands.m */; }; + FBCAFE000000000000003006 /* FBMobilerunA11yCommands.m in Sources */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000003004 /* FBMobilerunA11yCommands.m */; }; + FBCAFE000000000000003011 /* FBTestInterruptionSuppressionTests.m in Sources */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000003010 /* FBTestInterruptionSuppressionTests.m */; }; + FBCAFE000000000000003102 /* FBMobilerunActionsCommands.h in Headers */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000003101 /* FBMobilerunActionsCommands.h */; }; + FBCAFE000000000000003103 /* FBMobilerunActionsCommands.h in Headers */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000003101 /* FBMobilerunActionsCommands.h */; }; + FBCAFE000000000000003105 /* FBMobilerunActionsCommands.m in Sources */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000003104 /* FBMobilerunActionsCommands.m */; }; + FBCAFE000000000000003106 /* FBMobilerunActionsCommands.m in Sources */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000003104 /* FBMobilerunActionsCommands.m */; }; + FBCAFE000000000000003202 /* FBMobilerunActionsIntegrationTests.m in Sources */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000003201 /* FBMobilerunActionsIntegrationTests.m */; }; + FBCAFE000000000000004002 /* FBVideoStreamSessionTests.m in Sources */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000004001 /* FBVideoStreamSessionTests.m */; }; + FBCAFE000000000000005103 /* FBBroadcastSampleHandler.m in Sources */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000005102 /* FBBroadcastSampleHandler.m */; }; + FBCAFE000000000000005106 /* FBExtBroadcastClient.m in Sources */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000005105 /* FBExtBroadcastClient.m */; }; + FBCAFE000000000000005109 /* FBExtSessionPipeline.m in Sources */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000005108 /* FBExtSessionPipeline.m */; }; + FBCAFE000000000000005201 /* FBVideoEncoder.m in Sources */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000000010 /* FBVideoEncoder.m */; }; + FBCAFE000000000000005202 /* GCDAsyncSocket.m in Sources */ = {isa = PBXBuildFile; fileRef = 718226C82587443600661B83 /* GCDAsyncSocket.m */; }; + FBCAFE000000000000005203 /* FBBroadcastProtocol.m in Sources */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000005301 /* FBBroadcastProtocol.m */; }; + FBCAFE000000000000005302 /* FBBroadcastProtocol.h in Headers */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000005300 /* FBBroadcastProtocol.h */; }; + FBCAFE000000000000005303 /* FBBroadcastProtocol.h in Headers */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000005300 /* FBBroadcastProtocol.h */; }; + FBCAFE000000000000005304 /* FBBroadcastProtocol.m in Sources */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000005301 /* FBBroadcastProtocol.m */; }; + FBCAFE000000000000005305 /* FBBroadcastProtocol.m in Sources */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000005301 /* FBBroadcastProtocol.m */; }; + FBCAFE000000000000005306 /* FBBroadcastProtocol.m in Sources */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000005301 /* FBBroadcastProtocol.m */; }; + FBCAFE000000000000005312 /* FBBroadcastControlServer.h in Headers */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000005310 /* FBBroadcastControlServer.h */; }; + FBCAFE000000000000005313 /* FBBroadcastControlServer.h in Headers */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000005310 /* FBBroadcastControlServer.h */; }; + FBCAFE000000000000005314 /* FBBroadcastControlServer.m in Sources */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000005311 /* FBBroadcastControlServer.m */; }; + FBCAFE000000000000005315 /* FBBroadcastControlServer.m in Sources */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000005311 /* FBBroadcastControlServer.m */; }; + FBCAFE000000000000005322 /* FBBroadcastManager.h in Headers */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000005320 /* FBBroadcastManager.h */; }; + FBCAFE000000000000005323 /* FBBroadcastManager.h in Headers */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000005320 /* FBBroadcastManager.h */; }; + FBCAFE000000000000005324 /* FBBroadcastManager.m in Sources */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000005321 /* FBBroadcastManager.m */; }; + FBCAFE000000000000005325 /* FBBroadcastManager.m in Sources */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000005321 /* FBBroadcastManager.m */; }; + FBCAFE000000000000005332 /* FBBroadcastPickerHost.h in Headers */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000005330 /* FBBroadcastPickerHost.h */; }; + FBCAFE000000000000005333 /* FBBroadcastPickerHost.h in Headers */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000005330 /* FBBroadcastPickerHost.h */; }; + FBCAFE000000000000005334 /* FBBroadcastPickerHost.m in Sources */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000005331 /* FBBroadcastPickerHost.m */; }; + FBCAFE000000000000005335 /* FBBroadcastPickerHost.m in Sources */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000005331 /* FBBroadcastPickerHost.m */; }; + FBCAFE000000000000006002 /* FBScrcpyPacket.h in Headers */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000006000 /* FBScrcpyPacket.h */; }; + FBCAFE000000000000006003 /* FBScrcpyPacket.h in Headers */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000006000 /* FBScrcpyPacket.h */; }; + FBCAFE000000000000006004 /* FBScrcpyPacket.m in Sources */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000006001 /* FBScrcpyPacket.m */; }; + FBCAFE000000000000006005 /* FBScrcpyPacket.m in Sources */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000006001 /* FBScrcpyPacket.m */; }; + FBCAFE000000000000006012 /* FBAudioStreamSession.h in Headers */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000006010 /* FBAudioStreamSession.h */; }; + FBCAFE000000000000006013 /* FBAudioStreamSession.h in Headers */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000006010 /* FBAudioStreamSession.h */; }; + FBCAFE000000000000006014 /* FBAudioStreamSession.m in Sources */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000006011 /* FBAudioStreamSession.m */; }; + FBCAFE000000000000006015 /* FBAudioStreamSession.m in Sources */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000006011 /* FBAudioStreamSession.m */; }; + FBCAFE000000000000006022 /* FBAudioStreamManager.h in Headers */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000006020 /* FBAudioStreamManager.h */; }; + FBCAFE000000000000006023 /* FBAudioStreamManager.h in Headers */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000006020 /* FBAudioStreamManager.h */; }; + FBCAFE000000000000006024 /* FBAudioStreamManager.m in Sources */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000006021 /* FBAudioStreamManager.m */; }; + FBCAFE000000000000006025 /* FBAudioStreamManager.m in Sources */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000006021 /* FBAudioStreamManager.m */; }; + FBCAFE000000000000006032 /* FBAudioCaptureCommands.h in Headers */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000006030 /* FBAudioCaptureCommands.h */; }; + FBCAFE000000000000006033 /* FBAudioCaptureCommands.h in Headers */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000006030 /* FBAudioCaptureCommands.h */; }; + FBCAFE000000000000006034 /* FBAudioCaptureCommands.m in Sources */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000006031 /* FBAudioCaptureCommands.m */; }; + FBCAFE000000000000006035 /* FBAudioCaptureCommands.m in Sources */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000006031 /* FBAudioCaptureCommands.m */; }; + FBCAFE000000000000006042 /* FBExtAudioPipeline.m in Sources */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000006041 /* FBExtAudioPipeline.m */; }; + FBCAFE000000000000006051 /* FBAudioStreamTests.m in Sources */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000006050 /* FBAudioStreamTests.m */; }; + FBCAFE000000000000007002 /* FBSocks5TunnelProtocol.h in Headers */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000007000 /* FBSocks5TunnelProtocol.h */; }; + FBCAFE000000000000007003 /* FBSocks5TunnelProtocol.h in Headers */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000007000 /* FBSocks5TunnelProtocol.h */; }; + FBCAFE000000000000007004 /* FBSocks5TunnelProtocol.m in Sources */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000007001 /* FBSocks5TunnelProtocol.m */; }; + FBCAFE000000000000007005 /* FBSocks5TunnelProtocol.m in Sources */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000007001 /* FBSocks5TunnelProtocol.m */; }; + FBCAFE000000000000007006 /* FBSocks5TunnelProtocol.m in Sources */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000007001 /* FBSocks5TunnelProtocol.m */; }; + FBCAFE000000000000007012 /* FBSocks5URI.h in Headers */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000007010 /* FBSocks5URI.h */; }; + FBCAFE000000000000007013 /* FBSocks5URI.h in Headers */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000007010 /* FBSocks5URI.h */; }; + FBCAFE000000000000007014 /* FBSocks5URI.m in Sources */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000007011 /* FBSocks5URI.m */; }; + FBCAFE000000000000007015 /* FBSocks5URI.m in Sources */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000007011 /* FBSocks5URI.m */; }; + FBCAFE000000000000007021 /* FBSocks5URITests.m in Sources */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000007020 /* FBSocks5URITests.m */; }; + FBCAFE000000000000007023 /* FBSocks5ConfigTests.m in Sources */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000007022 /* FBSocks5ConfigTests.m */; }; + FBCAFE000000000000007032 /* FBSocks5TunnelManager.h in Headers */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000007030 /* FBSocks5TunnelManager.h */; }; + FBCAFE000000000000007033 /* FBSocks5TunnelManager.h in Headers */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000007030 /* FBSocks5TunnelManager.h */; }; + FBCAFE000000000000007034 /* FBSocks5TunnelManager.m in Sources */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000007031 /* FBSocks5TunnelManager.m */; }; + FBCAFE000000000000007035 /* FBSocks5TunnelManager.m in Sources */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000007031 /* FBSocks5TunnelManager.m */; }; + FBCAFE000000000000007042 /* FBMobilerunSocks5Commands.h in Headers */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000007040 /* FBMobilerunSocks5Commands.h */; }; + FBCAFE000000000000007043 /* FBMobilerunSocks5Commands.h in Headers */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000007040 /* FBMobilerunSocks5Commands.h */; }; + FBCAFE000000000000007044 /* FBMobilerunSocks5Commands.m in Sources */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000007041 /* FBMobilerunSocks5Commands.m */; }; + FBCAFE000000000000007045 /* FBMobilerunSocks5Commands.m in Sources */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000007041 /* FBMobilerunSocks5Commands.m */; }; + FBCAFE000000000000007112 /* FBTunnelPacketProvider.m in Sources */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000007111 /* FBTunnelPacketProvider.m */; }; + FBCAFE000000000000007115 /* FBTunFdFinder.m in Sources */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000007114 /* FBTunFdFinder.m */; }; + FBCAFE000000000000007118 /* FBTunnelHevRunner.m in Sources */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000007117 /* FBTunnelHevRunner.m */; }; + FBCAFE000000000000007121 /* HevSocks5Tunnel.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000007120 /* HevSocks5Tunnel.xcframework */; }; + FBCAFE000000000000007331 /* FBMobilerunSocks5IntegrationTests.m in Sources */ = {isa = PBXBuildFile; fileRef = FBCAFE000000000000007330 /* FBMobilerunSocks5IntegrationTests.m */; }; FBF064E4D96CFCD08E1F4EF7 /* XCUIDevice.h in Headers */ = {isa = PBXBuildFile; fileRef = EE35ACFD1E3B77D600A02D78 /* XCUIDevice.h */; }; FBFEC05ED2C01D1EAE34BE9A /* WDAScreenshotAndSourceIntegrationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 75F677B5B7737E7E1F321C20 /* WDAScreenshotAndSourceIntegrationTests.swift */; }; FC607DB5132FEF425237267B /* XCTMessagingRole_MemoryTesting-Protocol.h in Headers */ = {isa = PBXBuildFile; fileRef = 13D863EAE7F6F8B7E42D99B9 /* XCTMessagingRole_MemoryTesting-Protocol.h */; settings = {ATTRIBUTES = (Public, ); }; }; @@ -1498,56 +1522,6 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ - 718226C62587443600661B83 /* GCDAsyncUdpSocket.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = GCDAsyncUdpSocket.h; path = WebDriverAgentLib/Vendor/CocoaAsyncSocket/GCDAsyncUdpSocket.h; sourceTree = SOURCE_ROOT; }; - 718226C72587443600661B83 /* GCDAsyncSocket.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = GCDAsyncSocket.h; path = WebDriverAgentLib/Vendor/CocoaAsyncSocket/GCDAsyncSocket.h; sourceTree = SOURCE_ROOT; }; - 718226C82587443600661B83 /* GCDAsyncSocket.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = GCDAsyncSocket.m; path = WebDriverAgentLib/Vendor/CocoaAsyncSocket/GCDAsyncSocket.m; sourceTree = SOURCE_ROOT; }; - 718226C92587443600661B83 /* GCDAsyncUdpSocket.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = GCDAsyncUdpSocket.m; path = WebDriverAgentLib/Vendor/CocoaAsyncSocket/GCDAsyncUdpSocket.m; sourceTree = SOURCE_ROOT; }; - FBCAFE000000000000002001 /* FBImageUtilsTests.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = FBImageUtilsTests.m; sourceTree = ""; }; - FBCAFE000000000000001001 /* FBVideoStreamSession.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = FBVideoStreamSession.h; sourceTree = ""; }; - FBCAFE000000000000001004 /* FBVideoStreamSession.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = FBVideoStreamSession.m; sourceTree = ""; }; - FBCAFE000000000000000001 /* FBScreenCaptureCommands.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = FBScreenCaptureCommands.h; sourceTree = ""; }; - FBCAFE000000000000000004 /* FBScreenCaptureCommands.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = FBScreenCaptureCommands.m; sourceTree = ""; }; - FBCAFE000000000000003001 /* FBMobilerunA11yCommands.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = FBMobilerunA11yCommands.h; sourceTree = ""; }; - FBCAFE000000000000003101 /* FBMobilerunActionsCommands.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = FBMobilerunActionsCommands.h; sourceTree = ""; }; - FBCAFE000000000000003004 /* FBMobilerunA11yCommands.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = FBMobilerunA11yCommands.m; sourceTree = ""; }; - FBCAFE000000000000003104 /* FBMobilerunActionsCommands.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = FBMobilerunActionsCommands.m; sourceTree = ""; }; - FBCAFE000000000000000007 /* FBPixelBufferConverter.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = FBPixelBufferConverter.h; sourceTree = ""; }; - FBCAFE00000000000000000A /* FBPixelBufferConverter.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = FBPixelBufferConverter.m; sourceTree = ""; }; - FBCAFE00000000000000000D /* FBVideoEncoder.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = FBVideoEncoder.h; sourceTree = ""; }; - FBCAFE000000000000000010 /* FBVideoEncoder.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = FBVideoEncoder.m; sourceTree = ""; }; - FBCAFE000000000000000013 /* FBVideoStreamManager.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = FBVideoStreamManager.h; sourceTree = ""; }; - FBCAFE000000000000000016 /* FBVideoStreamManager.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = FBVideoStreamManager.m; sourceTree = ""; }; - FBCAFE000000000000000019 /* FBPixelBufferConverterTests.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = FBPixelBufferConverterTests.m; sourceTree = ""; }; - FBCAFE00000000000000001B /* FBVideoEncoderTests.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = FBVideoEncoderTests.m; sourceTree = ""; }; - FBCAFE000000000000004001 /* FBVideoStreamSessionTests.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = FBVideoStreamSessionTests.m; sourceTree = ""; }; - FBCAFE000000000000005002 /* WebDriverAgentBroadcast.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = WebDriverAgentBroadcast.appex; sourceTree = BUILT_PRODUCTS_DIR; }; - FBCAFE00000000000000500C /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; - FBCAFE000000000000005101 /* FBBroadcastSampleHandler.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = FBBroadcastSampleHandler.h; sourceTree = ""; }; - FBCAFE000000000000005102 /* FBBroadcastSampleHandler.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = FBBroadcastSampleHandler.m; sourceTree = ""; }; - FBCAFE000000000000005104 /* FBExtBroadcastClient.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = FBExtBroadcastClient.h; sourceTree = ""; }; - FBCAFE000000000000005105 /* FBExtBroadcastClient.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = FBExtBroadcastClient.m; sourceTree = ""; }; - FBCAFE000000000000005107 /* FBExtSessionPipeline.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = FBExtSessionPipeline.h; sourceTree = ""; }; - FBCAFE000000000000005108 /* FBExtSessionPipeline.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = FBExtSessionPipeline.m; sourceTree = ""; }; - FBCAFE000000000000006000 /* FBScrcpyPacket.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = FBScrcpyPacket.h; sourceTree = ""; }; - FBCAFE000000000000006001 /* FBScrcpyPacket.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = FBScrcpyPacket.m; sourceTree = ""; }; - FBCAFE000000000000006010 /* FBAudioStreamSession.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = FBAudioStreamSession.h; sourceTree = ""; }; - FBCAFE000000000000006011 /* FBAudioStreamSession.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = FBAudioStreamSession.m; sourceTree = ""; }; - FBCAFE000000000000006020 /* FBAudioStreamManager.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = FBAudioStreamManager.h; sourceTree = ""; }; - FBCAFE000000000000006021 /* FBAudioStreamManager.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = FBAudioStreamManager.m; sourceTree = ""; }; - FBCAFE000000000000006030 /* FBAudioCaptureCommands.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = FBAudioCaptureCommands.h; sourceTree = ""; }; - FBCAFE000000000000006031 /* FBAudioCaptureCommands.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = FBAudioCaptureCommands.m; sourceTree = ""; }; - FBCAFE000000000000006040 /* FBExtAudioPipeline.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = FBExtAudioPipeline.h; sourceTree = ""; }; - FBCAFE000000000000006041 /* FBExtAudioPipeline.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = FBExtAudioPipeline.m; sourceTree = ""; }; - FBCAFE000000000000006050 /* FBAudioStreamTests.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = FBAudioStreamTests.m; sourceTree = ""; }; - FBCAFE00000000000000510A /* FBExtLogging.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = FBExtLogging.h; sourceTree = ""; }; - FBCAFE000000000000005300 /* FBBroadcastProtocol.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = FBBroadcastProtocol.h; sourceTree = ""; }; - FBCAFE000000000000005301 /* FBBroadcastProtocol.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = FBBroadcastProtocol.m; sourceTree = ""; }; - FBCAFE000000000000005310 /* FBBroadcastControlServer.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = FBBroadcastControlServer.h; sourceTree = ""; }; - FBCAFE000000000000005311 /* FBBroadcastControlServer.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = FBBroadcastControlServer.m; sourceTree = ""; }; - FBCAFE000000000000005320 /* FBBroadcastManager.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = FBBroadcastManager.h; sourceTree = ""; }; - FBCAFE000000000000005321 /* FBBroadcastManager.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = FBBroadcastManager.m; sourceTree = ""; }; - FBCAFE000000000000005330 /* FBBroadcastPickerHost.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = FBBroadcastPickerHost.h; sourceTree = ""; }; - FBCAFE000000000000005331 /* FBBroadcastPickerHost.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = FBBroadcastPickerHost.m; sourceTree = ""; }; 003AAAB9CB5FB38E45E05F6F /* XCUIDeviceEventAndStateInterface-Protocol.h */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.h; path = "XCUIDeviceEventAndStateInterface-Protocol.h"; sourceTree = ""; }; 00834B3220005AD5A5ABEF7C /* XCUIApplicationManaging-Protocol.h */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.h; path = "XCUIApplicationManaging-Protocol.h"; sourceTree = ""; }; 00B1F89716AFE04C8509B916 /* FBHTTPServer.h */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.h; path = FBHTTPServer.h; sourceTree = ""; }; @@ -1667,7 +1641,6 @@ 71241D791FAE3D2500B9559F /* FBTouchActionCommands.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = FBTouchActionCommands.h; sourceTree = ""; }; 71241D7A1FAE3D2500B9559F /* FBTouchActionCommands.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = FBTouchActionCommands.m; sourceTree = ""; }; 71241D7D1FAF084E00B9559F /* FBW3CTouchActionsIntegrationTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = FBW3CTouchActionsIntegrationTests.m; sourceTree = ""; }; - FBCAFE000000000000003201 /* FBMobilerunActionsIntegrationTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = FBMobilerunActionsIntegrationTests.m; sourceTree = ""; }; 71241D7F1FAF087500B9559F /* FBW3CMultiTouchActionsIntegrationTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = FBW3CMultiTouchActionsIntegrationTests.m; sourceTree = ""; }; 712A0C841DA3E459007D02E5 /* FBXPathTests.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = FBXPathTests.m; sourceTree = ""; }; 712A0C861DA3E55D007D02E5 /* FBXPath-Private.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = "FBXPath-Private.h"; sourceTree = ""; }; @@ -1735,6 +1708,10 @@ 716F0DA52A17323300CDD977 /* NSDictionaryFBUtf8SafeTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = NSDictionaryFBUtf8SafeTests.m; sourceTree = ""; }; 717C0D702518ED2800CAA6EC /* TVOSSettings.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = TVOSSettings.xcconfig; sourceTree = ""; }; 717C0D862518ED7000CAA6EC /* TVOSTestSettings.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = TVOSTestSettings.xcconfig; sourceTree = ""; }; + 718226C62587443600661B83 /* GCDAsyncUdpSocket.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = GCDAsyncUdpSocket.h; path = WebDriverAgentLib/Vendor/CocoaAsyncSocket/GCDAsyncUdpSocket.h; sourceTree = SOURCE_ROOT; }; + 718226C72587443600661B83 /* GCDAsyncSocket.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = GCDAsyncSocket.h; path = WebDriverAgentLib/Vendor/CocoaAsyncSocket/GCDAsyncSocket.h; sourceTree = SOURCE_ROOT; }; + 718226C82587443600661B83 /* GCDAsyncSocket.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = GCDAsyncSocket.m; path = WebDriverAgentLib/Vendor/CocoaAsyncSocket/GCDAsyncSocket.m; sourceTree = SOURCE_ROOT; }; + 718226C92587443600661B83 /* GCDAsyncUdpSocket.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = GCDAsyncUdpSocket.m; path = WebDriverAgentLib/Vendor/CocoaAsyncSocket/GCDAsyncUdpSocket.m; sourceTree = SOURCE_ROOT; }; 7183E8C2B556594311CB8898 /* XCUIRemoteSiriInterface-Protocol.h */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.h; path = "XCUIRemoteSiriInterface-Protocol.h"; sourceTree = ""; }; 718F49C7230844330045FE8B /* FBProtocolHelpersTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = FBProtocolHelpersTests.m; sourceTree = ""; }; 71930C4020662E1F00D3AFEC /* FBPasteboard.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = FBPasteboard.h; sourceTree = ""; }; @@ -2041,7 +2018,6 @@ EE9B75D41CF7956C00275851 /* IntegrationApp.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = IntegrationApp.app; sourceTree = BUILT_PRODUCTS_DIR; }; EE9B75EC1CF7956C00275851 /* IntegrationTests_1.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = IntegrationTests_1.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; EE9B76571CF7987300275851 /* FBRouteTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = FBRouteTests.m; sourceTree = ""; }; - FBCAFE000000000000003010 /* FBTestInterruptionSuppressionTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = FBTestInterruptionSuppressionTests.m; sourceTree = ""; }; EE9B76581CF7987300275851 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; EE9B76821CF7997600275851 /* AppDelegate.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = AppDelegate.h; sourceTree = ""; }; EE9B76831CF7997600275851 /* AppDelegate.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = AppDelegate.m; sourceTree = ""; }; @@ -2085,6 +2061,76 @@ F46F78706C5157469122F730 /* FBXCElementSnapshotDouble.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = FBXCElementSnapshotDouble.m; sourceTree = ""; }; F59CD6D22EF16E5E00F91287 /* XCUIElement+FBCustomActions.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "XCUIElement+FBCustomActions.h"; sourceTree = ""; }; F59CD6D32EF16E5E00F91287 /* XCUIElement+FBCustomActions.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = "XCUIElement+FBCustomActions.m"; sourceTree = ""; }; + FBCAFE000000000000000001 /* FBScreenCaptureCommands.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = FBScreenCaptureCommands.h; sourceTree = ""; }; + FBCAFE000000000000000004 /* FBScreenCaptureCommands.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = FBScreenCaptureCommands.m; sourceTree = ""; }; + FBCAFE000000000000000007 /* FBPixelBufferConverter.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = FBPixelBufferConverter.h; sourceTree = ""; }; + FBCAFE00000000000000000A /* FBPixelBufferConverter.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = FBPixelBufferConverter.m; sourceTree = ""; }; + FBCAFE00000000000000000D /* FBVideoEncoder.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = FBVideoEncoder.h; sourceTree = ""; }; + FBCAFE000000000000000010 /* FBVideoEncoder.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = FBVideoEncoder.m; sourceTree = ""; }; + FBCAFE000000000000000013 /* FBVideoStreamManager.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = FBVideoStreamManager.h; sourceTree = ""; }; + FBCAFE000000000000000016 /* FBVideoStreamManager.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = FBVideoStreamManager.m; sourceTree = ""; }; + FBCAFE000000000000000019 /* FBPixelBufferConverterTests.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = FBPixelBufferConverterTests.m; sourceTree = ""; }; + FBCAFE00000000000000001B /* FBVideoEncoderTests.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = FBVideoEncoderTests.m; sourceTree = ""; }; + FBCAFE000000000000001001 /* FBVideoStreamSession.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = FBVideoStreamSession.h; sourceTree = ""; }; + FBCAFE000000000000001004 /* FBVideoStreamSession.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = FBVideoStreamSession.m; sourceTree = ""; }; + FBCAFE000000000000002001 /* FBImageUtilsTests.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = FBImageUtilsTests.m; sourceTree = ""; }; + FBCAFE000000000000003001 /* FBMobilerunA11yCommands.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = FBMobilerunA11yCommands.h; sourceTree = ""; }; + FBCAFE000000000000003004 /* FBMobilerunA11yCommands.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = FBMobilerunA11yCommands.m; sourceTree = ""; }; + FBCAFE000000000000003010 /* FBTestInterruptionSuppressionTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = FBTestInterruptionSuppressionTests.m; sourceTree = ""; }; + FBCAFE000000000000003101 /* FBMobilerunActionsCommands.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = FBMobilerunActionsCommands.h; sourceTree = ""; }; + FBCAFE000000000000003104 /* FBMobilerunActionsCommands.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = FBMobilerunActionsCommands.m; sourceTree = ""; }; + FBCAFE000000000000003201 /* FBMobilerunActionsIntegrationTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = FBMobilerunActionsIntegrationTests.m; sourceTree = ""; }; + FBCAFE000000000000004001 /* FBVideoStreamSessionTests.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = FBVideoStreamSessionTests.m; sourceTree = ""; }; + FBCAFE000000000000005002 /* WebDriverAgentBroadcast.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = WebDriverAgentBroadcast.appex; sourceTree = BUILT_PRODUCTS_DIR; }; + FBCAFE00000000000000500C /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + FBCAFE000000000000005101 /* FBBroadcastSampleHandler.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = FBBroadcastSampleHandler.h; sourceTree = ""; }; + FBCAFE000000000000005102 /* FBBroadcastSampleHandler.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = FBBroadcastSampleHandler.m; sourceTree = ""; }; + FBCAFE000000000000005104 /* FBExtBroadcastClient.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = FBExtBroadcastClient.h; sourceTree = ""; }; + FBCAFE000000000000005105 /* FBExtBroadcastClient.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = FBExtBroadcastClient.m; sourceTree = ""; }; + FBCAFE000000000000005107 /* FBExtSessionPipeline.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = FBExtSessionPipeline.h; sourceTree = ""; }; + FBCAFE000000000000005108 /* FBExtSessionPipeline.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = FBExtSessionPipeline.m; sourceTree = ""; }; + FBCAFE00000000000000510A /* FBExtLogging.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = FBExtLogging.h; sourceTree = ""; }; + FBCAFE000000000000005300 /* FBBroadcastProtocol.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = FBBroadcastProtocol.h; sourceTree = ""; }; + FBCAFE000000000000005301 /* FBBroadcastProtocol.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = FBBroadcastProtocol.m; sourceTree = ""; }; + FBCAFE000000000000005310 /* FBBroadcastControlServer.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = FBBroadcastControlServer.h; sourceTree = ""; }; + FBCAFE000000000000005311 /* FBBroadcastControlServer.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = FBBroadcastControlServer.m; sourceTree = ""; }; + FBCAFE000000000000005320 /* FBBroadcastManager.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = FBBroadcastManager.h; sourceTree = ""; }; + FBCAFE000000000000005321 /* FBBroadcastManager.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = FBBroadcastManager.m; sourceTree = ""; }; + FBCAFE000000000000005330 /* FBBroadcastPickerHost.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = FBBroadcastPickerHost.h; sourceTree = ""; }; + FBCAFE000000000000005331 /* FBBroadcastPickerHost.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = FBBroadcastPickerHost.m; sourceTree = ""; }; + FBCAFE000000000000006000 /* FBScrcpyPacket.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = FBScrcpyPacket.h; sourceTree = ""; }; + FBCAFE000000000000006001 /* FBScrcpyPacket.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = FBScrcpyPacket.m; sourceTree = ""; }; + FBCAFE000000000000006010 /* FBAudioStreamSession.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = FBAudioStreamSession.h; sourceTree = ""; }; + FBCAFE000000000000006011 /* FBAudioStreamSession.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = FBAudioStreamSession.m; sourceTree = ""; }; + FBCAFE000000000000006020 /* FBAudioStreamManager.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = FBAudioStreamManager.h; sourceTree = ""; }; + FBCAFE000000000000006021 /* FBAudioStreamManager.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = FBAudioStreamManager.m; sourceTree = ""; }; + FBCAFE000000000000006030 /* FBAudioCaptureCommands.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = FBAudioCaptureCommands.h; sourceTree = ""; }; + FBCAFE000000000000006031 /* FBAudioCaptureCommands.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = FBAudioCaptureCommands.m; sourceTree = ""; }; + FBCAFE000000000000006040 /* FBExtAudioPipeline.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = FBExtAudioPipeline.h; sourceTree = ""; }; + FBCAFE000000000000006041 /* FBExtAudioPipeline.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = FBExtAudioPipeline.m; sourceTree = ""; }; + FBCAFE000000000000006050 /* FBAudioStreamTests.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = FBAudioStreamTests.m; sourceTree = ""; }; + FBCAFE000000000000007000 /* FBSocks5TunnelProtocol.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = FBSocks5TunnelProtocol.h; sourceTree = ""; }; + FBCAFE000000000000007001 /* FBSocks5TunnelProtocol.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = FBSocks5TunnelProtocol.m; sourceTree = ""; }; + FBCAFE000000000000007010 /* FBSocks5URI.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = FBSocks5URI.h; sourceTree = ""; }; + FBCAFE000000000000007011 /* FBSocks5URI.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = FBSocks5URI.m; sourceTree = ""; }; + FBCAFE000000000000007020 /* FBSocks5URITests.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = FBSocks5URITests.m; sourceTree = ""; }; + FBCAFE000000000000007022 /* FBSocks5ConfigTests.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = FBSocks5ConfigTests.m; sourceTree = ""; }; + FBCAFE000000000000007030 /* FBSocks5TunnelManager.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = FBSocks5TunnelManager.h; sourceTree = ""; }; + FBCAFE000000000000007031 /* FBSocks5TunnelManager.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = FBSocks5TunnelManager.m; sourceTree = ""; }; + FBCAFE000000000000007040 /* FBMobilerunSocks5Commands.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = FBMobilerunSocks5Commands.h; sourceTree = ""; }; + FBCAFE000000000000007041 /* FBMobilerunSocks5Commands.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = FBMobilerunSocks5Commands.m; sourceTree = ""; }; + FBCAFE000000000000007102 /* WebDriverAgentTunnel.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = WebDriverAgentTunnel.appex; sourceTree = BUILT_PRODUCTS_DIR; }; + FBCAFE00000000000000710C /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = Info.plist; path = WebDriverAgentTunnel/Info.plist; sourceTree = SOURCE_ROOT; }; + FBCAFE000000000000007110 /* FBTunnelPacketProvider.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = FBTunnelPacketProvider.h; sourceTree = ""; }; + FBCAFE000000000000007111 /* FBTunnelPacketProvider.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = FBTunnelPacketProvider.m; sourceTree = ""; }; + FBCAFE000000000000007113 /* FBTunFdFinder.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = FBTunFdFinder.h; sourceTree = ""; }; + FBCAFE000000000000007114 /* FBTunFdFinder.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = FBTunFdFinder.m; sourceTree = ""; }; + FBCAFE000000000000007116 /* FBTunnelHevRunner.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = FBTunnelHevRunner.h; sourceTree = ""; }; + FBCAFE000000000000007117 /* FBTunnelHevRunner.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = FBTunnelHevRunner.m; sourceTree = ""; }; + FBCAFE000000000000007119 /* WebDriverAgentTunnel.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; path = WebDriverAgentTunnel.entitlements; sourceTree = ""; }; + FBCAFE00000000000000711A /* WebDriverAgentRunner.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; name = WebDriverAgentRunner.entitlements; path = WebDriverAgentRunner/WebDriverAgentRunner.entitlements; sourceTree = SOURCE_ROOT; }; + FBCAFE000000000000007120 /* HevSocks5Tunnel.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; name = HevSocks5Tunnel.xcframework; path = ThirdParty/HevSocks5Tunnel.xcframework; sourceTree = SOURCE_ROOT; }; + FBCAFE000000000000007330 /* FBMobilerunSocks5IntegrationTests.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = FBMobilerunSocks5IntegrationTests.m; sourceTree = ""; }; FCD1815F2BF21CA0936B04E1 /* XCTMessagingRole_SiriAutomation-Protocol.h */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.h; path = "XCTMessagingRole_SiriAutomation-Protocol.h"; sourceTree = ""; }; FDB15E393EA0850C004D26B2 /* XCTScreenCapturePolicy.h */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.h; path = XCTScreenCapturePolicy.h; sourceTree = ""; }; FF8E3B470FC9639D5F18E2EA /* Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; @@ -2238,6 +2284,14 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + FBCAFE000000000000007107 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + FBCAFE000000000000007121 /* HevSocks5Tunnel.xcframework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ @@ -2338,6 +2392,17 @@ name = iOS; sourceTree = ""; }; + 7182268F2587432E00661B83 /* CocoaAsyncSocket */ = { + isa = PBXGroup; + children = ( + 718226C72587443600661B83 /* GCDAsyncSocket.h */, + 718226C82587443600661B83 /* GCDAsyncSocket.m */, + 718226C62587443600661B83 /* GCDAsyncUdpSocket.h */, + 718226C92587443600661B83 /* GCDAsyncUdpSocket.m */, + ); + name = CocoaAsyncSocket; + sourceTree = ""; + }; 89DE54DD1FA69115F5538E11 /* IntegrationTests_tvOS */ = { isa = PBXGroup; children = ( @@ -2357,6 +2422,7 @@ EE9B75F91CF7964100275851 /* WebDriverAgentTests */, EEF988341C486655005CA669 /* WebDriverAgentRunner */, FBCAFE00000000000000500B /* WebDriverAgentBroadcast */, + FBCAFE00000000000000710B /* WebDriverAgentTunnel */, EE9AB8021CAEE182008C271F /* Scripts */, 91F9DAEA1B99DBC2001349B2 /* Products */, B6E83A410C45944B036B6B0F /* Frameworks */, @@ -2381,6 +2447,7 @@ 641EE6F82240C5CA00173FCB /* WebDriverAgentLib_tvOS.framework */, 64B264F9228C50E0002A5025 /* UnitTests_tvOS.xctest */, FBCAFE000000000000005002 /* WebDriverAgentBroadcast.appex */, + FBCAFE000000000000007102 /* WebDriverAgentTunnel.appex */, E005FEDAF49DCFA9FB77BEF3 /* IntegrationApp_tvOS.app */, ACA330765D30E21E3EEB163D /* IntegrationTests_tvOS.xctest */, 758FC0D745C185A2C28BEDA1 /* IntegrationApp_watchOS.app */, @@ -2453,6 +2520,14 @@ path = IntegrationApp_watchOS; sourceTree = ""; }; + E444DC4A24912EC40060D7EB /* Vendor */ = { + isa = PBXGroup; + children = ( + 7182268F2587432E00661B83 /* CocoaAsyncSocket */, + ); + name = Vendor; + sourceTree = ""; + }; E94EE979A7D7C50FDC1EFE7D /* IntegrationTests_watchOS */ = { isa = PBXGroup; children = ( @@ -2588,6 +2663,8 @@ FBCAFE000000000000003101 /* FBMobilerunActionsCommands.h */, FBCAFE000000000000003004 /* FBMobilerunA11yCommands.m */, FBCAFE000000000000003104 /* FBMobilerunActionsCommands.m */, + FBCAFE000000000000007040 /* FBMobilerunSocks5Commands.h */, + FBCAFE000000000000007041 /* FBMobilerunSocks5Commands.m */, ); name = Commands; path = WebDriverAgentLib/Commands; @@ -2752,6 +2829,12 @@ FBCAFE000000000000000016 /* FBVideoStreamManager.m */, FBCAFE000000000000005300 /* FBBroadcastProtocol.h */, FBCAFE000000000000005301 /* FBBroadcastProtocol.m */, + FBCAFE000000000000007000 /* FBSocks5TunnelProtocol.h */, + FBCAFE000000000000007001 /* FBSocks5TunnelProtocol.m */, + FBCAFE000000000000007010 /* FBSocks5URI.h */, + FBCAFE000000000000007011 /* FBSocks5URI.m */, + FBCAFE000000000000007030 /* FBSocks5TunnelManager.h */, + FBCAFE000000000000007031 /* FBSocks5TunnelManager.m */, FBCAFE000000000000005310 /* FBBroadcastControlServer.h */, FBCAFE000000000000005311 /* FBBroadcastControlServer.m */, FBCAFE000000000000005320 /* FBBroadcastManager.h */, @@ -2821,6 +2904,7 @@ A1B2C3D41F001A00A1B0003 /* FBVoiceOverTests.m */, 71241D7D1FAF084E00B9559F /* FBW3CTouchActionsIntegrationTests.m */, FBCAFE000000000000003201 /* FBMobilerunActionsIntegrationTests.m */, + FBCAFE000000000000007330 /* FBMobilerunSocks5IntegrationTests.m */, 71241D7F1FAF087500B9559F /* FBW3CMultiTouchActionsIntegrationTests.m */, 7136C0F8243A182400921C76 /* FBW3CTypeActionsTests.m */, EEBBDB9A1D1032F0000738CD /* XCElementSnapshotHelperTests.m */, @@ -2851,6 +2935,8 @@ 715D554A2229891B00524509 /* FBExceptionHandlerTests.m */, 713352FC26CEF31D00523CBC /* FBLRUCacheTests.m */, EE18883C1DA663EB00307AA8 /* FBMathUtilsTests.m */, + FBCAFE000000000000007020 /* FBSocks5URITests.m */, + FBCAFE000000000000007022 /* FBSocks5ConfigTests.m */, 718F49C7230844330045FE8B /* FBProtocolHelpersTests.m */, EE9B76571CF7987300275851 /* FBRouteTests.m */, FBCAFE000000000000003010 /* FBTestInterruptionSuppressionTests.m */, @@ -3102,29 +3188,13 @@ children = ( EE9AB7FC1CAEE048008C271F /* Info.plist */, EE9AB7FD1CAEE048008C271F /* UITestingUITests.m */, + FBCAFE00000000000000711A /* WebDriverAgentRunner.entitlements */, A1B2C3D4E5F600000000001A /* Assets.xcassets */, ); name = WebDriverAgentRunner; path = XCTUITestRunner; sourceTree = SOURCE_ROOT; }; - FBCAFE00000000000000500B /* WebDriverAgentBroadcast */ = { - isa = PBXGroup; - children = ( - FBCAFE00000000000000500C /* Info.plist */, - FBCAFE000000000000005101 /* FBBroadcastSampleHandler.h */, - FBCAFE000000000000005102 /* FBBroadcastSampleHandler.m */, - FBCAFE000000000000005104 /* FBExtBroadcastClient.h */, - FBCAFE000000000000005105 /* FBExtBroadcastClient.m */, - FBCAFE000000000000005107 /* FBExtSessionPipeline.h */, - FBCAFE000000000000005108 /* FBExtSessionPipeline.m */, - FBCAFE000000000000006040 /* FBExtAudioPipeline.h */, - FBCAFE000000000000006041 /* FBExtAudioPipeline.m */, - FBCAFE00000000000000510A /* FBExtLogging.h */, - ); - path = WebDriverAgentBroadcast; - sourceTree = ""; - }; F47AAEACC427354CDC7C935F /* IntegrationApp_tvOS */ = { isa = PBXGroup; children = ( @@ -3140,23 +3210,37 @@ path = IntegrationApp_tvOS; sourceTree = ""; }; - 7182268F2587432E00661B83 /* CocoaAsyncSocket */ = { + FBCAFE00000000000000500B /* WebDriverAgentBroadcast */ = { isa = PBXGroup; children = ( - 718226C72587443600661B83 /* GCDAsyncSocket.h */, - 718226C82587443600661B83 /* GCDAsyncSocket.m */, - 718226C62587443600661B83 /* GCDAsyncUdpSocket.h */, - 718226C92587443600661B83 /* GCDAsyncUdpSocket.m */, + FBCAFE00000000000000500C /* Info.plist */, + FBCAFE000000000000005101 /* FBBroadcastSampleHandler.h */, + FBCAFE000000000000005102 /* FBBroadcastSampleHandler.m */, + FBCAFE000000000000005104 /* FBExtBroadcastClient.h */, + FBCAFE000000000000005105 /* FBExtBroadcastClient.m */, + FBCAFE000000000000005107 /* FBExtSessionPipeline.h */, + FBCAFE000000000000005108 /* FBExtSessionPipeline.m */, + FBCAFE000000000000006040 /* FBExtAudioPipeline.h */, + FBCAFE000000000000006041 /* FBExtAudioPipeline.m */, + FBCAFE00000000000000510A /* FBExtLogging.h */, ); - name = CocoaAsyncSocket; + path = WebDriverAgentBroadcast; sourceTree = ""; }; - E444DC4A24912EC40060D7EB /* Vendor */ = { + FBCAFE00000000000000710B /* WebDriverAgentTunnel */ = { isa = PBXGroup; children = ( - 7182268F2587432E00661B83 /* CocoaAsyncSocket */, - ); - name = Vendor; + FBCAFE00000000000000710C /* Info.plist */, + FBCAFE000000000000007110 /* FBTunnelPacketProvider.h */, + FBCAFE000000000000007111 /* FBTunnelPacketProvider.m */, + FBCAFE000000000000007113 /* FBTunFdFinder.h */, + FBCAFE000000000000007114 /* FBTunFdFinder.m */, + FBCAFE000000000000007116 /* FBTunnelHevRunner.h */, + FBCAFE000000000000007117 /* FBTunnelHevRunner.m */, + FBCAFE000000000000007119 /* WebDriverAgentTunnel.entitlements */, + FBCAFE000000000000007120 /* HevSocks5Tunnel.xcframework */, + ); + path = WebDriverAgentTunnel; sourceTree = ""; }; /* End PBXGroup section */ @@ -3351,6 +3435,10 @@ FBCAFE00000000000000000F /* FBVideoEncoder.h in Headers */, FBCAFE000000000000000015 /* FBVideoStreamManager.h in Headers */, FBCAFE000000000000005302 /* FBBroadcastProtocol.h in Headers */, + FBCAFE000000000000007002 /* FBSocks5TunnelProtocol.h in Headers */, + FBCAFE000000000000007012 /* FBSocks5URI.h in Headers */, + FBCAFE000000000000007032 /* FBSocks5TunnelManager.h in Headers */, + FBCAFE000000000000007042 /* FBMobilerunSocks5Commands.h in Headers */, FBCAFE000000000000005312 /* FBBroadcastControlServer.h in Headers */, FBCAFE000000000000005322 /* FBBroadcastManager.h in Headers */, FBCAFE000000000000005332 /* FBBroadcastPickerHost.h in Headers */, @@ -3948,6 +4036,10 @@ FBCAFE00000000000000000E /* FBVideoEncoder.h in Headers */, FBCAFE000000000000000014 /* FBVideoStreamManager.h in Headers */, FBCAFE000000000000005303 /* FBBroadcastProtocol.h in Headers */, + FBCAFE000000000000007003 /* FBSocks5TunnelProtocol.h in Headers */, + FBCAFE000000000000007013 /* FBSocks5URI.h in Headers */, + FBCAFE000000000000007033 /* FBSocks5TunnelManager.h in Headers */, + FBCAFE000000000000007043 /* FBMobilerunSocks5Commands.h in Headers */, FBCAFE000000000000005313 /* FBBroadcastControlServer.h in Headers */, FBCAFE000000000000005323 /* FBBroadcastManager.h in Headers */, FBCAFE000000000000005333 /* FBBroadcastPickerHost.h in Headers */, @@ -4401,6 +4493,23 @@ productReference = FBCAFE000000000000005002 /* WebDriverAgentBroadcast.appex */; productType = "com.apple.product-type.app-extension"; }; + FBCAFE000000000000007101 /* WebDriverAgentTunnel */ = { + isa = PBXNativeTarget; + buildConfigurationList = FBCAFE000000000000007103 /* Build configuration list for PBXNativeTarget "WebDriverAgentTunnel" */; + buildPhases = ( + FBCAFE000000000000007106 /* Sources */, + FBCAFE000000000000007107 /* Frameworks */, + FBCAFE000000000000007108 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = WebDriverAgentTunnel; + productName = WebDriverAgentTunnel; + productReference = FBCAFE000000000000007102 /* WebDriverAgentTunnel.appex */; + productType = "com.apple.product-type.app-extension"; + }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -4447,6 +4556,9 @@ FBCAFE000000000000005001 = { CreatedOnToolsVersion = 15.0; }; + FBCAFE000000000000007101 = { + CreatedOnToolsVersion = 15.0; + }; }; }; buildConfigurationList = 91F9DAE41B99DBC2001349B2 /* Build configuration list for PBXProject "WebDriverAgent" */; @@ -4467,6 +4579,7 @@ 95186DE2383671DFA81D7FCB /* WebDriverAgentLib_watchOS */, EEF988291C486603005CA669 /* WebDriverAgentRunner */, FBCAFE000000000000005001 /* WebDriverAgentBroadcast */, + FBCAFE000000000000007101 /* WebDriverAgentTunnel */, 641EE2D92240BBE300173FCB /* WebDriverAgentRunner_tvOS */, C0A58256FB8AD3E976C8F8D2 /* WebDriverAgentRunner_watchOS */, EE836C011C0F118600D87246 /* UnitTests */, @@ -4605,6 +4718,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + FBCAFE000000000000007108 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXResourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ @@ -4754,6 +4874,10 @@ FBCAFE000000000000000012 /* FBVideoEncoder.m in Sources */, FBCAFE000000000000000018 /* FBVideoStreamManager.m in Sources */, FBCAFE000000000000005304 /* FBBroadcastProtocol.m in Sources */, + FBCAFE000000000000007004 /* FBSocks5TunnelProtocol.m in Sources */, + FBCAFE000000000000007014 /* FBSocks5URI.m in Sources */, + FBCAFE000000000000007034 /* FBSocks5TunnelManager.m in Sources */, + FBCAFE000000000000007044 /* FBMobilerunSocks5Commands.m in Sources */, FBCAFE000000000000005314 /* FBBroadcastControlServer.m in Sources */, FBCAFE000000000000005324 /* FBBroadcastManager.m in Sources */, FBCAFE000000000000005334 /* FBBroadcastPickerHost.m in Sources */, @@ -5049,6 +5173,10 @@ FBCAFE000000000000000011 /* FBVideoEncoder.m in Sources */, FBCAFE000000000000000017 /* FBVideoStreamManager.m in Sources */, FBCAFE000000000000005305 /* FBBroadcastProtocol.m in Sources */, + FBCAFE000000000000007005 /* FBSocks5TunnelProtocol.m in Sources */, + FBCAFE000000000000007015 /* FBSocks5URI.m in Sources */, + FBCAFE000000000000007035 /* FBSocks5TunnelManager.m in Sources */, + FBCAFE000000000000007045 /* FBMobilerunSocks5Commands.m in Sources */, FBCAFE000000000000005315 /* FBBroadcastControlServer.m in Sources */, FBCAFE000000000000005325 /* FBBroadcastManager.m in Sources */, FBCAFE000000000000005335 /* FBBroadcastPickerHost.m in Sources */, @@ -5072,6 +5200,7 @@ A1B2C3D41F001A00A1B0008 /* FBVoiceOverTests.m in Sources */, 71241D7E1FAF084E00B9559F /* FBW3CTouchActionsIntegrationTests.m in Sources */, FBCAFE000000000000003202 /* FBMobilerunActionsIntegrationTests.m in Sources */, + FBCAFE000000000000007331 /* FBMobilerunSocks5IntegrationTests.m in Sources */, 63FD950221F9D06100A3E356 /* FBImageProcessorTests.m in Sources */, 719CD8FF2126C90200C7D0C2 /* FBAutoAlertsHandlerTests.m in Sources */, EE2202131ECC612200A29571 /* FBIntegrationTestCase.m in Sources */, @@ -5137,6 +5266,8 @@ EE6A89261D0B19E60083E92B /* FBSessionTests.m in Sources */, 71A7EAFC1E229302001DA4F2 /* FBClassChainTests.m in Sources */, EE18883D1DA663EB00307AA8 /* FBMathUtilsTests.m in Sources */, + FBCAFE000000000000007021 /* FBSocks5URITests.m in Sources */, + FBCAFE000000000000007023 /* FBSocks5ConfigTests.m in Sources */, FBCAFE00000000000000001A /* FBPixelBufferConverterTests.m in Sources */, FBCAFE00000000000000001C /* FBVideoEncoderTests.m in Sources */, FBCAFE000000000000004002 /* FBVideoStreamSessionTests.m in Sources */, @@ -5204,6 +5335,17 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + FBCAFE000000000000007106 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + FBCAFE000000000000007112 /* FBTunnelPacketProvider.m in Sources */, + FBCAFE000000000000007115 /* FBTunFdFinder.m in Sources */, + FBCAFE000000000000007118 /* FBTunnelHevRunner.m in Sources */, + FBCAFE000000000000007006 /* FBSocks5TunnelProtocol.m in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; FEEB9E54CB496CD754234805 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; @@ -5435,7 +5577,7 @@ ); MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; - PRODUCT_BUNDLE_IDENTIFIER = com.facebook.WebDriverAgentRunner; + PRODUCT_BUNDLE_IDENTIFIER = "$(WDA_PRODUCT_BUNDLE_IDENTIFIER)"; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = appletvos; TARGETED_DEVICE_FAMILY = 3; @@ -5495,7 +5637,7 @@ ); MTL_FAST_MATH = YES; ONLY_ACTIVE_ARCH = YES; - PRODUCT_BUNDLE_IDENTIFIER = com.facebook.WebDriverAgentRunner; + PRODUCT_BUNDLE_IDENTIFIER = "$(WDA_PRODUCT_BUNDLE_IDENTIFIER)"; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = appletvos; TARGETED_DEVICE_FAMILY = 3; @@ -6452,6 +6594,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_STATIC_ANALYZER_MODE = deep; + CODE_SIGN_ENTITLEMENTS = "$(WDA_RUNNER_ENTITLEMENTS)"; DEBUG_INFORMATION_FORMAT = dwarf; ENABLE_TESTING_SEARCH_PATHS = YES; FRAMEWORK_SEARCH_PATHS = "$(inherited)"; @@ -6469,7 +6612,7 @@ "$(inherited)", "-all_load", ); - PRODUCT_BUNDLE_IDENTIFIER = com.facebook.WebDriverAgentRunner; + PRODUCT_BUNDLE_IDENTIFIER = "$(WDA_PRODUCT_BUNDLE_IDENTIFIER)"; PRODUCT_NAME = "$(TARGET_NAME)"; USES_XCTRUNNER = YES; WARNING_CFLAGS = ( @@ -6506,6 +6649,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_STATIC_ANALYZER_MODE = deep; + CODE_SIGN_ENTITLEMENTS = "$(WDA_RUNNER_ENTITLEMENTS)"; ENABLE_TESTING_SEARCH_PATHS = YES; FRAMEWORK_SEARCH_PATHS = "$(inherited)"; INFOPLIST_FILE = WebDriverAgentRunner/Info.plist; @@ -6523,7 +6667,7 @@ "$(inherited)", "-all_load", ); - PRODUCT_BUNDLE_IDENTIFIER = com.facebook.WebDriverAgentRunner; + PRODUCT_BUNDLE_IDENTIFIER = "$(WDA_PRODUCT_BUNDLE_IDENTIFIER)"; PRODUCT_NAME = "$(TARGET_NAME)"; USES_XCTRUNNER = YES; WARNING_CFLAGS = ( @@ -6562,7 +6706,7 @@ DEBUG_INFORMATION_FORMAT = dwarf; GCC_TREAT_WARNINGS_AS_ERRORS = NO; INFOPLIST_FILE = WebDriverAgentBroadcast/Info.plist; - PRODUCT_BUNDLE_IDENTIFIER = com.facebook.WebDriverAgentRunner.xctrunner.broadcast; + PRODUCT_BUNDLE_IDENTIFIER = "$(WDA_PRODUCT_BUNDLE_IDENTIFIER).xctrunner.broadcast"; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; @@ -6578,7 +6722,43 @@ GCC_TREAT_WARNINGS_AS_ERRORS = NO; INFOPLIST_FILE = WebDriverAgentBroadcast/Info.plist; ONLY_ACTIVE_ARCH = YES; - PRODUCT_BUNDLE_IDENTIFIER = com.facebook.WebDriverAgentRunner.xctrunner.broadcast; + PRODUCT_BUNDLE_IDENTIFIER = "$(WDA_PRODUCT_BUNDLE_IDENTIFIER).xctrunner.broadcast"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; + FBCAFE000000000000007104 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = EEE5CABF1C80361500CBBDD9 /* IOSSettings.xcconfig */; + buildSettings = { + APPLICATION_EXTENSION_API_ONLY = YES; + CODE_SIGN_ENTITLEMENTS = "$(WDA_TUNNEL_ENTITLEMENTS)"; + DEBUG_INFORMATION_FORMAT = dwarf; + DEVELOPMENT_TEAM = ""; + GCC_TREAT_WARNINGS_AS_ERRORS = NO; + INFOPLIST_FILE = WebDriverAgentTunnel/Info.plist; + PRODUCT_BUNDLE_IDENTIFIER = "$(WDA_PRODUCT_BUNDLE_IDENTIFIER).xctrunner.tunnel"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + FBCAFE000000000000007105 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = EEE5CABF1C80361500CBBDD9 /* IOSSettings.xcconfig */; + buildSettings = { + APPLICATION_EXTENSION_API_ONLY = YES; + CODE_SIGN_ENTITLEMENTS = "$(WDA_TUNNEL_ENTITLEMENTS)"; + DEVELOPMENT_TEAM = ""; + GCC_TREAT_WARNINGS_AS_ERRORS = NO; + INFOPLIST_FILE = WebDriverAgentTunnel/Info.plist; + ONLY_ACTIVE_ARCH = YES; + PRODUCT_BUNDLE_IDENTIFIER = "$(WDA_PRODUCT_BUNDLE_IDENTIFIER).xctrunner.tunnel"; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; @@ -6793,6 +6973,15 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + F80B35927AACA48FC55D05B5 /* Build configuration list for PBXNativeTarget "WebDriverAgentLib_watchOS" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 9AD9FD0D35FBBD5C87BC6F5B /* Debug */, + 12BC722A112C4D54D64F1A9B /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; FBCAFE000000000000005003 /* Build configuration list for PBXNativeTarget "WebDriverAgentBroadcast" */ = { isa = XCConfigurationList; buildConfigurations = ( @@ -6802,11 +6991,11 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; - F80B35927AACA48FC55D05B5 /* Build configuration list for PBXNativeTarget "WebDriverAgentLib_watchOS" */ = { + FBCAFE000000000000007103 /* Build configuration list for PBXNativeTarget "WebDriverAgentTunnel" */ = { isa = XCConfigurationList; buildConfigurations = ( - 9AD9FD0D35FBBD5C87BC6F5B /* Debug */, - 12BC722A112C4D54D64F1A9B /* Release */, + FBCAFE000000000000007104 /* Debug */, + FBCAFE000000000000007105 /* Release */, ); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; diff --git a/WebDriverAgent.xcodeproj/xcshareddata/xcschemes/IntegrationApp_watchOS.xcscheme b/WebDriverAgent.xcodeproj/xcshareddata/xcschemes/IntegrationApp_watchOS.xcscheme index 6dbf148f35..8f1d503318 100644 --- a/WebDriverAgent.xcodeproj/xcshareddata/xcschemes/IntegrationApp_watchOS.xcscheme +++ b/WebDriverAgent.xcodeproj/xcshareddata/xcschemes/IntegrationApp_watchOS.xcscheme @@ -46,6 +46,7 @@ BuildableIdentifier = "primary" BlueprintIdentifier = "9D6B02D8FC050BAF7159284F" BuildableName = "IntegrationApp_watchOS.app" + BlueprintName = "IntegrationApp_watchOS" ReferencedContainer = "container:WebDriverAgent.xcodeproj"> @@ -62,6 +63,7 @@ BuildableIdentifier = "primary" BlueprintIdentifier = "9D6B02D8FC050BAF7159284F" BuildableName = "IntegrationApp_watchOS.app" + BlueprintName = "IntegrationApp_watchOS" ReferencedContainer = "container:WebDriverAgent.xcodeproj"> diff --git a/WebDriverAgent.xcodeproj/xcshareddata/xcschemes/WebDriverAgentRunner-nodebug.xcscheme b/WebDriverAgent.xcodeproj/xcshareddata/xcschemes/WebDriverAgentRunner-nodebug.xcscheme index 57692f8224..118a3312f0 100644 --- a/WebDriverAgent.xcodeproj/xcshareddata/xcschemes/WebDriverAgentRunner-nodebug.xcscheme +++ b/WebDriverAgent.xcodeproj/xcshareddata/xcschemes/WebDriverAgentRunner-nodebug.xcscheme @@ -38,6 +38,22 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/WebDriverAgent.xcodeproj/xcshareddata/xcschemes/WebDriverAgentRunnerTunnel.xcscheme b/WebDriverAgent.xcodeproj/xcshareddata/xcschemes/WebDriverAgentRunnerTunnel.xcscheme new file mode 100644 index 0000000000..8278fc0e8d --- /dev/null +++ b/WebDriverAgent.xcodeproj/xcshareddata/xcschemes/WebDriverAgentRunnerTunnel.xcscheme @@ -0,0 +1,194 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/WebDriverAgentLib/Commands/FBMobilerunSocks5Commands.h b/WebDriverAgentLib/Commands/FBMobilerunSocks5Commands.h new file mode 100644 index 0000000000..afb90f9178 --- /dev/null +++ b/WebDriverAgentLib/Commands/FBMobilerunSocks5Commands.h @@ -0,0 +1,29 @@ +/** + * Copyright (c) 2026-present, Droidrun. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. + */ + +#import + +#import + +NS_ASSUME_NONNULL_BEGIN + +/** + mobilerun SOCKS5 VPN endpoints: routes device traffic through a SOCKS5 proxy via the + WebDriverAgentTunnel packet tunnel extension. + + POST /mobilerun/socks5/connect body: {"uri": "socks5[h]://[user:pass@]host[:port]", + "timeout": seconds (optional, default 30), + "consentButtonLabels": [string] (optional)} + POST /mobilerun/socks5/disconnect + GET /mobilerun/socks5/stats + */ +@interface FBMobilerunSocks5Commands : NSObject + +@end + +NS_ASSUME_NONNULL_END diff --git a/WebDriverAgentLib/Commands/FBMobilerunSocks5Commands.m b/WebDriverAgentLib/Commands/FBMobilerunSocks5Commands.m new file mode 100644 index 0000000000..9e7971d084 --- /dev/null +++ b/WebDriverAgentLib/Commands/FBMobilerunSocks5Commands.m @@ -0,0 +1,144 @@ +/** + * Copyright (c) 2026-present, Droidrun. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. + */ + +#import "FBMobilerunSocks5Commands.h" + +#include + +#import "FBCommandStatus.h" +#import "FBResponsePayload.h" +#import "FBRoute.h" +#import "FBRouteRequest.h" +#import "FBSocks5TunnelManager.h" +#import "FBSocks5URI.h" + +static const NSTimeInterval FBSocks5ConnectDefaultTimeout = 30.0; +static const NSTimeInterval FBSocks5ConnectMaximumTimeout = 300.0; + +BOOL FBSocks5ConnectTimeoutFromValue(id _Nullable value, NSTimeInterval *timeout) +{ + NSTimeInterval validatedTimeout = FBSocks5ConnectDefaultTimeout; + if (nil != value) { + if (![value isKindOfClass:NSNumber.class] + || CFGetTypeID((__bridge CFTypeRef)value) == CFBooleanGetTypeID()) { + return NO; + } + validatedTimeout = [value doubleValue]; + if (!isfinite(validatedTimeout) + || validatedTimeout <= 0 + || validatedTimeout > FBSocks5ConnectMaximumTimeout) { + return NO; + } + } + if (NULL != timeout) { + *timeout = validatedTimeout; + } + return YES; +} + +@implementation FBMobilerunSocks5Commands + +#pragma mark - + ++ (NSArray *)routes +{ + return + @[ + [[FBRoute POST:@"/mobilerun/socks5/connect"].standalone respondWithTarget:self action:@selector(handleConnect:)], + [[FBRoute POST:@"/mobilerun/socks5/disconnect"].standalone respondWithTarget:self action:@selector(handleDisconnect:)], + [[FBRoute GET:@"/mobilerun/socks5/stats"].standalone respondWithTarget:self action:@selector(handleStats:)], + [[FBRoute POST:@"/mobilerun/socks5/connect"].withoutSession.standalone respondWithTarget:self action:@selector(handleConnect:)], + [[FBRoute POST:@"/mobilerun/socks5/disconnect"].withoutSession.standalone respondWithTarget:self action:@selector(handleDisconnect:)], + [[FBRoute GET:@"/mobilerun/socks5/stats"].withoutSession.standalone respondWithTarget:self action:@selector(handleStats:)], + ]; +} + +#pragma mark - Commands + ++ (id)handleConnect:(FBRouteRequest *)request +{ + id uriValue = request.arguments[@"uri"]; + if (![uriValue isKindOfClass:NSString.class] || 0 == [uriValue length]) { + return FBResponseWithStatus([FBCommandStatus invalidArgumentErrorWithMessage:@"The request body must contain a 'uri' string like socks5h://user:pass@host:1080" + traceback:nil]); + } + NSError *parseError; + FBSocks5URI *uri = [FBSocks5URI parse:(NSString *)uriValue error:&parseError]; + if (nil == uri) { + return FBResponseWithStatus([FBCommandStatus invalidArgumentErrorWithMessage:parseError.localizedDescription + traceback:nil]); + } + NSTimeInterval timeout; + id timeoutValue = request.arguments[@"timeout"]; + if (!FBSocks5ConnectTimeoutFromValue(timeoutValue, &timeout)) { + return FBResponseWithStatus([FBCommandStatus invalidArgumentErrorWithMessage: + @"'timeout' must be a finite number greater than 0 and at most 300 seconds" + traceback:nil]); + } + NSArray *consentLabels = nil; + id labelsValue = request.arguments[@"consentButtonLabels"]; + if ([labelsValue isKindOfClass:NSArray.class]) { + NSPredicate *isString = [NSPredicate predicateWithBlock:^BOOL(id item, NSDictionary *bindings) { + return [item isKindOfClass:NSString.class]; + }]; + consentLabels = [(NSArray *)labelsValue filteredArrayUsingPredicate:isString]; + } + + NSError *error; + // The snapshot comes back from inside the same lifecycle transaction as the mutation; asking + // for stats afterwards would let a queued disconnect slip in and make this response describe + // a tunnel that is already gone. + NSDictionary *stats = + [FBSocks5TunnelManager.sharedInstance connectWithURI:uri + controlAddress:request.clientAddress + timeout:timeout + consentButtonLabels:consentLabels + error:&error]; + if (nil == stats) { + return [self responseWithTunnelManagerError:error]; + } + return FBResponseWithObject(stats); +} + ++ (id)handleDisconnect:(FBRouteRequest *)request +{ + NSError *error; + NSDictionary *stats = [FBSocks5TunnelManager.sharedInstance disconnectWithError:&error]; + if (nil == stats) { + return [self responseWithTunnelManagerError:error]; + } + return FBResponseWithObject(stats); +} + ++ (id)handleStats:(FBRouteRequest *)request +{ + return FBResponseWithObject(FBSocks5TunnelManager.sharedInstance.statsDictionary); +} + +#pragma mark - Helpers + ++ (id)responseWithTunnelManagerError:(NSError *)error +{ + if (![error.domain isEqualToString:FBSocks5TunnelManagerErrorDomain]) { + return FBResponseWithUnknownError(error); + } + switch ((FBSocks5TunnelManagerError)error.code) { + case FBSocks5TunnelManagerErrorUnsupported: + case FBSocks5TunnelManagerErrorNotAuthorized: + return FBResponseWithStatus([FBCommandStatus unsupportedOperationErrorWithMessage:error.localizedDescription + traceback:nil]); + case FBSocks5TunnelManagerErrorTimeout: + return FBResponseWithStatus([FBCommandStatus timeoutErrorWithMessage:error.localizedDescription + traceback:nil]); + case FBSocks5TunnelManagerErrorInternal: + return FBResponseWithUnknownError(error); + } + return FBResponseWithUnknownError(error); +} + +@end diff --git a/WebDriverAgentLib/Routing/FBHTTPServer.h b/WebDriverAgentLib/Routing/FBHTTPServer.h index 75ea80ccd4..9898a0c3c4 100644 --- a/WebDriverAgentLib/Routing/FBHTTPServer.h +++ b/WebDriverAgentLib/Routing/FBHTTPServer.h @@ -56,10 +56,10 @@ NS_ASSUME_NONNULL_BEGIN /** Registers a route handler that, when `standalone` is YES, bypasses -routeQueue entirely so a - handler stuck on that queue can never block it. Concurrent requests to the same method+path are - coalesced into a single in-flight execution, whose response is delivered to all of them; anything - else runs on its own queue, so distinct standalone endpoints always execute in parallel with each - other and with whatever is stuck on -routeQueue. + handler stuck on that queue can never block it. Concurrent requests from the same client address + with the same method, path, query, and body are coalesced into a single in-flight execution, whose + response is delivered to all of them; anything else runs on its own queue, so distinct standalone + requests always execute in parallel with each other and with whatever is stuck on -routeQueue. */ - (void)handleMethod:(NSString *)method withPath:(NSString *)path diff --git a/WebDriverAgentLib/Routing/FBHTTPServer.m b/WebDriverAgentLib/Routing/FBHTTPServer.m index 914e564c2a..fb7900c30e 100644 --- a/WebDriverAgentLib/Routing/FBHTTPServer.m +++ b/WebDriverAgentLib/Routing/FBHTTPServer.m @@ -35,6 +35,24 @@ // without limit. Far above anything a real request needs. static const NSUInteger FBMaxRequestHeaderSize = 64 * 1024; +static NSString * _Nullable FBClientAddress(nw_connection_t client) +{ + nw_endpoint_t endpoint = nw_connection_copy_endpoint(client); + if (nil == endpoint) { + return nil; + } + const char *hostname = nw_endpoint_get_hostname(endpoint); + return NULL == hostname ? nil : [NSString stringWithUTF8String:hostname]; +} + +NSArray *FBStandaloneRequestIdentity(NSString *method, + NSString *pathAndQuery, + NSData *body, + NSString *_Nullable clientAddress) +{ + return @[method, pathAndQuery, body, clientAddress ?: NSNull.null]; +} + // ASCII decimal digits only. -integerValue must not be used here: it maps garbage silently // ("bogus" -> 0, "12abc" -> 12), desyncing the framing of every later request on the connection. static BOOL FBParseContentLength(NSString *value, NSUInteger *outLength) @@ -123,9 +141,9 @@ @interface FBHTTPServer () // -processBufferForClient: from starting the next pipelined request, so responses on one // connection can't be written out of order. Guarded by @synchronized(self.connectionBuffers). @property (nonatomic, strong) NSMutableSet *connectionsAwaitingResponse; -// Keyed by "METHOD path" - requests waiting on an already in-flight standalone request for that -// endpoint. Guarded by @synchronized(self.standaloneWaiters). -@property (nonatomic, strong) NSMutableDictionary *> *standaloneWaiters; +// Keyed by an opaque method/path/body tuple - requests waiting on an already in-flight identical +// standalone request. Guarded by @synchronized(self.standaloneWaiters). +@property (nonatomic, strong) NSMutableDictionary *> *standaloneWaiters; // Keyed by the "sessionID" path param - requests currently queued or executing for that session, // standalone or not (except DELETE /session itself - see -dispatchMethod:). See // -abandonPendingRequestsForSessionID:. Guarded by @synchronized(self.pendingSessionRequests). @@ -612,7 +630,10 @@ - (void)dispatchMethod:(NSString *)method pathAndQuery:(NSString *)pathAndQuery } NSURL *url = [NSURL URLWithString:path] ?: [NSURL URLWithString:@"/"]; - RouteRequest *request = [[RouteRequest alloc] initWithURL:url params:params.copy body:body]; + RouteRequest *request = [[RouteRequest alloc] initWithURL:url + params:params.copy + body:body + clientAddress:FBClientAddress(client)]; RouteResponse *response = [RouteResponse new]; [self applyDefaultHeadersToResponse:response]; @@ -732,8 +753,11 @@ - (void)dispatchStandaloneRoute:(FBHTTPRoute *)route pathAndQuery:(NSString *)pathAndQuery sessionID:(nullable NSString *)sessionID { - // Includes the query string so requests with different params are never coalesced together. - NSString *key = [NSString stringWithFormat:@"%@ %@", method, pathAndQuery]; + // Includes the query string, raw body bytes, and controller address so only semantically + // identical requests from the same controller are coalesced. Keeping these values opaque also + // avoids exposing request contents in queue labels. + NSArray *key = FBStandaloneRequestIdentity(method, pathAndQuery, request.body, + request.clientAddress); FBPendingRequest *waiter = [[FBPendingRequest alloc] initWithClient:client]; if (nil != sessionID) { RouteResponse *abandonedResponse = [self trackPendingRequest:waiter forSessionID:sessionID]; @@ -758,7 +782,7 @@ - (void)dispatchStandaloneRoute:(FBHTTPRoute *)route return; } - dispatch_queue_t queue = dispatch_queue_create(key.UTF8String, DISPATCH_QUEUE_SERIAL); + dispatch_queue_t queue = dispatch_queue_create("com.facebook.WebDriverAgent.standalone-route", DISPATCH_QUEUE_SERIAL); __weak typeof(self) weakSelf = self; dispatch_async(queue, ^{ route.block(request, response); diff --git a/WebDriverAgentLib/Routing/FBRouteRequest-Private.h b/WebDriverAgentLib/Routing/FBRouteRequest-Private.h index 7144bd80ba..5383981761 100644 --- a/WebDriverAgentLib/Routing/FBRouteRequest-Private.h +++ b/WebDriverAgentLib/Routing/FBRouteRequest-Private.h @@ -14,6 +14,7 @@ NS_ASSUME_NONNULL_BEGIN @property (nonatomic, strong, readwrite) NSURL *URL; @property (nonatomic, copy, readwrite) NSDictionary *parameters; @property (nonatomic, copy, readwrite) NSDictionary *arguments; +@property (nonatomic, copy, readwrite, nullable) NSString *clientAddress; @property (nonatomic, strong, readwrite) FBSession *session; @end diff --git a/WebDriverAgentLib/Routing/FBRouteRequest.h b/WebDriverAgentLib/Routing/FBRouteRequest.h index c7938d5d67..3fe9f9a6ae 100644 --- a/WebDriverAgentLib/Routing/FBRouteRequest.h +++ b/WebDriverAgentLib/Routing/FBRouteRequest.h @@ -26,6 +26,9 @@ NS_ASSUME_NONNULL_BEGIN /*! Arguments sent with that request */ @property (nonatomic, copy, readonly) NSDictionary *arguments; +/*! Remote IP address of the HTTP client, when available */ +@property (nonatomic, copy, readonly, nullable) NSString *clientAddress; + /*! Session associated with that request */ @property (nonatomic, strong, readonly) FBSession *session; @@ -34,6 +37,11 @@ NS_ASSUME_NONNULL_BEGIN */ + (instancetype)routeRequestWithURL:(NSURL *)URL parameters:(NSDictionary *)parameters arguments:(NSDictionary *)arguments; ++ (instancetype)routeRequestWithURL:(NSURL *)URL + parameters:(NSDictionary *)parameters + arguments:(NSDictionary *)arguments + clientAddress:(nullable NSString *)clientAddress; + @end NS_ASSUME_NONNULL_END diff --git a/WebDriverAgentLib/Routing/FBRouteRequest.m b/WebDriverAgentLib/Routing/FBRouteRequest.m index b8656d2852..3fd1333a15 100644 --- a/WebDriverAgentLib/Routing/FBRouteRequest.m +++ b/WebDriverAgentLib/Routing/FBRouteRequest.m @@ -8,14 +8,102 @@ #import "FBRouteRequest-Private.h" +static NSString *FBRedactedURIString(NSString *value) +{ + NSRange schemeSeparator = [value rangeOfString:@"://"]; + if (NSNotFound == schemeSeparator.location) { + return value; + } + NSUInteger authorityStart = NSMaxRange(schemeSeparator); + NSRange authorityTail = NSMakeRange(authorityStart, value.length - authorityStart); + NSRange authorityEnd = [value rangeOfCharacterFromSet:[NSCharacterSet characterSetWithCharactersInString:@"/?#"] + options:(NSStringCompareOptions)0 + range:authorityTail]; + NSUInteger authorityLength = NSNotFound == authorityEnd.location + ? value.length - authorityStart + : authorityEnd.location - authorityStart; + NSRange userInfoSeparator = [value rangeOfString:@"@" + options:NSBackwardsSearch + range:NSMakeRange(authorityStart, authorityLength)]; + if (NSNotFound == userInfoSeparator.location) { + return value; + } + + NSMutableString *redacted = value.mutableCopy; + [redacted replaceCharactersInRange:NSMakeRange(authorityStart, + userInfoSeparator.location - authorityStart) + withString:@""]; + return redacted.copy; +} + +static NSString *FBRedactedProxyURIString(NSString *value) +{ + NSString *redacted = FBRedactedURIString(value); + if (![redacted isEqualToString:value]) { + return redacted; + } + NSRange userInfoSeparator = [value rangeOfString:@"@" options:NSBackwardsSearch]; + if (NSNotFound == userInfoSeparator.location) { + return value; + } + NSMutableString *fallback = value.mutableCopy; + [fallback replaceCharactersInRange:NSMakeRange(0, userInfoSeparator.location) + withString:@""]; + return fallback.copy; +} + +static id FBRedactedRequestLogValue(id value) +{ + if ([value isKindOfClass:NSString.class]) { + return FBRedactedURIString((NSString *)value); + } + if ([value isKindOfClass:NSArray.class]) { + NSMutableArray *result = [NSMutableArray arrayWithCapacity:[(NSArray *)value count]]; + for (id item in (NSArray *)value) { + [result addObject:FBRedactedRequestLogValue(item)]; + } + return result.copy; + } + if ([value isKindOfClass:NSDictionary.class]) { + NSMutableDictionary *result = [NSMutableDictionary dictionaryWithCapacity:[(NSDictionary *)value count]]; + [(NSDictionary *)value enumerateKeysAndObjectsUsingBlock:^(id key, id item, BOOL *stop) { + result[key] = FBRedactedRequestLogValue(item); + }]; + return result.copy; + } + return value; +} + +static NSDictionary *FBRedactedRequestArguments(NSURL *URL, NSDictionary *arguments) +{ + NSMutableDictionary *redacted = [(NSDictionary *)FBRedactedRequestLogValue(arguments) mutableCopy]; + id uri = arguments[@"uri"]; + if ([URL.path hasSuffix:@"/mobilerun/socks5/connect"] && [uri isKindOfClass:NSString.class]) { + redacted[@"uri"] = FBRedactedProxyURIString((NSString *)uri); + } + return redacted.copy; +} + @implementation FBRouteRequest + (instancetype)routeRequestWithURL:(NSURL *)URL parameters:(NSDictionary *)parameters arguments:(NSDictionary *)arguments +{ + return [self routeRequestWithURL:URL + parameters:parameters + arguments:arguments + clientAddress:nil]; +} + ++ (instancetype)routeRequestWithURL:(NSURL *)URL + parameters:(NSDictionary *)parameters + arguments:(NSDictionary *)arguments + clientAddress:(nullable NSString *)clientAddress { FBRouteRequest *request = [self.class new]; request.URL = URL; request.parameters = parameters; request.arguments = arguments; + request.clientAddress = clientAddress; return request; } @@ -25,7 +113,7 @@ - (NSString *)description @"Request URL %@ | Params %@ | Arguments %@", self.URL, self.parameters, - self.arguments + FBRedactedRequestArguments(self.URL, self.arguments) ]; } diff --git a/WebDriverAgentLib/Routing/FBWebServer.h b/WebDriverAgentLib/Routing/FBWebServer.h index ed590fd203..fd77fc6f3f 100644 --- a/WebDriverAgentLib/Routing/FBWebServer.h +++ b/WebDriverAgentLib/Routing/FBWebServer.h @@ -37,6 +37,37 @@ NS_ASSUME_NONNULL_BEGIN */ - (void)stopServing; +/** + Runs a block that touches XCUI/XCTest state on the main thread, serialized through the + same automation funnel the main-queue-served routes use. + + Routes marked `standalone` are served off the main queue, so they must not call + XCUI directly. Hopping straight to the main queue is not enough either: such a block + could be drained inside another handler's run-loop spin, which is exactly the + reentrancy the funnel exists to prevent. Going through the funnel first makes the + block wait for the in-flight automation request instead. + + No-ops onto a direct call when already on the main thread (the caller is then already + inside the funnel), and skips the funnel hop when already running on it. + + @param block The XCUI-touching work. Executed synchronously before this method returns. + */ ++ (void)performAutomationBlockOnMainQueue:(NS_NOESCAPE dispatch_block_t)block; + +/** + Runs an XCUI/XCTest block through the automation funnel only if it can begin by `deadline`. + + If the block is still queued when the deadline expires, it is cancelled and will not execute + later. Once execution has begun, this method preserves the synchronous contract and waits for + the block to finish. + + @param block The XCUI-touching work. + @param deadline The latest date at which the queued block may begin executing. + @return YES if the block began execution, otherwise NO. + */ ++ (BOOL)performAutomationBlockOnMainQueue:(dispatch_block_t)block + beforeDate:(NSDate *)deadline; + @end /** diff --git a/WebDriverAgentLib/Routing/FBWebServer.m b/WebDriverAgentLib/Routing/FBWebServer.m index cf6cf6c1ea..f7a82422d8 100644 --- a/WebDriverAgentLib/Routing/FBWebServer.m +++ b/WebDriverAgentLib/Routing/FBWebServer.m @@ -9,6 +9,7 @@ #import "FBWebServer.h" #import "FBHTTPServer.h" +#import #import "FBMjpegServer.h" #import "FBTCPSocket.h" #if !TARGET_OS_WATCH @@ -35,6 +36,9 @@ static NSString *const FBServerURLBeginMarker = @"ServerURLHere->"; static NSString *const FBServerURLEndMarker = @"<-ServerURLHere"; +/// Queue-specific marker used to detect that the caller already runs on the automation funnel. +static const void *FBAutomationFunnelKey = &FBAutomationFunnelKey; + @interface FBWebServer () @property (nonatomic, strong) FBExceptionHandler *exceptionHandler; @property (nonatomic, strong) FBHTTPServer *server; @@ -118,12 +122,159 @@ - (void)startServing } } +/** + The funnel is process-wide rather than per-server instance so that off-main callers + (see performAutomationBlockOnMainQueue:) serialize against the very same queue the + route dispatch uses. + */ ++ (dispatch_queue_t)automationFunnelQueue +{ + static dispatch_queue_t queue; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + queue = dispatch_queue_create("com.facebook.WebDriverAgent.automation-funnel", DISPATCH_QUEUE_SERIAL); + dispatch_queue_set_specific(queue, FBAutomationFunnelKey, (void *)FBAutomationFunnelKey, NULL); + }); + return queue; +} + ++ (void)performAutomationBlockOnMainQueue:(NS_NOESCAPE dispatch_block_t)block +{ + if (NSThread.isMainThread) { + // Already inside funnel -> main, so re-entering either would deadlock. + block(); + return; + } + __block NSException *blockException = nil; + if (NULL != dispatch_get_specific(FBAutomationFunnelKey)) { + dispatch_sync(dispatch_get_main_queue(), ^{ + @try { + block(); + } @catch (NSException *exception) { + blockException = exception; + } + }); + if (nil != blockException) { + @throw blockException; + } + return; + } + dispatch_sync(self.automationFunnelQueue, ^{ + dispatch_sync(dispatch_get_main_queue(), ^{ + @try { + block(); + } @catch (NSException *exception) { + blockException = exception; + } + }); + }); + if (nil != blockException) { + @throw blockException; + } +} + ++ (BOOL)performAutomationBlockOnMainQueue:(dispatch_block_t)block + beforeDate:(NSDate *)deadline +{ + if (NSThread.isMainThread) { + if (deadline.timeIntervalSinceNow <= 0) { + return NO; + } + block(); + return YES; + } + if (NULL != dispatch_get_specific(FBAutomationFunnelKey)) { + __block BOOL executed = NO; + __block NSException *blockException = nil; + dispatch_sync(dispatch_get_main_queue(), ^{ + if (deadline.timeIntervalSinceNow <= 0) { + return; + } + executed = YES; + @try { + block(); + } @catch (NSException *exception) { + blockException = exception; + } + }); + if (nil != blockException) { + @throw blockException; + } + return executed; + } + + enum { + FBAutomationBlockPending, + FBAutomationBlockExecuting, + FBAutomationBlockCancelled, + FBAutomationBlockFinished, + }; + __block volatile atomic_int state = FBAutomationBlockPending; + __block NSException *blockException = nil; + dispatch_semaphore_t completion = dispatch_semaphore_create(0); + dispatch_async(self.automationFunnelQueue, ^{ + @try { + if (atomic_load_explicit(&state, memory_order_acquire) == FBAutomationBlockCancelled) { + return; + } + dispatch_sync(dispatch_get_main_queue(), ^{ + int expected = FBAutomationBlockPending; + if (deadline.timeIntervalSinceNow > 0 + && atomic_compare_exchange_strong_explicit(&state, &expected, + FBAutomationBlockExecuting, + memory_order_acq_rel, + memory_order_acquire)) { + @try { + block(); + } @catch (NSException *exception) { + blockException = exception; + } @finally { + atomic_store_explicit(&state, FBAutomationBlockFinished, memory_order_release); + } + } else { + expected = FBAutomationBlockPending; + atomic_compare_exchange_strong_explicit(&state, &expected, + FBAutomationBlockCancelled, + memory_order_acq_rel, + memory_order_acquire); + } + }); + } @finally { + dispatch_semaphore_signal(completion); + } + }); + + NSTimeInterval budget = deadline.timeIntervalSinceNow; + long waitResult = budget <= 0 + ? 1 + : dispatch_semaphore_wait(completion, dispatch_time(DISPATCH_TIME_NOW, + (int64_t)(budget * NSEC_PER_SEC))); + if (0 != waitResult) { + int expected = FBAutomationBlockPending; + if (atomic_compare_exchange_strong_explicit(&state, &expected, + FBAutomationBlockCancelled, + memory_order_acq_rel, + memory_order_acquire)) { + return NO; + } + if (atomic_load_explicit(&state, memory_order_acquire) == FBAutomationBlockCancelled) { + return NO; + } + dispatch_semaphore_wait(completion, DISPATCH_TIME_FOREVER); + } + BOOL finished = atomic_load_explicit(&state, memory_order_acquire) == FBAutomationBlockFinished; + if (finished && nil != blockException) { + @throw blockException; + } + return finished; +} + - (BOOL)startHTTPServer { self.server = [[FBHTTPServer alloc] init]; // Serializes automation requests so at most one is ever in flight on the main queue; handlers // are invoked here and hop to main via dispatch_sync. See registerRouteHandlers:. - self.automationQueue = dispatch_queue_create("com.facebook.WebDriverAgent.automation-funnel", DISPATCH_QUEUE_SERIAL); + self.automationQueue = self.class.automationFunnelQueue; [self.server setRouteQueue:self.automationQueue]; [self.server setDefaultHeader:@"Server" value:@"WebDriverAgent/1.0"]; [self.server setDefaultHeader:@"Access-Control-Allow-Origin" value:@"*"]; @@ -294,6 +445,7 @@ - (void)registerRouteHandlers:(NSArray *)commandHandlerClasses routeRequestWithURL:request.url parameters:request.params arguments:arguments ?: @{} + clientAddress:request.clientAddress ]; [FBLogger verboseLog:routeParams.description]; diff --git a/WebDriverAgentLib/Routing/RouteRequest.h b/WebDriverAgentLib/Routing/RouteRequest.h index 1a5c1b458c..f1cbeb710b 100644 --- a/WebDriverAgentLib/Routing/RouteRequest.h +++ b/WebDriverAgentLib/Routing/RouteRequest.h @@ -18,10 +18,12 @@ NS_ASSUME_NONNULL_BEGIN @property (nonatomic, copy, readonly) NSURL *url; @property (nonatomic, copy, readonly) NSDictionary *params; @property (nonatomic, copy, readonly) NSData *body; +@property (nonatomic, copy, readonly, nullable) NSString *clientAddress; - (instancetype)initWithURL:(NSURL *)url params:(NSDictionary *)params - body:(NSData *)body; + body:(NSData *)body + clientAddress:(nullable NSString *)clientAddress; @end diff --git a/WebDriverAgentLib/Routing/RouteRequest.m b/WebDriverAgentLib/Routing/RouteRequest.m index 910a24e555..4793872400 100644 --- a/WebDriverAgentLib/Routing/RouteRequest.m +++ b/WebDriverAgentLib/Routing/RouteRequest.m @@ -13,11 +13,13 @@ @implementation RouteRequest - (instancetype)initWithURL:(NSURL *)url params:(NSDictionary *)params body:(NSData *)body + clientAddress:(nullable NSString *)clientAddress { if ((self = [super init])) { _url = url.copy; _params = params.copy; _body = body.copy; + _clientAddress = clientAddress.copy; } return self; } diff --git a/WebDriverAgentLib/Utilities/FBSocks5TunnelManager.h b/WebDriverAgentLib/Utilities/FBSocks5TunnelManager.h new file mode 100644 index 0000000000..95eb884dae --- /dev/null +++ b/WebDriverAgentLib/Utilities/FBSocks5TunnelManager.h @@ -0,0 +1,92 @@ +/** + * Copyright (c) 2026-present, Droidrun. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. + */ + +#import + +@class FBSocks5URI; + +NS_ASSUME_NONNULL_BEGIN + +extern NSErrorDomain const FBSocks5TunnelManagerErrorDomain; + +typedef NS_ERROR_ENUM(FBSocks5TunnelManagerErrorDomain, FBSocks5TunnelManagerError) { + /** Packet tunnels are not available in this environment (Simulator/tvOS). */ + FBSocks5TunnelManagerErrorUnsupported = 1, + /** Saving the VPN configuration was denied (consent alert rejected or not confirmable). */ + FBSocks5TunnelManagerErrorNotAuthorized = 2, + /** The tunnel did not reach the connected state within the allotted time. */ + FBSocks5TunnelManagerErrorTimeout = 3, + /** NetworkExtension reported an unexpected failure. */ + FBSocks5TunnelManagerErrorInternal = 4, +}; + +/** + Owns the NETunnelProviderManager lifecycle for the WebDriverAgentTunnel packet tunnel + extension embedded in the runner app: installs/updates the VPN configuration (auto-accepting + the system consent alert via UI automation), starts/stops the tunnel, and exposes traffic + counters queried from the extension. + + Must be called on the main thread (run-loop spinning + XCUITest interaction, like + FBBroadcastManager). + */ +@interface FBSocks5TunnelManager : NSObject + ++ (instancetype)sharedInstance; + +/** + Whether the given host bundle embeds the WebDriverAgentTunnel packet tunnel extension in its + PlugIns directory. Only the WebDriverAgentRunnerTunnel schemes embed it; the default runner + schemes build without the extension, and connect then fails with + FBSocks5TunnelManagerErrorUnsupported (see docs/socks5-tunnel.md). + + @param bundle the host app bundle to inspect (production code passes NSBundle.mainBundle) + */ ++ (BOOL)isTunnelExtensionEmbeddedInBundle:(NSBundle *)bundle; + +/** + Installs/updates the VPN configuration for the given proxy and starts the tunnel. + An already-running tunnel is replaced. Returns once the tunnel reports connected. + + @param uri the parsed socks5/socks5h proxy URI + @param timeout overall time budget in seconds for the tunnel to reach the connected state + @param consentButtonLabels labels to look for on the system "Add VPN Configurations" alert + (defaults to "Allow"); pass others when the device language is not English + @param error If there is an error, upon return contains an NSError describing the problem + @return The stats snapshot taken while the tunnel lifecycle was still held, or nil on failure. + Returning it from inside that transaction is what keeps the response truthful: a + separate statsDictionary call would let an overlapping disconnect run in between and + make a successful connect report connected:false. + */ +- (nullable NSDictionary *)connectWithURI:(FBSocks5URI *)uri + timeout:(NSTimeInterval)timeout + consentButtonLabels:(nullable NSArray *)consentButtonLabels + error:(NSError **)error; + +- (nullable NSDictionary *)connectWithURI:(FBSocks5URI *)uri + controlAddress:(nullable NSString *)controlAddress + timeout:(NSTimeInterval)timeout + consentButtonLabels:(nullable NSArray *)consentButtonLabels + error:(NSError **)error; + +/** + Stops the running tunnel (the VPN configuration stays installed). Succeeds when no tunnel + is running. + + @param error If there is an error, upon return contains an NSError describing the problem + @return The stats snapshot taken while the lifecycle was still held, or nil on failure. + */ +- (nullable NSDictionary *)disconnectWithError:(NSError **)error; + +/** @return The stats payload for GET /mobilerun/socks5/stats: connected flag, proxy + host/port/user (omitted while disconnected) and rx/tx byte/packet counters. Never fails; + counters fall back to zero when the extension cannot be queried. */ +- (NSDictionary *)statsDictionary; + +@end + +NS_ASSUME_NONNULL_END diff --git a/WebDriverAgentLib/Utilities/FBSocks5TunnelManager.m b/WebDriverAgentLib/Utilities/FBSocks5TunnelManager.m new file mode 100644 index 0000000000..a6aa7a7d6e --- /dev/null +++ b/WebDriverAgentLib/Utilities/FBSocks5TunnelManager.m @@ -0,0 +1,901 @@ +/** + * Copyright (c) 2026-present, Droidrun. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. + */ + +#import "FBSocks5TunnelManager.h" + +#include +#import + +#import "FBSocks5TunnelProtocol.h" +#import "FBSocks5URI.h" + +NSErrorDomain const FBSocks5TunnelManagerErrorDomain = @"com.facebook.WebDriverAgent.FBSocks5TunnelManager"; + +static BOOL FBSocks5Fail(NSError **error, FBSocks5TunnelManagerError code, NSString *message) +{ + if (nil != error) { + *error = [NSError errorWithDomain:FBSocks5TunnelManagerErrorDomain + code:code + userInfo:@{NSLocalizedDescriptionKey: message}]; + } + return NO; +} + +// The tunnel appex is only embedded by the WebDriverAgentRunnerTunnel schemes (the default +// runner schemes build without it so the hev submodule and paid-team signing stay optional); +// its presence in the host app is what decides whether SOCKS5 support exists in this build. +static BOOL FBSocks5TunnelExtensionEmbedded(NSBundle *bundle) +{ + NSString *appexPath = [bundle.bundlePath + stringByAppendingPathComponent:@"PlugIns/WebDriverAgentTunnel.appex"]; + BOOL isDirectory = NO; + return [NSFileManager.defaultManager fileExistsAtPath:appexPath isDirectory:&isDirectory] + && isDirectory; +} + +static const NSTimeInterval FBSocks5PreferencesTimeout = 10.0; +static NSString *const FBSocks5NEVPNErrorDomain = @"NEVPNErrorDomain"; +static NSString *const FBSocks5NEConfigurationErrorDomain = @"NEConfigurationErrorDomain"; +static const NSInteger FBSocks5NEVPNErrorConfigurationStale = 4; +static const NSInteger FBSocks5NEConfigurationErrorPermissionDenied = 10; + +typedef NS_ENUM(NSInteger, FBSocks5TunnelManagerSaveDisposition) { + FBSocks5TunnelManagerSaveDispositionRetryStale, + FBSocks5TunnelManagerSaveDispositionNotAuthorized, + FBSocks5TunnelManagerSaveDispositionInternal, +}; + +static BOOL FBSocks5TunnelManagerErrorChainContainsPermissionFailure(NSError *error) +{ + NSError *candidate = error; + for (NSUInteger depth = 0; nil != candidate && depth < 8; depth++) { + if (([candidate.domain isEqualToString:FBSocks5NEConfigurationErrorDomain] + && FBSocks5NEConfigurationErrorPermissionDenied == candidate.code) + || ([candidate.domain isEqualToString:NSPOSIXErrorDomain] + && (EACCES == candidate.code || EPERM == candidate.code)) + || ([candidate.domain isEqualToString:NSCocoaErrorDomain] + && (NSFileReadNoPermissionError == candidate.code + || NSFileWriteNoPermissionError == candidate.code))) { + return YES; + } + id underlying = candidate.userInfo[NSUnderlyingErrorKey]; + candidate = [underlying isKindOfClass:NSError.class] ? underlying : nil; + } + return NO; +} + +FBSocks5TunnelManagerSaveDisposition FBSocks5TunnelManagerSaveDispositionForError(NSError *error) +{ + if ([error.domain isEqualToString:FBSocks5NEVPNErrorDomain] + && FBSocks5NEVPNErrorConfigurationStale == error.code) { + return FBSocks5TunnelManagerSaveDispositionRetryStale; + } + if (FBSocks5TunnelManagerErrorChainContainsPermissionFailure(error)) { + return FBSocks5TunnelManagerSaveDispositionNotAuthorized; + } + return FBSocks5TunnelManagerSaveDispositionInternal; +} + +/** + How long an individual wait may block: whatever is left of the caller's whole-flow deadline, + never more than that stage's own cap. `deadline` is nil for the flows that do not carry one + (disconnect, stats), which then just get the cap. A non-positive result means the caller's + budget is exhausted and the stage must not start at all. + */ +static NSTimeInterval FBSocks5RemainingTimeout(NSDate *_Nullable deadline, NSTimeInterval cap) +{ + return nil == deadline ? cap : MIN(cap, deadline.timeIntervalSinceNow); +} + +@interface FBSocks5LifecycleGuard : NSObject +@property (nonatomic, strong) NSRecursiveLock *lifecycleLock; +/** + Signalled when a saveToPreferences that outlived its request finally completes. + + Returning from a timed-out save does not cancel it - NetworkExtension can still persist the + manager afterwards. A follow-up operation that ran before that landed would load no manager, + build a second one with the same provider id, and end up with two persisted configurations. + */ +@property (atomic, strong, nullable) dispatch_semaphore_t pendingSaveSignal; +- (BOOL)performLockedWithDeadline:(nullable NSDate *)deadline + block:(NS_NOESCAPE dispatch_block_t)block; +- (void)performLocked:(NS_NOESCAPE dispatch_block_t)block; +- (BOOL)fencePendingSaveWithDeadline:(nullable NSDate *)deadline error:(NSError **)error; +@end + +@implementation FBSocks5LifecycleGuard + +- (instancetype)init +{ + self = [super init]; + if (nil != self) { + _lifecycleLock = [[NSRecursiveLock alloc] init]; + _lifecycleLock.name = @"com.facebook.WebDriverAgent.socks5-lifecycle"; + } + return self; +} + +- (BOOL)performLockedWithDeadline:(nullable NSDate *)deadline + block:(NS_NOESCAPE dispatch_block_t)block +{ + BOOL acquired; + if (nil == deadline) { + [self.lifecycleLock lock]; + acquired = YES; + } else { + acquired = [self.lifecycleLock lockBeforeDate:(NSDate *)deadline]; + } + if (!acquired) { + return NO; + } + @try { + block(); + } @finally { + [self.lifecycleLock unlock]; + } + return YES; +} + +- (void)performLocked:(NS_NOESCAPE dispatch_block_t)block +{ + [self performLockedWithDeadline:nil block:block]; +} + +- (BOOL)fencePendingSaveWithDeadline:(nullable NSDate *)deadline error:(NSError **)error +{ + dispatch_semaphore_t signal = self.pendingSaveSignal; + if (nil == signal) { + return YES; + } + NSTimeInterval budget = FBSocks5RemainingTimeout(deadline, FBSocks5PreferencesTimeout); + if (budget <= 0 + || 0 != dispatch_semaphore_wait(signal, dispatch_time(DISPATCH_TIME_NOW, + (int64_t)(budget * NSEC_PER_SEC)))) { + return FBSocks5Fail(error, FBSocks5TunnelManagerErrorTimeout, + @"Timed out waiting for a previous VPN configuration save to finish"); + } + self.pendingSaveSignal = nil; + return YES; +} + +@end + +@interface FBSocks5TunnelManager () +/** Serializes the whole tunnel lifecycle without allowing a timed acquisition to execute later. */ +@property (nonatomic, strong) FBSocks5LifecycleGuard *lifecycle; +@end + +static NSMutableDictionary *FBSocks5DisconnectedStats(void) +{ + return [@{ + FBSocks5StatsKeyConnected: @NO, + FBSocks5StatsKeyRxBytes: @0, + FBSocks5StatsKeyTxBytes: @0, + FBSocks5StatsKeyRxPackets: @0, + FBSocks5StatsKeyTxPackets: @0, + } mutableCopy]; +} + +NSDictionary *_Nullable FBSocks5TunnelManagerDisconnectedStatsIfExtensionUnavailable(NSBundle *bundle) +{ + return FBSocks5TunnelExtensionEmbedded(bundle) ? nil : FBSocks5DisconnectedStats().copy; +} + +#if TARGET_OS_SIMULATOR || TARGET_OS_TV + +@implementation FBSocks5TunnelManager + ++ (instancetype)sharedInstance +{ + static FBSocks5TunnelManager *instance; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + instance = [[FBSocks5TunnelManager alloc] init]; + }); + return instance; +} + +- (nullable NSDictionary *)connectWithURI:(FBSocks5URI *)uri + timeout:(NSTimeInterval)timeout + consentButtonLabels:(nullable NSArray *)consentButtonLabels + error:(NSError **)error +{ + return [self connectWithURI:uri + controlAddress:nil + timeout:timeout + consentButtonLabels:consentButtonLabels + error:error]; +} + +- (nullable NSDictionary *)connectWithURI:(FBSocks5URI *)uri + controlAddress:(nullable NSString *)controlAddress + timeout:(NSTimeInterval)timeout + consentButtonLabels:(nullable NSArray *)consentButtonLabels + error:(NSError **)error +{ + FBSocks5Fail(error, FBSocks5TunnelManagerErrorUnsupported, + @"SOCKS5 tunnels require a NetworkExtension packet tunnel, which is not available on Simulator/tvOS"); + return nil; +} + +- (nullable NSDictionary *)disconnectWithError:(NSError **)error +{ + FBSocks5Fail(error, FBSocks5TunnelManagerErrorUnsupported, + @"SOCKS5 tunnels require a NetworkExtension packet tunnel, which is not available on Simulator/tvOS"); + return nil; +} + +- (NSDictionary *)statsDictionary +{ + return FBSocks5DisconnectedStats().copy; +} + ++ (BOOL)isTunnelExtensionEmbeddedInBundle:(NSBundle *)bundle +{ + return FBSocks5TunnelExtensionEmbedded(bundle); +} + +- (instancetype)init +{ + self = [super init]; + if (nil != self) { + _lifecycle = [[FBSocks5LifecycleGuard alloc] init]; + } + return self; +} + +@end + +#else + +#import + +#import "FBLogger.h" +#import "FBRunLoopSpinner.h" +#import "FBScreen.h" +#import "FBWebServer.h" +#import "FBXCTestDaemonsProxy.h" +#import "XCUIApplication.h" +#import "XCUIApplication+FBHelpers.h" +#import "XCUIApplication+FBTouchAction.h" +#import "XCUIElement.h" + +// The extension target is built as '.xctrunner.tunnel', which becomes +// '.tunnel'; derive the provider id from that final host id. +static NSString *const FBSocks5TunnelBundleSuffix = @".tunnel"; +static NSString *const FBSocks5TunnelDescription = @"mobilerun SOCKS5"; +static const NSTimeInterval FBSocks5StopTimeout = 10.0; +static const NSTimeInterval FBSocks5StatsReplyTimeout = 3.0; +static const NSTimeInterval FBSocks5DefaultConnectTimeout = 30.0; +/** Minimum gap between two consent taps, so a re-attempt cannot land during the dismissal animation. */ +static const uint64_t FBSocks5ConsentTapCooldownMs = 1000; + +static uint64_t FBSocks5NowMs(void) +{ + return clock_gettime_nsec_np(CLOCK_MONOTONIC_RAW) / NSEC_PER_MSEC; +} + +@interface FBSocks5TunnelManager () +@property (nonatomic, nullable) NETunnelProviderManager *activeManager; +/** Monotonic ms timestamp of the last consent tap dispatch; guards the re-attempt cooldown. */ +@property (nonatomic) uint64_t lastConsentTapMs; +- (BOOL)reloadManager:(NETunnelProviderManager *)manager + deadline:(NSDate *)deadline + context:(NSString *)context + error:(NSError **)error; +@end + +@implementation FBSocks5TunnelManager + ++ (instancetype)sharedInstance +{ + static FBSocks5TunnelManager *instance; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + instance = [[FBSocks5TunnelManager alloc] init]; + }); + return instance; +} + ++ (BOOL)isTunnelExtensionEmbeddedInBundle:(NSBundle *)bundle +{ + return FBSocks5TunnelExtensionEmbedded(bundle); +} + +- (instancetype)init +{ + self = [super init]; + if (nil != self) { + _lifecycle = [[FBSocks5LifecycleGuard alloc] init]; + } + return self; +} + +#pragma mark - Helpers + +- (NSString *)providerBundleIdentifier +{ + return [NSBundle.mainBundle.bundleIdentifier stringByAppendingString:FBSocks5TunnelBundleSuffix]; +} + +- (BOOL)loadAllManagers:(NSArray **)outManagers + deadline:(nullable NSDate *)deadline + error:(NSError **)error +{ + NSTimeInterval budget = FBSocks5RemainingTimeout(deadline, FBSocks5PreferencesTimeout); + if (budget <= 0) { + return FBSocks5Fail(error, FBSocks5TunnelManagerErrorTimeout, + @"Ran out of time before loading the VPN preferences"); + } + __block NSArray *managers = nil; + __block NSError *loadError = nil; + __block volatile atomic_bool done = false; + [NETunnelProviderManager loadAllFromPreferencesWithCompletionHandler:^(NSArray *all, NSError *err) { + managers = all; + loadError = err; + atomic_store_explicit(&done, true, memory_order_release); + }]; + [[[[FBRunLoopSpinner new] timeout:budget] interval:0.05] spinUntilTrue:^BOOL{ + return atomic_load_explicit(&done, memory_order_acquire); + }]; + // Running out of budget is a timeout, not an internal fault: collapsing the two would surface + // a plain deadline miss as 'unknown error' instead of the documented timeout response. + if (!atomic_load_explicit(&done, memory_order_acquire)) { + return FBSocks5Fail(error, FBSocks5TunnelManagerErrorTimeout, + @"Timed out loading the VPN preferences"); + } + if (nil != loadError) { + return FBSocks5Fail(error, FBSocks5TunnelManagerErrorInternal, + [NSString stringWithFormat:@"Cannot load the VPN preferences: %@", + loadError.localizedDescription]); + } + if (nil != outManagers) { + *outManagers = managers ?: @[]; + } + return YES; +} + +- (BOOL)reloadManager:(NETunnelProviderManager *)manager + deadline:(NSDate *)deadline + context:(NSString *)context + error:(NSError **)error +{ + NSTimeInterval budget = FBSocks5RemainingTimeout(deadline, FBSocks5PreferencesTimeout); + if (budget <= 0) { + return FBSocks5Fail(error, FBSocks5TunnelManagerErrorTimeout, + [NSString stringWithFormat:@"Timed out %@", context]); + } + __block volatile atomic_bool reloadDone = false; + __block NSError *reloadError = nil; + [manager loadFromPreferencesWithCompletionHandler:^(NSError *err) { + reloadError = err; + atomic_store_explicit(&reloadDone, true, memory_order_release); + }]; + [[[[FBRunLoopSpinner new] timeout:budget] interval:0.05] spinUntilTrue:^BOOL{ + return atomic_load_explicit(&reloadDone, memory_order_acquire); + }]; + if (!atomic_load_explicit(&reloadDone, memory_order_acquire)) { + return FBSocks5Fail(error, FBSocks5TunnelManagerErrorTimeout, + [NSString stringWithFormat:@"Timed out %@", context]); + } + if (nil != reloadError) { + return FBSocks5Fail(error, FBSocks5TunnelManagerErrorInternal, + [NSString stringWithFormat:@"Cannot %@: %@", + context, reloadError.localizedDescription]); + } + return YES; +} + +// Returns the manager for our own provider, preferring one that is not idle. Duplicates with the +// same provider id can exist transiently (see the pending-save fence); picking whichever came first +// would let a disconnected duplicate shadow the running tunnel, so disconnect would report +// success without stopping anything. +- (nullable NETunnelProviderManager *)ownManagerIn:(NSArray *)managers +{ + NSString *providerId = self.providerBundleIdentifier; + NETunnelProviderManager *idleMatch = nil; + for (NETunnelProviderManager *manager in managers) { + NETunnelProviderProtocol *protocol = (NETunnelProviderProtocol *)manager.protocolConfiguration; + if (![protocol isKindOfClass:NETunnelProviderProtocol.class] + || ![protocol.providerBundleIdentifier isEqualToString:providerId]) { + continue; + } + NEVPNStatus status = manager.connection.status; + if (status != NEVPNStatusDisconnected && status != NEVPNStatusInvalid) { + return manager; + } + if (nil == idleMatch) { + idleMatch = manager; + } + } + return idleMatch; +} + +- (BOOL)waitUntilStopped:(NETunnelProviderManager *)manager deadline:(nullable NSDate *)deadline +{ + NSTimeInterval budget = FBSocks5RemainingTimeout(deadline, FBSocks5StopTimeout); + if (budget <= 0) { + return NO; + } + return [[[[FBRunLoopSpinner new] timeout:budget] interval:0.2] spinUntilTrue:^BOOL{ + NEVPNStatus status = manager.connection.status; + return status == NEVPNStatusDisconnected || status == NEVPNStatusInvalid; + }]; +} + +// The first save of the configuration makes the system present a '"…" Would Like to Add VPN +// Configurations' alert that must be confirmed before the save completion fires, so this is +// polled from within the save wait loop. Devices with a passcode additionally ask for it, +// which cannot be automated (documented in docs/socks5-tunnel.md). +// XCUI is only safe to touch from the main thread, and the socks5 routes are served off it +// (they are marked standalone so the main queue stays free to drain the NetworkExtension +// completion handlers this class waits on). Every XCUI access below therefore hops onto the +// automation funnel and then the main queue, which is the same path the main-queue-served +// routes - e.g. the broadcast start/stop pair, whose consent handling this mirrors - take. +// +// The tap itself follows FBBroadcastManager's dismissal tap in three respects, each of which +// this code previously got wrong and which together left the alert standing while the caller +// believed it had been confirmed: +// 1. It is synthesized via the SYSTEM app, not the runner. The frame is read out of +// SpringBoard's coordinate space and the event record is stamped with the RECEIVER's +// interface orientation, so synthesizing through the (backgrounded, orientation-stale) +// runner can land the tap somewhere else entirely. +// 2. It is fire-and-forget. The blocking variant's acknowledgement can take the full +// event-synthesis timeout margin when the system sheds the event, which cannot be +// interrupted by the caller's much shorter spin deadline. +// 3. Its outcome is therefore observed via state (has the alert gone / did the save +// complete?), never inferred from the dispatch succeeding - so the caller must keep +// re-attempting, paced by the cooldown below, instead of latching after one dispatch. +// +// The button is matched inside the VPN alert rather than app-wide. An unscoped +// system.buttons[@"Allow"] query would happily select any other SpringBoard prompt that +// happens to be up when the save is requested - silently granting an unrelated permission - +// so the alert is located first and identified structurally, the way FBBroadcastManager +// anchors its own alert: exactly two buttons, one of them the consent label. +- (nullable XCUIElement *)consentAlertButtonWithLabel:(NSString *)label +{ + XCUIApplication *system = XCUIApplication.fb_systemApplication; + for (XCUIElement *alert in system.alerts.allElementsBoundByIndex) { + if (!alert.exists) { + continue; + } + NSArray *buttons = alert.buttons.allElementsBoundByIndex; + // "Would Like to Add VPN Configurations" is Allow / Don't Allow. A different button count is + // a different prompt, whatever its labels say. + if (buttons.count != 2) { + continue; + } + // Two buttons plus a common label is not an identity: other system permission prompts are + // also Allow / Don't Allow, and granting one of those instead would hand out an unrelated + // permission. Anchor on the alert's own text as well. "VPN" is an initialism Apple leaves + // untranslated in this title across locales; if that ever stops holding, the alert simply is + // not matched and connect fails on the save timeout, which is the safe direction to fail. + NSString *identity = [NSString stringWithFormat:@"%@ %@", alert.identifier ?: @"", alert.label ?: @""]; + if ([identity rangeOfString:@"VPN" options:NSCaseInsensitiveSearch].location == NSNotFound) { + continue; + } + for (XCUIElement *button in buttons) { + if ([button.label isEqualToString:label] && button.exists) { + return button; + } + } + } + return nil; +} + +- (BOOL)tapConsentButtonWithLabels:(NSArray *)labels deadline:(NSDate *)deadline +{ + __block BOOL dispatched = NO; + BOOL acquired = [FBWebServer performAutomationBlockOnMainQueue:^{ + XCUIApplication *system = XCUIApplication.fb_systemApplication; + for (NSString *label in labels) { + XCUIElement *button = [self consentAlertButtonWithLabel:label]; + if (nil == button) { + continue; + } + CGRect frame = button.frame; + if (CGRectIsEmpty(frame)) { + continue; + } + // Without a cooldown the next spin iteration could re-tap the same coordinates while the + // alert's dismissal animation is still running, landing the extra tap on the UI beneath. + if (FBSocks5NowMs() - self.lastConsentTapMs < FBSocks5ConsentTapCooldownMs) { + return; + } + CGFloat scale = (CGFloat)[FBScreen scale]; + CGPoint center = CGPointMake(CGRectGetMidX(frame) * scale, CGRectGetMidY(frame) * scale); + NSArray *tapActions = @[ + @{@"type": @"pointerDown", @"x": @(center.x), @"y": @(center.y)}, + @{@"type": @"pause", @"duration": @60}, + @{@"type": @"pointerUp", @"x": @(center.x), @"y": @(center.y)}, + ]; + NSError *tapError; + XCSynthesizedEventRecord *record = [system fb_mobilerunEventRecordFromActions:tapActions + scale:scale + error:&tapError]; + if (nil == record) { + [FBLogger logFmt:@"socks5/connect: cannot build the tap for the VPN consent button '%@': %@", + label, tapError.localizedDescription]; + return; + } + self.lastConsentTapMs = FBSocks5NowMs(); + [FBXCTestDaemonsProxy synthesizeEventAsyncWithRecord:record]; + [FBLogger logFmt:@"socks5/connect: dispatched a tap at the VPN consent button '%@'", label]; + dispatched = YES; + return; + } + } beforeDate:deadline]; + return acquired && dispatched; +} + +#pragma mark - Public API + +- (nullable NSDictionary *)connectWithURI:(FBSocks5URI *)uri + timeout:(NSTimeInterval)timeout + consentButtonLabels:(nullable NSArray *)consentButtonLabels + error:(NSError **)error +{ + return [self connectWithURI:uri + controlAddress:nil + timeout:timeout + consentButtonLabels:consentButtonLabels + error:error]; +} + +- (nullable NSDictionary *)connectWithURI:(FBSocks5URI *)uri + controlAddress:(nullable NSString *)controlAddress + timeout:(NSTimeInterval)timeout + consentButtonLabels:(nullable NSArray *)consentButtonLabels + error:(NSError **)error +{ + // Start the clock before queueing, not inside the locked body: waiting behind another + // operation is part of the caller's wall-clock budget, and a request with timeout:1 that + // queued behind a 30s connect must not then be handed a fresh one-second budget. + NSTimeInterval budget = timeout > 0 ? timeout : FBSocks5DefaultConnectTimeout; + NSDate *deadline = [NSDate dateWithTimeIntervalSinceNow:budget]; + if (!FBSocks5TunnelExtensionEmbedded(NSBundle.mainBundle)) { + FBSocks5Fail(error, FBSocks5TunnelManagerErrorUnsupported, + @"This build does not embed the WebDriverAgentTunnel extension; build the" + " WebDriverAgentRunnerTunnel scheme to include SOCKS5 VPN support" + " (see docs/socks5-tunnel.md)"); + return nil; + } + __block NSDictionary *snapshot = nil; + __block NSError *localError = nil; + BOOL acquired = [self.lifecycle performLockedWithDeadline:deadline block:^{ + if ([self lockedConnectWithURI:uri + controlAddress:controlAddress + deadline:deadline + consentButtonLabels:consentButtonLabels + error:&localError]) { + // Taken before the lock is released, so the payload cannot describe a tunnel that a + // queued disconnect has since torn down. Capped at the caller's deadline: the documented + // timeout covers the whole connect flow, so a slow provider stats reply must trim the + // counters (they fall back to zero) rather than blow the budget. + snapshot = [self lockedStatsDictionaryWithDeadline:deadline]; + } + }]; + if (!acquired) { + FBSocks5Fail(&localError, FBSocks5TunnelManagerErrorTimeout, + @"Timed out waiting for another SOCKS5 lifecycle operation to finish"); + } + if (nil == snapshot && nil != error) { + *error = localError; + } + return snapshot; +} + +- (BOOL)lockedConnectWithURI:(FBSocks5URI *)uri + controlAddress:(nullable NSString *)controlAddress + deadline:(NSDate *)deadline + consentButtonLabels:(nullable NSArray *)consentButtonLabels + error:(NSError **)error +{ + NSTimeInterval budget = deadline.timeIntervalSinceNow; + NSArray *labels = consentButtonLabels.count > 0 ? consentButtonLabels : @[@"Allow"]; + + if (![self.lifecycle fencePendingSaveWithDeadline:deadline error:error]) { + return NO; + } + NSArray *managers; + if (![self loadAllManagers:&managers deadline:deadline error:error]) { + return NO; + } + NETunnelProviderManager *manager = [self ownManagerIn:managers] ?: [[NETunnelProviderManager alloc] init]; + + // Connecting while a tunnel runs replaces it. Disconnecting counts as in-flight too: an + // immediate retry after a timed-out connect, or a connect racing an external VPN stop, would + // otherwise rewrite, save and start this very manager while its previous stop is still + // running - and that stop then rejects or tears down the replacement. + NEVPNStatus status = manager.connection.status; + if (status == NEVPNStatusConnected || status == NEVPNStatusConnecting + || status == NEVPNStatusReasserting || status == NEVPNStatusDisconnecting) { + if (status == NEVPNStatusDisconnecting) { + [FBLogger log:@"socks5/connect: waiting for the in-flight tunnel stop to settle first"]; + } else { + [FBLogger log:@"socks5/connect: stopping the already running tunnel first"]; + [manager.connection stopVPNTunnel]; + } + if (![self waitUntilStopped:manager deadline:deadline]) { + return FBSocks5Fail(error, FBSocks5TunnelManagerErrorTimeout, + @"Timed out stopping the previously running SOCKS5 tunnel"); + } + } + + NETunnelProviderProtocol *protocol = [[NETunnelProviderProtocol alloc] init]; + protocol.providerBundleIdentifier = self.providerBundleIdentifier; + protocol.serverAddress = uri.host; + protocol.providerConfiguration = [uri providerConfigurationWithControlAddress:controlAddress]; + protocol.disconnectOnSleep = NO; + manager.protocolConfiguration = protocol; + manager.localizedDescription = FBSocks5TunnelDescription; + manager.enabled = YES; + + __block BOOL consentTapped = NO; + NSUInteger staleRetries = 0; + while (YES) { + if (deadline.timeIntervalSinceNow <= 0) { + return FBSocks5Fail(error, FBSocks5TunnelManagerErrorTimeout, + @"Timed out before saving the VPN configuration"); + } + __block volatile atomic_bool saveDone = false; + __block NSError *saveError = nil; + dispatch_semaphore_t saveSignal = dispatch_semaphore_create(0); + self.lifecycle.pendingSaveSignal = saveSignal; + [manager saveToPreferencesWithCompletionHandler:^(NSError *err) { + saveError = err; + atomic_store_explicit(&saveDone, true, memory_order_release); + dispatch_semaphore_signal(saveSignal); + }]; + // Keep re-attempting for as long as the alert is still up: a dispatched tap can be shed by the + // system, so 'we dispatched one' is not evidence that it landed. tapConsentButtonWithLabels: + // paces the re-attempts itself and answers NO once the alert is gone. + // No MAX(..., 1.0) floor here: granting an already-exhausted request another second is + // exactly the overshoot the caller's timeout is supposed to prevent. + [[[[FBRunLoopSpinner new] timeout:deadline.timeIntervalSinceNow] interval:0.3] spinUntilTrue:^BOOL{ + if (atomic_load_explicit(&saveDone, memory_order_acquire)) { + return YES; + } + if ([self tapConsentButtonWithLabels:labels deadline:deadline]) { + consentTapped = YES; + } + return NO; + }]; + if (!atomic_load_explicit(&saveDone, memory_order_acquire)) { + return FBSocks5Fail(error, FBSocks5TunnelManagerErrorTimeout, + [NSString stringWithFormat: + @"Timed out saving the VPN configuration. The consent alert was %@; " + "pass 'consentButtonLabels' if the device language is not English, and note that " + "devices with a passcode cannot confirm the VPN consent automatically", + consentTapped ? @"confirmed" : @"not confirmed"]); + } + self.lifecycle.pendingSaveSignal = nil; + if (nil == saveError) { + break; + } + FBSocks5TunnelManagerSaveDisposition disposition = + FBSocks5TunnelManagerSaveDispositionForError(saveError); + if (FBSocks5TunnelManagerSaveDispositionRetryStale == disposition && 0 == staleRetries) { + staleRetries++; + [FBLogger log:@"socks5/connect: VPN configuration became stale; reloading and retrying the save once"]; + if (![self reloadManager:manager + deadline:deadline + context:@"reloading the stale VPN configuration" + error:error]) { + return NO; + } + manager.protocolConfiguration = protocol; + manager.localizedDescription = FBSocks5TunnelDescription; + manager.enabled = YES; + continue; + } + FBSocks5TunnelManagerError code = FBSocks5TunnelManagerSaveDispositionNotAuthorized == disposition + ? FBSocks5TunnelManagerErrorNotAuthorized + : FBSocks5TunnelManagerErrorInternal; + NSString *prefix = FBSocks5TunnelManagerSaveDispositionNotAuthorized == disposition + ? @"The VPN configuration was not authorized" + : @"Cannot save the VPN configuration"; + return FBSocks5Fail(error, code, + [NSString stringWithFormat:@"%@: %@", prefix, saveError.localizedDescription]); + } + + // A freshly saved configuration must be re-loaded before the tunnel can be started. + if (![self reloadManager:manager + deadline:deadline + context:@"reloading the saved VPN configuration" + error:error]) { + return NO; + } + + if (deadline.timeIntervalSinceNow <= 0) { + return FBSocks5Fail(error, FBSocks5TunnelManagerErrorTimeout, + @"Timed out before the SOCKS5 tunnel could start"); + } + NSDictionary *startOptions = @{ + FBSocks5OptionStartupDeadline: @(deadline.timeIntervalSinceReferenceDate), + }; + NSError *startError; + if (![(NETunnelProviderSession *)manager.connection startVPNTunnelWithOptions:startOptions + andReturnError:&startError]) { + return FBSocks5Fail(error, FBSocks5TunnelManagerErrorInternal, + [NSString stringWithFormat:@"Cannot start the SOCKS5 tunnel: %@", + startError.localizedDescription]); + } + // The provider now validates the proxy before it reports startup, so a rejected start comes + // back as the session dropping to disconnected. Stop on that rather than spinning out the + // caller's whole deadline for a tunnel that is never going to come up. + __block BOOL startRejected = NO; + __block BOOL leftIdle = NO; + BOOL connected = [[[[FBRunLoopSpinner new] timeout:deadline.timeIntervalSinceNow] interval:0.2] spinUntilTrue:^BOOL{ + NEVPNStatus currentStatus = manager.connection.status; + if (currentStatus == NEVPNStatusConnected) { + return YES; + } + // The session can still read as disconnected for an instant after startVPNTunnel, so only + // treat that as terminal once it has actually entered a starting state. Reasserting still + // counts as in-flight; only a settled stop is terminal. + if (currentStatus != NEVPNStatusDisconnected && currentStatus != NEVPNStatusInvalid) { + leftIdle = YES; + } else if (leftIdle) { + startRejected = YES; + return YES; + } + return NO; + }]; + if (!connected || startRejected) { + NEVPNStatus finalStatus = manager.connection.status; + [manager.connection stopVPNTunnel]; + return FBSocks5Fail(error, FBSocks5TunnelManagerErrorTimeout, + startRejected + ? [NSString stringWithFormat: + @"The SOCKS5 tunnel stopped right after starting. The proxy at %@:%lu " + "is unreachable, is not a SOCKS5 proxy, or rejected the credentials", + uri.host, (unsigned long)uri.port] + : [NSString stringWithFormat: + @"The SOCKS5 tunnel did not connect within %.0fs (status %ld). " + "Check that the proxy at %@:%lu is reachable from the device", + budget, (long)finalStatus, uri.host, (unsigned long)uri.port]); + } + self.activeManager = manager; + [FBLogger logFmt:@"socks5/connect: tunnel connected through %@:%lu", uri.host, (unsigned long)uri.port]; + return YES; +} + +- (nullable NSDictionary *)disconnectWithError:(NSError **)error +{ + NSDictionary *unavailableSnapshot = + FBSocks5TunnelManagerDisconnectedStatsIfExtensionUnavailable(NSBundle.mainBundle); + if (nil != unavailableSnapshot) { + return unavailableSnapshot; + } + __block NSDictionary *snapshot = nil; + __block NSError *localError = nil; + [self.lifecycle performLocked:^{ + if ([self lockedDisconnectWithError:&localError]) { + snapshot = [self lockedStatsDictionary]; + } + }]; + if (nil == snapshot && nil != error) { + *error = localError; + } + return snapshot; +} + +- (BOOL)lockedDisconnectWithError:(NSError **)error +{ + // Without this a disconnect racing a timed-out connect could load nothing, or load a duplicate + // that is not the tunnel actually running, and report success while the VPN stayed up. + if (![self.lifecycle fencePendingSaveWithDeadline:nil error:error]) { + return NO; + } + NSArray *managers; + if (![self loadAllManagers:&managers deadline:nil error:error]) { + return NO; + } + NETunnelProviderManager *manager = [self ownManagerIn:managers] ?: self.activeManager; + if (nil == manager) { + return YES; + } + self.activeManager = manager; + NEVPNStatus status = manager.connection.status; + if (status == NEVPNStatusDisconnected || status == NEVPNStatusInvalid) { + return YES; + } + [manager.connection stopVPNTunnel]; + if (![self waitUntilStopped:manager deadline:nil]) { + return FBSocks5Fail(error, FBSocks5TunnelManagerErrorTimeout, + @"Timed out stopping the SOCKS5 tunnel"); + } + [FBLogger log:@"socks5/disconnect: tunnel stopped"]; + return YES; +} + +// Serialized like the mutating operations so a caller never observes the tunnel halfway through +// a stop/save/reload/start sequence. +- (NSDictionary *)statsDictionary +{ + __block NSDictionary *snapshot = nil; + [self.lifecycle performLocked:^{ + snapshot = [self lockedStatsDictionary]; + }]; + return snapshot; +} + +- (NSDictionary *)lockedStatsDictionary +{ + return [self lockedStatsDictionaryWithDeadline:nil]; +} + +- (NSDictionary *)lockedStatsDictionaryWithDeadline:(nullable NSDate *)deadline +{ + NSMutableDictionary *stats = FBSocks5DisconnectedStats(); + NETunnelProviderManager *manager = self.activeManager; + if (nil == manager) { + // Adopt a tunnel that survived a WDA restart (the configuration persists per install). + NSArray *managers; + if ([self loadAllManagers:&managers deadline:nil error:nil]) { + manager = [self ownManagerIn:managers]; + self.activeManager = manager; + } + } + if (manager.connection.status != NEVPNStatusConnected) { + return stats.copy; + } + stats[FBSocks5StatsKeyConnected] = @YES; + NETunnelProviderProtocol *protocol = (NETunnelProviderProtocol *)manager.protocolConfiguration; + if ([protocol isKindOfClass:NETunnelProviderProtocol.class]) { + NSDictionary *config = protocol.providerConfiguration; + stats[FBSocks5StatsKeyHost] = config[FBSocks5KeyHost]; + stats[FBSocks5StatsKeyPort] = config[FBSocks5KeyPort]; + stats[FBSocks5StatsKeyUser] = config[FBSocks5KeyUser]; + } + + // Counters are best-effort: with the caller's budget already exhausted, skip the round trip + // to the extension instead of stretching the response past the documented timeout. + NSTimeInterval statsBudget = FBSocks5RemainingTimeout(deadline, FBSocks5StatsReplyTimeout); + if (statsBudget <= 0) { + return stats.copy; + } + NETunnelProviderSession *session = (NETunnelProviderSession *)manager.connection; + __block NSDictionary *counters = nil; + __block volatile atomic_bool done = false; + NSError *messageError; + BOOL sent = [session sendProviderMessage:(NSData *)[FBSocks5MsgStats dataUsingEncoding:NSUTF8StringEncoding] + returnError:&messageError + responseHandler:^(NSData *responseData) { + if (nil != responseData) { + id parsed = [NSJSONSerialization JSONObjectWithData:responseData + options:(NSJSONReadingOptions)0 + error:nil]; + counters = [parsed isKindOfClass:NSDictionary.class] ? parsed : nil; + } + atomic_store_explicit(&done, true, memory_order_release); + }]; + if (sent) { + [[[[FBRunLoopSpinner new] timeout:statsBudget] interval:0.05] spinUntilTrue:^BOOL{ + return atomic_load_explicit(&done, memory_order_acquire); + }]; + } else { + [FBLogger logFmt:@"socks5/stats: cannot query the tunnel extension: %@", messageError.localizedDescription]; + } + // Counters stay zero when the extension cannot answer in time; do not read the result storage + // unless the acquire observed the callback's release publication. + NSDictionary *completedCounters = atomic_load_explicit(&done, memory_order_acquire) ? counters : nil; + for (NSString *key in @[FBSocks5StatsKeyRxBytes, FBSocks5StatsKeyTxBytes, + FBSocks5StatsKeyRxPackets, FBSocks5StatsKeyTxPackets]) { + NSNumber *value = completedCounters[key]; + if ([value isKindOfClass:NSNumber.class]) { + stats[key] = value; + } + } + return stats.copy; +} + +@end + +#endif diff --git a/WebDriverAgentLib/Utilities/FBSocks5TunnelProtocol.h b/WebDriverAgentLib/Utilities/FBSocks5TunnelProtocol.h new file mode 100644 index 0000000000..92c25eca0c --- /dev/null +++ b/WebDriverAgentLib/Utilities/FBSocks5TunnelProtocol.h @@ -0,0 +1,114 @@ +/** + * Copyright (c) 2026-present, Droidrun. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. + */ + +#import + +NS_ASSUME_NONNULL_BEGIN + +/** + Contract shared between WebDriverAgent and the WebDriverAgentTunnel packet tunnel extension. + + This file is compiled into both WebDriverAgentLib and the WebDriverAgentTunnel extension + target, so it must only depend on Foundation. + + The host configures the tunnel by storing a dictionary keyed by the FBSocks5Key* constants + in NETunnelProviderProtocol.providerConfiguration; the extension turns that dictionary into + a hev-socks5-tunnel YAML config via FBSocks5HevConfigFromProviderConfiguration. Runtime + traffic counters are queried through sendProviderMessage with the FBSocks5MsgStats verb; + the extension answers with UTF-8 JSON keyed by the FBSocks5StatsKey* constants. + */ + +/** providerConfiguration keys */ +extern NSString *const FBSocks5KeyHost; +extern NSString *const FBSocks5KeyPort; +extern NSString *const FBSocks5KeyUser; +extern NSString *const FBSocks5KeyPass; +/** @YES when DNS must be resolved through the proxy (socks5h://). */ +extern NSString *const FBSocks5KeyRemoteDNS; +/** Remote WDA controller IP to exclude from the full-tunnel routes. */ +extern NSString *const FBSocks5KeyControlAddress; + +/** startVPNTunnelWithOptions key carrying the host's absolute whole-flow deadline. */ +extern NSString *const FBSocks5OptionStartupDeadline; +/** Startup budget used when the tunnel is launched outside the WDA connect route. */ +extern const NSTimeInterval FBSocks5DefaultStartupTimeout; + +/** + Resolves the provider's absolute startup deadline from host-supplied options, or applies the + default startup timeout when no valid deadline was supplied. + */ +NSDate *FBSocks5TunnelStartupDeadlineFromOptions(NSDictionary *_Nullable options, + NSDate *now); +/** Returns the nonnegative time remaining before `deadline`, capped to one operation's limit. */ +NSTimeInterval FBSocks5TunnelRemainingStartupTime(NSDate *deadline, NSDate *now, NSTimeInterval cap); +/** Validates the RFC 1929 username/password sub-negotiation reply. */ +BOOL FBSocks5TunnelUsernamePasswordAuthReplySucceeded(uint8_t version, uint8_t status); +/** Returns whether a proxy-selected authentication method was present in the client greeting. */ +BOOL FBSocks5TunnelAuthenticationMethodWasOffered(uint8_t method, BOOL hasCredentials); +/** Normalizes an IPv4/IPv6 literal while preserving a valid IPv6 scope identifier. */ +NSString *_Nullable FBSocks5NormalizedIPAddress(NSString *_Nullable address, BOOL *isIPv6); +/** Splits a scoped IPv6 literal into its address and numeric interface scope. */ +BOOL FBSocks5ParseIPv6Address(NSString *address, NSString **literal, NSUInteger *scopeID); +/** Appends a resolver-provided numeric interface scope to an IPv6 literal. */ +NSString *FBSocks5IPv6AddressWithScope(NSString *literal, NSUInteger scopeID); + +/** sendProviderMessage verb (UTF-8 encoded) asking the extension for traffic counters. */ +extern NSString *const FBSocks5MsgStats; + +/** Keys of the JSON stats reply (also used in the /mobilerun/socks5/stats response). */ +extern NSString *const FBSocks5StatsKeyConnected; +extern NSString *const FBSocks5StatsKeyHost; +extern NSString *const FBSocks5StatsKeyPort; +extern NSString *const FBSocks5StatsKeyUser; +extern NSString *const FBSocks5StatsKeyRxBytes; +extern NSString *const FBSocks5StatsKeyTxBytes; +extern NSString *const FBSocks5StatsKeyRxPackets; +extern NSString *const FBSocks5StatsKeyTxPackets; + +/** Default SOCKS5 port when the URI does not specify one. */ +extern const NSUInteger FBSocks5DefaultPort; + +/** The utun interface address; must match between NEPacketTunnelNetworkSettings and the + hev config, so both come from these constants. */ +extern NSString *const FBSocks5TunnelIPv4Address; +extern NSString *const FBSocks5TunnelIPv4Netmask; +/** Link-local-ish ULA the tunnel claims so IPv6 cannot bypass it; see FBTunnelPacketProvider. */ +extern NSString *const FBSocks5TunnelIPv6Address; +extern const NSUInteger FBSocks5TunnelIPv6PrefixLength; +/** Synthetic resolver address hev's mapdns listens on (socks5h mode); used as the tunnel's + DNS server so queries are hijacked into hostname-preserving CONNECTs. */ +extern NSString *const FBSocks5TunnelMapDNSAddress; +/** Interface MTU shared by NEPacketTunnelNetworkSettings and the hev config. */ +extern const NSUInteger FBSocks5TunnelMTU; + +/** + Renders the hev-socks5-tunnel YAML config for a providerConfiguration dictionary. + + @param providerConfiguration dictionary keyed by the FBSocks5Key* constants + @return the YAML config string consumed by hev_socks5_tunnel_main_from_str + */ +NSString *FBSocks5HevConfigFromProviderConfiguration(NSDictionary *providerConfiguration); + +/** + Coordinates packet-tunnel startup with stop requests so a stop cannot complete while startup + or network-settings cleanup is still pending. + */ +@interface FBSocks5TunnelStartupFence : NSObject + +@property (nonatomic, readonly, getter=isStopping) BOOL stopping; + +- (void)beginStartupWithCompletion:(void (^)(NSError *_Nullable error))completion; +- (BOOL)waitForSignal:(dispatch_semaphore_t)signal beforeDate:(NSDate *)deadline; +- (BOOL)performStartupActionIfNotStopping:(dispatch_block_t)block; +- (BOOL)requestStopWithCompletion:(dispatch_block_t)completion; +- (BOOL)finishStartupWithError:(nullable NSError *)error stoppedError:(NSError *)stoppedError; +- (void)finishStopCleanup; + +@end + +NS_ASSUME_NONNULL_END diff --git a/WebDriverAgentLib/Utilities/FBSocks5TunnelProtocol.m b/WebDriverAgentLib/Utilities/FBSocks5TunnelProtocol.m new file mode 100644 index 0000000000..fb854d934d --- /dev/null +++ b/WebDriverAgentLib/Utilities/FBSocks5TunnelProtocol.m @@ -0,0 +1,305 @@ +/** + * Copyright (c) 2026-present, Droidrun. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. + */ + +#import "FBSocks5TunnelProtocol.h" + +#include +#include +#include +#include + +NSString *const FBSocks5KeyHost = @"host"; +NSString *const FBSocks5KeyPort = @"port"; +NSString *const FBSocks5KeyUser = @"user"; +NSString *const FBSocks5KeyPass = @"pass"; +NSString *const FBSocks5KeyRemoteDNS = @"remoteDNS"; +NSString *const FBSocks5KeyControlAddress = @"controlAddress"; +NSString *const FBSocks5OptionStartupDeadline = @"startupDeadline"; + +const NSTimeInterval FBSocks5DefaultStartupTimeout = 30.0; + +NSString *const FBSocks5MsgStats = @"stats"; + +NSString *const FBSocks5StatsKeyConnected = @"connected"; +NSString *const FBSocks5StatsKeyHost = @"host"; +NSString *const FBSocks5StatsKeyPort = @"port"; +NSString *const FBSocks5StatsKeyUser = @"user"; +NSString *const FBSocks5StatsKeyRxBytes = @"rxBytes"; +NSString *const FBSocks5StatsKeyTxBytes = @"txBytes"; +NSString *const FBSocks5StatsKeyRxPackets = @"rxPackets"; +NSString *const FBSocks5StatsKeyTxPackets = @"txPackets"; + +const NSUInteger FBSocks5DefaultPort = 1080; + +NSString *const FBSocks5TunnelIPv4Address = @"198.18.0.1"; +NSString *const FBSocks5TunnelIPv4Netmask = @"255.255.255.0"; +// ULA (fc00::/7) so it cannot collide with a real global address on the device. +NSString *const FBSocks5TunnelIPv6Address = @"fd6d:6f62:696c::1"; +const NSUInteger FBSocks5TunnelIPv6PrefixLength = 64; +NSString *const FBSocks5TunnelMapDNSAddress = @"198.18.0.2"; +const NSUInteger FBSocks5TunnelMTU = 8500; + +NSDate *FBSocks5TunnelStartupDeadlineFromOptions(NSDictionary *_Nullable options, + NSDate *now) +{ + NSNumber *encodedDeadline = [options[FBSocks5OptionStartupDeadline] isKindOfClass:NSNumber.class] + ? (NSNumber *)options[FBSocks5OptionStartupDeadline] + : nil; + if (nil != encodedDeadline && isfinite(encodedDeadline.doubleValue)) { + return [NSDate dateWithTimeIntervalSinceReferenceDate:encodedDeadline.doubleValue]; + } + return [now dateByAddingTimeInterval:FBSocks5DefaultStartupTimeout]; +} + +NSTimeInterval FBSocks5TunnelRemainingStartupTime(NSDate *deadline, NSDate *now, NSTimeInterval cap) +{ + return MAX(0.0, MIN(cap, [deadline timeIntervalSinceDate:now])); +} + +BOOL FBSocks5TunnelUsernamePasswordAuthReplySucceeded(uint8_t version, uint8_t status) +{ + return 0x01 == version && 0x00 == status; +} + +BOOL FBSocks5TunnelAuthenticationMethodWasOffered(uint8_t method, BOOL hasCredentials) +{ + return 0x00 == method || (hasCredentials && 0x02 == method); +} + +BOOL FBSocks5ParseIPv6Address(NSString *address, NSString **literal, NSUInteger *scopeID) +{ + NSRange separator = [address rangeOfString:@"%" options:NSBackwardsSearch]; + NSString *addressPart = NSNotFound == separator.location + ? address + : [address substringToIndex:separator.location]; + NSUInteger parsedScope = 0; + if (NSNotFound != separator.location) { + NSString *zone = [address substringFromIndex:NSMaxRange(separator)]; + if (0 == zone.length) { + return NO; + } + errno = 0; + char *end = NULL; + unsigned long numericScope = strtoul(zone.UTF8String, &end, 10); + if (0 == errno && NULL != end && '\0' == *end && numericScope > 0 + && numericScope <= UINT32_MAX) { + parsedScope = (NSUInteger)numericScope; + } else { + parsedScope = (NSUInteger)if_nametoindex(zone.UTF8String); + if (0 == parsedScope) { + return NO; + } + } + } + struct in6_addr ipv6; + if (1 != inet_pton(AF_INET6, addressPart.UTF8String, &ipv6)) { + return NO; + } + if (nil != literal) { + *literal = addressPart; + } + if (NULL != scopeID) { + *scopeID = parsedScope; + } + return YES; +} + +NSString *FBSocks5IPv6AddressWithScope(NSString *literal, NSUInteger scopeID) +{ + if (0 == scopeID) { + return literal; + } + char name[IF_NAMESIZE] = {0}; + NSString *zone = NULL != if_indextoname((unsigned int)scopeID, name) + ? [NSString stringWithUTF8String:name] + : [NSString stringWithFormat:@"%lu", (unsigned long)scopeID]; + return [NSString stringWithFormat:@"%@%%%@", literal, zone]; +} + +NSString *_Nullable FBSocks5NormalizedIPAddress(NSString *_Nullable address, BOOL *isIPv6) +{ + if (0 == address.length) { + return nil; + } + NSString *candidate = address; + if (candidate.length >= 2 && [candidate hasPrefix:@"["] && [candidate hasSuffix:@"]"]) { + candidate = [candidate substringWithRange:NSMakeRange(1, candidate.length - 2)]; + } + struct in_addr ipv4; + if (NSNotFound == [candidate rangeOfString:@"%"].location + && 1 == inet_pton(AF_INET, candidate.UTF8String, &ipv4)) { + if (NULL != isIPv6) { + *isIPv6 = NO; + } + return candidate; + } + NSString *literal = nil; + NSUInteger scopeID = 0; + if (FBSocks5ParseIPv6Address(candidate, &literal, &scopeID)) { + if (NULL != isIPv6) { + *isIPv6 = YES; + } + return FBSocks5IPv6AddressWithScope(literal, scopeID); + } + return nil; +} + +@interface FBSocks5TunnelStartupFence () +@property (nonatomic) BOOL stopping; +@property (nonatomic) BOOL startupInProgress; +@property (nonatomic) BOOL stopCleanupInProgress; +@property (nonatomic, copy, nullable) void (^startupCompletion)(NSError *_Nullable error); +@property (nonatomic, strong) NSMutableArray *stopCompletions; +@end + +@implementation FBSocks5TunnelStartupFence + +- (BOOL)isStopping +{ + @synchronized (self) { + return _stopping; + } +} + +- (void)beginStartupWithCompletion:(void (^)(NSError *_Nullable))completion +{ + @synchronized (self) { + self.startupInProgress = YES; + self.startupCompletion = completion; + } +} + +- (BOOL)waitForSignal:(dispatch_semaphore_t)signal beforeDate:(NSDate *)deadline +{ + static const NSTimeInterval pollInterval = 0.05; + while (YES) { + if (self.isStopping) { + return NO; + } + NSTimeInterval remaining = deadline.timeIntervalSinceNow; + if (remaining <= 0) { + return NO; + } + dispatch_time_t waitUntil = dispatch_time(DISPATCH_TIME_NOW, + (int64_t)(MIN(pollInterval, remaining) * NSEC_PER_SEC)); + if (0 == dispatch_semaphore_wait(signal, waitUntil)) { + return !self.isStopping; + } + } +} + +- (BOOL)performStartupActionIfNotStopping:(dispatch_block_t)block +{ + @synchronized (self) { + if (_stopping) { + return NO; + } + block(); + return YES; + } +} + +- (BOOL)requestStopWithCompletion:(dispatch_block_t)completion +{ + @synchronized (self) { + _stopping = YES; + if (nil == self.stopCompletions) { + self.stopCompletions = [NSMutableArray array]; + } + [self.stopCompletions addObject:[completion copy]]; + if (self.startupInProgress || self.stopCleanupInProgress) { + return NO; + } + self.stopCleanupInProgress = YES; + return YES; + } +} + +- (BOOL)finishStartupWithError:(nullable NSError *)error stoppedError:(NSError *)stoppedError +{ + void (^completion)(NSError *_Nullable) = nil; + NSError *completionError = nil; + BOOL shouldStartCleanup = NO; + @synchronized (self) { + if (!self.startupInProgress) { + return NO; + } + self.startupInProgress = NO; + completion = self.startupCompletion; + self.startupCompletion = nil; + completionError = _stopping && nil == error ? stoppedError : error; + if (_stopping && !self.stopCleanupInProgress && self.stopCompletions.count > 0) { + self.stopCleanupInProgress = YES; + shouldStartCleanup = YES; + } + } + if (nil != completion) { + completion(completionError); + } + return shouldStartCleanup; +} + +- (void)finishStopCleanup +{ + NSArray *completions; + @synchronized (self) { + completions = self.stopCompletions.copy; + [self.stopCompletions removeAllObjects]; + self.stopCleanupInProgress = NO; + } + for (dispatch_block_t completion in completions) { + completion(); + } +} + +@end + +// YAML single-quoted scalar: the only escape is doubling embedded single quotes. +static NSString *FBSocks5YAMLQuote(NSString *value) +{ + return [NSString stringWithFormat:@"'%@'", [value stringByReplacingOccurrencesOfString:@"'" withString:@"''"]]; +} + +NSString *FBSocks5HevConfigFromProviderConfiguration(NSDictionary *providerConfiguration) +{ + NSString *host = providerConfiguration[FBSocks5KeyHost]; + NSUInteger port = [providerConfiguration[FBSocks5KeyPort] unsignedIntegerValue]; + NSString *user = providerConfiguration[FBSocks5KeyUser]; + NSString *pass = providerConfiguration[FBSocks5KeyPass]; + BOOL remoteDNS = [providerConfiguration[FBSocks5KeyRemoteDNS] boolValue]; + + NSMutableString *yaml = [NSMutableString string]; + [yaml appendString:@"tunnel:\n"]; + [yaml appendFormat:@" mtu: %lu\n", (unsigned long)FBSocks5TunnelMTU]; + [yaml appendFormat:@" ipv4: %@\n", FBSocks5TunnelIPv4Address]; + [yaml appendString:@"socks5:\n"]; + [yaml appendFormat:@" address: %@\n", FBSocks5YAMLQuote(host)]; + [yaml appendFormat:@" port: %lu\n", (unsigned long)port]; + [yaml appendString:@" udp: 'udp'\n"]; + if (user.length > 0) { + [yaml appendFormat:@" username: %@\n", FBSocks5YAMLQuote(user)]; + } + if (pass.length > 0) { + [yaml appendFormat:@" password: %@\n", FBSocks5YAMLQuote(pass)]; + } + if (remoteDNS) { + // mapdns answers DNS queries with synthetic IPs from the 100.64.0.0/10 pool and + // restores the original hostname when those IPs are connected to, so the proxy + // receives CONNECT-by-hostname (socks5h semantics) without needing UDP support. + [yaml appendString:@"mapdns:\n"]; + [yaml appendFormat:@" address: %@\n", FBSocks5TunnelMapDNSAddress]; + [yaml appendString:@" port: 53\n"]; + [yaml appendString:@" network: 100.64.0.0\n"]; + [yaml appendString:@" netmask: 255.192.0.0\n"]; + [yaml appendString:@" cache-size: 10000\n"]; + } + [yaml appendString:@"misc:\n"]; + [yaml appendString:@" log-file: stderr\n"]; + [yaml appendString:@" log-level: warn\n"]; + return yaml.copy; +} diff --git a/WebDriverAgentLib/Utilities/FBSocks5URI.h b/WebDriverAgentLib/Utilities/FBSocks5URI.h new file mode 100644 index 0000000000..5471c296f4 --- /dev/null +++ b/WebDriverAgentLib/Utilities/FBSocks5URI.h @@ -0,0 +1,51 @@ +/** + * Copyright (c) 2026-present, Droidrun. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. + */ + +#import + +NS_ASSUME_NONNULL_BEGIN + +/** + A parsed socks5:// or socks5h:// proxy URI as accepted by POST /mobilerun/socks5/connect. + + socks5h means hostnames are resolved remotely through the proxy (curl semantics); with + plain socks5 the device resolves them itself. Only depends on Foundation so it stays + unit-testable and compiles for tvOS. + */ +@interface FBSocks5URI : NSObject + +/** Proxy host: DNS name, IPv4, or IPv6 literal (without brackets). */ +@property (nonatomic, readonly, copy) NSString *host; +/** Proxy port; FBSocks5DefaultPort when the URI does not specify one. */ +@property (nonatomic, readonly) NSUInteger port; +/** Percent-decoded username, or nil when the URI carries no credentials. */ +@property (nonatomic, readonly, copy, nullable) NSString *user; +/** Percent-decoded password, or nil when the URI carries no credentials. */ +@property (nonatomic, readonly, copy, nullable) NSString *pass; +/** YES for socks5h:// (remote DNS resolution through the proxy). */ +@property (nonatomic, readonly) BOOL remoteDNS; + +/** + Parses and validates a SOCKS5 proxy URI. + + @param uriString the URI, e.g. socks5h://user:pass@proxy.example.com:1080 + @param error populated with a human-readable reason when parsing fails + @return the parsed URI, or nil when the string is not a valid socks5(h) URI + */ ++ (nullable instancetype)parse:(nullable NSString *)uriString error:(NSError **)error; + +/** The NETunnelProviderProtocol.providerConfiguration payload for this URI, + keyed by the FBSocks5Key* constants (credentials omitted when absent). */ +- (NSDictionary *)providerConfiguration; + +/** Adds the active WDA controller IP to the provider configuration when available. */ +- (NSDictionary *)providerConfigurationWithControlAddress:(nullable NSString *)controlAddress; + +@end + +NS_ASSUME_NONNULL_END diff --git a/WebDriverAgentLib/Utilities/FBSocks5URI.m b/WebDriverAgentLib/Utilities/FBSocks5URI.m new file mode 100644 index 0000000000..4758aeb371 --- /dev/null +++ b/WebDriverAgentLib/Utilities/FBSocks5URI.m @@ -0,0 +1,149 @@ +/** + * Copyright (c) 2026-present, Droidrun. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. + */ + +#import "FBSocks5URI.h" + +#import "FBSocks5TunnelProtocol.h" + +static NSString *const FBSocks5URIErrorDomain = @"com.facebook.WebDriverAgent.socks5"; + +static BOOL FBSocks5URISetError(NSError **error, NSString *message) +{ + if (nil != error) { + *error = [NSError errorWithDomain:FBSocks5URIErrorDomain + code:1 + userInfo:@{NSLocalizedDescriptionKey: message}]; + } + return NO; +} + +static BOOL FBSocks5URIHasExplicitPort(NSString *uriString) +{ + NSRange schemeSeparator = [uriString rangeOfString:@"://"]; + if (NSNotFound == schemeSeparator.location) { + return NO; + } + NSUInteger authorityStart = NSMaxRange(schemeSeparator); + NSCharacterSet *authorityTerminators = [NSCharacterSet characterSetWithCharactersInString:@"/?#"]; + NSRange tail = NSMakeRange(authorityStart, uriString.length - authorityStart); + NSRange authorityEnd = [uriString rangeOfCharacterFromSet:authorityTerminators + options:(NSStringCompareOptions)0 + range:tail]; + NSUInteger authorityLength = NSNotFound == authorityEnd.location + ? uriString.length - authorityStart + : authorityEnd.location - authorityStart; + NSString *authority = [uriString substringWithRange:NSMakeRange(authorityStart, authorityLength)]; + NSRange userInfoSeparator = [authority rangeOfString:@"@" options:NSBackwardsSearch]; + NSString *hostAndPort = NSNotFound == userInfoSeparator.location + ? authority + : [authority substringFromIndex:NSMaxRange(userInfoSeparator)]; + if ([hostAndPort hasPrefix:@"["]) { + NSRange closingBracket = [hostAndPort rangeOfString:@"]"]; + return NSNotFound != closingBracket.location + && NSMaxRange(closingBracket) < hostAndPort.length + && ':' == [hostAndPort characterAtIndex:NSMaxRange(closingBracket)]; + } + return NSNotFound != [hostAndPort rangeOfString:@":" options:NSBackwardsSearch].location; +} + +@interface FBSocks5URI () +@property (nonatomic, copy) NSString *host; +@property (nonatomic) NSUInteger port; +@property (nonatomic, copy, nullable) NSString *user; +@property (nonatomic, copy, nullable) NSString *pass; +@property (nonatomic) BOOL remoteDNS; +@end + +@implementation FBSocks5URI + ++ (nullable instancetype)parse:(nullable NSString *)uriString error:(NSError **)error +{ + if (0 == uriString.length) { + FBSocks5URISetError(error, @"The socks5 URI must not be empty"); + return nil; + } + NSURLComponents *components = [NSURLComponents componentsWithString:(NSString *)uriString]; + NSString *scheme = components.scheme.lowercaseString; + BOOL remoteDNS = [scheme isEqualToString:@"socks5h"]; + if (!remoteDNS && ![scheme isEqualToString:@"socks5"]) { + FBSocks5URISetError(error, + @"The value is not a valid SOCKS5 proxy URI. Expected socks5://[user:pass@]host[:port] or socks5h://… for remote DNS resolution"); + return nil; + } + NSString *host = components.host; + // Depending on the OS version, -[NSURLComponents host] may keep the brackets around + // IPv6 literals; the bare address is wanted everywhere downstream. + if (host.length >= 2 && [host hasPrefix:@"["] && [host hasSuffix:@"]"]) { + host = [host substringWithRange:NSMakeRange(1, host.length - 2)]; + } + if (0 == host.length) { + FBSocks5URISetError(error, @"The socks5 URI must include a proxy host"); + return nil; + } + NSUInteger port = FBSocks5DefaultPort; + if (nil == components.port && FBSocks5URIHasExplicitPort((NSString *)uriString)) { + FBSocks5URISetError(error, @"The socks5 URI contains an invalid proxy port"); + return nil; + } + if (nil != components.port) { + NSInteger rawPort = components.port.integerValue; + if (rawPort <= 0 || rawPort > UINT16_MAX) { + FBSocks5URISetError(error, [NSString stringWithFormat:@"The socks5 proxy port %@ is out of range (1-65535)", components.port]); + return nil; + } + port = (NSUInteger)rawPort; + } + + NSString *user = components.user.length > 0 ? components.user : nil; + NSString *pass = components.password.length > 0 ? components.password : nil; + if ((nil != components.user || nil != components.password) && (nil == user || nil == pass)) { + FBSocks5URISetError(error, @"The socks5 URI must include both a username and password, or neither"); + return nil; + } + if ([user lengthOfBytesUsingEncoding:NSUTF8StringEncoding] > UINT8_MAX + || [pass lengthOfBytesUsingEncoding:NSUTF8StringEncoding] > UINT8_MAX) { + FBSocks5URISetError(error, @"The socks5 username and password must each be at most 255 UTF-8 bytes"); + return nil; + } + NSMutableCharacterSet *forbiddenCredentialCharacters = [NSCharacterSet.controlCharacterSet mutableCopy]; + [forbiddenCredentialCharacters formUnionWithCharacterSet:NSCharacterSet.newlineCharacterSet]; + if ((nil != user && [user rangeOfCharacterFromSet:forbiddenCredentialCharacters].location != NSNotFound) + || (nil != pass && [pass rangeOfCharacterFromSet:forbiddenCredentialCharacters].location != NSNotFound)) { + FBSocks5URISetError(error, @"The socks5 username and password must not contain control characters"); + return nil; + } + + FBSocks5URI *uri = [[self alloc] init]; + uri.host = host; + uri.port = port; + uri.user = user; + uri.pass = pass; + uri.remoteDNS = remoteDNS; + return uri; +} + +- (NSDictionary *)providerConfiguration +{ + return [self providerConfigurationWithControlAddress:nil]; +} + +- (NSDictionary *)providerConfigurationWithControlAddress:(nullable NSString *)controlAddress +{ + NSMutableDictionary *config = [NSMutableDictionary dictionary]; + config[FBSocks5KeyHost] = self.host; + config[FBSocks5KeyPort] = @(self.port); + config[FBSocks5KeyRemoteDNS] = @(self.remoteDNS); + config[FBSocks5KeyUser] = self.user; + config[FBSocks5KeyPass] = self.pass; + if (controlAddress.length > 0) { + config[FBSocks5KeyControlAddress] = controlAddress; + } + return config.copy; +} + +@end diff --git a/WebDriverAgentRunner/WebDriverAgentRunner.entitlements b/WebDriverAgentRunner/WebDriverAgentRunner.entitlements new file mode 100644 index 0000000000..ffab33e018 --- /dev/null +++ b/WebDriverAgentRunner/WebDriverAgentRunner.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.developer.networking.networkextension + + packet-tunnel-provider + + + diff --git a/WebDriverAgentTests/IntegrationTests/FBMobilerunSocks5IntegrationTests.m b/WebDriverAgentTests/IntegrationTests/FBMobilerunSocks5IntegrationTests.m new file mode 100644 index 0000000000..af37068abd --- /dev/null +++ b/WebDriverAgentTests/IntegrationTests/FBMobilerunSocks5IntegrationTests.m @@ -0,0 +1,86 @@ +/** + * Copyright (c) 2026-present, Droidrun. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. + */ + +#import + +#import "FBMobilerunSocks5Commands.h" +#import "FBRoute.h" +#import "FBSocks5TunnelManager.h" +#import "FBSocks5TunnelProtocol.h" +#import "FBSocks5URI.h" + +/** + NetworkExtension packet tunnels cannot run on the Simulator, so these tests pin down the + contract the endpoints must keep there: connect/disconnect surface the unsupported error and + stats always returns the well-formed disconnected payload. The actual tunnel path needs a + real device with paid-team signing (see docs/socks5-tunnel.md). + */ +@interface FBMobilerunSocks5IntegrationTests : XCTestCase +@end + +@implementation FBMobilerunSocks5IntegrationTests + +#if TARGET_OS_SIMULATOR + +- (void)testConnectIsUnsupportedOnSimulator +{ + FBSocks5URI *uri = [FBSocks5URI parse:@"socks5h://user:pass@1.2.3.4:1080" error:nil]; + XCTAssertNotNil(uri); + NSError *error; + BOOL connected = [FBSocks5TunnelManager.sharedInstance connectWithURI:(FBSocks5URI *)uri + timeout:5 + consentButtonLabels:nil + error:&error]; + XCTAssertFalse(connected); + XCTAssertEqualObjects(error.domain, FBSocks5TunnelManagerErrorDomain); + XCTAssertEqual(error.code, FBSocks5TunnelManagerErrorUnsupported); +} + +- (void)testDisconnectIsUnsupportedOnSimulator +{ + NSError *error; + XCTAssertFalse([FBSocks5TunnelManager.sharedInstance disconnectWithError:&error]); + XCTAssertEqualObjects(error.domain, FBSocks5TunnelManagerErrorDomain); + XCTAssertEqual(error.code, FBSocks5TunnelManagerErrorUnsupported); +} + +#endif + +- (void)testStatsReportDisconnectedShape +{ + NSDictionary *stats = [FBSocks5TunnelManager.sharedInstance statsDictionary]; + XCTAssertEqualObjects(stats[FBSocks5StatsKeyConnected], @NO); + for (NSString *key in @[FBSocks5StatsKeyRxBytes, FBSocks5StatsKeyTxBytes, + FBSocks5StatsKeyRxPackets, FBSocks5StatsKeyTxPackets]) { + XCTAssertEqualObjects(stats[key], @0, @"missing zero counter for %@", key); + } + XCTAssertNil(stats[FBSocks5StatsKeyHost]); + XCTAssertNil(stats[FBSocks5StatsKeyPort]); + XCTAssertNil(stats[FBSocks5StatsKeyUser]); +} + +- (void)testRoutesAreRegistered +{ + NSArray *routes = [FBMobilerunSocks5Commands routes]; + XCTAssertEqual(routes.count, 6); + NSMutableSet *seen = [NSMutableSet set]; + for (FBRoute *route in routes) { + if ([route.path hasSuffix:@"/mobilerun/socks5/connect"] || [route.path hasSuffix:@"/mobilerun/socks5/disconnect"]) { + XCTAssertEqualObjects(route.verb, @"POST"); + } else if ([route.path hasSuffix:@"/mobilerun/socks5/stats"]) { + XCTAssertEqualObjects(route.verb, @"GET"); + } else { + XCTFail(@"unexpected route %@ %@", route.verb, route.path); + } + [seen addObject:route.path]; + } + // Each endpoint must be reachable both with and without a session prefix. + XCTAssertEqual(seen.count, 6); +} + +@end diff --git a/WebDriverAgentTests/UnitTests/FBRouteTests.m b/WebDriverAgentTests/UnitTests/FBRouteTests.m index f323abc1e1..0ef73f5f6d 100644 --- a/WebDriverAgentTests/UnitTests/FBRouteTests.m +++ b/WebDriverAgentTests/UnitTests/FBRouteTests.m @@ -14,6 +14,12 @@ @class RouteResponse; +extern BOOL FBSocks5ConnectTimeoutFromValue(id _Nullable value, NSTimeInterval *timeout); +extern NSArray *FBStandaloneRequestIdentity(NSString *method, + NSString *pathAndQuery, + NSData *body, + NSString *_Nullable clientAddress); + @interface FBHandlerMock : NSObject @property (nonatomic, assign) BOOL didCallSomeSelector; @end @@ -143,6 +149,68 @@ - (void)testStandaloneSurvivesRespondWithBlock XCTAssertTrue(route.isStandalone); } +- (void)testRequestDescriptionRedactsSocks5Credentials +{ + FBRouteRequest *request = [FBRouteRequest + routeRequestWithURL:[NSURL URLWithString:@"/mobilerun/socks5/connect"] + parameters:@{} + arguments:@{ + @"uri": @"socks5://alice:secret@proxy.example.com:1080", + @"timeout": @5, + }]; + + NSString *description = request.description; + XCTAssertFalse([description containsString:@"alice"]); + XCTAssertFalse([description containsString:@"secret"]); + XCTAssertTrue([description containsString:@"proxy.example.com:1080"]); + XCTAssertTrue([description containsString:@"timeout"]); + XCTAssertTrue([description containsString:@"5"]); +} + +- (void)testRequestDescriptionRedactsCredentialsFromMalformedProxyURIs +{ + for (NSString *uriString in @[ + @"socks5x://alice:secret@proxy.example.com:1080", + @" socks5://alice:secret@proxy.example.com:1080", + @"socks5:alice:secret@proxy.example.com", + ]) { + FBRouteRequest *request = [FBRouteRequest + routeRequestWithURL:[NSURL URLWithString:@"/mobilerun/socks5/connect"] + parameters:@{} + arguments:@{@"uri": uriString}]; + + NSString *description = request.description; + XCTAssertFalse([description containsString:@"alice"], @"%@", description); + XCTAssertFalse([description containsString:@"secret"], @"%@", description); + XCTAssertTrue([description containsString:@"proxy.example.com"], @"%@", description); + } +} + +- (void)testSocks5ConnectTimeoutIsFiniteAndBounded +{ + NSTimeInterval timeout = 0; + XCTAssertTrue(FBSocks5ConnectTimeoutFromValue(nil, &timeout)); + XCTAssertEqual(timeout, 30.0); + XCTAssertTrue(FBSocks5ConnectTimeoutFromValue(@300, &timeout)); + XCTAssertEqual(timeout, 300.0); + + for (NSNumber *invalid in @[@0, @(-1), @301, @(1e308), @(INFINITY), @(NAN), @YES]) { + XCTAssertFalse(FBSocks5ConnectTimeoutFromValue(invalid, &timeout), @"%@ should be rejected", invalid); + } + XCTAssertFalse(FBSocks5ConnectTimeoutFromValue(@"30", &timeout)); +} + +- (void)testStandaloneRequestIdentityIncludesControllerAddress +{ + NSData *body = [@"same" dataUsingEncoding:NSUTF8StringEncoding]; + NSArray *first = FBStandaloneRequestIdentity(@"POST", @"/mobilerun/socks5/connect", body, @"192.0.2.10"); + NSArray *second = FBStandaloneRequestIdentity(@"POST", @"/mobilerun/socks5/connect", body, @"192.0.2.11"); + NSArray *sameAsFirst = FBStandaloneRequestIdentity(@"POST", @"/mobilerun/socks5/connect", body, @"192.0.2.10"); + + XCTAssertNotEqualObjects(first, second); + XCTAssertEqualObjects(first, sameAsFirst); +} + + (id)dummyHandler:(FBRouteRequest *)request { return nil; @@ -157,6 +225,7 @@ - (void)testStandaloneSurvivesRespondWithBlock static atomic_bool gControlProbeDone; static atomic_bool gControlProbeRanOffMain; +static NSString *gControlProbeClientAddress; static atomic_bool gAutomationProbeDone; static atomic_bool gAutomationProbeRanOnMain; static atomic_int gSpinningProbeDepth; @@ -164,6 +233,9 @@ - (void)testStandaloneSurvivesRespondWithBlock static atomic_int gSpinningProbeCompletions; @interface FBWebServer (DispatchTests) ++ (dispatch_queue_t)automationFunnelQueue; ++ (BOOL)performAutomationBlockOnMainQueue:(dispatch_block_t)block + beforeDate:(NSDate *)deadline; - (void)registerRouteHandlers:(NSArray *)commandHandlerClasses; - (void)registerServerKeyRouteHandlers; - (FBHTTPServer *)server; @@ -184,6 +256,7 @@ + (NSArray *)routes { return @[ [[FBRoute GET:@"/probe/control"].withoutSession.standalone respondWithBlock:^ id (FBRouteRequest *request) { + gControlProbeClientAddress = request.clientAddress; atomic_store(&gControlProbeRanOffMain, !NSThread.isMainThread); atomic_store(&gControlProbeDone, true); return FBResponseWithOK(); @@ -225,6 +298,7 @@ - (void)setUp [super setUp]; atomic_store(&gControlProbeDone, false); atomic_store(&gControlProbeRanOffMain, false); + gControlProbeClientAddress = nil; atomic_store(&gAutomationProbeDone, false); atomic_store(&gAutomationProbeRanOnMain, false); atomic_store(&gSpinningProbeDepth, 0); @@ -312,6 +386,7 @@ - (void)testControlRouteRespondsWhileMainThreadIsBusy } XCTAssertTrue(atomic_load(&gControlProbeDone)); XCTAssertTrue(atomic_load(&gControlProbeRanOffMain)); + XCTAssertEqualObjects(gControlProbeClientAddress, @"127.0.0.1"); } - (void)testAutomationRouteRunsOnMainQueue @@ -370,6 +445,88 @@ - (void)testAutomationRequestsDoNotNestInsideRunLoopSpin XCTAssertEqual(atomic_load(&gSpinningProbeMaxDepth), 1, @"a second automation request must never nest inside the first"); } +- (void)testAutomationFunnelDeadlineCancelsQueuedBlock +{ + dispatch_semaphore_t funnelEntered = dispatch_semaphore_create(0); + dispatch_semaphore_t releaseFunnel = dispatch_semaphore_create(0); + dispatch_async(FBWebServer.automationFunnelQueue, ^{ + dispatch_semaphore_signal(funnelEntered); + dispatch_semaphore_wait(releaseFunnel, DISPATCH_TIME_FOREVER); + }); + XCTAssertEqual(dispatch_semaphore_wait(funnelEntered, dispatch_time(DISPATCH_TIME_NOW, NSEC_PER_SEC)), 0L); + + __block volatile atomic_bool blockRan; + atomic_init(&blockRan, false); + __block BOOL acquired = YES; + __block NSTimeInterval elapsed = 0; + XCTestExpectation *returned = [self expectationWithDescription:@"deadline-aware funnel acquisition returned"]; + dispatch_async(dispatch_get_global_queue(QOS_CLASS_DEFAULT, 0), ^{ + NSDate *startedAt = NSDate.date; + acquired = [FBWebServer performAutomationBlockOnMainQueue:^{ + atomic_store(&blockRan, true); + } beforeDate:[NSDate dateWithTimeIntervalSinceNow:0.1]]; + elapsed = -startedAt.timeIntervalSinceNow; + [returned fulfill]; + }); + + [self waitForExpectations:@[returned] timeout:1.0]; + XCTAssertFalse(acquired); + XCTAssertLessThan(elapsed, 0.75); + XCTAssertFalse(atomic_load(&blockRan)); + + XCTestExpectation *funnelDrained = [self expectationWithDescription:@"cancelled funnel block drained"]; + dispatch_semaphore_signal(releaseFunnel); + dispatch_async(FBWebServer.automationFunnelQueue, ^{ + [funnelDrained fulfill]; + }); + [self waitForExpectations:@[funnelDrained] timeout:1.0]; + XCTAssertFalse(atomic_load(&blockRan), @"an expired queued block must not execute later"); +} + +- (void)testAutomationFunnelRethrowsExceptionOnCallerQueue +{ + NSException *expectedException = [NSException exceptionWithName:@"FBExpectedAutomationException" + reason:@"synthetic XCUI failure" + userInfo:nil]; + __block NSException *caughtException = nil; + XCTestExpectation *returned = [self expectationWithDescription:@"automation exception returned to caller queue"]; + dispatch_async(dispatch_get_global_queue(QOS_CLASS_DEFAULT, 0), ^{ + @try { + [FBWebServer performAutomationBlockOnMainQueue:^{ + @throw expectedException; + }]; + } @catch (NSException *exception) { + caughtException = exception; + } + [returned fulfill]; + }); + + [self waitForExpectations:@[returned] timeout:2.0]; + XCTAssertEqual(caughtException, expectedException); +} + +- (void)testDeadlineAutomationFunnelRethrowsExceptionOnCallerQueue +{ + NSException *expectedException = [NSException exceptionWithName:@"FBExpectedDeadlineAutomationException" + reason:@"synthetic consent query failure" + userInfo:nil]; + __block NSException *caughtException = nil; + XCTestExpectation *returned = [self expectationWithDescription:@"deadline automation exception returned to caller queue"]; + dispatch_async(dispatch_get_global_queue(QOS_CLASS_DEFAULT, 0), ^{ + @try { + [FBWebServer performAutomationBlockOnMainQueue:^{ + @throw expectedException; + } beforeDate:[NSDate dateWithTimeIntervalSinceNow:1.0]]; + } @catch (NSException *exception) { + caughtException = exception; + } + [returned fulfill]; + }); + + [self waitForExpectations:@[returned] timeout:2.0]; + XCTAssertEqual(caughtException, expectedException); +} + @end #import @@ -377,6 +534,9 @@ - (void)testAutomationRequestsDoNotNestInsideRunLoopSpin #import static atomic_int gFramingProbeHits; +static atomic_int gStandaloneBodyProbeHits; +static dispatch_semaphore_t gStandaloneFirstBodyEntered; +static dispatch_semaphore_t gStandaloneReleaseFirstBody; // Exercises FBHTTPServer's HTTP framing defenses with raw socket data that URL-loading APIs // cannot produce: malformed Content-Length values and header blocks that never terminate. @@ -391,6 +551,9 @@ - (void)setUp { [super setUp]; atomic_store(&gFramingProbeHits, 0); + atomic_store(&gStandaloneBodyProbeHits, 0); + gStandaloneFirstBodyEntered = dispatch_semaphore_create(0); + gStandaloneReleaseFirstBody = dispatch_semaphore_create(0); self.server = [FBHTTPServer new]; [self.server handleMethod:@"POST" withPath:@"/framing/probe" block:^(RouteRequest *request, RouteResponse *response) { atomic_fetch_add(&gFramingProbeHits, 1); @@ -403,6 +566,15 @@ - (void)setUp atomic_fetch_add(&gFramingProbeHits, 1); [response respondWithString:@"session-probe-ok"]; }]; + [self.server handleMethod:@"POST" withPath:@"/standalone/body" standalone:YES block:^(RouteRequest *request, RouteResponse *response) { + atomic_fetch_add(&gStandaloneBodyProbeHits, 1); + NSString *body = [[NSString alloc] initWithData:request.body encoding:NSUTF8StringEncoding] ?: @""; + if ([body isEqualToString:@"first"]) { + dispatch_semaphore_signal(gStandaloneFirstBodyEntered); + dispatch_semaphore_wait(gStandaloneReleaseFirstBody, DISPATCH_TIME_FOREVER); + } + [response respondWithString:body]; + }]; self.server.port = 0; NSError *error; XCTAssertTrue([self.server start:&error], @"%@", error); @@ -416,6 +588,43 @@ - (void)tearDown [super tearDown]; } +- (void)testStandaloneRequestsWithDifferentBodiesAreNotCoalesced +{ + NSURL *url = [NSURL URLWithString:[NSString stringWithFormat:@"http://127.0.0.1:%d/standalone/body", self.port]]; + NSMutableURLRequest *firstRequest = [NSMutableURLRequest requestWithURL:url]; + firstRequest.HTTPMethod = @"POST"; + firstRequest.HTTPBody = [@"first" dataUsingEncoding:NSUTF8StringEncoding]; + NSMutableURLRequest *secondRequest = [NSMutableURLRequest requestWithURL:url]; + secondRequest.HTTPMethod = @"POST"; + secondRequest.HTTPBody = [@"second" dataUsingEncoding:NSUTF8StringEncoding]; + + XCTestExpectation *firstResponse = [self expectationWithDescription:@"first standalone response"]; + XCTestExpectation *secondResponse = [self expectationWithDescription:@"second standalone response"]; + __block NSString *firstBody = nil; + __block NSString *secondBody = nil; + [[NSURLSession.sharedSession dataTaskWithRequest:firstRequest completionHandler:^(NSData *data, NSURLResponse *response, NSError *error) { + firstBody = [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding]; + [firstResponse fulfill]; + }] resume]; + XCTAssertEqual(0, dispatch_semaphore_wait(gStandaloneFirstBodyEntered, + dispatch_time(DISPATCH_TIME_NOW, NSEC_PER_SEC))); + + [[NSURLSession.sharedSession dataTaskWithRequest:secondRequest completionHandler:^(NSData *data, NSURLResponse *response, NSError *error) { + secondBody = [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding]; + [secondResponse fulfill]; + }] resume]; + NSDate *secondHandlerDeadline = [NSDate dateWithTimeIntervalSinceNow:1.0]; + while (atomic_load(&gStandaloneBodyProbeHits) < 2 && secondHandlerDeadline.timeIntervalSinceNow > 0) { + [NSThread sleepForTimeInterval:0.01]; + } + dispatch_semaphore_signal(gStandaloneReleaseFirstBody); + + [self waitForExpectations:@[firstResponse, secondResponse] timeout:5.0]; + XCTAssertEqual(atomic_load(&gStandaloneBodyProbeHits), 2); + XCTAssertEqualObjects(firstBody, @"first"); + XCTAssertEqualObjects(secondBody, @"second"); +} + // Sends `payload` as-is and reads until the server closes the connection or `timeout` elapses. // Returns everything received (nil on connect failure); *didClose reports whether EOF was seen. - (NSString *)responseForRawPayload:(NSData *)payload timeout:(NSTimeInterval)timeout didClose:(BOOL *)didClose diff --git a/WebDriverAgentTests/UnitTests/FBSocks5ConfigTests.m b/WebDriverAgentTests/UnitTests/FBSocks5ConfigTests.m new file mode 100644 index 0000000000..b5d4dbef5d --- /dev/null +++ b/WebDriverAgentTests/UnitTests/FBSocks5ConfigTests.m @@ -0,0 +1,407 @@ +/** + * Copyright (c) 2026-present, Droidrun. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. + */ + +#import +#import + +#include + +#import "FBSocks5TunnelManager.h" +#import "FBSocks5TunnelProtocol.h" +#import "FBSocks5URI.h" + +@interface FBSocks5LifecycleGuard : NSObject +@property (atomic, strong, nullable) dispatch_semaphore_t pendingSaveSignal; +- (BOOL)performLockedWithDeadline:(nullable NSDate *)deadline + block:(NS_NOESCAPE dispatch_block_t)block; +- (BOOL)fencePendingSaveWithDeadline:(nullable NSDate *)deadline error:(NSError **)error; +@end + +typedef NS_ENUM(NSInteger, FBSocks5TunnelManagerSaveDisposition) { + FBSocks5TunnelManagerSaveDispositionRetryStale, + FBSocks5TunnelManagerSaveDispositionNotAuthorized, + FBSocks5TunnelManagerSaveDispositionInternal, +}; + +extern FBSocks5TunnelManagerSaveDisposition FBSocks5TunnelManagerSaveDispositionForError(NSError *error); +extern NSDictionary *_Nullable FBSocks5TunnelManagerDisconnectedStatsIfExtensionUnavailable(NSBundle *bundle); + +@interface FBSocks5ConfigTests : XCTestCase +@property (nonatomic, nullable, copy) NSString *tempBundleRoot; +@end + +@implementation FBSocks5ConfigTests + +- (NSString *)yamlForURI:(NSString *)uriString +{ + FBSocks5URI *uri = [FBSocks5URI parse:uriString error:nil]; + NSDictionary *config = uri.providerConfiguration; + return FBSocks5HevConfigFromProviderConfiguration(config); +} + +- (void)testYAMLContainsSocks5Server +{ + NSString *yaml = [self yamlForURI:@"socks5://1.2.3.4:9050"]; + XCTAssertTrue([yaml containsString:@"address: '1.2.3.4'"]); + XCTAssertTrue([yaml containsString:@"port: 9050"]); + XCTAssertTrue([yaml containsString:@"udp: 'udp'"]); +} + +- (void)testYAMLConfiguresTunnelInterfaceFromSharedConstants +{ + NSString *yaml = [self yamlForURI:@"socks5://1.2.3.4"]; + NSString *mtuLine = [NSString stringWithFormat:@"mtu: %lu", (unsigned long)FBSocks5TunnelMTU]; + NSString *ipv4Line = [NSString stringWithFormat:@"ipv4: %@", FBSocks5TunnelIPv4Address]; + XCTAssertTrue([yaml containsString:mtuLine]); + XCTAssertTrue([yaml containsString:ipv4Line]); +} + +- (void)testYAMLOmitsCredentialsWhenAbsent +{ + NSString *yaml = [self yamlForURI:@"socks5://1.2.3.4"]; + XCTAssertFalse([yaml containsString:@"username"]); + XCTAssertFalse([yaml containsString:@"password"]); +} + +- (void)testYAMLContainsCredentialsWhenProvided +{ + NSString *yaml = [self yamlForURI:@"socks5://user:pa55@1.2.3.4"]; + XCTAssertTrue([yaml containsString:@"username: 'user'"]); + XCTAssertTrue([yaml containsString:@"password: 'pa55'"]); +} + +- (void)testYAMLEscapesSingleQuotesInCredentials +{ + NSString *yaml = [self yamlForURI:@"socks5://user:p%27s@1.2.3.4"]; + XCTAssertTrue([yaml containsString:@"password: 'p''s'"]); +} + +- (void)testYAMLOmitsMapDNSForLocalResolution +{ + NSString *yaml = [self yamlForURI:@"socks5://1.2.3.4"]; + XCTAssertFalse([yaml containsString:@"mapdns"]); +} + +- (void)testYAMLEnablesMapDNSForRemoteResolution +{ + NSString *yaml = [self yamlForURI:@"socks5h://1.2.3.4"]; + NSString *dnsLine = [NSString stringWithFormat:@"address: %@", FBSocks5TunnelMapDNSAddress]; + XCTAssertTrue([yaml containsString:@"mapdns:"]); + XCTAssertTrue([yaml containsString:dnsLine]); +} + +- (void)testProviderConfigurationCarriesControlAddress +{ + NSError *error; + FBSocks5URI *uri = [FBSocks5URI parse:@"socks5://proxy.example.com:1080" error:&error]; + XCTAssertNotNil(uri, @"%@", error); + + NSDictionary *config = [uri providerConfigurationWithControlAddress:@"192.0.2.20"]; + XCTAssertEqualObjects(config[FBSocks5KeyControlAddress], @"192.0.2.20"); +} + +- (void)testScopedIPv6AddressPreservesAndTranslatesItsZone +{ + unsigned int loopbackScope = if_nametoindex("lo0"); + XCTAssertNotEqual(loopbackScope, 0u); + + BOOL isIPv6 = NO; + NSString *normalized = FBSocks5NormalizedIPAddress(@"fe80::1%lo0", &isIPv6); + NSString *literal = nil; + NSUInteger scopeID = 0; + + XCTAssertTrue(isIPv6); + XCTAssertNotNil(normalized); + XCTAssertTrue(FBSocks5ParseIPv6Address(normalized, &literal, &scopeID)); + XCTAssertEqualObjects(literal, @"fe80::1"); + XCTAssertEqual(scopeID, loopbackScope); + + NSString *numeric = [NSString stringWithFormat:@"fe80::2%%%u", loopbackScope]; + XCTAssertTrue(FBSocks5ParseIPv6Address(numeric, &literal, &scopeID)); + XCTAssertEqualObjects(literal, @"fe80::2"); + XCTAssertEqual(scopeID, loopbackScope); + + NSString *resolved = FBSocks5IPv6AddressWithScope(@"fe80::3", loopbackScope); + XCTAssertTrue(FBSocks5ParseIPv6Address(resolved, &literal, &scopeID)); + XCTAssertEqualObjects(literal, @"fe80::3"); + XCTAssertEqual(scopeID, loopbackScope); +} + +#pragma mark - Tunnel extension presence + +- (NSBundle *)makeFakeRunnerBundleWithTunnelAppex:(BOOL)withAppex +{ + NSString *root = [NSTemporaryDirectory() stringByAppendingPathComponent:NSUUID.UUID.UUIDString]; + NSString *plugIns = [root stringByAppendingPathComponent:@"PlugIns"]; + NSError *error; + XCTAssertTrue([NSFileManager.defaultManager createDirectoryAtPath:plugIns + withIntermediateDirectories:YES + attributes:nil + error:&error], + @"%@", error); + if (withAppex) { + NSString *appex = [plugIns stringByAppendingPathComponent:@"WebDriverAgentTunnel.appex"]; + XCTAssertTrue([NSFileManager.defaultManager createDirectoryAtPath:appex + withIntermediateDirectories:YES + attributes:nil + error:&error], + @"%@", error); + } + self.tempBundleRoot = root; + NSBundle *bundle = [NSBundle bundleWithPath:root]; + XCTAssertNotNil(bundle); + return bundle; +} + +- (void)tearDown +{ + if (nil != self.tempBundleRoot) { + [NSFileManager.defaultManager removeItemAtPath:self.tempBundleRoot error:nil]; + self.tempBundleRoot = nil; + } + [super tearDown]; +} + +- (void)testTunnelExtensionDetectedWhenAppexEmbedded +{ + NSBundle *bundle = [self makeFakeRunnerBundleWithTunnelAppex:YES]; + XCTAssertTrue([FBSocks5TunnelManager isTunnelExtensionEmbeddedInBundle:bundle]); +} + +- (void)testTunnelExtensionNotDetectedWithoutAppex +{ + NSBundle *bundle = [self makeFakeRunnerBundleWithTunnelAppex:NO]; + XCTAssertFalse([FBSocks5TunnelManager isTunnelExtensionEmbeddedInBundle:bundle]); +} + +- (void)testMissingTunnelExtensionProducesDisconnectedNoOpSnapshot +{ + NSBundle *bundleWithoutExtension = [self makeFakeRunnerBundleWithTunnelAppex:NO]; + NSDictionary *snapshot = + FBSocks5TunnelManagerDisconnectedStatsIfExtensionUnavailable(bundleWithoutExtension); + + XCTAssertEqualObjects(snapshot[FBSocks5StatsKeyConnected], @NO); + XCTAssertEqualObjects(snapshot[FBSocks5StatsKeyRxBytes], @0); + XCTAssertEqualObjects(snapshot[FBSocks5StatsKeyTxBytes], @0); +} + +- (void)testEmbeddedTunnelExtensionDoesNotShortCircuitDisconnect +{ + NSBundle *bundleWithExtension = [self makeFakeRunnerBundleWithTunnelAppex:YES]; + XCTAssertNil(FBSocks5TunnelManagerDisconnectedStatsIfExtensionUnavailable(bundleWithExtension)); +} + +#pragma mark - Lifecycle serialization + +- (void)testLifecycleLockAcquisitionExpiresWithoutRunningQueuedBlockLater +{ + FBSocks5LifecycleGuard *guard = [[FBSocks5LifecycleGuard alloc] init]; + dispatch_semaphore_t lockHeld = dispatch_semaphore_create(0); + dispatch_semaphore_t releaseLock = dispatch_semaphore_create(0); + dispatch_semaphore_t holderDone = dispatch_semaphore_create(0); + dispatch_async(dispatch_get_global_queue(QOS_CLASS_DEFAULT, 0), ^{ + [guard performLockedWithDeadline:nil block:^{ + dispatch_semaphore_signal(lockHeld); + dispatch_semaphore_wait(releaseLock, DISPATCH_TIME_FOREVER); + }]; + dispatch_semaphore_signal(holderDone); + }); + XCTAssertEqual(0, dispatch_semaphore_wait(lockHeld, dispatch_time(DISPATCH_TIME_NOW, NSEC_PER_SEC))); + + __block BOOL queuedBlockRan = NO; + NSDate *startedAt = [NSDate date]; + BOOL acquired = [guard performLockedWithDeadline:[NSDate dateWithTimeIntervalSinceNow:0.05] + block:^{ + queuedBlockRan = YES; + }]; + NSTimeInterval elapsed = -startedAt.timeIntervalSinceNow; + + XCTAssertFalse(acquired); + XCTAssertFalse(queuedBlockRan); + XCTAssertLessThan(elapsed, 0.2); + dispatch_semaphore_signal(releaseLock); + XCTAssertEqual(0, dispatch_semaphore_wait(holderDone, dispatch_time(DISPATCH_TIME_NOW, NSEC_PER_SEC))); + XCTAssertFalse(queuedBlockRan); +} + +- (void)testTimedOutPendingSaveFenceRemainsArmedUntilSaveCompletes +{ + FBSocks5LifecycleGuard *guard = [[FBSocks5LifecycleGuard alloc] init]; + dispatch_semaphore_t saveSignal = dispatch_semaphore_create(0); + guard.pendingSaveSignal = saveSignal; + + NSError *error; + XCTAssertFalse([guard fencePendingSaveWithDeadline:[NSDate dateWithTimeIntervalSinceNow:0.03] + error:&error]); + XCTAssertEqual(error.code, FBSocks5TunnelManagerErrorTimeout); + XCTAssertEqual(guard.pendingSaveSignal, saveSignal); + + dispatch_semaphore_signal(saveSignal); + error = nil; + XCTAssertTrue([guard fencePendingSaveWithDeadline:[NSDate dateWithTimeIntervalSinceNow:0.1] + error:&error]); + XCTAssertNil(error); + XCTAssertNil(guard.pendingSaveSignal); +} + +- (void)testTunnelStopWaitsForPendingStartupAndSettingsCleanup +{ + FBSocks5TunnelStartupFence *fence = [[FBSocks5TunnelStartupFence alloc] init]; + __block NSError *startupError = nil; + __block BOOL stopCompleted = NO; + [fence beginStartupWithCompletion:^(NSError *error) { + startupError = error; + }]; + + XCTAssertFalse([fence requestStopWithCompletion:^{ + stopCompleted = YES; + }]); + __block BOOL lateStartupActionRan = NO; + XCTAssertFalse([fence performStartupActionIfNotStopping:^{ + lateStartupActionRan = YES; + }]); + + NSError *stoppedError = [NSError errorWithDomain:@"test" code:1 userInfo:nil]; + XCTAssertTrue([fence finishStartupWithError:nil stoppedError:stoppedError]); + XCTAssertEqual(startupError, stoppedError); + XCTAssertFalse(lateStartupActionRan); + XCTAssertFalse(stopCompleted, @"stop must wait until network settings have been cleared"); + + [fence finishStopCleanup]; + XCTAssertTrue(stopCompleted); +} + +- (void)testTunnelStartupDeadlineUsesOneHostSuppliedBudget +{ + NSDate *now = [NSDate dateWithTimeIntervalSinceReferenceDate:1000]; + NSDate *hostDeadline = [NSDate dateWithTimeIntervalSinceReferenceDate:1005]; + NSDictionary *options = @{ + FBSocks5OptionStartupDeadline: @(hostDeadline.timeIntervalSinceReferenceDate), + }; + + NSDate *deadline = FBSocks5TunnelStartupDeadlineFromOptions(options, now); + + XCTAssertEqualObjects(deadline, hostDeadline); + XCTAssertEqualWithAccuracy(FBSocks5TunnelRemainingStartupTime(deadline, + [now dateByAddingTimeInterval:3], + 8.0), + 2.0, 0.001); + XCTAssertEqual(FBSocks5TunnelRemainingStartupTime(deadline, + [now dateByAddingTimeInterval:6], + 8.0), + 0.0); +} + +- (void)testTunnelStartupDeadlineDefaultsWhenHostOptionIsAbsent +{ + NSDate *now = [NSDate dateWithTimeIntervalSinceReferenceDate:1000]; + + NSDate *deadline = FBSocks5TunnelStartupDeadlineFromOptions(nil, now); + + XCTAssertEqualWithAccuracy([deadline timeIntervalSinceDate:now], + FBSocks5DefaultStartupTimeout, + 0.001); +} + +- (void)testTunnelStartupSignalWaitStopsAtDeadline +{ + FBSocks5TunnelStartupFence *fence = [[FBSocks5TunnelStartupFence alloc] init]; + dispatch_semaphore_t lateSignal = dispatch_semaphore_create(0); + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.5 * NSEC_PER_SEC)), + dispatch_get_global_queue(QOS_CLASS_DEFAULT, 0), ^{ + dispatch_semaphore_signal(lateSignal); + }); + + NSDate *startedAt = [NSDate date]; + XCTAssertFalse([fence waitForSignal:lateSignal + beforeDate:[NSDate dateWithTimeIntervalSinceNow:0.03]]); + XCTAssertLessThan(-startedAt.timeIntervalSinceNow, 0.25); +} + +- (void)testTunnelStartupSignalWaitCancelsForStop +{ + FBSocks5TunnelStartupFence *fence = [[FBSocks5TunnelStartupFence alloc] init]; + dispatch_semaphore_t neverSignal = dispatch_semaphore_create(0); + __block BOOL stopCompleted = NO; + [fence beginStartupWithCompletion:^(NSError *error) {}]; + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.03 * NSEC_PER_SEC)), + dispatch_get_global_queue(QOS_CLASS_DEFAULT, 0), ^{ + [fence requestStopWithCompletion:^{ + stopCompleted = YES; + }]; + }); + + NSDate *startedAt = [NSDate date]; + XCTAssertFalse([fence waitForSignal:neverSignal + beforeDate:[NSDate dateWithTimeIntervalSinceNow:1.0]]); + XCTAssertLessThan(-startedAt.timeIntervalSinceNow, 0.3); + NSError *stoppedError = [NSError errorWithDomain:@"test" code:1 userInfo:nil]; + XCTAssertTrue([fence finishStartupWithError:nil stoppedError:stoppedError]); + XCTAssertFalse(stopCompleted); + [fence finishStopCleanup]; + XCTAssertTrue(stopCompleted); +} + +- (void)testUsernamePasswordAuthReplyRequiresExpectedVersionAndSuccessStatus +{ + XCTAssertTrue(FBSocks5TunnelUsernamePasswordAuthReplySucceeded(0x01, 0x00)); + XCTAssertFalse(FBSocks5TunnelUsernamePasswordAuthReplySucceeded(0x00, 0x00)); + XCTAssertFalse(FBSocks5TunnelUsernamePasswordAuthReplySucceeded(0x02, 0x00)); + XCTAssertFalse(FBSocks5TunnelUsernamePasswordAuthReplySucceeded(0x01, 0x01)); +} + +- (void)testProxyMayOnlySelectAnAuthenticationMethodOfferedByTheClient +{ + XCTAssertTrue(FBSocks5TunnelAuthenticationMethodWasOffered(0x00, NO)); + XCTAssertFalse(FBSocks5TunnelAuthenticationMethodWasOffered(0x02, NO)); + XCTAssertTrue(FBSocks5TunnelAuthenticationMethodWasOffered(0x00, YES)); + XCTAssertTrue(FBSocks5TunnelAuthenticationMethodWasOffered(0x02, YES)); + XCTAssertFalse(FBSocks5TunnelAuthenticationMethodWasOffered(0x01, YES)); +} + +- (void)testStalePreferencesSaveRequiresReloadAndRetry +{ + NSError *stale = [NSError errorWithDomain:NEVPNErrorDomain + code:NEVPNErrorConfigurationStale + userInfo:nil]; + NSError *sameCodeWrongDomain = [NSError errorWithDomain:@"test" + code:NEVPNErrorConfigurationStale + userInfo:nil]; + + XCTAssertEqual(FBSocks5TunnelManagerSaveDispositionForError(stale), + FBSocks5TunnelManagerSaveDispositionRetryStale); + XCTAssertEqual(FBSocks5TunnelManagerSaveDispositionForError(sameCodeWrongDomain), + FBSocks5TunnelManagerSaveDispositionInternal); +} + +- (void)testOnlyPermissionFailuresAreClassifiedAsNotAuthorized +{ + NSError *permission = [NSError errorWithDomain:@"NEConfigurationErrorDomain" + code:10 + userInfo:nil]; + NSError *wrappedPermission = [NSError errorWithDomain:NEVPNErrorDomain + code:NEVPNErrorConfigurationReadWriteFailed + userInfo:@{NSUnderlyingErrorKey: permission}]; + NSError *readWrite = [NSError errorWithDomain:NEVPNErrorDomain + code:NEVPNErrorConfigurationReadWriteFailed + userInfo:nil]; + NSError *invalid = [NSError errorWithDomain:NEVPNErrorDomain + code:NEVPNErrorConfigurationInvalid + userInfo:nil]; + + XCTAssertEqual(FBSocks5TunnelManagerSaveDispositionForError(permission), + FBSocks5TunnelManagerSaveDispositionNotAuthorized); + XCTAssertEqual(FBSocks5TunnelManagerSaveDispositionForError(wrappedPermission), + FBSocks5TunnelManagerSaveDispositionNotAuthorized); + XCTAssertEqual(FBSocks5TunnelManagerSaveDispositionForError(readWrite), + FBSocks5TunnelManagerSaveDispositionInternal); + XCTAssertEqual(FBSocks5TunnelManagerSaveDispositionForError(invalid), + FBSocks5TunnelManagerSaveDispositionInternal); +} + +@end diff --git a/WebDriverAgentTests/UnitTests/FBSocks5URITests.m b/WebDriverAgentTests/UnitTests/FBSocks5URITests.m new file mode 100644 index 0000000000..2d17197cdb --- /dev/null +++ b/WebDriverAgentTests/UnitTests/FBSocks5URITests.m @@ -0,0 +1,190 @@ +/** + * Copyright (c) 2026-present, Droidrun. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. + */ + +#import + +#import "FBSocks5TunnelProtocol.h" +#import "FBSocks5URI.h" + +@interface FBSocks5URITests : XCTestCase +@end + +@implementation FBSocks5URITests + +- (void)testParsesPlainSocks5URI +{ + NSError *error; + FBSocks5URI *uri = [FBSocks5URI parse:@"socks5://1.2.3.4:9050" error:&error]; + XCTAssertNotNil(uri); + XCTAssertNil(error); + XCTAssertEqualObjects(uri.host, @"1.2.3.4"); + XCTAssertEqual(uri.port, 9050); + XCTAssertNil(uri.user); + XCTAssertNil(uri.pass); + XCTAssertFalse(uri.remoteDNS); +} + +- (void)testSocks5hSetsRemoteDNSAndDefaultsPort +{ + NSError *error; + FBSocks5URI *uri = [FBSocks5URI parse:@"socks5h://proxy.example.com" error:&error]; + XCTAssertNotNil(uri); + XCTAssertEqualObjects(uri.host, @"proxy.example.com"); + XCTAssertEqual(uri.port, 1080); + XCTAssertTrue(uri.remoteDNS); +} + +- (void)testSchemeIsCaseInsensitive +{ + FBSocks5URI *uri = [FBSocks5URI parse:@"SOCKS5H://proxy.example.com" error:nil]; + XCTAssertNotNil(uri); + XCTAssertTrue(uri.remoteDNS); +} + +- (void)testParsesPercentEncodedCredentials +{ + NSError *error; + FBSocks5URI *uri = [FBSocks5URI parse:@"socks5://u%40ser:p%3As%27s@host.example:1081" error:&error]; + XCTAssertNotNil(uri); + XCTAssertEqualObjects(uri.user, @"u@ser"); + XCTAssertEqualObjects(uri.pass, @"p:s's"); + XCTAssertEqualObjects(uri.host, @"host.example"); + XCTAssertEqual(uri.port, 1081); +} + +- (void)testParsesIPv6LiteralHost +{ + NSError *error; + FBSocks5URI *uri = [FBSocks5URI parse:@"socks5://[fc00::1]:1080" error:&error]; + XCTAssertNotNil(uri); + XCTAssertEqualObjects(uri.host, @"fc00::1"); + XCTAssertEqual(uri.port, 1080); +} + +- (void)testPreservesIPv6ScopeIdentifier +{ + NSError *error; + FBSocks5URI *uri = [FBSocks5URI parse:@"socks5://[fe80::1%25en0]:1080" error:&error]; + XCTAssertNotNil(uri, @"%@", error); + XCTAssertEqualObjects(uri.host, @"fe80::1%en0"); +} + +- (void)testRejectsUnsupportedScheme +{ + for (NSString *bad in @[@"http://host", @"socks4://host", @"socks://host"]) { + NSError *error; + XCTAssertNil([FBSocks5URI parse:bad error:&error], @"%@ should be rejected", bad); + XCTAssertNotNil(error, @"%@ should produce an error", bad); + } +} + +- (void)testRejectsMissingHost +{ + for (NSString *bad in @[@"socks5://", @"socks5://:1080", @"socks5h://user:pass@"]) { + NSError *error; + XCTAssertNil([FBSocks5URI parse:bad error:&error], @"%@ should be rejected", bad); + XCTAssertNotNil(error, @"%@ should produce an error", bad); + } +} + +- (void)testRejectsInvalidInput +{ + for (NSString *bad in @[@"", @"not a uri at all", @"socks5://h:port", @"socks5://h:70000", + @"socks5://proxy:", @"socks5://proxy:18446744073709551616"]) { + NSError *error; + XCTAssertNil([FBSocks5URI parse:bad error:&error], @"'%@' should be rejected", bad); + XCTAssertNotNil(error, @"'%@' should produce an error", bad); + } + NSError *error; + XCTAssertNil([FBSocks5URI parse:nil error:&error]); + XCTAssertNotNil(error); +} + +- (void)testRejectsIncompleteCredentials +{ + for (NSString *bad in @[@"socks5://user@proxy", @"socks5://:pass@proxy"]) { + NSError *error; + XCTAssertNil([FBSocks5URI parse:bad error:&error], @"'%@' should be rejected", bad); + XCTAssertNotNil(error, @"'%@' should produce an error", bad); + } +} + +- (void)testRejectsCredentialsAboveTheSocks5ByteLimit +{ + NSString *maxCredential = [@"a" stringByPaddingToLength:255 withString:@"a" startingAtIndex:0]; + NSString *oversizedASCII = [maxCredential stringByAppendingString:@"a"]; + NSString *oversizedUnicode = [@"é" stringByPaddingToLength:128 withString:@"é" startingAtIndex:0]; + NSString *maxCredentialURI = [NSString stringWithFormat:@"socks5://%@:pass@proxy", maxCredential]; + + XCTAssertNotNil([FBSocks5URI parse:maxCredentialURI error:nil]); + for (NSString *bad in @[ + [NSString stringWithFormat:@"socks5://%@:pass@proxy", oversizedASCII], + [NSString stringWithFormat:@"socks5://user:%@@proxy", oversizedASCII], + [NSString stringWithFormat:@"socks5://%@:pass@proxy", oversizedUnicode], + ]) { + NSError *error; + XCTAssertNil([FBSocks5URI parse:bad error:&error], @"'%@' should be rejected", bad); + XCTAssertNotNil(error); + XCTAssertTrue([error.localizedDescription containsString:@"255 UTF-8 bytes"], @"%@", error); + } +} + +- (void)testRejectsYAMLControlCharactersInCredentials +{ + for (NSString *bad in @[ + @"socks5://line%0Abreak:pass@proxy", + @"socks5://user:tab%09password@proxy", + @"socks5://user:nel%C2%85password@proxy", + @"socks5://user:separator%E2%80%A8password@proxy", + ]) { + NSError *error; + XCTAssertNil([FBSocks5URI parse:bad error:&error], @"'%@' should be rejected", bad); + XCTAssertNotNil(error); + XCTAssertTrue([error.localizedDescription containsString:@"control characters"], @"%@", error); + } +} + +- (void)testParseErrorsDoNotExposeCredentials +{ + NSDictionary *invalidURIs = @{ + @"http://alice:secret@proxy": @"not a valid SOCKS5 proxy URI", + @"socks5://alice:secret@": @"must include a proxy host", + @"socks5://alice:secret@proxy:": @"invalid proxy port", + }; + [invalidURIs enumerateKeysAndObjectsUsingBlock:^(NSString *uriString, NSString *reason, BOOL *stop) { + NSError *error; + XCTAssertNil([FBSocks5URI parse:uriString error:&error]); + XCTAssertNotNil(error); + XCTAssertTrue([error.localizedDescription containsString:reason], @"%@", error); + XCTAssertFalse([error.localizedDescription containsString:@"alice"], @"%@", error); + XCTAssertFalse([error.localizedDescription containsString:@"secret"], @"%@", error); + }]; +} + +- (void)testProviderConfigurationContainsConnectionDetails +{ + FBSocks5URI *uri = [FBSocks5URI parse:@"socks5h://user:pass@1.2.3.4:9050" error:nil]; + NSDictionary *config = uri.providerConfiguration; + XCTAssertEqualObjects(config[FBSocks5KeyHost], @"1.2.3.4"); + XCTAssertEqualObjects(config[FBSocks5KeyPort], @9050); + XCTAssertEqualObjects(config[FBSocks5KeyUser], @"user"); + XCTAssertEqualObjects(config[FBSocks5KeyPass], @"pass"); + XCTAssertEqualObjects(config[FBSocks5KeyRemoteDNS], @YES); +} + +- (void)testProviderConfigurationOmitsAbsentCredentials +{ + FBSocks5URI *uri = [FBSocks5URI parse:@"socks5://1.2.3.4" error:nil]; + NSDictionary *config = uri.providerConfiguration; + XCTAssertNil(config[FBSocks5KeyUser]); + XCTAssertNil(config[FBSocks5KeyPass]); + XCTAssertEqualObjects(config[FBSocks5KeyPort], @1080); + XCTAssertEqualObjects(config[FBSocks5KeyRemoteDNS], @NO); +} + +@end diff --git a/WebDriverAgentTunnel/FBTunFdFinder.h b/WebDriverAgentTunnel/FBTunFdFinder.h new file mode 100644 index 0000000000..41c19595c9 --- /dev/null +++ b/WebDriverAgentTunnel/FBTunFdFinder.h @@ -0,0 +1,23 @@ +/** + * Copyright (c) 2026-present, Droidrun. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. + */ + +#import + +NS_ASSUME_NONNULL_BEGIN + +/** + Locates the file descriptor of the packet tunnel's utun interface inside a + NEPacketTunnelProvider process. There is no supported API for this; the descriptor is found + by scanning the process' descriptors for the kernel-control socket backing NEPacketTunnelFlow + (public POSIX calls only). + + @return the utun file descriptor, or -1 when none is found + */ +int FBTunnelFindTunFd(void); + +NS_ASSUME_NONNULL_END diff --git a/WebDriverAgentTunnel/FBTunFdFinder.m b/WebDriverAgentTunnel/FBTunFdFinder.m new file mode 100644 index 0000000000..34233bb6f0 --- /dev/null +++ b/WebDriverAgentTunnel/FBTunFdFinder.m @@ -0,0 +1,58 @@ +/** + * Copyright (c) 2026-present, Droidrun. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. + * + * The utun descriptor scan is adapted from Tun2SocksKit (MIT License, + * Copyright (c) 2023 Ebrahim Tahernejad, + * https://github.com/EbrahimTahernejad/Tun2SocksKit). + */ + +#import "FBTunFdFinder.h" + +#include +#include +#include + +// From , which is not part of the iOS SDK. +#define FB_CTLIOCGINFO 0xc0644e03UL + +struct fb_ctl_info { + uint32_t ctl_id; + char ctl_name[96]; +}; + +struct fb_sockaddr_ctl { + unsigned char sc_len; + unsigned char sc_family; + uint16_t ss_sysaddr; + uint32_t sc_id; + uint32_t sc_unit; + uint32_t sc_reserved[5]; +}; + +int FBTunnelFindTunFd(void) +{ + struct fb_ctl_info ctlInfo; + memset(&ctlInfo, 0, sizeof(ctlInfo)); + strlcpy(ctlInfo.ctl_name, "com.apple.net.utun_control", sizeof(ctlInfo.ctl_name)); + for (int fd = 0; fd <= 1024; fd++) { + struct fb_sockaddr_ctl addr; + memset(&addr, 0, sizeof(addr)); + socklen_t len = sizeof(addr); + if (0 != getpeername(fd, (struct sockaddr *)&addr, &len) || AF_SYSTEM != addr.sc_family) { + continue; + } + if (0 == ctlInfo.ctl_id) { + if (0 != ioctl(fd, FB_CTLIOCGINFO, &ctlInfo)) { + continue; + } + } + if (addr.sc_id == ctlInfo.ctl_id) { + return fd; + } + } + return -1; +} diff --git a/WebDriverAgentTunnel/FBTunnelHevRunner.h b/WebDriverAgentTunnel/FBTunnelHevRunner.h new file mode 100644 index 0000000000..0707bd67fe --- /dev/null +++ b/WebDriverAgentTunnel/FBTunnelHevRunner.h @@ -0,0 +1,54 @@ +/** + * Copyright (c) 2026-present, Droidrun. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. + */ + +#import + +NS_ASSUME_NONNULL_BEGIN + +/** + Runs the hev-socks5-tunnel engine on a dedicated thread. + hev_socks5_tunnel_main_from_str blocks for the tunnel's whole lifetime, so start spawns a + thread and stop unblocks it via hev_socks5_tunnel_quit. + */ +@interface FBTunnelHevRunner : NSObject + +/** YES while the engine thread is running. */ +@property (atomic, readonly) BOOL isRunning; + +/** + Starts the engine thread. No-op while the engine is already running. + + @param configYAML the hev-socks5-tunnel YAML config + @param tunFd the utun file descriptor the engine reads/writes packets on + */ +- (void)startWithConfigYAML:(NSString *)configYAML + tunFd:(int)tunFd + exitHandler:(nullable void (^)(int exitCode))exitHandler; + +/** + Asks the engine to quit and waits for the engine thread to exit. + + A timed-out quit request may still be blocked inside hev's process-global state. The caller must + terminate the extension process before starting another engine generation when this returns NO. + + @param timeout maximum time in seconds to wait for the engine thread + @return YES when the engine stopped within the timeout + */ +- (BOOL)stopAndWait:(NSTimeInterval)timeout; + +/** + Snapshots the engine's cumulative traffic counters (safe to call cross-thread while running). + */ +- (void)getStatsTxPackets:(size_t *)txPackets + txBytes:(size_t *)txBytes + rxPackets:(size_t *)rxPackets + rxBytes:(size_t *)rxBytes; + +@end + +NS_ASSUME_NONNULL_END diff --git a/WebDriverAgentTunnel/FBTunnelHevRunner.m b/WebDriverAgentTunnel/FBTunnelHevRunner.m new file mode 100644 index 0000000000..279a403c38 --- /dev/null +++ b/WebDriverAgentTunnel/FBTunnelHevRunner.m @@ -0,0 +1,76 @@ +/** + * Copyright (c) 2026-present, Droidrun. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. + */ + +#import "FBTunnelHevRunner.h" + +#import + +@interface FBTunnelHevRunner () +@property (atomic, readwrite) BOOL isRunning; +@property (nonatomic, nullable) dispatch_semaphore_t exitSemaphore; +@end + +@implementation FBTunnelHevRunner + +- (void)startWithConfigYAML:(NSString *)configYAML + tunFd:(int)tunFd + exitHandler:(nullable void (^)(int exitCode))exitHandler +{ + if (self.isRunning) { + return; + } + self.isRunning = YES; + dispatch_semaphore_t exitSemaphore = dispatch_semaphore_create(0); + self.exitSemaphore = exitSemaphore; + NSData *config = [configYAML dataUsingEncoding:NSUTF8StringEncoding]; + __weak typeof(self) weakSelf = self; + NSThread *thread = [[NSThread alloc] initWithBlock:^{ + int code = hev_socks5_tunnel_main_from_str((const unsigned char *)config.bytes, + (unsigned int)config.length, tunFd); + NSLog(@"WebDriverAgentTunnel: hev-socks5-tunnel exited with code %d", code); + weakSelf.isRunning = NO; + dispatch_semaphore_signal(exitSemaphore); + // Reported even for a clean stop; the provider tells the two apart by whether it asked + // the engine to quit. An engine that exits on its own leaves the tunnel blackholed, so + // the provider must hear about it rather than keep advertising a working tunnel. + if (nil != exitHandler) { + exitHandler(code); + } + }]; + thread.name = @"hev-socks5-tunnel"; + thread.qualityOfService = NSQualityOfServiceUserInitiated; + [thread start]; +} + +- (BOOL)stopAndWait:(NSTimeInterval)timeout +{ + dispatch_semaphore_t exitSemaphore = self.exitSemaphore; + if (!self.isRunning || nil == exitSemaphore) { + return YES; + } + // hev_socks5_tunnel_quit() is not itself bounded: called before the engine has initialized its + // event fds - or concurrently with the engine exiting - it can block waiting for event_fds[1] + // to become valid. Waiting on the semaphore only afterwards would leave the whole stop + // unbounded, and NetworkExtension's stop callback would never fire. Issue the quit off the + // caller's thread so the deadline below covers the request as well as the exit. + dispatch_async(dispatch_get_global_queue(QOS_CLASS_USER_INITIATED, 0), ^{ + hev_socks5_tunnel_quit(); + }); + return 0 == dispatch_semaphore_wait(exitSemaphore, + dispatch_time(DISPATCH_TIME_NOW, (int64_t)(timeout * NSEC_PER_SEC))); +} + +- (void)getStatsTxPackets:(size_t *)txPackets + txBytes:(size_t *)txBytes + rxPackets:(size_t *)rxPackets + rxBytes:(size_t *)rxBytes +{ + hev_socks5_tunnel_stats(txPackets, txBytes, rxPackets, rxBytes); +} + +@end diff --git a/WebDriverAgentTunnel/FBTunnelPacketProvider.h b/WebDriverAgentTunnel/FBTunnelPacketProvider.h new file mode 100644 index 0000000000..07bb1acfa6 --- /dev/null +++ b/WebDriverAgentTunnel/FBTunnelPacketProvider.h @@ -0,0 +1,22 @@ +/** + * Copyright (c) 2026-present, Droidrun. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. + */ + +#import + +NS_ASSUME_NONNULL_BEGIN + +/** + Packet tunnel provider routing the device's traffic through a SOCKS5 proxy via the + hev-socks5-tunnel engine. Configured by FBSocks5TunnelManager through the + providerConfiguration dictionary defined in FBSocks5TunnelProtocol.h. + */ +@interface FBTunnelPacketProvider : NEPacketTunnelProvider + +@end + +NS_ASSUME_NONNULL_END diff --git a/WebDriverAgentTunnel/FBTunnelPacketProvider.m b/WebDriverAgentTunnel/FBTunnelPacketProvider.m new file mode 100644 index 0000000000..e0b1014c9c --- /dev/null +++ b/WebDriverAgentTunnel/FBTunnelPacketProvider.m @@ -0,0 +1,748 @@ +/** + * Copyright (c) 2026-present, Droidrun. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. + */ + +#import "FBTunnelPacketProvider.h" + +#include +#include +#include +#include +#include +#include +#include + +#import "FBSocks5TunnelProtocol.h" +#import "FBTunFdFinder.h" +#import "FBTunnelHevRunner.h" + +static NSString *const FBTunnelErrorDomain = @"com.facebook.WebDriverAgent.WebDriverAgentTunnel"; + +static NSError *FBTunnelError(NSString *message) +{ + return [NSError errorWithDomain:FBTunnelErrorDomain + code:1 + userInfo:@{NSLocalizedDescriptionKey: message}]; +} + +// Resolves a proxy host to its literal IPs, IPv4 first (the engine's preferred family), each +// family preserving resolver order, without duplicates. The engine must not resolve the host +// itself: once the tunnel's DNS settings are active, an in-provider lookup would be routed +// back into the tunnel that is not functional yet. Every candidate is returned rather than +// just the first per family, so the pre-flight can fail over to later A/AAAA records when the +// first one is unreachable. An entry's family is recoverable from the literal itself (IPv6 +// literals contain ':'). +static NSArray *FBTunnelResolveHostAddressesBlocking(NSString *host) +{ + struct addrinfo hints; + memset(&hints, 0, sizeof(hints)); + hints.ai_family = AF_UNSPEC; + hints.ai_socktype = SOCK_STREAM; + struct addrinfo *results = NULL; + if (0 != getaddrinfo(host.UTF8String, NULL, &hints, &results)) { + return @[]; + } + NSMutableArray *ipv4 = [NSMutableArray array]; + NSMutableArray *ipv6 = [NSMutableArray array]; + for (struct addrinfo *entry = results; NULL != entry; entry = entry->ai_next) { + char buffer[INET6_ADDRSTRLEN] = {0}; + if (AF_INET == entry->ai_family) { + struct sockaddr_in *addr = (struct sockaddr_in *)entry->ai_addr; + if (NULL != inet_ntop(AF_INET, &addr->sin_addr, buffer, sizeof(buffer))) { + NSString *literal = [NSString stringWithUTF8String:buffer]; + if (![ipv4 containsObject:literal]) { + [ipv4 addObject:literal]; + } + } + } else if (AF_INET6 == entry->ai_family) { + struct sockaddr_in6 *addr = (struct sockaddr_in6 *)entry->ai_addr; + if (NULL != inet_ntop(AF_INET6, &addr->sin6_addr, buffer, sizeof(buffer))) { + NSString *literal = FBSocks5IPv6AddressWithScope([NSString stringWithUTF8String:buffer], + addr->sin6_scope_id); + if (![ipv6 containsObject:literal]) { + [ipv6 addObject:literal]; + } + } + } + } + freeaddrinfo(results); + return [ipv4 arrayByAddingObjectsFromArray:ipv6]; +} + +static dispatch_queue_t FBTunnelResolverQueue(void) +{ + static dispatch_queue_t queue; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + queue = dispatch_queue_create("com.facebook.WebDriverAgent.socks5-resolver", + dispatch_queue_attr_make_with_qos_class(DISPATCH_QUEUE_SERIAL, + QOS_CLASS_UTILITY, 0)); + }); + return queue; +} + +static NSArray *_Nullable FBTunnelResolveHostAddresses(NSString *host, + NSDate *deadline, + FBSocks5TunnelStartupFence *startupFence, + BOOL *completed) +{ + BOOL literalIsIPv6 = NO; + NSString *literal = FBSocks5NormalizedIPAddress(host, &literalIsIPv6); + if (nil != literal) { + *completed = YES; + return @[literal]; + } + if (startupFence.isStopping || deadline.timeIntervalSinceNow <= 0) { + *completed = NO; + return nil; + } + __block NSArray *addresses = nil; + __block volatile atomic_bool cancelled = false; + dispatch_semaphore_t resolved = dispatch_semaphore_create(0); + dispatch_async(FBTunnelResolverQueue(), ^{ + if (atomic_load_explicit(&cancelled, memory_order_acquire)) { + return; + } + @autoreleasepool { + NSArray *result = FBTunnelResolveHostAddressesBlocking(host); + if (atomic_load_explicit(&cancelled, memory_order_acquire)) { + return; + } + addresses = result; + dispatch_semaphore_signal(resolved); + } + }); + if (![startupFence waitForSignal:resolved beforeDate:deadline]) { + atomic_store_explicit(&cancelled, true, memory_order_release); + *completed = NO; + return nil; + } + *completed = YES; + return addresses; +} + +/// How long the pre-flight SOCKS5 handshake may take before the proxy counts as unreachable. +static const NSTimeInterval FBTunnelProbeTimeout = 8.0; +/// Poll in short slices so stop requests cancel an in-flight connect or handshake promptly. +static const NSTimeInterval FBTunnelProbePollInterval = 0.1; +/// Grace period for the engine to fail its own initialization before startup is declared good. +static const NSTimeInterval FBTunnelEngineSettleTimeout = 0.75; + +typedef NS_ENUM(NSUInteger, FBTunnelProbeIOResult) { + FBTunnelProbeIOResultSuccess, + FBTunnelProbeIOResultTransportFailure, + FBTunnelProbeIOResultCandidateTimeout, + FBTunnelProbeIOResultStartupTimeout, + FBTunnelProbeIOResultStopped, +}; + +static FBTunnelProbeIOResult FBTunnelWaitForSocket(int fd, short events, + NSDate *candidateDeadline, + NSDate *startupDeadline, + FBSocks5TunnelStartupFence *startupFence) +{ + while (YES) { + if (startupFence.isStopping) { + return FBTunnelProbeIOResultStopped; + } + NSDate *now = [NSDate date]; + NSTimeInterval startupRemaining = [startupDeadline timeIntervalSinceDate:now]; + if (startupRemaining <= 0) { + return FBTunnelProbeIOResultStartupTimeout; + } + NSTimeInterval candidateRemaining = [candidateDeadline timeIntervalSinceDate:now]; + if (candidateRemaining <= 0) { + return FBTunnelProbeIOResultCandidateTimeout; + } + NSTimeInterval wait = MIN(FBTunnelProbePollInterval, + MIN(startupRemaining, candidateRemaining)); + struct pollfd pfd; + memset(&pfd, 0, sizeof(pfd)); + pfd.fd = fd; + pfd.events = events; + int result = poll(&pfd, 1, MAX(1, (int)(wait * 1000))); + if (result > 0) { + return FBTunnelProbeIOResultSuccess; + } + if (result < 0 && EINTR != errno) { + return FBTunnelProbeIOResultTransportFailure; + } + } +} + +static FBTunnelProbeIOResult FBTunnelWriteFully(int fd, const void *bytes, size_t length, + NSDate *candidateDeadline, + NSDate *startupDeadline, + FBSocks5TunnelStartupFence *startupFence) +{ + size_t sent = 0; + while (sent < length) { + FBTunnelProbeIOResult waitResult = FBTunnelWaitForSocket(fd, POLLOUT, candidateDeadline, + startupDeadline, startupFence); + if (FBTunnelProbeIOResultSuccess != waitResult) { + return waitResult; + } + ssize_t count = send(fd, (const uint8_t *)bytes + sent, length - sent, 0); + if (count > 0) { + sent += (size_t)count; + continue; + } + if (count < 0 && (EINTR == errno || EAGAIN == errno || EWOULDBLOCK == errno)) { + continue; + } + return FBTunnelProbeIOResultTransportFailure; + } + return FBTunnelProbeIOResultSuccess; +} + +static FBTunnelProbeIOResult FBTunnelReadFully(int fd, void *bytes, size_t length, + NSDate *candidateDeadline, + NSDate *startupDeadline, + FBSocks5TunnelStartupFence *startupFence) +{ + size_t received = 0; + while (received < length) { + FBTunnelProbeIOResult waitResult = FBTunnelWaitForSocket(fd, POLLIN, candidateDeadline, + startupDeadline, startupFence); + if (FBTunnelProbeIOResultSuccess != waitResult) { + return waitResult; + } + ssize_t count = recv(fd, (uint8_t *)bytes + received, length - received, 0); + if (count > 0) { + received += (size_t)count; + continue; + } + if (count < 0 && (EINTR == errno || EAGAIN == errno || EWOULDBLOCK == errno)) { + continue; + } + return FBTunnelProbeIOResultTransportFailure; + } + return FBTunnelProbeIOResultSuccess; +} + +static NSError *FBTunnelProbeError(FBTunnelProbeIOResult result, NSString *transportMessage, + BOOL *outRetryable) +{ + if (FBTunnelProbeIOResultStopped == result) { + return FBTunnelError(@"The tunnel was stopped while it was still starting up"); + } + if (FBTunnelProbeIOResultStartupTimeout == result) { + return FBTunnelError(@"Timed out while starting the SOCKS5 tunnel"); + } + *outRetryable = YES; + return FBTunnelError(transportMessage); +} + +/** + Performs a SOCKS5 greeting (and username/password sub-negotiation when credentials are + configured) against the proxy, then closes the connection. + + hev only dials the proxy once tunneled traffic creates a session, so without this the provider + would report success for a proxy that is unreachable or rejects the credentials, and every + packet routed into the tunnel would be silently blackholed. Runs before the tunnel's network + settings are applied, so it cannot be captured by the tunnel it is validating. + + `outRetryable` is set for transport failures (connect, timeout, or EOF) that may be specific to + one resolved backend. Protocol and credential rejections are terminal because the proxy itself + answered them and they should repeat on the other addresses for that service. + */ +static NSError *_Nullable FBTunnelProbeSocks5(NSString *proxyIP, BOOL isIPv6, uint16_t port, + NSString *_Nullable user, NSString *_Nullable pass, + NSDate *candidateDeadline, NSDate *startupDeadline, + FBSocks5TunnelStartupFence *startupFence, + BOOL *outRetryable) +{ + *outRetryable = NO; + int fd = socket(isIPv6 ? AF_INET6 : AF_INET, SOCK_STREAM, 0); + if (fd < 0) { + *outRetryable = YES; + return FBTunnelError(@"Cannot create a socket to probe the SOCKS5 proxy"); + } + // A proxy that closes the connection mid-handshake makes send() raise SIGPIPE on Darwin, + // which would terminate the extension outright. The engine installs a process-wide SIGPIPE + // ignore, but that happens later - this probe runs before it, so opt out per socket. + int noSigPipe = 1; + setsockopt(fd, SOL_SOCKET, SO_NOSIGPIPE, &noSigPipe, sizeof(noSigPipe)); + + // Keep the socket non-blocking throughout connect and handshake. Short poll slices make both + // the per-candidate budget and a concurrent stop request observable inside every I/O wait. + int flags = fcntl(fd, F_GETFL, 0); + if (flags < 0 || 0 != fcntl(fd, F_SETFL, flags | O_NONBLOCK)) { + close(fd); + *outRetryable = YES; + return FBTunnelError(@"Cannot make the SOCKS5 probe socket non-blocking"); + } + + int connected = -1; + if (isIPv6) { + struct sockaddr_in6 addr; + memset(&addr, 0, sizeof(addr)); + addr.sin6_family = AF_INET6; + addr.sin6_port = htons(port); + NSString *literal = nil; + NSUInteger scopeID = 0; + if (!FBSocks5ParseIPv6Address(proxyIP, &literal, &scopeID) + || 1 != inet_pton(AF_INET6, literal.UTF8String, &addr.sin6_addr)) { + close(fd); + return FBTunnelError(@"Cannot parse the resolved SOCKS5 proxy address"); + } + addr.sin6_scope_id = (uint32_t)scopeID; + connected = connect(fd, (struct sockaddr *)&addr, sizeof(addr)); + } else { + struct sockaddr_in addr; + memset(&addr, 0, sizeof(addr)); + addr.sin_family = AF_INET; + addr.sin_port = htons(port); + if (1 != inet_pton(AF_INET, proxyIP.UTF8String, &addr.sin_addr)) { + close(fd); + return FBTunnelError(@"Cannot parse the resolved SOCKS5 proxy address"); + } + connected = connect(fd, (struct sockaddr *)&addr, sizeof(addr)); + } + if (0 != connected && EINPROGRESS == errno) { + FBTunnelProbeIOResult waitResult = FBTunnelWaitForSocket(fd, POLLOUT, candidateDeadline, + startupDeadline, startupFence); + if (FBTunnelProbeIOResultSuccess == waitResult) { + int socketError = 0; + socklen_t errorLength = sizeof(socketError); + if (0 == getsockopt(fd, SOL_SOCKET, SO_ERROR, &socketError, &errorLength) && 0 == socketError) { + connected = 0; + } else { + errno = 0 != socketError ? socketError : ETIMEDOUT; + } + } else { + close(fd); + return FBTunnelProbeError(waitResult, + [NSString stringWithFormat:@"Cannot reach the SOCKS5 proxy at %@:%u", + proxyIP, port], + outRetryable); + } + } + if (0 != connected) { + int err = errno; + close(fd); + *outRetryable = YES; + return FBTunnelError([NSString stringWithFormat: + @"Cannot reach the SOCKS5 proxy at %@:%u: %s", proxyIP, port, strerror(err)]); + } + + // Mirror hev_socks5_client_write_auth_methods exactly: it always offers a single method - + // username/password when BOTH fields are set, no-auth otherwise. Offering both here would let + // a proxy pick one hev never sends, so preflight would pass while every real session is + // rejected: a no-auth-only proxy with credentials, or a user-without-password URI, would both + // report connected:true over a tunnel that cannot carry traffic. + BOOL hasCredentials = user.length > 0 && pass.length > 0; + uint8_t greeting[3]; + greeting[0] = 0x05; + greeting[1] = 1; + greeting[2] = hasCredentials ? 0x02 : 0x00; + const size_t greetingLength = sizeof(greeting); + FBTunnelProbeIOResult ioResult = FBTunnelWriteFully(fd, greeting, greetingLength, + candidateDeadline, startupDeadline, + startupFence); + if (FBTunnelProbeIOResultSuccess != ioResult) { + close(fd); + return FBTunnelProbeError(ioResult, + @"The SOCKS5 proxy closed the connection during the greeting", + outRetryable); + } + + uint8_t choice[2] = {0}; + ioResult = FBTunnelReadFully(fd, choice, sizeof(choice), candidateDeadline, startupDeadline, + startupFence); + if (FBTunnelProbeIOResultSuccess != ioResult) { + close(fd); + return FBTunnelProbeError(ioResult, @"The SOCKS5 proxy did not answer the greeting", + outRetryable); + } + if (0x05 != choice[0]) { + close(fd); + return FBTunnelError([NSString stringWithFormat: + @"The server at %@:%u is not a SOCKS5 proxy", proxyIP, port]); + } + if (0xFF == choice[1]) { + close(fd); + return FBTunnelError(hasCredentials + ? @"The SOCKS5 proxy rejected username/password authentication" + : @"The SOCKS5 proxy requires authentication, but the URI has no user AND password pair"); + } + if (!FBSocks5TunnelAuthenticationMethodWasOffered(choice[1], hasCredentials)) { + close(fd); + if (0x02 == choice[1]) { + return FBTunnelError(@"The SOCKS5 proxy selected username/password authentication that the client did not offer"); + } + return FBTunnelError([NSString stringWithFormat: + @"The SOCKS5 proxy selected unsupported authentication method 0x%02x", choice[1]]); + } + if (0x02 == choice[1]) { + NSData *userData = [user dataUsingEncoding:NSUTF8StringEncoding]; + NSData *passData = [(NSString *)pass dataUsingEncoding:NSUTF8StringEncoding]; + if (userData.length > 255 || passData.length > 255) { + close(fd); + return FBTunnelError(@"The SOCKS5 credentials exceed the 255 byte protocol limit"); + } + NSMutableData *auth = [NSMutableData dataWithBytes:(uint8_t[]){0x01} length:1]; + uint8_t userLength = (uint8_t)userData.length; + [auth appendBytes:&userLength length:1]; + [auth appendData:userData]; + uint8_t passLength = (uint8_t)passData.length; + [auth appendBytes:&passLength length:1]; + [auth appendData:passData]; + ioResult = FBTunnelWriteFully(fd, auth.bytes, auth.length, candidateDeadline, startupDeadline, + startupFence); + if (FBTunnelProbeIOResultSuccess != ioResult) { + close(fd); + return FBTunnelProbeError(ioResult, + @"The SOCKS5 proxy closed the connection during authentication", + outRetryable); + } + uint8_t authReply[2] = {0}; + ioResult = FBTunnelReadFully(fd, authReply, sizeof(authReply), candidateDeadline, + startupDeadline, startupFence); + if (FBTunnelProbeIOResultSuccess != ioResult) { + close(fd); + return FBTunnelProbeError(ioResult, + @"The SOCKS5 proxy did not answer the authentication request", + outRetryable); + } + if (!FBSocks5TunnelUsernamePasswordAuthReplySucceeded(authReply[0], authReply[1])) { + close(fd); + if (0x01 != authReply[0]) { + return FBTunnelError(@"The SOCKS5 proxy returned an invalid username/password authentication reply"); + } + return FBTunnelError(@"The SOCKS5 proxy rejected the configured credentials"); + } + } + close(fd); + return nil; +} + +@interface FBTunnelPacketProvider () +@property (atomic, nullable) FBTunnelHevRunner *runner; +@property (nonatomic, strong) FBSocks5TunnelStartupFence *startupFence; +@end + +@implementation FBTunnelPacketProvider + +- (FBSocks5TunnelStartupFence *)startupFence +{ + @synchronized (self) { + if (nil == _startupFence) { + _startupFence = [[FBSocks5TunnelStartupFence alloc] init]; + } + return _startupFence; + } +} + +- (void)finishStartupWithError:(nullable NSError *)error +{ + NSError *stoppedError = FBTunnelError(@"The tunnel was stopped while it was still starting up"); + if ([self.startupFence finishStartupWithError:error stoppedError:stoppedError]) { + [self finishStopCleanup]; + } +} + +- (void)finishStopCleanup +{ + FBTunnelHevRunner *runner; + @synchronized (self) { + runner = self.runner; + self.runner = nil; + } + + if (nil != runner && ![runner stopAndWait:5.0]) { + NSLog(@"WebDriverAgentTunnel: HEV did not stop within five seconds; terminating the extension process"); + _exit(EXIT_FAILURE); + } + FBSocks5TunnelStartupFence *startupFence = self.startupFence; + [self setTunnelNetworkSettings:nil completionHandler:^(NSError *_Nullable settingsError) { + if (nil != settingsError) { + NSLog(@"WebDriverAgentTunnel: failed to clear tunnel network settings during stop: %@", + settingsError.localizedDescription); + } + [startupFence finishStopCleanup]; + }]; +} + +- (void)startTunnelWithOptions:(nullable NSDictionary *)options + completionHandler:(void (^)(NSError *_Nullable))completionHandler +{ + FBSocks5TunnelStartupFence *startupFence = self.startupFence; + [startupFence beginStartupWithCompletion:completionHandler]; + NSDate *startupDeadline = FBSocks5TunnelStartupDeadlineFromOptions(options, [NSDate date]); + NETunnelProviderProtocol *protocol = (NETunnelProviderProtocol *)self.protocolConfiguration; + NSDictionary *config = [protocol isKindOfClass:NETunnelProviderProtocol.class] + ? protocol.providerConfiguration + : nil; + NSString *host = config[FBSocks5KeyHost]; + NSNumber *port = config[FBSocks5KeyPort]; + BOOL controlIsIPv6 = NO; + NSString *controlAddress = FBSocks5NormalizedIPAddress(config[FBSocks5KeyControlAddress], &controlIsIPv6); + BOOL remoteDNS = [config[FBSocks5KeyRemoteDNS] boolValue]; + if (0 == host.length || nil == port) { + [self finishStartupWithError:FBTunnelError(@"The tunnel provider configuration is missing the proxy host/port")]; + return; + } + + BOOL resolutionCompleted = NO; + NSArray *candidates = FBTunnelResolveHostAddresses(host, startupDeadline, + startupFence, &resolutionCompleted); + if (!resolutionCompleted) { + NSError *resolutionError = startupFence.isStopping + ? FBTunnelError(@"The tunnel was stopped while it was still starting up") + : FBTunnelError(@"Timed out resolving the SOCKS5 proxy host"); + [self finishStartupWithError:resolutionError]; + return; + } + if (0 == candidates.count) { + [self finishStartupWithError:FBTunnelError([NSString stringWithFormat:@"Cannot resolve the SOCKS5 proxy host '%@'", host])]; + return; + } + if (FBSocks5TunnelRemainingStartupTime(startupDeadline, [NSDate date], FBTunnelProbeTimeout) <= 0) { + [self finishStartupWithError:FBTunnelError(@"Timed out while starting the SOCKS5 tunnel")]; + return; + } + NSLog(@"WebDriverAgentTunnel: starting tunnel through %@:%@ (resolved %@, remoteDNS=%d)", + host, port, [candidates componentsJoinedByString:@", "], remoteDNS); + + // Fail before any routes are installed, so an unreachable proxy or bad credentials surface as + // a start error instead of a "connected" tunnel that drops every packet. Probing the resolved + // addresses in order gives the usual DNS failover: a connect/timeout/EOF transport failure + // moves on to the next record, while a protocol/credential rejection is answered by the proxy + // itself and fails immediately (it would repeat on every address of the same service). + NSString *proxyIP = nil; + BOOL proxyIsIPv6 = NO; + NSError *probeError = nil; + for (NSString *candidate in candidates) { + if (self.startupFence.isStopping) { + probeError = FBTunnelError(@"The tunnel was stopped while it was still starting up"); + break; + } + NSDate *now = [NSDate date]; + NSTimeInterval candidateBudget = FBSocks5TunnelRemainingStartupTime(startupDeadline, now, + FBTunnelProbeTimeout); + if (candidateBudget <= 0) { + probeError = FBTunnelError(@"Timed out while starting the SOCKS5 tunnel"); + break; + } + NSDate *candidateDeadline = [now dateByAddingTimeInterval:candidateBudget]; + BOOL candidateIsIPv6 = [candidate containsString:@":"]; + BOOL retryable = NO; + probeError = FBTunnelProbeSocks5(candidate, candidateIsIPv6, (uint16_t)port.unsignedIntValue, + config[FBSocks5KeyUser], config[FBSocks5KeyPass], + candidateDeadline, startupDeadline, self.startupFence, + &retryable); + if (nil == probeError) { + proxyIP = candidate; + proxyIsIPv6 = candidateIsIPv6; + break; + } + NSLog(@"WebDriverAgentTunnel: proxy pre-flight failed for %@: %@", + candidate, probeError.localizedDescription); + if (!retryable) { + break; + } + } + if (nil == proxyIP) { + [self finishStartupWithError:probeError]; + return; + } + if (self.startupFence.isStopping) { + [self finishStartupWithError:FBTunnelError(@"The tunnel was stopped while it was still starting up")]; + return; + } + + if (nil != controlAddress) { + NSLog(@"WebDriverAgentTunnel: preserving WDA control route to %@", controlAddress); + } + + NEPacketTunnelNetworkSettings *settings = + [[NEPacketTunnelNetworkSettings alloc] initWithTunnelRemoteAddress:proxyIP]; + NEIPv4Settings *ipv4 = [[NEIPv4Settings alloc] initWithAddresses:@[FBSocks5TunnelIPv4Address] + subnetMasks:@[FBSocks5TunnelIPv4Netmask]]; + ipv4.includedRoutes = @[NEIPv4Route.defaultRoute]; + NSMutableArray *excludedIPv4Routes = [NSMutableArray array]; + if (!proxyIsIPv6) { + // The engine's own TCP connection to the proxy must not loop back into the tunnel. + [excludedIPv4Routes addObject:[[NEIPv4Route alloc] initWithDestinationAddress:proxyIP + subnetMask:@"255.255.255.255"]]; + } + if (nil != controlAddress && !controlIsIPv6 && ![controlAddress isEqualToString:proxyIP]) { + [excludedIPv4Routes addObject:[[NEIPv4Route alloc] initWithDestinationAddress:controlAddress + subnetMask:@"255.255.255.255"]]; + } + if (excludedIPv4Routes.count > 0) { + ipv4.excludedRoutes = excludedIPv4Routes.copy; + } + settings.IPv4Settings = ipv4; + + // Claim IPv6 as well, even though the engine only speaks IPv4. Leaving IPv6 unclaimed is not + // neutral: on a dual-stack device every AAAA-reachable destination would keep using the real + // egress while the tunnel was up, quietly defeating the point of routing through the proxy. + // Capturing it without an IPv6 path in hev means such traffic is dropped rather than leaked - + // deliberately failing closed. Connections then fall back to IPv4 through the tunnel; a + // genuinely IPv6-only destination becomes unreachable while connected, which is the trade + // this makes. Giving hev a real IPv6 interface would lift that and is the follow-up. + NEIPv6Settings *ipv6 = [[NEIPv6Settings alloc] initWithAddresses:@[FBSocks5TunnelIPv6Address] + networkPrefixLengths:@[@(FBSocks5TunnelIPv6PrefixLength)]]; + ipv6.includedRoutes = @[NEIPv6Route.defaultRoute]; + NSMutableArray *excludedIPv6Routes = [NSMutableArray array]; + if (proxyIsIPv6) { + // Same reasoning as the IPv4 exclusion: keep the engine's own dial out of its own tunnel. + [excludedIPv6Routes addObject:[[NEIPv6Route alloc] initWithDestinationAddress:proxyIP + networkPrefixLength:@128]]; + } + if (nil != controlAddress && controlIsIPv6 && ![controlAddress isEqualToString:proxyIP]) { + [excludedIPv6Routes addObject:[[NEIPv6Route alloc] initWithDestinationAddress:controlAddress + networkPrefixLength:@128]]; + } + if (excludedIPv6Routes.count > 0) { + ipv6.excludedRoutes = excludedIPv6Routes.copy; + } + settings.IPv6Settings = ipv6; + // socks5h: point DNS at hev's mapdns so queries become hostname-preserving CONNECTs. + // socks5: use public resolvers; the queries travel through the tunnel via the proxy's + // UDP relay (requires a proxy with UDP ASSOCIATE support). + NSArray *dnsServers = remoteDNS + ? @[FBSocks5TunnelMapDNSAddress] + : @[@"8.8.8.8", @"1.1.1.1"]; + NEDNSSettings *dns = [[NEDNSSettings alloc] initWithServers:dnsServers]; + dns.matchDomains = @[@""]; + settings.DNSSettings = dns; + settings.MTU = @(FBSocks5TunnelMTU); + + __weak typeof(self) weakSelf = self; + [self setTunnelNetworkSettings:settings completionHandler:^(NSError *_Nullable settingsError) { + __strong typeof(weakSelf) strongSelf = weakSelf; + if (nil == strongSelf) { + NSError *deallocatedError = FBTunnelError(@"The tunnel provider was deallocated during startup"); + NSError *stoppedError = FBTunnelError(@"The tunnel was stopped while it was still starting up"); + if ([startupFence finishStartupWithError:deallocatedError stoppedError:stoppedError]) { + [startupFence finishStopCleanup]; + } + return; + } + if (nil != settingsError) { + [strongSelf finishStartupWithError:settingsError]; + return; + } + if (strongSelf.startupFence.isStopping) { + [strongSelf finishStartupWithError:FBTunnelError(@"The tunnel was stopped while it was still starting up")]; + return; + } + int tunFd = FBTunnelFindTunFd(); + if (tunFd < 0) { + [strongSelf finishStartupWithError:FBTunnelError(@"Cannot locate the utun file descriptor in the tunnel provider")]; + return; + } + // The engine must connect to the already probed address (see FBTunnelResolveHostAddresses). + NSMutableDictionary *engineConfig = [config mutableCopy]; + engineConfig[FBSocks5KeyHost] = proxyIP; + NSString *yaml = FBSocks5HevConfigFromProviderConfiguration(engineConfig); + FBTunnelHevRunner *runner = [[FBTunnelHevRunner alloc] init]; + // An engine that fails to initialize returns from its main almost immediately. Report that + // as a start failure rather than letting NetworkExtension reach NEVPNStatusConnected; once + // startup has been acknowledged, an unexpected exit tears the tunnel down instead of leaving + // it advertised but blackholed. + // Acknowledgement and exit have to agree under one lock. Checking a plain flag leaves a + // window where the engine dies after the settle wait but before the flag is set: the exit + // callback reads NO and suppresses cancellation, and startup then reports success for a dead + // engine. Under the lock exactly one of the two paths wins - either the exit is already + // recorded and startup fails, or startup is acknowledged and the exit cancels the tunnel. + dispatch_semaphore_t settled = dispatch_semaphore_create(0); + NSObject *startupLock = [NSObject new]; + __block BOOL startupAcknowledged = NO; + __block BOOL engineExited = NO; + void (^exitHandler)(int) = ^(int exitCode) { + __strong typeof(weakSelf) exitSelf = weakSelf; + BOOL shouldCancel = NO; + @synchronized (startupLock) { + engineExited = YES; + shouldCancel = startupAcknowledged; + } + dispatch_semaphore_signal(settled); + if (!shouldCancel || nil == exitSelf || exitSelf.startupFence.isStopping) { + return; + } + NSLog(@"WebDriverAgentTunnel: engine exited unexpectedly (code %d); cancelling the tunnel", exitCode); + [exitSelf cancelTunnelWithError: + FBTunnelError([NSString stringWithFormat:@"The SOCKS5 engine exited unexpectedly with code %d", exitCode])]; + }; + // Publishing and starting the runner must be atomic against stopTunnelWithReason:. The + // startup fence either rejects this action after a stop request, or keeps stop cleanup + // deferred until startup hands ownership of the published runner back to the fence. + BOOL started = [strongSelf.startupFence performStartupActionIfNotStopping:^{ + @synchronized (strongSelf) { + strongSelf.runner = runner; + [runner startWithConfigYAML:yaml tunFd:tunFd exitHandler:exitHandler]; + } + }]; + if (!started) { + [strongSelf finishStartupWithError:FBTunnelError(@"The tunnel was stopped while it was still starting up")]; + return; + } + dispatch_semaphore_wait(settled, dispatch_time(DISPATCH_TIME_NOW, + (int64_t)(FBTunnelEngineSettleTimeout * NSEC_PER_SEC))); + BOOL startupFailed = NO; + @synchronized (startupLock) { + if (engineExited) { + startupFailed = YES; + } else { + startupAcknowledged = YES; + } + } + if (startupFailed) { + @synchronized (strongSelf) { + if (strongSelf.runner == runner) { + strongSelf.runner = nil; + } + } + [strongSelf finishStartupWithError:FBTunnelError(@"The SOCKS5 engine failed to start; check the proxy configuration")]; + return; + } + [strongSelf finishStartupWithError:nil]; + }]; +} + +- (void)stopTunnelWithReason:(NEProviderStopReason)reason + completionHandler:(void (^)(void))completionHandler +{ + NSLog(@"WebDriverAgentTunnel: stopping tunnel (reason %ld)", (long)reason); + if ([self.startupFence requestStopWithCompletion:completionHandler]) { + [self finishStopCleanup]; + } +} + +- (void)handleAppMessage:(NSData *)messageData + completionHandler:(nullable void (^)(NSData *_Nullable))completionHandler +{ + if (nil == completionHandler) { + return; + } + NSString *message = [[NSString alloc] initWithData:messageData encoding:NSUTF8StringEncoding]; + if (![message isEqualToString:FBSocks5MsgStats]) { + completionHandler(nil); + return; + } + size_t txPackets = 0; + size_t txBytes = 0; + size_t rxPackets = 0; + size_t rxBytes = 0; + FBTunnelHevRunner *runner = self.runner; + if (nil != runner && runner.isRunning) { + [runner getStatsTxPackets:&txPackets txBytes:&txBytes rxPackets:&rxPackets rxBytes:&rxBytes]; + } + NSDictionary *stats = @{ + FBSocks5StatsKeyRxBytes: @(rxBytes), + FBSocks5StatsKeyTxBytes: @(txBytes), + FBSocks5StatsKeyRxPackets: @(rxPackets), + FBSocks5StatsKeyTxPackets: @(txPackets), + }; + completionHandler([NSJSONSerialization dataWithJSONObject:stats options:(NSJSONWritingOptions)0 error:nil]); +} + +@end diff --git a/WebDriverAgentTunnel/Info.plist b/WebDriverAgentTunnel/Info.plist new file mode 100644 index 0000000000..4ad5d9d372 --- /dev/null +++ b/WebDriverAgentTunnel/Info.plist @@ -0,0 +1,33 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleDisplayName + WebDriverAgent Tunnel + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + XPC! + CFBundleShortVersionString + 1.0 + CFBundleSignature + ???? + CFBundleVersion + 1 + NSExtension + + NSExtensionPointIdentifier + com.apple.networkextension.packet-tunnel + NSExtensionPrincipalClass + FBTunnelPacketProvider + + + diff --git a/WebDriverAgentTunnel/WebDriverAgentTunnel.entitlements b/WebDriverAgentTunnel/WebDriverAgentTunnel.entitlements new file mode 100644 index 0000000000..ffab33e018 --- /dev/null +++ b/WebDriverAgentTunnel/WebDriverAgentTunnel.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.developer.networking.networkextension + + packet-tunnel-provider + + + diff --git a/docs/socks5-tunnel.md b/docs/socks5-tunnel.md new file mode 100644 index 0000000000..a55b7f9374 --- /dev/null +++ b/docs/socks5-tunnel.md @@ -0,0 +1,152 @@ +# SOCKS5 VPN Tunnel + +WebDriverAgent can embed a NetworkExtension **packet tunnel provider** +(`WebDriverAgentTunnel.appex`) into the generated `WebDriverAgentRunner-Runner.app`. When +connected, the tunnel captures the device's IPv4 traffic on a virtual interface and forwards +it through a SOCKS5 proxy using the [hev-socks5-tunnel](https://github.com/heiher/hev-socks5-tunnel) +engine (MIT) running inside the extension. WDA configures and controls the tunnel in-process +via `NETunnelProviderManager`. + +The extension is **opt-in at build time**: only the `WebDriverAgentRunnerTunnel` / +`WebDriverAgentRunnerTunnel-nodebug` schemes build and embed it. The default +`WebDriverAgentRunner` / `WebDriverAgentRunner-nodebug` schemes produce a runner without the +extension — no git submodule, no engine build and no paid team needed — whose socks5 +endpoints answer `unsupported operation`. + +The tunnel only works on **physical iOS devices** (packet tunnel providers do not run on the +Simulator or tvOS — the endpoints answer `unsupported operation` there) and requires +**paid-team signing**: free/personal Apple developer teams cannot register App IDs with the +Network Extension capability. + +## HTTP endpoints + +| Endpoint | Method | Description | +|---|---|---| +| `/mobilerun/socks5/connect` | POST | Installs/updates the VPN configuration (auto-accepting the system consent alert via UI automation) and starts the tunnel. Replaces an already running tunnel. Returns once the tunnel reports connected. | +| `/mobilerun/socks5/disconnect` | POST | Stops the running tunnel. The VPN profile stays installed. Succeeds when no tunnel is running. Send `{}` as the body (WDA rejects body-less POSTs with HTTP 400). | +| `/mobilerun/socks5/stats` | GET | Connection state plus traffic counters queried from the extension. Never fails; counters fall back to zero when the extension cannot be reached. | + +`connect` body: + +```json +{ + "uri": "socks5h://user:pass@proxy.example.com:1080", + "timeout": 30, + "consentButtonLabels": ["Allow"] +} +``` + +- `uri` (required) — `socks5://` or `socks5h://`, optional percent-encoded `user:pass@`, + host (DNS name, IPv4 or bracketed IPv6 literal), optional port (default 1080). + With `socks5h` hostnames are resolved **through the proxy**: the tunnel runs hev's + `mapdns`, which hijacks DNS queries to synthetic IPs and forwards the original hostname in + the SOCKS5 CONNECT. With plain `socks5` the tunnel uses public resolvers (8.8.8.8/1.1.1.1) + and relays the DNS datagrams through the proxy's UDP relay — the proxy must support UDP + ASSOCIATE for that; prefer `socks5h` when unsure. +- `timeout` (optional, default 30, maximum 300) — seconds for the whole connect flow + (consent + tunnel reaching connected). +- `consentButtonLabels` (optional, default `["Allow"]`) — labels to look for on the system + "Would Like to Add VPN Configurations" alert. Pass the localized label when the device + language is not English. + +The tunnel installs full IPv4 and IPv6 routes, but excludes both the selected proxy address +and the IP address of the HTTP client that issued `connect`. The latter preserves the active +WDA response and subsequent `stats` or `disconnect` requests when the controller reaches the +device through its physical default route. + +`stats` response value (also returned by successful `connect`/`disconnect` calls): + +```json +{ + "connected": true, + "host": "proxy.example.com", + "port": 1080, + "user": "user", + "rxBytes": 123456, + "txBytes": 7890, + "rxPackets": 321, + "txPackets": 123 +} +``` + +`host`/`port`/`user` are omitted while disconnected (`user` also when the proxy needs no +auth). Counters are cumulative since the tunnel start and reset on reconnect. + +## Build (opt-in) + +The default runner schemes do not reference the appex at all; `connect` on such a build +fails fast with `unsupported operation: this build does not embed the WebDriverAgentTunnel +extension` (`disconnect` and `stats` keep answering as disconnected). To include the tunnel, +build a `WebDriverAgentRunnerTunnel*` scheme — it builds the `WebDriverAgentTunnel` appex +alongside the runner and carries the embed post-action. + +The engine is vendored as a git submodule and compiled into a static-library xcframework +that only the appex links: + +```bash +git submodule update --init --recursive +Scripts/build-hev-socks5-tunnel.sh # -> ThirdParty/HevSocks5Tunnel.xcframework (gitignored) +``` + +`Scripts/build.sh` runs the engine build automatically for `TARGET=tunnel_runner`, and it is +a no-op while the stamp file matches the submodule SHA (`--force` rebuilds). Plain +`xcodebuild` invocations of the tunnel schemes need the script run once beforehand. + +Like the broadcast extension, the appex cannot reach the Xcode-generated `Runner.app` through +a regular embed phase; the tunnel schemes' post-action `Scripts/embed-tunnel-extension.sh` +copies it into `Runner.app/PlugIns`, verifies its build-time bundle id is `.tunnel`, +and re-signs inner-first (post-actions run on scheme-based CLI builds, including +`build-for-testing`). Set `WDA_PRODUCT_BUNDLE_IDENTIFIER` to the runner's base identifier so +the runner and extension are provisioned with their final identifiers. + +## Signing (device) + +Both the host app and the appex must carry the +`com.apple.developer.networking.networkextension` entitlement with the +`packet-tunnel-provider` value, and the team's App IDs for the runner and +`.xctrunner.tunnel` need the Network Extension capability — **paid teams only**. + +The entitlements are wired through build variables that default to empty, so regular +(free-team / CI / Simulator) builds keep working unchanged. For a paid-team device build pass +them explicitly: + +```bash +xcodebuild build-for-testing -project WebDriverAgent.xcodeproj -scheme WebDriverAgentRunnerTunnel \ + -destination 'id=' -allowProvisioningUpdates \ + DEVELOPMENT_TEAM= \ + WDA_RUNNER_ENTITLEMENTS=WebDriverAgentRunner/WebDriverAgentRunner.entitlements \ + WDA_TUNNEL_ENTITLEMENTS=WebDriverAgentTunnel/WebDriverAgentTunnel.entitlements +``` + +`Scripts/embed-tunnel-extension.sh` additionally injects the NE entitlement into +`Runner.app`'s signature when the appex carries it but the host does not (Xcode does not +document whether a UI-test target's `CODE_SIGN_ENTITLEMENTS` propagates to the generated +runner). Cloud device farms that re-sign WDA must re-sign the nested appex with the same +team first and preserve the NE entitlement on both bundles. + +Without the entitlements the build still succeeds and the appex is embedded, but +`saveToPreferences` is rejected on device and `connect` answers +`unsupported operation: The VPN configuration was not authorized`. + +## Consent alert + +The first `saveToPreferences` per install triggers the system "Would Like to Add VPN +Configurations" alert. The connect handler auto-confirms it by tapping the consent button on +Springboard while the save is pending. **Devices with a passcode additionally prompt for the +passcode, which cannot be automated** — confirm once manually; the profile then persists +until the app is uninstalled. Reinstalling WDA (new install, not upgrade) removes the +profile, so the next connect repeats the consent flow. + +## Device verification checklist (deferred until a paid team is available) + +1. Run a local SOCKS5 server on the Mac: `microsocks -p 1080` (or `ssh -D 0.0.0.0:1080 -N localhost`). +2. Build + install with the paid-team invocation above; start WDA. +3. `GET /mobilerun/socks5/stats` → `connected: false`. +4. `POST /mobilerun/socks5/connect` with `socks5h://:1080` → `connected: true`; + check `codesign -d --entitlements -` on both `Runner.app` and the appex if it fails. +5. Egress check: fetch `https://api.ipify.org` on the device → the Mac's egress IP. +6. Download a known-size file, re-poll `stats`: `rxBytes` must grow by roughly that size + (confirms the rx/tx direction mapping of the engine counters; swap the mapping in + `FBSocks5TunnelManager.statsDictionary`/`FBTunnelPacketProvider.handleAppMessage` if + inverted). +7. `POST /mobilerun/socks5/disconnect` → `connected: false`, egress IP reverts. diff --git a/lib/xcodebuild.ts b/lib/xcodebuild.ts index 34753beca4..5081823457 100644 --- a/lib/xcodebuild.ts +++ b/lib/xcodebuild.ts @@ -418,7 +418,7 @@ export class XcodeBuild { args.push(`DEVELOPMENT_TEAM=${this.xcodeOrgId}`, `CODE_SIGN_IDENTITY=${this.xcodeSigningId}`); } if (this.updatedWDABundleId) { - args.push(`PRODUCT_BUNDLE_IDENTIFIER=${this.updatedWDABundleId}`); + args.push(`WDA_PRODUCT_BUNDLE_IDENTIFIER=${this.updatedWDABundleId}`); } } diff --git a/package.json b/package.json index 248b0bb2c3..65958e531b 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,8 @@ "Scripts/build.sh", "Scripts/embed-runner-icon.sh", "Scripts/embed-broadcast-extension.sh", + "Scripts/embed-tunnel-extension.sh", + "Scripts/build-hev-socks5-tunnel.sh", "Scripts/*.mjs", "Configurations", "PrivateHeaders", @@ -32,6 +34,8 @@ "WebDriverAgentBroadcast", "WebDriverAgentLib", "WebDriverAgentRunner", + "WebDriverAgentTunnel", + "ThirdParty/hev-socks5-tunnel", "WebDriverAgentTests", "XCTWebDriverAgentLib", "CHANGELOG.md" diff --git a/test/unit/webdriveragent.spec.ts b/test/unit/webdriveragent.spec.ts index c5273f1a1e..371f5b2209 100644 --- a/test/unit/webdriveragent.spec.ts +++ b/test/unit/webdriveragent.spec.ts @@ -120,6 +120,18 @@ describe('WebDriverAgent', function () { }), ); }); + + it('should pass a custom WDA bundle id without overriding every target bundle id', function () { + const agent = new WebDriverAgent({ + ...fakeConstructorArgs, + realDevice: true, + updatedWDABundleId: 'io.appium.wda', + }); + const {args} = (agent.xcodebuild as any).getCommand(true); + + assert.ok(args.includes('WDA_PRODUCT_BUNDLE_IDENTIFIER=io.appium.wda')); + assert.ok(!args.includes('PRODUCT_BUNDLE_IDENTIFIER=io.appium.wda')); + }); }); describe('launch', function () {