Compare commits

..
Author SHA1 Message Date
dijunkun 3b989fb70d [fix] set up Python for iOS CI 2026-08-31 02:20:46 +08:00
dijunkun 7595ab8bd8 [feat] add native iOS client support 2026-08-31 02:14:27 +08:00
50 changed files with 7396 additions and 432 deletions
+166
View File
@@ -0,0 +1,166 @@
name: Build iOS
on:
push:
branches:
- "**"
- "!ci/linux-build-image"
tags:
- "*"
workflow_dispatch:
inputs:
patch:
description: "Hotfix patch number, for example 1 or 2. Use 0 for a normal build."
required: false
default: "0"
permissions:
contents: read
env:
IOS_DEPLOYMENT_TARGET: "16.0"
EXPECTED_IOS_SDK: "26.5"
jobs:
build-ios:
name: Build iOS (arm64)
runs-on: macos-26
timeout-minutes: 120
steps:
- name: Extract version number
shell: bash
run: |
VERSION_REF="${GITHUB_REF##*/}"
VERSION_BASE="${VERSION_REF#v}"
BUILD_DATE=$(TZ=Asia/Shanghai date +%Y%m%d)
PATCH_NUMBER="${{ github.event.inputs.patch }}"
if [[ ! "${PATCH_NUMBER}" =~ ^[0-9]+$ ]]; then
PATCH_NUMBER=0
fi
if [[ "${VERSION_BASE}" =~ ^([0-9]+(\.[0-9]+){1,3})-([0-9]+)-([0-9]{8})$ ]]; then
VERSION_BASE="${BASH_REMATCH[1]}"
PATCH_NUMBER="${BASH_REMATCH[3]}"
BUILD_DATE="${BASH_REMATCH[4]}"
elif [[ "${VERSION_BASE}" =~ ^([0-9]+(\.[0-9]+){1,3})-([0-9]{8})-([0-9]+)$ ]]; then
VERSION_BASE="${BASH_REMATCH[1]}"
BUILD_DATE="${BASH_REMATCH[3]}"
PATCH_NUMBER="${BASH_REMATCH[4]}"
elif [[ "${VERSION_BASE}" =~ ^([0-9]+(\.[0-9]+){1,3})-([0-9]{8})$ ]]; then
VERSION_BASE="${BASH_REMATCH[1]}"
BUILD_DATE="${BASH_REMATCH[3]}"
elif [[ "${VERSION_BASE}" =~ ^([0-9]+(\.[0-9]+){1,3})-([0-9]+)$ && "${PATCH_NUMBER}" == "0" ]]; then
VERSION_BASE="${BASH_REMATCH[1]}"
PATCH_NUMBER="${BASH_REMATCH[3]}"
fi
if [[ "${PATCH_NUMBER}" != "0" ]]; then
VERSION_NUM="v${VERSION_BASE}-${PATCH_NUMBER}-${BUILD_DATE}"
else
VERSION_NUM="v${VERSION_BASE}-${BUILD_DATE}"
fi
echo "VERSION_NUM=${VERSION_NUM}" >> "${GITHUB_ENV}"
echo "VERSION_NUM=${VERSION_NUM}"
- name: Select Xcode 26.6
shell: bash
run: |
sudo xcode-select -s /Applications/Xcode_26.6.app/Contents/Developer
xcodebuild -version
SDK_VERSION="$(xcrun --sdk iphoneos --show-sdk-version)"
if [[ "${SDK_VERSION}" != "${EXPECTED_IOS_SDK}" ]]; then
echo "Unexpected iOS SDK: expected ${EXPECTED_IOS_SDK}, got ${SDK_VERSION}" >&2
exit 1
fi
- name: Checkout code
uses: actions/checkout@v5
- name: Initialize submodules
run: git submodule update --init --recursive
- name: Cache xmake dependencies
uses: actions/cache@v5
with:
path: ios/.xmake/packages
key: "${{ runner.os }}-xmake-deps-ios-arm64-xcode26.6-python3.13-min16-${{ github.run_id }}"
restore-keys: |
${{ runner.os }}-xmake-deps-ios-arm64-xcode26.6-python3.13-min16-
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: "3.13"
- name: Install xmake
run: brew install xmake
- name: Update xmake repositories
run: xmake repo -u
- name: Build CrossDesk Mobile
shell: bash
run: |
DERIVED_DATA="${RUNNER_TEMP}/CrossDeskMobileDerivedData"
echo "DERIVED_DATA=${DERIVED_DATA}" >> "${GITHUB_ENV}"
xcodebuild \
-project ios/CrossDeskMobile.xcodeproj \
-scheme CrossDeskMobile \
-configuration Release \
-destination 'generic/platform=iOS' \
-derivedDataPath "${DERIVED_DATA}" \
CODE_SIGNING_ALLOWED=NO \
CODE_SIGNING_REQUIRED=NO \
COMPILER_INDEX_STORE_ENABLE=NO \
IPHONEOS_DEPLOYMENT_TARGET="${IOS_DEPLOYMENT_TARGET}" \
build
- name: Verify CrossDesk Mobile
shell: bash
run: |
APP_PATH="${DERIVED_DATA}/Build/Products/Release-iphoneos/CrossDeskMobile.app"
EXECUTABLE_PATH="${APP_PATH}/CrossDeskMobile"
INFO_PLIST="${APP_PATH}/Info.plist"
if [[ ! -x "${EXECUTABLE_PATH}" ]]; then
echo "CrossDeskMobile executable is missing from ${APP_PATH}" >&2
exit 1
fi
xcrun lipo "${EXECUTABLE_PATH}" -verify_arch arm64
MINIMUM_OS=$(/usr/libexec/PlistBuddy -c 'Print :MinimumOSVersion' "${INFO_PLIST}")
if [[ "${MINIMUM_OS}" != "${IOS_DEPLOYMENT_TARGET}" ]]; then
echo "Unexpected minimum iOS version: expected ${IOS_DEPLOYMENT_TARGET}, got ${MINIMUM_OS}" >&2
exit 1
fi
xcrun lipo -info "${EXECUTABLE_PATH}"
echo "Minimum iOS version: ${MINIMUM_OS}"
- name: Package CrossDesk Mobile
shell: bash
run: |
APP_PATH="${DERIVED_DATA}/Build/Products/Release-iphoneos/CrossDeskMobile.app"
PACKAGE_DIR="${RUNNER_TEMP}/crossdesk-ios-arm64-unsigned-${VERSION_NUM}"
PACKAGE_FILE="${GITHUB_WORKSPACE}/crossdesk-ios-arm64-unsigned-${VERSION_NUM}.zip"
mkdir -p "${PACKAGE_DIR}"
cp -R "${APP_PATH}" "${PACKAGE_DIR}/"
DSYM_PATH="${DERIVED_DATA}/Build/Products/Release-iphoneos/CrossDeskMobile.app.dSYM"
if [[ -d "${DSYM_PATH}" ]]; then
cp -R "${DSYM_PATH}" "${PACKAGE_DIR}/"
fi
ditto -c -k --sequesterRsrc --keepParent "${PACKAGE_DIR}" "${PACKAGE_FILE}"
echo "PACKAGE_FILE=${PACKAGE_FILE}" >> "${GITHUB_ENV}"
- name: Upload build artifact
uses: actions/upload-artifact@v6
with:
name: crossdesk-ios-arm64-unsigned-${{ env.VERSION_NUM }}
path: ${{ env.PACKAGE_FILE }}
if-no-files-found: error
+6 -1
View File
@@ -1,9 +1,14 @@
# Xmake cache
.xmake/
build/
ios/Vendor/
ios/DerivedData/
ios/.xmake/
xcuserdata/
*.xcuserstate
# MacOS Cache
.DS_Store
# VSCode cache
.vscode
.vscode
+4 -1
View File
@@ -69,6 +69,8 @@ Render
底层功能模块不应反向依赖具体面板、工具栏或窗口。
桌面端与 iOS Bridge 复用 `src/` 中的协议定义:`device_controller/` 保存控制消息,`common/` 保存数据流名称和鼠标指针类型,`tools/` 保存文件传输线格式。操作系统输入注入、剪贴板、文件读写以及界面状态仍由各自应用实现。
## 核心类型
### Render
@@ -196,7 +198,8 @@ SDL quit / tray exit
| SDL 初始化、事件循环、窗口生命周期 | `application/` |
| 连接、会话、Peer 回调和平台运行时 | `runtime/` |
| 视频、音频、鼠标、键盘设备生命周期 | `features/devices/` |
| 键盘协议和按键状态 | `features/input/` |
| 键盘状态与桌面输入处理 | `features/input/` |
| 控制消息和线格式 | `device_controller/``common/``tools/` |
| 剪贴板同步 | `features/clipboard/` |
| 文件传输 | `features/file_transfer/` |
| 配置持久化 | `features/settings/` |
@@ -0,0 +1,388 @@
// !$*UTF8*$!
{
archiveVersion = 1;
classes = {
};
objectVersion = 60;
objects = {
/* Begin PBXBuildFile section */
100000000000000000000001 /* CrossDeskMobileApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 200000000000000000000001 /* CrossDeskMobileApp.swift */; };
100000000000000000000002 /* RemoteSessionModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 200000000000000000000002 /* RemoteSessionModel.swift */; };
100000000000000000000003 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 200000000000000000000003 /* ContentView.swift */; };
100000000000000000000004 /* RemoteSessionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 200000000000000000000004 /* RemoteSessionView.swift */; };
100000000000000000000005 /* CrossDeskRTCBridge.mm in Sources */ = {isa = PBXBuildFile; fileRef = 200000000000000000000006 /* CrossDeskRTCBridge.mm */; };
100000000000000000000006 /* NativeMetalVideoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 200000000000000000000009 /* NativeMetalVideoView.swift */; };
100000000000000000000007 /* RemoteTouchInputView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20000000000000000000000A /* RemoteTouchInputView.swift */; };
100000000000000000000008 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 20000000000000000000000D /* Assets.xcassets */; };
/* End PBXBuildFile section */
/* Begin PBXFileReference section */
200000000000000000000001 /* CrossDeskMobileApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CrossDeskMobileApp.swift; sourceTree = "<group>"; };
200000000000000000000002 /* RemoteSessionModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteSessionModel.swift; sourceTree = "<group>"; };
200000000000000000000003 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
200000000000000000000004 /* RemoteSessionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteSessionView.swift; sourceTree = "<group>"; };
200000000000000000000005 /* CrossDeskRTCBridge.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = CrossDeskRTCBridge.h; sourceTree = "<group>"; };
200000000000000000000006 /* CrossDeskRTCBridge.mm */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.objcpp; path = CrossDeskRTCBridge.mm; sourceTree = "<group>"; };
200000000000000000000007 /* CrossDeskMobile-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "CrossDeskMobile-Bridging-Header.h"; sourceTree = "<group>"; };
200000000000000000000008 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
200000000000000000000009 /* NativeMetalVideoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeMetalVideoView.swift; sourceTree = "<group>"; };
20000000000000000000000A /* RemoteTouchInputView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteTouchInputView.swift; sourceTree = "<group>"; };
20000000000000000000000B /* CrossDeskMobile.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = CrossDeskMobile.app; sourceTree = BUILT_PRODUCTS_DIR; };
20000000000000000000000C /* build_minirtc_ios.sh */ = {isa = PBXFileReference; lastKnownFileType = text.script.sh; path = build_minirtc_ios.sh; sourceTree = "<group>"; };
20000000000000000000000D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
300000000000000000000001 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
400000000000000000000001 = {
isa = PBXGroup;
children = (
400000000000000000000002 /* CrossDeskMobile */,
400000000000000000000006 /* scripts */,
400000000000000000000007 /* Products */,
);
sourceTree = "<group>";
};
400000000000000000000002 /* CrossDeskMobile */ = {
isa = PBXGroup;
children = (
200000000000000000000001 /* CrossDeskMobileApp.swift */,
200000000000000000000002 /* RemoteSessionModel.swift */,
200000000000000000000003 /* ContentView.swift */,
200000000000000000000004 /* RemoteSessionView.swift */,
400000000000000000000003 /* Bridge */,
400000000000000000000004 /* Rendering */,
400000000000000000000005 /* Input */,
20000000000000000000000D /* Assets.xcassets */,
200000000000000000000007 /* CrossDeskMobile-Bridging-Header.h */,
200000000000000000000008 /* Info.plist */,
);
path = CrossDeskMobile;
sourceTree = "<group>";
};
400000000000000000000003 /* Bridge */ = {
isa = PBXGroup;
children = (
200000000000000000000005 /* CrossDeskRTCBridge.h */,
200000000000000000000006 /* CrossDeskRTCBridge.mm */,
);
path = Bridge;
sourceTree = "<group>";
};
400000000000000000000004 /* Rendering */ = {
isa = PBXGroup;
children = (
200000000000000000000009 /* NativeMetalVideoView.swift */,
);
path = Rendering;
sourceTree = "<group>";
};
400000000000000000000005 /* Input */ = {
isa = PBXGroup;
children = (
20000000000000000000000A /* RemoteTouchInputView.swift */,
);
path = Input;
sourceTree = "<group>";
};
400000000000000000000006 /* scripts */ = {
isa = PBXGroup;
children = (
20000000000000000000000C /* build_minirtc_ios.sh */,
);
path = scripts;
sourceTree = "<group>";
};
400000000000000000000007 /* Products */ = {
isa = PBXGroup;
children = (
20000000000000000000000B /* CrossDeskMobile.app */,
);
name = Products;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
500000000000000000000001 /* CrossDeskMobile */ = {
isa = PBXNativeTarget;
buildConfigurationList = 900000000000000000000002 /* Build configuration list for PBXNativeTarget "CrossDeskMobile" */;
buildPhases = (
700000000000000000000001 /* Build native MiniRTC */,
600000000000000000000001 /* Sources */,
300000000000000000000001 /* Frameworks */,
600000000000000000000002 /* Resources */,
);
buildRules = (
);
dependencies = (
);
name = CrossDeskMobile;
productName = CrossDeskMobile;
productReference = 20000000000000000000000B /* CrossDeskMobile.app */;
productType = "com.apple.product-type.application";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
800000000000000000000001 /* Project object */ = {
isa = PBXProject;
attributes = {
BuildIndependentTargetsInParallel = 1;
LastSwiftUpdateCheck = 2660;
LastUpgradeCheck = 2660;
TargetAttributes = {
500000000000000000000001 = {
CreatedOnToolsVersion = 26.6;
};
};
};
buildConfigurationList = 900000000000000000000001 /* Build configuration list for PBXProject "CrossDeskMobile" */;
compatibilityVersion = "Xcode 15.0";
developmentRegion = zh_CN;
hasScannedForEncodings = 0;
knownRegions = (
Base,
en,
zh_CN,
);
mainGroup = 400000000000000000000001;
productRefGroup = 400000000000000000000007 /* Products */;
projectDirPath = "";
projectRoot = "";
targets = (
500000000000000000000001 /* CrossDeskMobile */,
);
};
/* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */
600000000000000000000002 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
100000000000000000000008 /* Assets.xcassets in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXShellScriptBuildPhase section */
700000000000000000000001 /* Build native MiniRTC */ = {
isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
);
inputPaths = (
);
name = "Build native MiniRTC";
outputFileListPaths = (
);
outputPaths = (
"$(PROJECT_DIR)/Vendor/iphoneos/$(CONFIGURATION)/libCrossDeskMiniRTC.a",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/zsh;
shellScript = "\"${PROJECT_DIR}/scripts/build_minirtc_ios.sh\"\n";
};
/* End PBXShellScriptBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
600000000000000000000001 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
100000000000000000000001 /* CrossDeskMobileApp.swift in Sources */,
100000000000000000000002 /* RemoteSessionModel.swift in Sources */,
100000000000000000000003 /* ContentView.swift in Sources */,
100000000000000000000004 /* RemoteSessionView.swift in Sources */,
100000000000000000000005 /* CrossDeskRTCBridge.mm in Sources */,
100000000000000000000006 /* NativeMetalVideoView.swift in Sources */,
100000000000000000000007 /* RemoteTouchInputView.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin XCBuildConfiguration section */
A00000000000000000000001 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
CLANG_ANALYZER_NONNULL = YES;
CLANG_CXX_LANGUAGE_STANDARD = "c++17";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
DEBUG_INFORMATION_FORMAT = dwarf;
ENABLE_TESTABILITY = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
GCC_OPTIMIZATION_LEVEL = 0;
GCC_PREPROCESSOR_DEFINITIONS = (
"DEBUG=1",
"$(inherited)",
);
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = iphoneos;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
};
name = Debug;
};
A00000000000000000000002 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
CLANG_ANALYZER_NONNULL = YES;
CLANG_CXX_LANGUAGE_STANDARD = "c++17";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
GCC_C_LANGUAGE_STANDARD = gnu17;
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = iphoneos;
SWIFT_COMPILATION_MODE = wholemodule;
VALIDATE_PRODUCT = YES;
};
name = Release;
};
A00000000000000000000003 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ARCHS = arm64;
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = 8DNC3SZ86S;
ENABLE_BITCODE = NO;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
GENERATE_INFOPLIST_FILE = NO;
HEADER_SEARCH_PATHS = (
"$(inherited)",
"$(PROJECT_DIR)/../submodules/minirtc/src/api",
"$(PROJECT_DIR)/../src/device_controller",
"$(PROJECT_DIR)/../src/common",
"$(PROJECT_DIR)/../src/tools",
);
INFOPLIST_FILE = CrossDeskMobile/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
MARKETING_VERSION = 1.0;
OTHER_LDFLAGS = (
"$(inherited)",
"$(PROJECT_DIR)/Vendor/iphoneos/$(CONFIGURATION)/libCrossDeskMiniRTC.a",
"-liconv",
"-lresolv",
"-framework",
VideoToolbox,
"-framework",
CoreMedia,
"-framework",
CoreVideo,
"-framework",
Security,
"-framework",
SystemConfiguration,
"-framework",
CoreFoundation,
"-framework",
Foundation,
);
PRODUCT_BUNDLE_IDENTIFIER = cn.crossdesk.mobile;
PRODUCT_NAME = "$(TARGET_NAME)";
SUPPORTED_PLATFORMS = iphoneos;
SWIFT_OBJC_BRIDGING_HEADER = "CrossDeskMobile/CrossDeskMobile-Bridging-Header.h";
SWIFT_STRICT_CONCURRENCY = minimal;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Debug;
};
A00000000000000000000004 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ARCHS = arm64;
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = 8DNC3SZ86S;
ENABLE_BITCODE = NO;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
GENERATE_INFOPLIST_FILE = NO;
HEADER_SEARCH_PATHS = (
"$(inherited)",
"$(PROJECT_DIR)/../submodules/minirtc/src/api",
"$(PROJECT_DIR)/../src/device_controller",
"$(PROJECT_DIR)/../src/common",
"$(PROJECT_DIR)/../src/tools",
);
INFOPLIST_FILE = CrossDeskMobile/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
MARKETING_VERSION = 1.0;
OTHER_LDFLAGS = (
"$(inherited)",
"$(PROJECT_DIR)/Vendor/iphoneos/$(CONFIGURATION)/libCrossDeskMiniRTC.a",
"-liconv",
"-lresolv",
"-framework",
VideoToolbox,
"-framework",
CoreMedia,
"-framework",
CoreVideo,
"-framework",
Security,
"-framework",
SystemConfiguration,
"-framework",
CoreFoundation,
"-framework",
Foundation,
);
PRODUCT_BUNDLE_IDENTIFIER = cn.crossdesk.mobile;
PRODUCT_NAME = "$(TARGET_NAME)";
SUPPORTED_PLATFORMS = iphoneos;
SWIFT_OBJC_BRIDGING_HEADER = "CrossDeskMobile/CrossDeskMobile-Bridging-Header.h";
SWIFT_STRICT_CONCURRENCY = minimal;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Release;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
900000000000000000000001 /* Build configuration list for PBXProject "CrossDeskMobile" */ = {
isa = XCConfigurationList;
buildConfigurations = (
A00000000000000000000001 /* Debug */,
A00000000000000000000002 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
900000000000000000000002 /* Build configuration list for PBXNativeTarget "CrossDeskMobile" */ = {
isa = XCConfigurationList;
buildConfigurations = (
A00000000000000000000003 /* Debug */,
A00000000000000000000004 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
};
rootObject = 800000000000000000000001 /* Project object */;
}
@@ -0,0 +1,76 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "2660"
version = "1.7">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES"
buildArchitectures = "Automatic">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "500000000000000000000001"
BuildableName = "CrossDeskMobile.app"
BlueprintName = "CrossDeskMobile"
ReferencedContainer = "container:CrossDeskMobile.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES">
<Testables>
</Testables>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
<BuildableProductRunnable runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "500000000000000000000001"
BuildableName = "CrossDeskMobile.app"
BlueprintName = "CrossDeskMobile"
ReferencedContainer = "container:CrossDeskMobile.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "500000000000000000000001"
BuildableName = "CrossDeskMobile.app"
BlueprintName = "CrossDeskMobile"
ReferencedContainer = "container:CrossDeskMobile.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>
@@ -0,0 +1,14 @@
{
"images" : [
{
"filename" : "CrossDeskAppIcon-1024.png",
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 217 KiB

@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -0,0 +1,121 @@
#import <CoreVideo/CoreVideo.h>
#import <Foundation/Foundation.h>
NS_ASSUME_NONNULL_BEGIN
typedef NS_ENUM(NSInteger, CrossDeskSignalState) {
CrossDeskSignalStateConnecting = 0,
CrossDeskSignalStateConnected,
CrossDeskSignalStateFailed,
CrossDeskSignalStateClosed,
CrossDeskSignalStateReconnecting,
CrossDeskSignalStateServerClosed,
CrossDeskSignalStateTLSCertificateError,
};
typedef NS_ENUM(NSInteger, CrossDeskConnectionState) {
CrossDeskConnectionStateConnecting = 0,
CrossDeskConnectionStateConnected,
CrossDeskConnectionStateGathering,
CrossDeskConnectionStateDisconnected,
CrossDeskConnectionStateFailed,
CrossDeskConnectionStateClosed,
CrossDeskConnectionStateIncorrectPassword,
CrossDeskConnectionStateNoSuchID,
CrossDeskConnectionStateRemoteUnavailable,
};
typedef NS_ENUM(NSInteger, CrossDeskPointerAction) {
CrossDeskPointerActionMove = 0,
CrossDeskPointerActionLeftDown,
CrossDeskPointerActionLeftUp,
CrossDeskPointerActionRightDown,
CrossDeskPointerActionRightUp,
CrossDeskPointerActionMiddleDown,
CrossDeskPointerActionMiddleUp,
};
@class CrossDeskRTCBridge;
@protocol CrossDeskRTCBridgeDelegate <NSObject>
@optional
- (void)rtcBridge:(CrossDeskRTCBridge *)bridge
didChangeSignalState:(CrossDeskSignalState)state;
- (void)rtcBridge:(CrossDeskRTCBridge *)bridge
didChangeConnectionState:(CrossDeskConnectionState)state
remoteID:(NSString *)remoteID;
- (void)rtcBridge:(CrossDeskRTCBridge *)bridge
didProvisionIdentity:(NSString *)identity;
- (void)rtcBridge:(CrossDeskRTCBridge *)bridge
didReceivePixelBuffer:(CVPixelBufferRef)pixelBuffer
width:(NSInteger)width
height:(NSInteger)height;
- (void)rtcBridge:(CrossDeskRTCBridge *)bridge
didReceiveHostName:(NSString *)hostName
displayNames:(NSArray<NSString *> *)displayNames
displaySizes:(NSArray<NSValue *> *)displaySizes;
- (void)rtcBridge:(CrossDeskRTCBridge *)bridge
didReceivePresence:(NSDictionary<NSString *, NSNumber *> *)presence;
- (void)rtcBridge:(CrossDeskRTCBridge *)bridge
didReceiveCursorVisible:(BOOL)visible
shape:(NSInteger)shape
positionUpdate:(BOOL)positionUpdate
positionValid:(BOOL)positionValid
x:(float)x
y:(float)y
visualOffsetX:(float)visualOffsetX
visualOffsetY:(float)visualOffsetY
displayIndex:(NSInteger)displayIndex
sequence:(uint32_t)sequence;
- (void)rtcBridge:(CrossDeskRTCBridge *)bridge
didReceiveAudioPCM:(NSData *)pcmData;
- (void)rtcBridge:(CrossDeskRTCBridge *)bridge
didReceiveClipboardText:(NSString *)text;
- (void)rtcBridge:(CrossDeskRTCBridge *)bridge
didUpdateFileTransfer:(NSString *)fileName
progress:(double)progress
sending:(BOOL)sending;
- (void)rtcBridge:(CrossDeskRTCBridge *)bridge
didReceiveFileAtURL:(NSURL *)fileURL;
- (void)rtcBridge:(CrossDeskRTCBridge *)bridge
didUpdateBitrate:(NSUInteger)bitsPerSecond
lossRate:(float)lossRate
usingTURN:(BOOL)usingTURN;
@end
/// Objective-C++ boundary around the native MiniRTC C API.
///
/// All MiniRTC ownership and calls are serialized internally. Delegate methods
/// are delivered on the main queue so SwiftUI never touches callback threads.
@interface CrossDeskRTCBridge : NSObject
@property(nonatomic, weak, nullable) id<CrossDeskRTCBridgeDelegate> delegate;
- (void)configureWithSignalHost:(NSString *)host
signalPort:(NSInteger)signalPort
turnPort:(NSInteger)turnPort
enableSRTP:(BOOL)enableSRTP;
- (void)setHardwareAccelerationEnabled:(BOOL)enabled;
- (void)requestPresenceForRemoteIDs:(NSArray<NSString *> *)remoteIDs
NS_SWIFT_NAME(requestPresence(remoteIDs:));
- (void)connectToRemoteID:(NSString *)remoteID password:(NSString *)password;
- (void)disconnect;
- (void)sendPointerAtX:(float)x
y:(float)y
action:(CrossDeskPointerAction)action
NS_SWIFT_NAME(sendPointer(x:y:action:));
- (void)sendScrollX:(float)x y:(float)y deltaX:(NSInteger)deltaX
deltaY:(NSInteger)deltaY;
- (void)sendWindowsKeyCode:(NSUInteger)keyCode isDown:(BOOL)isDown;
- (void)sendText:(NSString *)text;
- (void)switchToDisplay:(NSInteger)displayIndex;
- (void)setAudioEnabled:(BOOL)enabled;
- (void)sendClipboardText:(NSString *)text;
- (void)sendFileAtURL:(NSURL *)fileURL;
- (void)sendSecureAttentionSequence;
@end
NS_ASSUME_NONNULL_END
File diff suppressed because it is too large Load Diff
+731
View File
@@ -0,0 +1,731 @@
import SwiftUI
import UIKit
struct ContentView: View {
@Environment(\.scenePhase) private var scenePhase
@ObservedObject var session: RemoteSessionModel
var body: some View {
Group {
if session.sessionVisible {
RemoteSessionView(session: session)
.transition(.opacity)
} else {
ConnectionHomeView(session: session)
.transition(.opacity)
}
}
.animation(.easeInOut(duration: 0.2), value: session.sessionVisible)
.preferredColorScheme(session.sessionVisible ? .dark : .light)
.onAppear {
if !session.sessionVisible {
AppOrientation.update(to: .portrait)
}
if scenePhase == .active {
session.refreshRecentConnectionPresenceAfterForeground()
}
}
.onChange(of: scenePhase) { phase in
if phase == .active {
session.refreshRecentConnectionPresenceAfterForeground()
}
}
}
}
private struct KeyboardDismissTapView: UIViewRepresentable {
let onDismiss: () -> Void
func makeCoordinator() -> Coordinator {
Coordinator(onDismiss: onDismiss)
}
func makeUIView(context: Context) -> UIView {
let view = UIView(frame: .zero)
view.backgroundColor = .clear
view.isUserInteractionEnabled = false
installRecognizer(for: view, coordinator: context.coordinator)
return view
}
func updateUIView(_ view: UIView, context: Context) {
context.coordinator.onDismiss = onDismiss
installRecognizer(for: view, coordinator: context.coordinator)
}
static func dismantleUIView(_ view: UIView, coordinator: Coordinator) {
coordinator.uninstall()
}
private func installRecognizer(for view: UIView, coordinator: Coordinator) {
DispatchQueue.main.async { [weak view, weak coordinator] in
guard let window = view?.window else { return }
coordinator?.install(in: window)
}
}
final class Coordinator: NSObject, UIGestureRecognizerDelegate {
var onDismiss: () -> Void
private weak var window: UIWindow?
private var recognizer: UITapGestureRecognizer?
init(onDismiss: @escaping () -> Void) {
self.onDismiss = onDismiss
}
func install(in window: UIWindow) {
guard self.window !== window || recognizer == nil else { return }
uninstall()
let recognizer = UITapGestureRecognizer(target: self,
action: #selector(didTapOutsideInput))
recognizer.cancelsTouchesInView = false
recognizer.delegate = self
window.addGestureRecognizer(recognizer)
self.window = window
self.recognizer = recognizer
}
func uninstall() {
if let recognizer {
window?.removeGestureRecognizer(recognizer)
}
recognizer = nil
window = nil
}
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer,
shouldReceive touch: UITouch) -> Bool {
var touchedView = touch.view
while let view = touchedView {
if view is UITextField || view is UITextView {
return false
}
touchedView = view.superview
}
return true
}
@objc private func didTapOutsideInput() {
onDismiss()
window?.endEditing(true)
}
}
}
private struct ConnectionHomeView: View {
@ObservedObject var session: RemoteSessionModel
@FocusState private var remoteIDFocused: Bool
@State private var passwordPromptVisible = false
@State private var settingsVisible = false
@State private var promptRemoteID = ""
@State private var promptPassword = ""
@State private var promptRememberPassword = false
private let connectionAccent = Color(red: 0.18, green: 0.48, blue: 0.86)
private let connectionAccentEnd = Color(red: 0.12, green: 0.38, blue: 0.78)
private var trimmedRemoteID: String {
session.remoteID.trimmingCharacters(in: .whitespacesAndNewlines)
}
private var groupedRemoteID: Binding<String> {
Binding(
get: { formatRemoteID(session.remoteID) },
set: { value in
session.remoteID = String(value.filter { $0.isNumber }.prefix(9))
}
)
}
private var signalIsConnected: Bool {
session.signalStatus == "已连接服务器"
}
private var signalHasError: Bool {
session.signalStatus.contains("失败") ||
session.signalStatus.contains("无效") ||
session.signalStatus.contains("关闭") ||
session.signalStatus.contains("未知")
}
private var signalTint: Color {
if signalIsConnected { return .green }
if signalHasError { return .red }
return .orange
}
private var signalSymbol: String {
if signalIsConnected { return "checkmark.circle.fill" }
if signalHasError { return "exclamationmark.triangle.fill" }
return "arrow.triangle.2.circlepath"
}
private var recentConnectionColumns: [GridItem] {
[
GridItem(.flexible(), spacing: 12, alignment: .top),
GridItem(.flexible(), spacing: 12, alignment: .top)
]
}
private var orderedRecentConnections: [RecentConnection] {
session.recentConnections.enumerated()
.sorted { lhs, rhs in
let lhsOnline = session.isRecentConnectionOnline(lhs.element)
let rhsOnline = session.isRecentConnectionOnline(rhs.element)
if lhsOnline != rhsOnline { return lhsOnline }
return lhs.offset < rhs.offset
}
.map(\.element)
}
var body: some View {
ZStack {
Color(.systemGroupedBackground).ignoresSafeArea()
VStack(spacing: 0) {
header
remoteConnectionPanel
.padding(.top, 24)
recentConnectionsPanel
.padding(.top, 20)
.frame(maxHeight: .infinity)
}
.padding(.horizontal, 20)
.padding(.bottom, 12)
if session.isConnecting {
connectionProgressOverlay
}
}
.background {
KeyboardDismissTapView {
remoteIDFocused = false
}
.allowsHitTesting(false)
}
.sheet(isPresented: $passwordPromptVisible) {
PasswordPromptView(remoteID: promptRemoteID,
password: $promptPassword,
rememberPassword: $promptRememberPassword) {
passwordPromptVisible = false
session.remoteID = promptRemoteID
session.connect(password: promptPassword,
rememberPassword: promptRememberPassword)
}
.presentationDetents([.height(340)])
.presentationDragIndicator(.visible)
}
.sheet(isPresented: $settingsVisible) {
ServerSettingsView(session: session)
.presentationDetents([.fraction(0.80)])
.presentationDragIndicator(.visible)
}
.alert("设备离线", isPresented: Binding(
get: { session.deviceOfflineAlertVisible },
set: { visible in
if !visible { session.dismissDeviceOfflineAlert() }
}
)) {
Button("确定") {
session.dismissDeviceOfflineAlert()
}
}
.onChange(of: session.remoteID) { value in
let formatted = String(value.filter { $0.isNumber }.prefix(9))
if formatted != value {
session.remoteID = formatted
}
}
}
private var header: some View {
HStack(spacing: 8) {
Label {
Text(session.signalStatus)
.font(.caption.weight(.semibold))
.lineLimit(1)
} icon: {
Image(systemName: signalSymbol)
.font(.caption.weight(.bold))
}
.foregroundStyle(signalTint)
.padding(.horizontal, 11)
.frame(height: 34)
.background(signalTint.opacity(0.11), in: Capsule())
.overlay {
Capsule()
.stroke(signalTint.opacity(0.22), lineWidth: 1)
}
Spacer()
Button {
settingsVisible = true
} label: {
Image(systemName: "gearshape")
.font(.system(size: 18, weight: .semibold))
.frame(width: 40, height: 40)
.background(Color(.secondarySystemGroupedBackground),
in: RoundedRectangle(cornerRadius: 10,
style: .continuous))
}
.foregroundStyle(.primary)
.accessibilityLabel("设置")
}
.padding(.top, 8)
}
private var remoteConnectionPanel: some View {
VStack(alignment: .leading, spacing: 16) {
HStack {
Text("远程桌面")
.font(.title3.weight(.bold))
Spacer()
}
HStack(spacing: 10) {
TextField("对端 ID", text: groupedRemoteID)
.keyboardType(.numberPad)
.textContentType(.username)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.font(.system(.title3, design: .rounded).weight(.semibold))
.monospacedDigit()
.focused($remoteIDFocused)
.padding(.horizontal, 15)
.frame(height: 54)
.background(Color(.systemGroupedBackground),
in: RoundedRectangle(cornerRadius: 12,
style: .continuous))
.overlay {
RoundedRectangle(cornerRadius: 12, style: .continuous)
.stroke(remoteIDFocused
? connectionAccent
: Color(.separator).opacity(0.4),
lineWidth: remoteIDFocused ? 2 : 1)
}
.shadow(color: remoteIDFocused
? connectionAccent.opacity(0.12) : .clear,
radius: 8, y: 2)
Button {
presentPasswordPrompt(for: trimmedRemoteID)
} label: {
HStack(spacing: 6) {
Text("连接")
Image(systemName: "arrow.right")
}
.font(.system(size: 16, weight: .bold))
.frame(width: 96, height: 54)
.foregroundStyle(.white)
.background(LinearGradient(colors: [
connectionAccent,
connectionAccentEnd
], startPoint: .topLeading, endPoint: .bottomTrailing))
.clipShape(RoundedRectangle(cornerRadius: 12,
style: .continuous))
.shadow(color: connectionAccent.opacity(0.24),
radius: 8, y: 4)
}
.buttonStyle(.plain)
.disabled(trimmedRemoteID.isEmpty || session.isConnecting)
.opacity(trimmedRemoteID.isEmpty || session.isConnecting ? 0.45 : 1)
.accessibilityLabel("连接")
}
}
.padding(18)
.background(Color(.secondarySystemGroupedBackground),
in: RoundedRectangle(cornerRadius: 20, style: .continuous))
.overlay {
RoundedRectangle(cornerRadius: 20, style: .continuous)
.stroke(Color(.separator).opacity(0.28), lineWidth: 1)
}
.shadow(color: .black.opacity(0.055), radius: 14, y: 5)
}
@ViewBuilder
private var recentConnectionsPanel: some View {
VStack(alignment: .leading, spacing: 14) {
HStack(alignment: .firstTextBaseline) {
Text("最近连接")
.font(.title3.bold())
Spacer()
if !session.recentConnections.isEmpty {
Text("\(session.recentConnections.count) 台设备")
.font(.caption)
.foregroundStyle(.secondary)
}
}
if session.recentConnections.isEmpty {
VStack(spacing: 10) {
Image(systemName: "clock.arrow.circlepath")
.font(.system(size: 30, weight: .medium))
.foregroundStyle(.tertiary)
Text("还没有连接记录")
.font(.headline)
Text("成功连接后,这里会显示远端桌面的缩略图。")
.font(.footnote)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else {
ScrollView(.vertical) {
LazyVGrid(columns: recentConnectionColumns, spacing: 12) {
ForEach(orderedRecentConnections) { connection in
RecentConnectionCard(
connection: connection,
thumbnail: session.thumbnailImage(for: connection),
online: session.isRecentConnectionOnline(connection),
connect: {
connectRecent(connection)
},
delete: {
session.removeRecentConnection(connection)
}
)
}
}
.padding(.bottom, 2)
}
}
}
.padding(16)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
.background(Color(.secondarySystemGroupedBackground),
in: RoundedRectangle(cornerRadius: 18, style: .continuous))
.overlay {
RoundedRectangle(cornerRadius: 18, style: .continuous)
.stroke(Color(.separator).opacity(0.35), lineWidth: 1)
}
}
private var connectionProgressOverlay: some View {
ZStack {
Color.clear
.ignoresSafeArea()
.contentShape(Rectangle())
.onTapGesture { }
VStack(spacing: 0) {
VStack(spacing: 14) {
ProgressView()
.controlSize(.large)
.tint(connectionAccent)
VStack(spacing: 6) {
Text("正在连接")
.font(.title3.weight(.semibold))
.foregroundStyle(.primary)
Text(session.connectionStatus)
.font(.subheadline)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
.lineLimit(2)
.frame(minHeight: 36)
}
}
.padding(.horizontal, 26)
.padding(.top, 24)
.padding(.bottom, 20)
Divider()
Button(role: .cancel, action: session.disconnect) {
Text("取消连接")
.font(.subheadline.weight(.semibold))
.foregroundStyle(Color.red)
.frame(maxWidth: .infinity)
.frame(height: 46)
}
.buttonStyle(.plain)
}
.frame(width: 292)
.background(Color.white,
in: RoundedRectangle(cornerRadius: 24,
style: .continuous))
.overlay {
RoundedRectangle(cornerRadius: 24, style: .continuous)
.stroke(Color(.separator).opacity(0.24), lineWidth: 0.8)
}
.shadow(color: .black.opacity(0.22), radius: 28, y: 10)
}
.transition(.opacity.combined(with: .scale(scale: 0.96)))
}
private func presentPasswordPrompt(for identifier: String) {
let trimmed = identifier.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else {
remoteIDFocused = true
return
}
remoteIDFocused = false
promptRemoteID = trimmed
promptPassword = session.savedPassword(for: trimmed)
promptRememberPassword = session.remembersPassword(for: trimmed)
passwordPromptVisible = true
}
private func formatRemoteID(_ value: String) -> String {
let digits = Array(value.filter { $0.isNumber }.prefix(9))
return stride(from: 0, to: digits.count, by: 3)
.map { start in
String(digits[start..<min(start + 3, digits.count)])
}
.joined(separator: " ")
}
private func connectRecent(_ connection: RecentConnection) {
session.remoteID = connection.remoteID
remoteIDFocused = false
if connection.remembersPassword,
let savedPassword = session.savedCredential(for: connection.remoteID) {
session.connect(password: savedPassword, rememberPassword: true)
} else {
presentPasswordPrompt(for: connection.remoteID)
}
}
}
private struct RecentConnectionCard: View {
let connection: RecentConnection
let thumbnail: UIImage?
let online: Bool
let connect: () -> Void
let delete: () -> Void
var body: some View {
Button(action: connect) {
VStack(alignment: .leading, spacing: 0) {
ZStack {
if let thumbnail {
Image(uiImage: thumbnail)
.resizable()
.scaledToFill()
} else {
LinearGradient(colors: [
Color(red: 0.18, green: 0.48, blue: 0.86),
Color(red: 0.38, green: 0.68, blue: 0.96)
], startPoint: .topLeading, endPoint: .bottomTrailing)
Image(systemName: "display")
.font(.system(size: 34, weight: .medium))
.foregroundStyle(.white.opacity(0.9))
}
}
.frame(maxWidth: .infinity)
.aspectRatio(16 / 9, contentMode: .fit)
.clipped()
VStack(alignment: .leading, spacing: 3) {
Text(connection.displayName)
.font(.caption.weight(.semibold))
.foregroundStyle(.primary)
.lineLimit(1)
HStack(spacing: 5) {
Text("ID \(connection.remoteID)")
.font(.caption2.monospacedDigit())
.foregroundStyle(.secondary)
.lineLimit(1)
Spacer(minLength: 4)
if connection.remembersPassword {
Image(systemName: "key.fill")
.font(.system(size: 9, weight: .semibold))
.foregroundStyle(.secondary)
.accessibilityLabel("已保存密码")
}
Text(online ? "在线" : "离线")
.font(.caption2.weight(.semibold))
.foregroundStyle(online ? Color.green : Color.secondary)
.accessibilityLabel(online ? "在线" : "离线")
}
}
.padding(9)
}
.background(Color(.secondarySystemGroupedBackground))
.clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
.overlay {
RoundedRectangle(cornerRadius: 14, style: .continuous)
.stroke(Color(.separator).opacity(0.35), lineWidth: 1)
}
}
.buttonStyle(.plain)
.contextMenu {
Button(action: connect) {
Label("连接", systemImage: "arrow.right.circle")
}
Button(role: .destructive, action: delete) {
Label("删除记录", systemImage: "trash")
}
}
}
}
private struct PasswordPromptView: View {
let remoteID: String
@Binding var password: String
@Binding var rememberPassword: Bool
let connect: () -> Void
@Environment(\.dismiss) private var dismiss
@FocusState private var passwordFocused: Bool
@State private var passwordVisible = false
var body: some View {
VStack(alignment: .leading, spacing: 18) {
HStack {
VStack(alignment: .leading, spacing: 4) {
Text("连接远程桌面")
.font(.title2.bold())
Text("对端 ID \(remoteID)")
.font(.subheadline.monospacedDigit())
.foregroundStyle(.secondary)
}
Spacer()
Button {
dismiss()
} label: {
Image(systemName: "xmark.circle.fill")
.font(.title2)
.foregroundStyle(.secondary)
}
.accessibilityLabel("关闭")
}
HStack(spacing: 10) {
Group {
if passwordVisible {
TextField("访问密码", text: $password)
} else {
SecureField("访问密码", text: $password)
}
}
.keyboardType(.numberPad)
.textContentType(.password)
.focused($passwordFocused)
Button {
passwordVisible.toggle()
} label: {
Image(systemName: passwordVisible ? "eye.slash" : "eye")
.foregroundStyle(.secondary)
}
.accessibilityLabel(passwordVisible ? "隐藏密码" : "显示密码")
}
.padding(.horizontal, 13)
.frame(height: 50)
.background(Color(.secondarySystemGroupedBackground),
in: RoundedRectangle(cornerRadius: 10,
style: .continuous))
.overlay {
RoundedRectangle(cornerRadius: 10, style: .continuous)
.stroke(Color(.separator).opacity(0.5), lineWidth: 1)
}
Toggle(isOn: $rememberPassword) {
VStack(alignment: .leading, spacing: 2) {
Text("保存密码")
.font(.subheadline.weight(.semibold))
Text("密码将安全保存在本机 Keychain 中")
.font(.caption)
.foregroundStyle(.secondary)
}
}
Button(action: connect) {
Label("连接", systemImage: "arrow.right")
.font(.headline)
.frame(maxWidth: .infinity)
.frame(height: 48)
}
.buttonStyle(.borderedProminent)
.buttonBorderShape(.roundedRectangle(radius: 10))
}
.padding(22)
.onAppear {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) {
passwordFocused = true
}
}
}
}
private struct ServerSettingsView: View {
@ObservedObject var session: RemoteSessionModel
@Environment(\.dismiss) private var dismiss
var body: some View {
NavigationStack {
Form {
Section("鼠标控制") {
Picker("控制模式", selection: $session.mouseControlMode) {
ForEach(MouseControlMode.allCases) { mode in
Text(mode.title).tag(mode)
}
}
.pickerStyle(.segmented)
Text(session.mouseControlMode.detail)
.font(.footnote)
.foregroundStyle(.secondary)
}
Section("视频编解码") {
Picker("处理方式", selection: $session.videoCodecMode) {
ForEach(VideoCodecMode.allCases) { mode in
Text(mode.title).tag(mode)
}
}
.pickerStyle(.segmented)
Text(session.videoCodecMode.detail)
.font(.footnote)
.foregroundStyle(.secondary)
Text("修改后从下一次连接开始生效。")
.font(.caption)
.foregroundStyle(.tertiary)
}
Section("服务器") {
TextField("信令服务器", text: $session.signalHost)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
TextField("信令端口", text: $session.signalPort)
.keyboardType(.numberPad)
TextField("STUN/TURN 端口", text: $session.turnPort)
.keyboardType(.numberPad)
}
Section {
Toggle("启用 SRTP", isOn: $session.enableSRTP)
} header: {
Text("传输")
} footer: {
Text(session.localIdentity.isEmpty
? "正在获取本机 ID…"
: "本机 ID \(session.localIdentity)")
.font(.caption2.monospacedDigit())
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, alignment: .center)
.padding(.top, 16)
.textSelection(.enabled)
}
}
.navigationTitle("设置")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .confirmationAction) {
Button("应用") {
session.configureBridge()
dismiss()
}
}
ToolbarItem(placement: .cancellationAction) {
Button("取消") { dismiss() }
}
}
}
}
}
@@ -0,0 +1 @@
#import "Bridge/CrossDeskRTCBridge.h"
@@ -0,0 +1,43 @@
import SwiftUI
import UIKit
final class CrossDeskAppDelegate: NSObject, UIApplicationDelegate {
static var supportedOrientations: UIInterfaceOrientationMask = .portrait
func application(_ application: UIApplication,
supportedInterfaceOrientationsFor window: UIWindow?)
-> UIInterfaceOrientationMask {
Self.supportedOrientations
}
}
enum AppOrientation {
static func update(to orientations: UIInterfaceOrientationMask) {
DispatchQueue.main.async {
CrossDeskAppDelegate.supportedOrientations = orientations
for case let scene as UIWindowScene in UIApplication.shared.connectedScenes {
scene.windows.forEach {
$0.rootViewController?.setNeedsUpdateOfSupportedInterfaceOrientations()
}
scene.requestGeometryUpdate(
.iOS(interfaceOrientations: orientations)
) { error in
NSLog("CrossDesk orientation update failed: %@", error.localizedDescription)
}
}
}
}
}
@main
struct CrossDeskMobileApp: App {
@UIApplicationDelegateAdaptor(CrossDeskAppDelegate.self) private var appDelegate
@StateObject private var session = RemoteSessionModel()
var body: some Scene {
WindowGroup {
ContentView(session: session)
}
}
}
+53
View File
@@ -0,0 +1,53 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDisplayName</key>
<string>CrossDesk</string>
<key>CFBundleDevelopmentRegion</key>
<string>zh_CN</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleVersion</key>
<string>2</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>NSLocalNetworkUsageDescription</key>
<string>CrossDesk 使用局域网发现并建立原生 P2P 远程控制连接。</string>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
<key>UIFileSharingEnabled</key>
<true/>
<key>LSSupportsOpeningDocumentsInPlace</key>
<true/>
<key>UILaunchScreen</key>
<dict/>
<key>UIRequiredDeviceCapabilities</key>
<array>
<string>arm64</string>
</array>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
</dict>
</plist>
@@ -0,0 +1,502 @@
import SwiftUI
import UIKit
enum RemoteVideoGeometry {
private static func pixelAligned(_ rect: CGRect) -> CGRect {
let scale = max(UIScreen.main.scale, 1)
let minX = (rect.minX * scale).rounded() / scale
let minY = (rect.minY * scale).rounded() / scale
let maxX = (rect.maxX * scale).rounded() / scale
let maxY = (rect.maxY * scale).rounded() / scale
return CGRect(x: minX, y: minY,
width: max(0, maxX - minX),
height: max(0, maxY - minY))
}
static func aspectFitRect(containerSize: CGSize,
videoSize: CGSize) -> CGRect? {
guard containerSize.width > 0, containerSize.height > 0,
videoSize.width > 0, videoSize.height > 0 else { return nil }
let videoAspect = videoSize.width / videoSize.height
let containerAspect = containerSize.width / containerSize.height
if videoAspect > containerAspect {
let height = containerSize.width / videoAspect
return pixelAligned(
CGRect(x: 0, y: (containerSize.height - height) / 2,
width: containerSize.width, height: height)
)
}
let width = containerSize.height * videoAspect
return pixelAligned(
CGRect(x: (containerSize.width - width) / 2, y: 0,
width: width, height: containerSize.height)
)
}
static func transformedRect(containerSize: CGSize,
videoSize: CGSize,
scale: CGFloat,
offset: CGSize) -> CGRect? {
guard let baseRect = aspectFitRect(containerSize: containerSize,
videoSize: videoSize) else {
return nil
}
// SwiftUI scales the video around the center of its own pixel-aligned
// frame. That center can differ fractionally from the container center
// after edge alignment, and the difference is amplified at 10x.
let center = CGPoint(x: baseRect.midX, y: baseRect.midY)
return CGRect(
x: center.x + (baseRect.minX - center.x) * scale + offset.width,
y: center.y + (baseRect.minY - center.y) * scale + offset.height,
width: baseRect.width * scale,
height: baseRect.height * scale
)
}
}
struct RemoteTouchInputView: UIViewRepresentable {
let videoSize: CGSize
let controlMode: MouseControlMode
let remoteCursorPosition: CGPoint?
let viewportScale: CGFloat
let viewportOffset: CGSize
let viewportChanged: (CGFloat, CGSize) -> Void
let move: (Float, Float) -> Void
let leftDown: (Float, Float) -> Void
let leftUp: (Float, Float) -> Void
let rightClick: (Float, Float) -> Void
let scroll: (Float, Float, Int, Int) -> Void
func makeUIView(context: Context) -> RemoteTouchSurface {
RemoteTouchSurface()
}
func updateUIView(_ view: RemoteTouchSurface, context: Context) {
view.videoSize = videoSize
view.controlMode = controlMode
view.setViewport(scale: viewportScale, offset: viewportOffset)
view.onViewportChanged = viewportChanged
view.onMove = move
view.onLeftDown = leftDown
view.onLeftUp = leftUp
view.onRightClick = rightClick
view.onScroll = scroll
view.synchronizeRemoteCursor(remoteCursorPosition)
}
}
final class RemoteTouchSurface: UIView, UIGestureRecognizerDelegate {
var videoSize = CGSize.zero {
didSet { setNeedsLayout() }
}
var controlMode: MouseControlMode = .absolute {
didSet {
guard oldValue != controlMode else { return }
relativeCursorPoint = lastRemoteCursorPoint ?? (0.5, 0.5)
relativePanActive = false
heldDragPoint = nil
heldDragLastLocation = nil
hoverLastLocation = nil
}
}
var onMove: ((Float, Float) -> Void)?
var onLeftDown: ((Float, Float) -> Void)?
var onLeftUp: ((Float, Float) -> Void)?
var onRightClick: ((Float, Float) -> Void)?
var onScroll: ((Float, Float, Int, Int) -> Void)?
var onViewportChanged: ((CGFloat, CGSize) -> Void)?
private var relativeCursorPoint: (Float, Float) = (0.5, 0.5)
private var lastRemoteCursorPoint: (Float, Float)?
private var relativePanActive = false
private var heldDragPoint: (Float, Float)?
private var heldDragLastLocation: CGPoint?
private var hoverLastLocation: CGPoint?
private var viewportScale: CGFloat = 1
private var viewportOffset = CGSize.zero
private var pinchActive = false
private var viewportPanActive = false
private let relativePointerSpeed: CGFloat = 1.35
private let maximumViewportScale: CGFloat = 10
override init(frame: CGRect) {
super.init(frame: frame)
backgroundColor = .clear
isMultipleTouchEnabled = true
let tap = UITapGestureRecognizer(target: self, action: #selector(handleTap(_:)))
tap.numberOfTouchesRequired = 1
addGestureRecognizer(tap)
let rightTap = UITapGestureRecognizer(target: self, action: #selector(handleRightTap(_:)))
rightTap.numberOfTouchesRequired = 2
tap.require(toFail: rightTap)
addGestureRecognizer(rightTap)
let drag = UIPanGestureRecognizer(target: self, action: #selector(handleDrag(_:)))
drag.minimumNumberOfTouches = 1
drag.maximumNumberOfTouches = 1
drag.delegate = self
addGestureRecognizer(drag)
let heldDrag = UILongPressGestureRecognizer(
target: self,
action: #selector(handleHeldDrag(_:))
)
heldDrag.minimumPressDuration = 0.35
heldDrag.allowableMovement = 14
heldDrag.numberOfTouchesRequired = 1
heldDrag.delegate = self
addGestureRecognizer(heldDrag)
let pinch = UIPinchGestureRecognizer(target: self,
action: #selector(handlePinch(_:)))
pinch.delegate = self
addGestureRecognizer(pinch)
let wheel = UIPanGestureRecognizer(target: self, action: #selector(handleWheel(_:)))
wheel.minimumNumberOfTouches = 2
wheel.maximumNumberOfTouches = 2
wheel.delegate = self
addGestureRecognizer(wheel)
let hover = UIHoverGestureRecognizer(target: self,
action: #selector(handleHover(_:)))
addGestureRecognizer(hover)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func layoutSubviews() {
super.layoutSubviews()
let constrained = constrainedViewportOffset(viewportOffset,
scale: viewportScale)
guard constrained != viewportOffset else { return }
viewportOffset = constrained
publishViewport()
}
func setViewport(scale: CGFloat, offset: CGSize) {
let nextScale = min(max(scale, 1), maximumViewportScale)
viewportScale = nextScale
viewportOffset = constrainedViewportOffset(offset, scale: nextScale)
}
func synchronizeRemoteCursor(_ position: CGPoint?) {
guard let position else {
lastRemoteCursorPoint = nil
return
}
let point = (
Float(min(max(position.x, 0), 1)),
Float(min(max(position.y, 0), 1))
)
lastRemoteCursorPoint = point
applyRemoteCursorIfIdle()
}
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer,
shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
let recognizers = [gestureRecognizer, otherGestureRecognizer]
let hasPinch = recognizers.contains { $0 is UIPinchGestureRecognizer }
let hasTwoFingerPan = recognizers.contains { recognizer in
guard let pan = recognizer as? UIPanGestureRecognizer else {
return false
}
return pan.minimumNumberOfTouches == 2
}
return hasPinch && hasTwoFingerPan
}
@objc private func handleTap(_ recognizer: UITapGestureRecognizer) {
let location = recognizer.location(in: self)
guard recognizer.state == .ended,
let point = pointerPoint(for: location) else { return }
#if DEBUG
NSLog("CrossDesk tap location=%@ normalized=(%.5f, %.5f) " +
"render=%@ scale=%.4f offset=%@ mode=%@",
String(describing: location), point.0, point.1,
renderRect().map { String(describing: $0) } ?? "nil",
viewportScale, String(describing: viewportOffset),
controlMode == .relative ? "relative" : "absolute")
#endif
onMove?(point.0, point.1)
onLeftDown?(point.0, point.1)
onLeftUp?(point.0, point.1)
}
@objc private func handleRightTap(_ recognizer: UITapGestureRecognizer) {
guard recognizer.state == .ended,
let point = pointerPoint(for: recognizer.location(in: self)) else { return }
onMove?(point.0, point.1)
onRightClick?(point.0, point.1)
}
@objc private func handleDrag(_ recognizer: UIPanGestureRecognizer) {
if controlMode == .relative {
handleRelativeDrag(recognizer)
return
}
switch recognizer.state {
case .began, .changed:
guard let point = normalized(recognizer.location(in: self)) else { return }
onMove?(point.0, point.1)
default:
break
}
}
@objc private func handleHeldDrag(_ recognizer: UILongPressGestureRecognizer) {
if controlMode == .relative {
handleRelativeHeldDrag(recognizer)
return
}
let point = normalized(recognizer.location(in: self)) ?? heldDragPoint
switch recognizer.state {
case .began:
guard let point else { return }
heldDragPoint = point
onMove?(point.0, point.1)
onLeftDown?(point.0, point.1)
case .changed:
guard let point else { return }
heldDragPoint = point
onMove?(point.0, point.1)
case .ended, .cancelled, .failed:
if let point {
onLeftUp?(point.0, point.1)
}
heldDragPoint = nil
default:
break
}
}
@objc private func handlePinch(_ recognizer: UIPinchGestureRecognizer) {
let focus = recognizer.location(in: self)
switch recognizer.state {
case .began:
pinchActive = renderRect()?.contains(focus) == true
recognizer.scale = 1
case .changed:
guard pinchActive else { return }
let oldScale = viewportScale
let newScale = min(max(oldScale * recognizer.scale, 1),
maximumViewportScale)
recognizer.scale = 1
guard abs(newScale - oldScale) > 0.0001 else { return }
let center = CGPoint(x: bounds.midX, y: bounds.midY)
let ratio = newScale / oldScale
let proposedOffset = CGSize(
width: focus.x - center.x -
(focus.x - center.x - viewportOffset.width) * ratio,
height: focus.y - center.y -
(focus.y - center.y - viewportOffset.height) * ratio
)
viewportScale = newScale
viewportOffset = constrainedViewportOffset(proposedOffset,
scale: newScale)
publishViewport()
case .ended, .cancelled, .failed:
pinchActive = false
if viewportScale <= 1.001 {
viewportScale = 1
viewportOffset = .zero
publishViewport()
}
default:
break
}
}
@objc private func handleWheel(_ recognizer: UIPanGestureRecognizer) {
if viewportScale > 1.001 || pinchActive {
switch recognizer.state {
case .began:
viewportPanActive = renderRect()?.contains(
recognizer.location(in: self)
) == true
recognizer.setTranslation(.zero, in: self)
case .changed:
if !viewportPanActive {
viewportPanActive = renderRect()?.contains(
recognizer.location(in: self)
) == true
}
guard viewportPanActive else { return }
let translation = recognizer.translation(in: self)
recognizer.setTranslation(.zero, in: self)
let proposedOffset = CGSize(
width: viewportOffset.width + translation.x,
height: viewportOffset.height + translation.y
)
viewportOffset = constrainedViewportOffset(proposedOffset,
scale: viewportScale)
publishViewport()
default:
viewportPanActive = false
}
return
}
guard let point = pointerPoint(for: recognizer.location(in: self)) else { return }
let translation = recognizer.translation(in: self)
let deltaX = Int(-translation.x / 12)
let deltaY = Int(-translation.y / 12)
if deltaX != 0 || deltaY != 0 {
onScroll?(point.0, point.1, deltaX, deltaY)
recognizer.setTranslation(.zero, in: self)
}
}
@objc private func handleHover(_ recognizer: UIHoverGestureRecognizer) {
let location = recognizer.location(in: self)
if controlMode == .relative {
switch recognizer.state {
case .began:
guard renderRect()?.contains(location) == true else { return }
hoverLastLocation = location
onMove?(relativeCursorPoint.0, relativeCursorPoint.1)
case .changed:
guard let previous = hoverLastLocation else { return }
hoverLastLocation = location
if let point = updateRelativeCursor(
by: CGPoint(x: location.x - previous.x,
y: location.y - previous.y)
) {
onMove?(point.0, point.1)
}
default:
hoverLastLocation = nil
}
return
}
guard recognizer.state == .began || recognizer.state == .changed,
let point = normalized(location) else { return }
onMove?(point.0, point.1)
}
private func handleRelativeDrag(_ recognizer: UIPanGestureRecognizer) {
switch recognizer.state {
case .began:
let location = recognizer.location(in: self)
relativePanActive = renderRect()?.contains(location) == true
recognizer.setTranslation(.zero, in: self)
if relativePanActive {
onMove?(relativeCursorPoint.0, relativeCursorPoint.1)
}
case .changed:
guard relativePanActive else { return }
let translation = recognizer.translation(in: self)
recognizer.setTranslation(.zero, in: self)
if let point = updateRelativeCursor(by: translation) {
onMove?(point.0, point.1)
}
default:
relativePanActive = false
applyRemoteCursorIfIdle()
}
}
private func handleRelativeHeldDrag(_ recognizer: UILongPressGestureRecognizer) {
let location = recognizer.location(in: self)
switch recognizer.state {
case .began:
guard renderRect()?.contains(location) == true else { return }
heldDragLastLocation = location
heldDragPoint = relativeCursorPoint
onMove?(relativeCursorPoint.0, relativeCursorPoint.1)
onLeftDown?(relativeCursorPoint.0, relativeCursorPoint.1)
case .changed:
guard let previous = heldDragLastLocation else { return }
heldDragLastLocation = location
if let point = updateRelativeCursor(
by: CGPoint(x: location.x - previous.x,
y: location.y - previous.y)
) {
heldDragPoint = point
onMove?(point.0, point.1)
}
case .ended, .cancelled, .failed:
if let point = heldDragPoint {
onLeftUp?(point.0, point.1)
}
heldDragPoint = nil
heldDragLastLocation = nil
applyRemoteCursorIfIdle()
default:
break
}
}
private func pointerPoint(for location: CGPoint) -> (Float, Float)? {
if controlMode == .relative {
guard renderRect()?.contains(location) == true else { return nil }
return relativeCursorPoint
}
return normalized(location)
}
private func updateRelativeCursor(by translation: CGPoint) -> (Float, Float)? {
guard let renderRect = renderRect(),
renderRect.width > 0, renderRect.height > 0 else { return nil }
let nextX = CGFloat(relativeCursorPoint.0) +
translation.x / renderRect.width * relativePointerSpeed
let nextY = CGFloat(relativeCursorPoint.1) +
translation.y / renderRect.height * relativePointerSpeed
relativeCursorPoint = (
Float(min(max(nextX, 0), 1)),
Float(min(max(nextY, 0), 1))
)
return relativeCursorPoint
}
private func applyRemoteCursorIfIdle() {
guard controlMode == .relative,
!relativePanActive,
heldDragPoint == nil,
let point = lastRemoteCursorPoint else { return }
relativeCursorPoint = point
}
private func normalized(_ point: CGPoint) -> (Float, Float)? {
guard let renderRect = renderRect(), renderRect.contains(point) else { return nil }
return (Float((point.x - renderRect.minX) / renderRect.width),
Float((point.y - renderRect.minY) / renderRect.height))
}
private func renderRect() -> CGRect? {
RemoteVideoGeometry.transformedRect(
containerSize: bounds.size,
videoSize: videoSize,
scale: viewportScale,
offset: viewportOffset
)
}
private func baseRenderRect() -> CGRect? {
RemoteVideoGeometry.aspectFitRect(containerSize: bounds.size,
videoSize: videoSize)
}
private func constrainedViewportOffset(_ offset: CGSize,
scale: CGFloat) -> CGSize {
guard let baseRect = baseRenderRect() else { return offset }
let maximumX = max(0, baseRect.width * (scale - 1) / 2)
let maximumY = max(0, baseRect.height * (scale - 1) / 2)
return CGSize(
width: min(max(offset.width, -maximumX), maximumX),
height: min(max(offset.height, -maximumY), maximumY)
)
}
private func publishViewport() {
onViewportChanged?(viewportScale, viewportOffset)
}
}
@@ -0,0 +1,875 @@
import AVFoundation
import CoreImage
import CoreVideo
import Foundation
import Security
import UIKit
enum MouseControlMode: String, CaseIterable, Identifiable {
case relative
case absolute
private static let defaultsKey = "crossdesk.mobile.mouse-control-mode"
var id: String { rawValue }
var title: String {
switch self {
case .absolute: return "绝对位置"
case .relative: return "相对位置"
}
}
var detail: String {
switch self {
case .absolute:
return "触摸位置直接对应远端屏幕位置,适合快速定位。"
case .relative:
return "像触控板一样滑动光标,点击时操作当前光标位置。"
}
}
static var saved: MouseControlMode {
guard let rawValue = UserDefaults.standard.string(forKey: defaultsKey),
let mode = MouseControlMode(rawValue: rawValue) else {
return .relative
}
return mode
}
func save() {
UserDefaults.standard.set(rawValue, forKey: Self.defaultsKey)
}
}
enum VideoCodecMode: String, CaseIterable, Identifiable {
case hardware
case software
private static let defaultsKey = "crossdesk.mobile.video-codec-mode"
var id: String { rawValue }
var title: String {
switch self {
case .hardware: return "硬件"
case .software: return "软件"
}
}
var detail: String {
switch self {
case .hardware:
return "使用 VideoToolbox,延迟和耗电更低,推荐日常使用。"
case .software:
return "使用 OpenH264 软件编解码,适合兼容性测试。"
}
}
static var saved: VideoCodecMode {
guard let rawValue = UserDefaults.standard.string(forKey: defaultsKey),
let mode = VideoCodecMode(rawValue: rawValue) else {
return .hardware
}
return mode
}
func save() {
UserDefaults.standard.set(rawValue, forKey: Self.defaultsKey)
}
}
private struct RemoteVideoFrame {
let pixelBuffer: CVPixelBuffer
let encodedSize: CGSize
}
struct RecentConnection: Codable, Identifiable, Equatable {
let remoteID: String
var displayName: String
var lastConnectedAt: Date
var remembersPassword: Bool
var thumbnailFileName: String?
var id: String { remoteID }
}
private enum RecentConnectionStore {
private static let defaultsKey = "crossdesk.mobile.recent-connections.v1"
private static let thumbnailQueue =
DispatchQueue(label: "cn.crossdesk.mobile.thumbnails", qos: .utility)
private static let context = CIContext(options: [.cacheIntermediates: false])
static func load() -> [RecentConnection] {
guard let data = UserDefaults.standard.data(forKey: defaultsKey),
let connections = try? JSONDecoder().decode([RecentConnection].self,
from: data) else {
return []
}
return connections.sorted { $0.lastConnectedAt > $1.lastConnectedAt }
}
static func save(_ connections: [RecentConnection]) {
guard let data = try? JSONEncoder().encode(connections) else { return }
UserDefaults.standard.set(data, forKey: defaultsKey)
}
static func thumbnailURL(fileName: String) -> URL? {
guard let directory = thumbnailDirectory() else { return nil }
return directory.appendingPathComponent(fileName, isDirectory: false)
}
static func captureThumbnail(from pixelBuffer: CVPixelBuffer,
remoteID: String,
completion: @escaping (String?) -> Void) {
thumbnailQueue.async {
let fileName: String? = autoreleasepool {
let image = CIImage(cvPixelBuffer: pixelBuffer)
let targetSize = CGSize(width: 640, height: 360)
guard image.extent.width > 0, image.extent.height > 0 else {
return nil
}
// Scale in Core Image before materializing a CGImage. Creating
// a full 4K bitmap and then drawing it into a thumbnail can add
// tens of megabytes to the first-frame memory peak.
let scale = max(targetSize.width / image.extent.width,
targetSize.height / image.extent.height)
let scaled = image.transformed(by: CGAffineTransform(
scaleX: scale,
y: scale
))
let cropRect = CGRect(
x: scaled.extent.midX - targetSize.width / 2,
y: scaled.extent.midY - targetSize.height / 2,
width: targetSize.width,
height: targetSize.height
).integral
guard let thumbnailImage = context.createCGImage(
scaled.cropped(to: cropRect),
from: cropRect
) else {
return nil
}
let safeID = remoteID.filter {
$0.isLetter || $0.isNumber || $0 == "-"
}
let name = "\(safeID.isEmpty ? "remote" : safeID).jpg"
guard let url = thumbnailURL(fileName: name),
let data = UIImage(cgImage: thumbnailImage)
.jpegData(compressionQuality: 0.78) else {
return nil
}
do {
try data.write(to: url, options: .atomic)
return name
} catch {
return nil
}
}
DispatchQueue.main.async { completion(fileName) }
}
}
static func removeThumbnail(fileName: String?) {
guard let fileName, let url = thumbnailURL(fileName: fileName) else { return }
try? FileManager.default.removeItem(at: url)
}
private static func thumbnailDirectory() -> URL? {
guard let base = FileManager.default.urls(for: .applicationSupportDirectory,
in: .userDomainMask).first else {
return nil
}
let directory = base.appendingPathComponent("RecentConnectionThumbnails",
isDirectory: true)
do {
try FileManager.default.createDirectory(at: directory,
withIntermediateDirectories: true)
return directory
} catch {
return nil
}
}
}
private enum ConnectionCredentialStore {
private static let service = "cn.crossdesk.mobile.saved-password"
static func password(for remoteID: String) -> String? {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: remoteID,
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne
]
var result: CFTypeRef?
guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess,
let data = result as? Data else {
return nil
}
return String(data: data, encoding: .utf8)
}
static func save(password: String, for remoteID: String) {
removePassword(for: remoteID)
let attributes: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: remoteID,
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly,
kSecValueData as String: Data(password.utf8)
]
SecItemAdd(attributes as CFDictionary, nil)
}
static func removePassword(for remoteID: String) {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: remoteID
]
SecItemDelete(query as CFDictionary)
}
}
private final class RemoteAudioPlayer {
private let engine = AVAudioEngine()
private let player = AVAudioPlayerNode()
private let queue = DispatchQueue(label: "cn.crossdesk.mobile.audio")
private let format = AVAudioFormat(commonFormat: .pcmFormatInt16,
sampleRate: 48_000,
channels: 1,
interleaved: false)!
private var enabled = true
private var queuedBuffers = 0
init() {
engine.attach(player)
engine.connect(player, to: engine.mainMixerNode, format: format)
}
func setEnabled(_ value: Bool) {
queue.async {
self.enabled = value
if value {
self.startIfNeeded()
} else {
self.player.stop()
self.engine.stop()
self.queuedBuffers = 0
}
}
}
func enqueue(_ data: Data) {
guard !data.isEmpty, data.count.isMultiple(of: MemoryLayout<Int16>.size) else { return }
queue.async {
guard self.enabled, self.queuedBuffers < 80 else { return }
self.startIfNeeded()
guard self.engine.isRunning,
let buffer = AVAudioPCMBuffer(pcmFormat: self.format,
frameCapacity: AVAudioFrameCount(data.count / 2)),
let samples = buffer.int16ChannelData?[0] else { return }
buffer.frameLength = buffer.frameCapacity
data.withUnsafeBytes { bytes in
guard let source = bytes.baseAddress else { return }
memcpy(samples, source, data.count)
}
self.queuedBuffers += 1
self.player.scheduleBuffer(buffer) {
self.queue.async {
self.queuedBuffers = max(0, self.queuedBuffers - 1)
}
}
if !self.player.isPlaying {
self.player.play()
}
}
}
private func startIfNeeded() {
guard enabled, !engine.isRunning else { return }
do {
let session = AVAudioSession.sharedInstance()
try session.setCategory(.playback, mode: .moviePlayback,
options: [.mixWithOthers])
try session.setActive(true)
try engine.start()
player.play()
} catch {
// The next PCM packet retries activation after route changes.
}
}
}
final class RemoteSessionModel: NSObject, ObservableObject, CrossDeskRTCBridgeDelegate {
@Published var signalHost = "api.crossdesk.cn"
@Published var signalPort = "9099"
@Published var turnPort = "3478"
@Published var enableSRTP = false
@Published var mouseControlMode = MouseControlMode.saved {
didSet { mouseControlMode.save() }
}
@Published var videoCodecMode = VideoCodecMode.saved {
didSet { videoCodecMode.save() }
}
@Published var remoteID = ""
@Published var password = ""
@Published private(set) var signalStatus = "正在连接信令服务"
@Published private(set) var connectionStatus = "未连接"
@Published private(set) var localIdentity = ""
@Published private(set) var isConnecting = false
@Published private(set) var isConnected = false
@Published private(set) var sessionVisible = false
@Published private var videoFrame: RemoteVideoFrame?
@Published private(set) var remoteCursorVisible = true
@Published private(set) var remoteCursorShape = 0
@Published private(set) var remoteCursorPosition: CGPoint?
@Published private(set) var remoteCursorVisualOffset = CGPoint.zero
@Published private(set) var hasRemoteCursorState = false
@Published private(set) var bitrate: UInt = 0
@Published private(set) var lossRate: Float = 0
@Published private(set) var usingTURN = false
@Published private(set) var displays: [String] = ["显示器 1"]
@Published private(set) var displaySizes: [CGSize] = []
@Published var selectedDisplay = 0
@Published var audioEnabled = true
@Published private(set) var clipboardStatus = ""
@Published private(set) var transferStatus = ""
@Published private(set) var transferProgress = 0.0
@Published private(set) var receivedFileURL: URL?
@Published private(set) var frameCount: UInt64 = 0
@Published private(set) var recentConnections = RecentConnectionStore.load()
@Published private(set) var recentConnectionPresence: [String: Bool] = [:]
@Published private(set) var deviceOfflineAlertVisible = false
let bridge = CrossDeskRTCBridge()
private let audioPlayer = RemoteAudioPlayer()
private var activeRemoteID = ""
private var pendingRememberPassword = false
private var connectionRecorded = false
private var shouldCaptureThumbnail = false
private var activeDisplayName = ""
private var remoteCursorSequence: UInt32?
private var pendingPresenceRemoteID: String?
private var presenceProbeGeneration: UInt64 = 0
var pixelBuffer: CVPixelBuffer? { videoFrame?.pixelBuffer }
var frameSize: CGSize { videoFrame?.encodedSize ?? .zero }
var displayGeometrySize: CGSize {
guard displaySizes.indices.contains(selectedDisplay) else {
return frameSize
}
let size = displaySizes[selectedDisplay]
return size.width > 0 && size.height > 0 ? size : frameSize
}
override init() {
super.init()
bridge.delegate = self
configureBridge()
}
func configureBridge() {
guard let signal = Int(signalPort), let turn = Int(turnPort),
!signalHost.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
signalStatus = "服务器配置无效"
return
}
bridge.setHardwareAccelerationEnabled(videoCodecMode == .hardware)
bridge.configure(withSignalHost: signalHost,
signalPort: signal,
turnPort: turn,
enableSRTP: enableSRTP)
}
func connect(password: String, rememberPassword: Bool) {
self.password = password
pendingRememberPassword = rememberPassword
connect()
}
func connect() {
let identifier = remoteID.trimmingCharacters(in: .whitespacesAndNewlines)
guard !identifier.isEmpty else {
connectionStatus = "请输入远程设备 ID"
return
}
remoteID = identifier
configureBridge()
if recentConnectionPresence[identifier] == true {
beginRemoteConnection(identifier)
} else {
beginPresenceProbe(identifier)
}
}
private func beginRemoteConnection(_ identifier: String) {
cancelPresenceProbe()
activeRemoteID = identifier
activeDisplayName = ""
displaySizes = []
connectionRecorded = false
shouldCaptureThumbnail = true
isConnecting = true
isConnected = false
sessionVisible = false
resetRemoteCursorState()
connectionStatus = "正在准备连接…"
bridge.connect(toRemoteID: identifier, password: password)
}
private func beginPresenceProbe(_ identifier: String) {
guard signalStatus == "已连接服务器" else {
showDeviceOffline()
return
}
presenceProbeGeneration &+= 1
let generation = presenceProbeGeneration
pendingPresenceRemoteID = identifier
isConnecting = true
isConnected = false
sessionVisible = false
connectionStatus = "正在确认设备状态…"
bridge.requestPresence(remoteIDs: [identifier])
DispatchQueue.main.asyncAfter(deadline: .now() + 5) { [weak self] in
guard let self,
self.presenceProbeGeneration == generation,
self.pendingPresenceRemoteID == identifier else {
return
}
self.cancelPresenceProbe()
self.refreshRecentConnectionPresence()
self.showDeviceOffline()
}
}
private func cancelPresenceProbe() {
presenceProbeGeneration &+= 1
pendingPresenceRemoteID = nil
}
private func showDeviceOffline() {
isConnecting = false
isConnected = false
sessionVisible = false
connectionStatus = "设备离线"
deviceOfflineAlertVisible = true
}
func dismissDeviceOfflineAlert() {
deviceOfflineAlertVisible = false
}
func disconnect() {
let wasCheckingPresence = pendingPresenceRemoteID != nil
cancelPresenceProbe()
if wasCheckingPresence {
refreshRecentConnectionPresence()
}
bridge.disconnect()
isConnecting = false
isConnected = false
sessionVisible = false
videoFrame = nil
resetRemoteCursorState()
frameCount = 0
bitrate = 0
displays = ["显示器 1"]
displaySizes = []
selectedDisplay = 0
connectionStatus = "未连接"
audioPlayer.setEnabled(false)
AppOrientation.update(to: .portrait)
}
func retry() {
bridge.disconnect()
connect()
}
func savedPassword(for remoteID: String) -> String {
ConnectionCredentialStore.password(for: remoteID) ?? ""
}
func savedCredential(for remoteID: String) -> String? {
ConnectionCredentialStore.password(for: remoteID)
}
func remembersPassword(for remoteID: String) -> Bool {
recentConnections.first(where: { $0.remoteID == remoteID })?
.remembersPassword == true
}
func thumbnailImage(for connection: RecentConnection) -> UIImage? {
guard let fileName = connection.thumbnailFileName,
let url = RecentConnectionStore.thumbnailURL(fileName: fileName) else {
return nil
}
return UIImage(contentsOfFile: url.path)
}
func isRecentConnectionOnline(_ connection: RecentConnection) -> Bool {
recentConnectionPresence[connection.remoteID] == true
}
private func refreshRecentConnectionPresence() {
bridge.requestPresence(remoteIDs: recentConnections.map(\.remoteID))
}
func refreshRecentConnectionPresenceAfterForeground() {
// iOS can suspend the process while it is in the background, so presence
// updates received during that time are missed. Treat cached values as
// unknown (and therefore offline, like the desktop client) until the
// signaling server returns a fresh snapshot.
recentConnectionPresence = [:]
if let pendingPresenceRemoteID {
bridge.requestPresence(remoteIDs: [pendingPresenceRemoteID])
} else {
refreshRecentConnectionPresence()
}
}
func removeRecentConnection(_ connection: RecentConnection) {
recentConnections.removeAll { $0.remoteID == connection.remoteID }
recentConnectionPresence.removeValue(forKey: connection.remoteID)
RecentConnectionStore.save(recentConnections)
RecentConnectionStore.removeThumbnail(fileName: connection.thumbnailFileName)
ConnectionCredentialStore.removePassword(for: connection.remoteID)
refreshRecentConnectionPresence()
}
private func recordSuccessfulConnectionIfNeeded() {
guard !connectionRecorded, !activeRemoteID.isEmpty else { return }
connectionRecorded = true
if pendingRememberPassword {
ConnectionCredentialStore.save(password: password, for: activeRemoteID)
} else {
ConnectionCredentialStore.removePassword(for: activeRemoteID)
}
let previous = recentConnections.first { $0.remoteID == activeRemoteID }
let title = activeDisplayName.isEmpty
? (previous?.displayName ?? activeRemoteID)
: activeDisplayName
let connection = RecentConnection(
remoteID: activeRemoteID,
displayName: title,
lastConnectedAt: Date(),
remembersPassword: pendingRememberPassword,
thumbnailFileName: previous?.thumbnailFileName
)
recentConnections.removeAll { $0.remoteID == activeRemoteID }
recentConnections.insert(connection, at: 0)
recentConnectionPresence[activeRemoteID] = true
RecentConnectionStore.save(recentConnections)
refreshRecentConnectionPresence()
}
private func updateActiveConnectionName(_ name: String) {
let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty, !activeRemoteID.isEmpty else { return }
activeDisplayName = trimmed
guard let index = recentConnections.firstIndex(where: {
$0.remoteID == activeRemoteID
}) else { return }
recentConnections[index].displayName = trimmed
RecentConnectionStore.save(recentConnections)
}
private func captureRecentThumbnailIfNeeded(_ pixelBuffer: CVPixelBuffer) {
guard shouldCaptureThumbnail, !activeRemoteID.isEmpty else { return }
shouldCaptureThumbnail = false
let identifier = activeRemoteID
RecentConnectionStore.captureThumbnail(from: pixelBuffer,
remoteID: identifier) { [weak self] fileName in
guard let self, let fileName,
let index = self.recentConnections.firstIndex(where: {
$0.remoteID == identifier
}) else { return }
self.recentConnections[index].thumbnailFileName = fileName
RecentConnectionStore.save(self.recentConnections)
}
}
func selectDisplay(_ index: Int) {
guard displays.indices.contains(index) else { return }
selectedDisplay = index
videoFrame = nil
resetRemoteCursorState()
frameCount = 0
bridge.switch(toDisplay: index)
}
private func resetRemoteCursorState() {
remoteCursorVisible = true
remoteCursorShape = 0
remoteCursorPosition = nil
remoteCursorVisualOffset = .zero
hasRemoteCursorState = false
remoteCursorSequence = nil
}
func toggleAudio() {
audioEnabled.toggle()
audioPlayer.setEnabled(audioEnabled)
bridge.setAudioEnabled(audioEnabled)
}
func sendClipboard() {
guard let text = UIPasteboard.general.string, !text.isEmpty else {
clipboardStatus = "剪贴板中没有文本"
return
}
guard text.lengthOfBytes(using: .utf8) <= 128 * 1024 else {
clipboardStatus = "剪贴板文本超过 128 KiB"
return
}
bridge.sendClipboardText(text)
clipboardStatus = "已发送本机剪贴板"
}
func sendFile(_ url: URL) {
transferStatus = "正在发送 \(url.lastPathComponent)"
transferProgress = 0
bridge.sendFile(at: url)
}
func sendKeyStroke(_ keyCode: UInt) {
bridge.sendWindowsKeyCode(keyCode, isDown: true)
bridge.sendWindowsKeyCode(keyCode, isDown: false)
}
func sendKeyState(_ keyCode: UInt, isDown: Bool) {
bridge.sendWindowsKeyCode(keyCode, isDown: isDown)
}
func rtcBridge(_ bridge: CrossDeskRTCBridge,
didChange state: CrossDeskSignalState) {
switch state.rawValue {
case 0: signalStatus = "正在连接信令服务"
case 1: signalStatus = "已连接服务器"
case 2: signalStatus = "信令连接失败"
case 3: signalStatus = "信令连接已关闭"
case 4: signalStatus = "信令服务重连中"
case 5: signalStatus = "信令服务器已关闭连接"
case 6: signalStatus = "TLS 证书校验失败"
default: signalStatus = "未知信令状态"
}
if state.rawValue == 1 {
if let pendingPresenceRemoteID {
bridge.requestPresence(remoteIDs: [pendingPresenceRemoteID])
} else {
refreshRecentConnectionPresence()
}
}
}
func rtcBridge(_ bridge: CrossDeskRTCBridge,
didChange state: CrossDeskConnectionState,
remoteID: String) {
isConnected = state.rawValue == 1
isConnecting = [0, 2].contains(state.rawValue)
switch state.rawValue {
case 0: connectionStatus = "正在建立远程连接"
case 1:
connectionStatus = "已连接"
if !remoteID.isEmpty {
recentConnectionPresence[remoteID] = true
}
sessionVisible = true
recordSuccessfulConnectionIfNeeded()
audioPlayer.setEnabled(audioEnabled)
bridge.setAudioEnabled(audioEnabled)
AppOrientation.update(to: .allButUpsideDown)
case 2: connectionStatus = "正在收集 ICE 候选"
case 3: connectionStatus = "网络连接已中断"
case 4:
connectionStatus = "P2P/TURN 建链失败"
sessionVisible = false
AppOrientation.update(to: .portrait)
case 5:
connectionStatus = "未连接"
sessionVisible = false
AppOrientation.update(to: .portrait)
case 6:
connectionStatus = "访问密码错误"
sessionVisible = false
AppOrientation.update(to: .portrait)
case 7:
connectionStatus = "远程设备 ID 不存在"
if !remoteID.isEmpty {
recentConnectionPresence[remoteID] = false
}
sessionVisible = false
AppOrientation.update(to: .portrait)
case 8:
connectionStatus = "远程设备当前不可用"
if !remoteID.isEmpty {
recentConnectionPresence[remoteID] = false
}
sessionVisible = false
AppOrientation.update(to: .portrait)
default: connectionStatus = "未知连接状态"
}
}
func rtcBridge(_ bridge: CrossDeskRTCBridge, didProvisionIdentity identity: String) {
localIdentity = identity.split(separator: "@").first.map(String.init) ?? identity
if let pendingPresenceRemoteID {
bridge.requestPresence(remoteIDs: [pendingPresenceRemoteID])
} else {
refreshRecentConnectionPresence()
}
}
func rtcBridge(_ bridge: CrossDeskRTCBridge,
didReceivePresence presence: [String: NSNumber]) {
var updated = recentConnectionPresence
for (remoteID, online) in presence {
updated[remoteID] = online.boolValue
}
recentConnectionPresence = updated
guard let pendingRemoteID = pendingPresenceRemoteID,
let online = presence[pendingRemoteID]?.boolValue else {
return
}
cancelPresenceProbe()
refreshRecentConnectionPresence()
if online {
beginRemoteConnection(pendingRemoteID)
} else {
showDeviceOffline()
}
}
func rtcBridge(_ bridge: CrossDeskRTCBridge,
didReceive pixelBuffer: CVPixelBuffer,
width: Int,
height: Int) {
// Buffer and encoded dimensions must be one observable value. Adaptive
// resolution changes must never expose a new frame with the previous
// frame's geometry to SwiftUI.
videoFrame = RemoteVideoFrame(
pixelBuffer: pixelBuffer,
encodedSize: CGSize(width: width, height: height)
)
frameCount &+= 1
captureRecentThumbnailIfNeeded(pixelBuffer)
}
func rtcBridge(_ bridge: CrossDeskRTCBridge,
didReceiveHostName hostName: String,
displayNames remoteDisplayNames: [String],
displaySizes remoteDisplaySizes: [NSValue]) {
updateActiveConnectionName(hostName)
let names = remoteDisplayNames.enumerated().map { index, displayName in
let name = displayName.trimmingCharacters(in: .whitespacesAndNewlines)
return name.isEmpty ? "显示器 \(index + 1)" : name
}
if !names.isEmpty {
displays = names
displaySizes = remoteDisplaySizes.map(\.cgSizeValue)
selectedDisplay = min(selectedDisplay, names.count - 1)
bridge.switch(toDisplay: selectedDisplay)
}
}
func rtcBridge(_ bridge: CrossDeskRTCBridge,
didReceiveCursorVisible visible: Bool,
shape: Int,
positionUpdate: Bool,
positionValid: Bool,
x: Float,
y: Float,
visualOffsetX: Float,
visualOffsetY: Float,
display displayIndex: Int,
sequence: UInt32) {
if let previous = remoteCursorSequence,
sequence != 0,
Int32(bitPattern: sequence &- previous) <= 0 {
return
}
remoteCursorSequence = sequence
remoteCursorVisible = visible
remoteCursorShape = min(max(shape, 0), 28)
remoteCursorVisualOffset = CGPoint(x: CGFloat(visualOffsetX),
y: CGFloat(visualOffsetY))
if positionUpdate {
if positionValid, displayIndex == selectedDisplay {
remoteCursorPosition = CGPoint(
x: CGFloat(min(max(x, 0), 1)),
y: CGFloat(min(max(y, 0), 1))
)
} else {
remoteCursorPosition = nil
}
}
hasRemoteCursorState = true
}
func rtcBridge(_ bridge: CrossDeskRTCBridge, didReceiveAudioPCM pcmData: Data) {
audioPlayer.enqueue(pcmData)
}
func rtcBridge(_ bridge: CrossDeskRTCBridge, didReceiveClipboardText text: String) {
UIPasteboard.general.string = text
clipboardStatus = "已接收远端剪贴板"
}
func rtcBridge(_ bridge: CrossDeskRTCBridge,
didUpdateFileTransfer fileName: String,
progress: Double,
sending: Bool) {
if progress < 0 {
transferStatus = "\(fileName) 传输失败"
transferProgress = 0
return
}
transferProgress = progress
let percent = Int((progress * 100).rounded())
transferStatus = "\(sending ? "发送" : "接收") \(fileName) · \(percent)%"
}
func rtcBridge(_ bridge: CrossDeskRTCBridge, didReceiveFileAt fileURL: URL) {
receivedFileURL = fileURL
transferStatus = "已接收 \(fileURL.lastPathComponent)"
transferProgress = 1
}
func rtcBridge(_ bridge: CrossDeskRTCBridge,
didUpdateBitrate bitsPerSecond: UInt,
lossRate: Float,
usingTURN: Bool) {
bitrate = bitsPerSecond
self.lossRate = lossRate
self.usingTURN = usingTURN
}
var formattedBitrate: String {
if bitrate >= 1_000_000 {
return String(format: "%.1f Mbps", Double(bitrate) / 1_000_000)
}
if bitrate >= 1_000 {
return String(format: "%.0f Kbps", Double(bitrate) / 1_000)
}
return "\(bitrate) bps"
}
var videoStatus: String {
if frameCount > 0 {
return "视频 \(Int(frameSize.width))×\(Int(frameSize.height)) · \(frameCount)"
}
return isConnected ? "正在取回远程画面…" : "等待连接"
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,151 @@
import AVFoundation
import CoreMedia
import CoreVideo
import SwiftUI
import UIKit
/// Presents decoded NV12 frames through iOS's native video display path.
///
/// `AVSampleBufferDisplayLayer` handles YUV conversion and drawable scheduling
/// itself. This avoids the Core Image -> MTKView path, which can remain black
/// on a physical device even though VideoToolbox is producing valid frames.
struct NativeVideoView: UIViewRepresentable {
let pixelBuffer: CVPixelBuffer?
func makeUIView(context: Context) -> SampleBufferVideoView {
SampleBufferVideoView(frame: .zero)
}
func updateUIView(_ uiView: SampleBufferVideoView, context: Context) {
uiView.display(pixelBuffer)
}
}
final class SampleBufferVideoView: UIView {
override class var layerClass: AnyClass {
AVSampleBufferDisplayLayer.self
}
private var videoLayer: AVSampleBufferDisplayLayer {
layer as! AVSampleBufferDisplayLayer
}
private var lastPixelBuffer: CVPixelBuffer?
private var submittedFrames: UInt64 = 0
private var droppedFrames: UInt64 = 0
override init(frame: CGRect) {
super.init(frame: frame)
configureLayer()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
configureLayer()
}
private func configureLayer() {
backgroundColor = .black
isOpaque = true
videoLayer.backgroundColor = UIColor.black.cgColor
// The SwiftUI host gives this view the exact aspect-fit video rect.
// Filling that rect avoids a second, independently rounded aspect-fit
// calculation inside AVSampleBufferDisplayLayer. That rounding becomes
// visibly amplified when the remote desktop is zoomed up to 10x.
videoLayer.videoGravity = .resize
}
func display(_ pixelBuffer: CVPixelBuffer?) {
guard let pixelBuffer else {
lastPixelBuffer = nil
submittedFrames = 0
droppedFrames = 0
videoLayer.flushAndRemoveImage()
return
}
// SwiftUI can refresh for unrelated status fields. Do not enqueue the
// same retained CVPixelBuffer more than once.
guard lastPixelBuffer !== pixelBuffer else { return }
lastPixelBuffer = pixelBuffer
if videoLayer.status == .failed {
NSLog("CrossDesk video layer failed: %@",
videoLayer.error?.localizedDescription ?? "unknown error")
videoLayer.flush()
}
// Never let AVSampleBufferDisplayLayer turn temporary rendering
// pressure into seconds of latency. Its queued buffers are already
// stale by the time it stops accepting more data, so discard them and
// present the newest decoded IOSurface instead.
if !videoLayer.isReadyForMoreMediaData {
droppedFrames &+= 1
videoLayer.flush()
if droppedFrames == 1 || droppedFrames.isMultiple(of: 300) {
NSLog("CrossDesk dropped stale display frames: %llu",
droppedFrames)
}
}
guard videoLayer.isReadyForMoreMediaData else { return }
var formatDescription: CMVideoFormatDescription?
guard CMVideoFormatDescriptionCreateForImageBuffer(
allocator: kCFAllocatorDefault,
imageBuffer: pixelBuffer,
formatDescriptionOut: &formatDescription
) == noErr, let formatDescription else {
NSLog("CrossDesk could not create a video format description")
return
}
var timing = CMSampleTimingInfo(
duration: .invalid,
presentationTimeStamp: .invalid,
decodeTimeStamp: .invalid
)
var sampleBuffer: CMSampleBuffer?
guard CMSampleBufferCreateReadyWithImageBuffer(
allocator: kCFAllocatorDefault,
imageBuffer: pixelBuffer,
formatDescription: formatDescription,
sampleTiming: &timing,
sampleBufferOut: &sampleBuffer
) == noErr, let sampleBuffer else {
NSLog("CrossDesk could not create a video sample buffer")
return
}
if let attachments = CMSampleBufferGetSampleAttachmentsArray(
sampleBuffer,
createIfNecessary: true
), CFArrayGetCount(attachments) > 0 {
let attachment = unsafeBitCast(
CFArrayGetValueAtIndex(attachments, 0),
to: CFMutableDictionary.self
)
CFDictionarySetValue(
attachment,
Unmanaged.passUnretained(kCMSampleAttachmentKey_DisplayImmediately).toOpaque(),
Unmanaged.passUnretained(kCFBooleanTrue).toOpaque()
)
}
videoLayer.enqueue(sampleBuffer)
submittedFrames &+= 1
if submittedFrames == 1 || submittedFrames.isMultiple(of: 300) {
NSLog("CrossDesk presented video frame %llu (%zu x %zu)",
submittedFrames,
CVPixelBufferGetWidth(pixelBuffer),
CVPixelBufferGetHeight(pixelBuffer))
}
if submittedFrames == 1 {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { [weak self] in
guard let self else { return }
NSLog("CrossDesk video layer status=%ld error=%@",
self.videoLayer.status.rawValue,
self.videoLayer.error?.localizedDescription ?? "none")
}
}
}
}
+70
View File
@@ -0,0 +1,70 @@
# CrossDesk Mobile (native iOS)
This target is a native MiniRTC controller. It uses the same WebSocket
signaling, libnice ICE, SRTP/RTP and data-stream protocol as the desktop app.
There is no `WKWebView` or browser runtime.
## Requirements
- Xcode 16 or newer
- Xmake available on `PATH`
- A physical arm64 iPhone or iPad running iOS 16 or newer
## Build
Open `CrossDeskMobile.xcodeproj`, select the `CrossDeskMobile` scheme and a
physical device, then build. The first build phase compiles MiniRTC and the
shared CrossDesk protocol, then merges their iPhoneOS static dependencies into
a local archive under `Vendor/`.
You can also verify the target without code signing:
```sh
xcodebuild -project ios/CrossDeskMobile.xcodeproj \
-scheme CrossDeskMobile \
-configuration Debug \
-destination 'generic/platform=iOS' \
CODE_SIGNING_ALLOWED=NO build
```
The first connection to a signaling server provisions and stores an identity
for that server. Remote control sessions then log in as `C-<identity>` and use
the desktop-compatible `DisplayN`, `control_audio`, `mouse`, `keyboard`,
`control_data`, `clipboard`, `file`, and `file_feedback` streams.
## Video codecs
Every iOS build includes VideoToolbox, OpenH264, dav1d, libaom, and SVT-AV1. H.264 uses
VideoToolbox by default; the in-app **Settings > Video Codec** selector switches
H.264 software processing to OpenH264. AV1 encoding uses SVT-AV1 and AV1 decoding
uses dav1d; VideoToolbox AV1 hardware decoding is currently unsupported.
## Physical-device test checklist
Run the app from Xcode on a physical device and connect to a current desktop
build. Test the features in this order so a media problem is not confused with
a data-channel problem:
1. **Video:** after the connection turns green, the waiting panel should be
replaced by the remote desktop. The status bar reports the decoded size and
frame count.
2. **Audio:** play continuous sound on the remote computer, then toggle the
speaker button. Audio is Opus-decoded by MiniRTC and played as 48 kHz mono
16-bit PCM through `AVAudioEngine`.
3. **Clipboard:** copy a short text value on the phone and choose **Send local
clipboard**. Copy a different value on the desktop and confirm that it is
written to the iOS pasteboard. Text is limited to 128 KiB.
4. **Displays:** open the display menu, switch every listed monitor, and check
that the frame counter restarts and the selected monitor appears after a
new key frame.
5. **Files:** use the folder button to pick a small file, then send a file back
from the desktop. Progress is ACK-driven. Received files are stored in the
app's `Documents/Received` directory and can be exported with the share
button or Finder's Files tab.
For black-screen diagnosis, keep Xcode's device console open and filter for
`CrossDesk`. A working pipeline prints both `VideoToolbox decoded frame` from
MiniRTC and `CrossDesk received decoded frame` from the iOS bridge. If only the
first message appears, verify that the selected stream is named `Display1`,
`Display2`, and so on. If neither appears, update and rebuild the desktop side
and request a key frame.
+147
View File
@@ -0,0 +1,147 @@
#!/bin/zsh
set -euo pipefail
SCRIPT_DIR="${0:A:h}"
IOS_DIR="${SCRIPT_DIR:h}"
REPO_DIR="${IOS_DIR:h}"
MINIRTC_DIR="${REPO_DIR}/submodules/minirtc"
CONFIG_NAME="${CONFIGURATION:-Debug}"
MODE="${CONFIG_NAME:l}"
# Keep iOS dependencies isolated from desktop Xmake packages. Besides avoiding
# cross-project cache collisions, this guarantees every archive is compiled
# with the iOS 16 deployment target instead of the active SDK version.
export XMAKE_PKG_INSTALLDIR="${IOS_DIR}/.xmake/packages"
if [[ "${MODE}" != "debug" && "${MODE}" != "release" ]]; then
MODE="release"
fi
ARCH_NAME="${CURRENT_ARCH:-arm64}"
if [[ "${ARCH_NAME}" == "undefined_arch" ]]; then
ARCH_NAME="arm64"
fi
if [[ "${ARCH_NAME}" != "arm64" ]]; then
print -u2 "CrossDesk Mobile currently supports physical iOS arm64 builds only."
exit 64
fi
XMAKE_BIN="${XMAKE_BIN:-}"
if [[ -z "${XMAKE_BIN}" ]]; then
XMAKE_BIN="$(command -v xmake 2>/dev/null || true)"
fi
if [[ -z "${XMAKE_BIN}" ]]; then
for candidate in /opt/homebrew/bin/xmake /usr/local/bin/xmake "${HOME}/.local/bin/xmake"; do
if [[ -x "${candidate}" ]]; then
XMAKE_BIN="${candidate}"
break
fi
done
fi
if [[ -z "${XMAKE_BIN}" || ! -x "${XMAKE_BIN}" ]]; then
print -u2 "xmake is required. Install it from https://xmake.io first."
exit 69
fi
OUTPUT_DIR="${IOS_DIR}/Vendor/iphoneos/${CONFIG_NAME}"
OUTPUT_LIBRARY="${OUTPUT_DIR}/libCrossDeskMiniRTC.a"
MINIRTC_BUILD_DIR="${IOS_DIR}/.xmake/minirtc-build"
MINIRTC_LIBRARY="${MINIRTC_BUILD_DIR}/iphoneos/arm64/${MODE}/libminirtc.a"
CROSSDESK_SOURCE_DIR="${REPO_DIR}/src"
CROSSDESK_BUILD_DIR="${IOS_DIR}/.xmake/crossdesk-build/iphoneos/arm64/${MODE}"
mkdir -p "${OUTPUT_DIR}"
# Xmake stores the configured build directory relative to the process working
# directory. Xcode does not guarantee that directory for build phases, so keep
# configuration, compilation and inspection anchored to the MiniRTC project.
(
cd "${MINIRTC_DIR}"
"${XMAKE_BIN}" f -P "${MINIRTC_DIR}" -c -o "${MINIRTC_BUILD_DIR}" \
-p iphoneos -a arm64 -m "${MODE}" \
--target_minver=16.0 --USE_CUDA=false -y
"${XMAKE_BIN}" b -P "${MINIRTC_DIR}" minirtc
)
if [[ ! -f "${MINIRTC_LIBRARY}" ]]; then
print -u2 "MiniRTC archive was not produced at ${MINIRTC_LIBRARY}."
exit 66
fi
# Xmake owns the package hashes, so query its resolved target instead of
# embedding machine-specific ~/.xmake paths in the Xcode project.
TARGET_INFO="$(cd "${MINIRTC_DIR}" && TERM=dumb NO_COLOR=1 \
"${XMAKE_BIN}" show -P "${MINIRTC_DIR}" -t minirtc)"
CLEAN_INFO="$(print -r -- "${TARGET_INFO}" | sed $'s/\033\\[[0-9;]*[[:alpha:]]//g')"
LINK_DIRS=("${(@f)$(print -r -- "${CLEAN_INFO}" | sed -nE 's|.*-> (/.*)/lib -> package.*|\1/lib|p' | sort -u)}")
REQUIRED_LINKS=(
nice glib-2.0 gobject-2.0 gmodule-2.0 gio-2.0 gthread-2.0 intl
ffi pcre2-8 pcre2-posix z ssl crypto srtp2 openfec opus yuv kcp
datachannel usrsctp openh264 dav1d aom SvtAv1Enc
)
DEPENDENCY_ARCHIVES=()
for link_name in "${REQUIRED_LINKS[@]}"; do
archive_path=""
for link_dir in "${LINK_DIRS[@]}"; do
candidate="${link_dir}/lib${link_name}.a"
if [[ -f "${candidate}" ]]; then
archive_path="${candidate}"
break
fi
done
if [[ -z "${archive_path}" ]]; then
print -u2 "Unable to resolve static dependency lib${link_name}.a from Xmake."
exit 66
fi
DEPENDENCY_ARCHIVES+=("${archive_path}")
done
# Build the CrossDesk message and file-transfer codecs for the same iOS
# architecture, then merge them into the aggregate archive consumed by Xcode.
NLOHMANN_HEADER="$(find "${XMAKE_PKG_INSTALLDIR}/n/nlohmann_json" \
-path '*/include/nlohmann/json.hpp' -print -quit 2>/dev/null || true)"
if [[ -z "${NLOHMANN_HEADER}" ]]; then
print -u2 "Unable to locate the nlohmann_json headers installed by Xmake."
exit 66
fi
NLOHMANN_INCLUDE="${NLOHMANN_HEADER:h:h}"
IOS_SDK_PATH="$(xcrun --sdk iphoneos --show-sdk-path)"
mkdir -p "${CROSSDESK_BUILD_DIR}"
CROSSDESK_CXXFLAGS=(
-std=c++17 -arch arm64 -isysroot "${IOS_SDK_PATH}"
-miphoneos-version-min=16.0
-I"${CROSSDESK_SOURCE_DIR}/device_controller"
-I"${CROSSDESK_SOURCE_DIR}/common"
-I"${CROSSDESK_SOURCE_DIR}/tools"
-I"${NLOHMANN_INCLUDE}"
)
if [[ "${MODE}" == "debug" ]]; then
CROSSDESK_CXXFLAGS+=(-O0 -g)
else
CROSSDESK_CXXFLAGS+=(-O2 -DNDEBUG)
fi
CROSSDESK_SOURCES=(
"${CROSSDESK_SOURCE_DIR}/device_controller/remote_action.cpp"
"${CROSSDESK_SOURCE_DIR}/tools/file_transfer_protocol.cpp"
)
CROSSDESK_OBJECTS=()
for source_path in "${CROSSDESK_SOURCES[@]}"; do
object_path="${CROSSDESK_BUILD_DIR}/${source_path:t:r}.o"
xcrun --sdk iphoneos clang++ "${CROSSDESK_CXXFLAGS[@]}" \
-c "${source_path}" -o "${object_path}"
CROSSDESK_OBJECTS+=("${object_path}")
done
TEMP_LIBRARY="${OUTPUT_LIBRARY}.tmp"
rm -f "${TEMP_LIBRARY}"
/usr/bin/libtool -static -o "${TEMP_LIBRARY}" \
"${MINIRTC_LIBRARY}" "${DEPENDENCY_ARCHIVES[@]}" \
"${CROSSDESK_OBJECTS[@]}"
mv -f "${TEMP_LIBRARY}" "${OUTPUT_LIBRARY}"
print "Created ${OUTPUT_LIBRARY}"
+7 -4
View File
@@ -1,16 +1,19 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
APP_NAME="crossdesk"
APP_NAME_UPPER="CrossDesk"
EXECUTABLE_PATH="./build/macosx/arm64/release/crossdesk"
EXECUTABLE_PATH="${PROJECT_ROOT}/build/macosx/arm64/release/crossdesk"
PLATFORM="macos"
ARCH="arm64"
BINARY_ARCH="arm64"
IDENTIFIER="cn.crossdesk.app"
ICON_PATH="icons/macos/crossdesk.icns"
ICON_PATH="${PROJECT_ROOT}/icons/macos/crossdesk.icns"
MACOS_MIN_VERSION="14.0"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$PROJECT_ROOT"
normalize_app_version() {
local input="$1"
@@ -103,7 +106,7 @@ find "${APP_BUNDLE}" -name '._*' -delete
echo ".app created successfully."
mkdir -p build_pkg_scripts
cp scripts/macosx/tcc_postinstall.sh build_pkg_scripts/postinstall
cp "${SCRIPT_DIR}/tcc_postinstall.sh" build_pkg_scripts/postinstall
chmod +x build_pkg_scripts/postinstall
mkdir -p build_pkg_resources
+7 -4
View File
@@ -1,16 +1,19 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
APP_NAME="crossdesk"
APP_NAME_UPPER="CrossDesk"
EXECUTABLE_PATH="build/macosx/x86_64/release/crossdesk"
EXECUTABLE_PATH="${PROJECT_ROOT}/build/macosx/x86_64/release/crossdesk"
PLATFORM="macos"
ARCH="x64"
BINARY_ARCH="x86_64"
IDENTIFIER="cn.crossdesk.app"
ICON_PATH="icons/macos/crossdesk.icns"
ICON_PATH="${PROJECT_ROOT}/icons/macos/crossdesk.icns"
MACOS_MIN_VERSION="14.0"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$PROJECT_ROOT"
normalize_app_version() {
local input="$1"
@@ -103,7 +106,7 @@ find "${APP_BUNDLE}" -name '._*' -delete
echo ".app created successfully."
mkdir -p build_pkg_scripts
cp scripts/macosx/tcc_postinstall.sh build_pkg_scripts/postinstall
cp "${SCRIPT_DIR}/tcc_postinstall.sh" build_pkg_scripts/postinstall
chmod +x build_pkg_scripts/postinstall
mkdir -p build_pkg_resources
+22
View File
@@ -0,0 +1,22 @@
#ifndef _STREAM_NAMES_H_
#define _STREAM_NAMES_H_
#include <cstddef>
namespace crossdesk::protocol {
inline constexpr char kAudioStream[] = "control_audio";
inline constexpr char kDataStream[] = "data";
inline constexpr char kMouseStream[] = "mouse";
inline constexpr char kKeyboardStream[] = "keyboard";
inline constexpr char kControlStream[] = "control_data";
inline constexpr char kFileStream[] = "file";
inline constexpr char kFileFeedbackStream[] = "file_feedback";
inline constexpr char kClipboardStream[] = "clipboard";
inline constexpr std::size_t kFileChunkSize = 64 * 1024;
inline constexpr std::size_t kMaxClipboardBytes = 128 * 1024;
} // namespace crossdesk::protocol
#endif
+59 -233
View File
@@ -7,20 +7,16 @@
#ifndef _DEVICE_CONTROLLER_H_
#define _DEVICE_CONTROLLER_H_
#include <stdio.h>
#include <cstddef>
#include <cstdint>
#include <cstring>
#include <nlohmann/json.hpp>
#include <string>
#include "display_info.h"
#include "remote_cursor_shape.h"
using json = nlohmann::json;
namespace crossdesk {
typedef enum {
enum ControlType {
invalid = -1,
mouse = 0,
keyboard = 1,
audio_capture = 2,
@@ -30,8 +26,9 @@ typedef enum {
service_command = 6,
keyboard_state = 7,
cursor_state = 8,
} ControlType;
typedef enum {
};
enum MouseFlag {
move = 0,
left_down,
left_up,
@@ -40,66 +37,80 @@ typedef enum {
middle_down,
middle_up,
wheel_vertical,
wheel_horizontal
} MouseFlag;
typedef enum { key_down = 0, key_up } KeyFlag;
typedef enum { send_sas = 0, lock_workstation } ServiceCommandFlag;
typedef struct {
wheel_horizontal,
};
enum KeyFlag { key_down = 0, key_up };
enum ServiceCommandFlag { send_sas = 0, lock_workstation };
struct Mouse {
float x;
float y;
int s;
MouseFlag flag;
} Mouse;
};
typedef struct {
size_t key_value;
struct Key {
std::size_t key_value;
uint32_t scan_code;
bool extended;
KeyFlag flag;
} Key;
};
inline constexpr size_t kMaxKeyboardStateKeys = 32;
inline constexpr std::size_t kMaxKeyboardStateKeys = 32;
typedef struct {
size_t key_value;
struct KeyboardStateKey {
std::size_t key_value;
uint32_t scan_code;
bool extended;
} KeyboardStateKey;
};
typedef struct {
struct KeyboardState {
uint32_t seq;
size_t pressed_count;
std::size_t pressed_count;
KeyboardStateKey pressed_keys[kMaxKeyboardStateKeys];
} KeyboardState;
};
typedef struct {
struct CursorState {
uint32_t seq;
bool visible;
RemoteCursorShape shape;
} CursorState;
bool position_valid;
float x;
float y;
// Normalized displacement from the input hotspot to the visible cursor
// anchor. This is presentation metadata and must not affect input mapping.
float visual_offset_x;
float visual_offset_y;
int display_id;
// Whether receivers should apply the position fields in this message.
// Shape-only updates set this to false so cursor appearance can remain
// responsive while position feedback to the input source is suppressed.
bool position_update;
};
typedef struct {
struct HostInfo {
char host_name[64];
size_t host_name_size;
std::size_t host_name_size;
char** display_list;
size_t display_num;
std::size_t display_num;
int* left;
int* top;
int* right;
int* bottom;
} HostInfo;
};
typedef struct {
struct ServiceStatus {
bool available;
char interactive_stage[32];
} ServiceStatus;
};
typedef struct {
struct ServiceCommand {
ServiceCommandFlag flag;
} ServiceCommand;
};
struct RemoteAction {
ControlType type;
ControlType type = ControlType::invalid;
union {
Mouse m;
Key k;
@@ -112,210 +123,25 @@ struct RemoteAction {
ServiceCommand c;
};
// parse
std::string to_json() const { return ToJson(*this); }
std::string to_json() const;
bool from_json(const std::string& json_string);
bool from_json(const std::string& json_str) {
RemoteAction temp;
if (!FromJson(json_str, temp)) return false;
*this = temp;
return true;
}
static std::string ToJson(const RemoteAction& a) {
json j;
j["type"] = a.type;
switch (a.type) {
case ControlType::mouse:
j["mouse"] = {
{"x", a.m.x}, {"y", a.m.y}, {"s", a.m.s}, {"flag", a.m.flag}};
break;
case ControlType::keyboard:
j["keyboard"] = {{"key_value", a.k.key_value},
{"scan_code", a.k.scan_code},
{"extended", a.k.extended},
{"flag", a.k.flag}};
break;
case ControlType::keyboard_state: {
json keys = json::array();
const size_t pressed_count =
a.ks.pressed_count < kMaxKeyboardStateKeys
? a.ks.pressed_count
: kMaxKeyboardStateKeys;
for (size_t idx = 0; idx < pressed_count; ++idx) {
keys.push_back({{"key_value", a.ks.pressed_keys[idx].key_value},
{"scan_code", a.ks.pressed_keys[idx].scan_code},
{"extended", a.ks.pressed_keys[idx].extended}});
}
j["keyboard_state"] = {{"seq", a.ks.seq}, {"pressed_keys", keys}};
break;
}
case ControlType::cursor_state:
j["cursor_state"] = {{"seq", a.cs.seq},
{"visible", a.cs.visible},
{"shape", static_cast<int>(a.cs.shape)}};
break;
case ControlType::audio_capture:
j["audio_capture"] = a.a;
break;
case ControlType::display_id:
j["display_id"] = a.d;
break;
case ControlType::service_status:
j["service_status"] = {{"available", a.ss.available},
{"interactive_stage", a.ss.interactive_stage}};
break;
case ControlType::service_command:
j["service_command"] = {{"flag", a.c.flag}};
break;
case ControlType::host_infomation: {
json displays = json::array();
for (size_t idx = 0; idx < a.i.display_num; idx++) {
displays.push_back(
{{"name", a.i.display_list ? a.i.display_list[idx] : ""},
{"left", a.i.left ? a.i.left[idx] : 0},
{"top", a.i.top ? a.i.top[idx] : 0},
{"right", a.i.right ? a.i.right[idx] : 0},
{"bottom", a.i.bottom ? a.i.bottom[idx] : 0}});
}
j["host_info"] = {{"host_name", a.i.host_name},
{"display_num", a.i.display_num},
{"displays", displays}};
break;
}
}
return j.dump();
}
static bool FromJson(const std::string& json_str, RemoteAction& out) {
try {
json j = json::parse(json_str);
out.type = (ControlType)j.at("type").get<int>();
switch (out.type) {
case ControlType::mouse:
out.m.x = j.at("mouse").at("x").get<float>();
out.m.y = j.at("mouse").at("y").get<float>();
out.m.s = j.at("mouse").at("s").get<int>();
out.m.flag = (MouseFlag)j.at("mouse").at("flag").get<int>();
break;
case ControlType::keyboard:
out.k.key_value = j.at("keyboard").at("key_value").get<size_t>();
out.k.scan_code =
j.at("keyboard").value("scan_code", static_cast<uint32_t>(0));
out.k.extended = j.at("keyboard").value("extended", false);
out.k.flag = (KeyFlag)j.at("keyboard").at("flag").get<int>();
break;
case ControlType::keyboard_state: {
const auto& keyboard_state_json = j.at("keyboard_state");
out.ks.seq = keyboard_state_json.value("seq", 0u);
out.ks.pressed_count = 0;
const auto keys_json =
keyboard_state_json.value("pressed_keys", json::array());
if (!keys_json.is_array()) {
break;
}
const size_t count =
keys_json.size() < kMaxKeyboardStateKeys
? keys_json.size()
: kMaxKeyboardStateKeys;
for (size_t idx = 0; idx < count; ++idx) {
const auto& key_json = keys_json[idx];
out.ks.pressed_keys[idx].key_value =
key_json.at("key_value").get<size_t>();
out.ks.pressed_keys[idx].scan_code =
key_json.value("scan_code", static_cast<uint32_t>(0));
out.ks.pressed_keys[idx].extended =
key_json.value("extended", false);
}
out.ks.pressed_count = count;
break;
}
case ControlType::cursor_state: {
const auto& cursor_state_json = j.at("cursor_state");
const int shape = cursor_state_json.at("shape").get<int>();
if (shape < static_cast<int>(RemoteCursorShape::default_cursor) ||
shape > static_cast<int>(RemoteCursorShape::nwse_resize)) {
return false;
}
out.cs.seq = cursor_state_json.at("seq").get<uint32_t>();
out.cs.visible = cursor_state_json.at("visible").get<bool>();
out.cs.shape = static_cast<RemoteCursorShape>(shape);
break;
}
case ControlType::audio_capture:
out.a = j.at("audio_capture").get<bool>();
break;
case ControlType::display_id:
out.d = j.at("display_id").get<int>();
break;
case ControlType::service_status: {
const auto& service_status_json = j.at("service_status");
out.ss.available = service_status_json.value("available", false);
std::string interactive_stage =
service_status_json.value("interactive_stage", std::string());
std::strncpy(out.ss.interactive_stage, interactive_stage.c_str(),
sizeof(out.ss.interactive_stage) - 1);
out.ss.interactive_stage[sizeof(out.ss.interactive_stage) - 1] = '\0';
break;
}
case ControlType::service_command:
out.c.flag = static_cast<ServiceCommandFlag>(
j.at("service_command").at("flag").get<int>());
break;
case ControlType::host_infomation: {
std::string host_name =
j.at("host_info").at("host_name").get<std::string>();
strncpy(out.i.host_name, host_name.c_str(), sizeof(out.i.host_name));
out.i.host_name[sizeof(out.i.host_name) - 1] = '\0';
out.i.host_name_size = host_name.size();
out.i.display_num = j.at("host_info").at("display_num").get<size_t>();
auto displays = j.at("host_info").at("displays");
out.i.display_list =
(char**)malloc(out.i.display_num * sizeof(char*));
out.i.left = (int*)malloc(out.i.display_num * sizeof(int));
out.i.top = (int*)malloc(out.i.display_num * sizeof(int));
out.i.right = (int*)malloc(out.i.display_num * sizeof(int));
out.i.bottom = (int*)malloc(out.i.display_num * sizeof(int));
for (size_t idx = 0; idx < out.i.display_num; idx++) {
std::string name = displays[idx].at("name").get<std::string>();
out.i.display_list[idx] = (char*)malloc(name.size() + 1);
strcpy(out.i.display_list[idx], name.c_str());
out.i.left[idx] = displays[idx].at("left").get<int>();
out.i.top[idx] = displays[idx].at("top").get<int>();
out.i.right[idx] = displays[idx].at("right").get<int>();
out.i.bottom[idx] = displays[idx].at("bottom").get<int>();
}
break;
}
}
return true;
} catch (const std::exception& e) {
printf("Failed to parse RemoteAction JSON: %s\n", e.what());
return false;
}
}
static std::string ToJson(const RemoteAction& action);
static bool FromJson(const std::string& json_string, RemoteAction& output);
};
// Releases the dynamically allocated display arrays held by host information.
// Other RemoteAction variants do not own memory and are left unchanged.
void FreeRemoteAction(RemoteAction& action);
// int key_code, bool is_down, uint32_t scan_code, bool extended
typedef void (*OnKeyAction)(int, bool, uint32_t, bool, void*);
using OnKeyAction = void (*)(int, bool, uint32_t, bool, void*);
class DeviceController {
public:
virtual ~DeviceController() {}
public:
// virtual int Init(int screen_width, int screen_height);
// virtual int Destroy();
// virtual int SendMouseCommand(RemoteAction remote_action);
// virtual int Hook();
// virtual int Unhook();
virtual ~DeviceController() = default;
};
} // namespace crossdesk
#endif
@@ -13,6 +13,7 @@
#include <vector>
#include "device_controller.h"
#include "display_info.h"
struct DBusConnection;
struct DBusMessageIter;
@@ -4,6 +4,7 @@
#include <algorithm>
#include <chrono>
#include <cmath>
#include <cstdlib>
#include "rd_log.h"
@@ -44,6 +45,13 @@ int MouseController::Init(std::vector<DisplayInfo> display_info_list) {
int MouseController::Destroy() { return 0; }
void MouseController::UpdateDisplayInfoList(
const std::vector<DisplayInfo>& display_info_list) {
if (!display_info_list.empty()) {
display_info_list_ = display_info_list;
}
}
int MouseController::BeginClick(ClickTracker& tracker, int x, int y) {
const auto now = std::chrono::steady_clock::now();
const bool continues_previous_click =
@@ -94,10 +102,26 @@ int MouseController::SendMouseCommand(RemoteAction remote_action,
return -1;
}
const float normalized_x = std::clamp(remote_action.m.x, 0.0f, 1.0f);
const float normalized_y = std::clamp(remote_action.m.y, 0.0f, 1.0f);
int mouse_pos_x = normalized_x * display_info.width + display_info.left;
int mouse_pos_y = normalized_y * display_info.height + display_info.top;
const double normalized_x =
std::clamp(static_cast<double>(remote_action.m.x), 0.0, 1.0);
const double normalized_y =
std::clamp(static_cast<double>(remote_action.m.y), 0.0, 1.0);
// Keep the coordinate continuous until it reaches Core Graphics. This
// avoids turning a one-pixel rounding error into a visible offset when the
// client zooms the remote image. The upper bound remains just inside the
// display because CG display rectangles use an exclusive right/bottom edge.
const double max_x = std::nextafter(static_cast<double>(display_info.right),
static_cast<double>(display_info.left));
const double max_y = std::nextafter(static_cast<double>(display_info.bottom),
static_cast<double>(display_info.top));
const double mouse_pos_x = std::clamp(
display_info.left + normalized_x * display_info.width,
static_cast<double>(display_info.left), max_x);
const double mouse_pos_y = std::clamp(
display_info.top + normalized_y * display_info.height,
static_cast<double>(display_info.top), max_y);
const int tracked_mouse_pos_x = static_cast<int>(std::lround(mouse_pos_x));
const int tracked_mouse_pos_y = static_cast<int>(std::lround(mouse_pos_y));
CGEventRef mouse_event = nullptr;
CGEventType mouse_type;
@@ -109,7 +133,8 @@ int MouseController::SendMouseCommand(RemoteAction remote_action,
case MouseFlag::left_down:
mouse_type = kCGEventLeftMouseDown;
left_dragging_ = true;
click_state = BeginClick(left_click_tracker_, mouse_pos_x, mouse_pos_y);
click_state = BeginClick(left_click_tracker_, tracked_mouse_pos_x,
tracked_mouse_pos_y);
mouse_event = CGEventCreateMouseEvent(NULL, mouse_type, mouse_point,
kCGMouseButtonLeft);
SetClickState(mouse_event, click_state);
@@ -117,7 +142,8 @@ int MouseController::SendMouseCommand(RemoteAction remote_action,
case MouseFlag::left_up:
mouse_type = kCGEventLeftMouseUp;
left_dragging_ = false;
click_state = EndClick(left_click_tracker_, mouse_pos_x, mouse_pos_y);
click_state = EndClick(left_click_tracker_, tracked_mouse_pos_x,
tracked_mouse_pos_y);
mouse_event = CGEventCreateMouseEvent(NULL, mouse_type, mouse_point,
kCGMouseButtonLeft);
SetClickState(mouse_event, click_state);
@@ -125,7 +151,8 @@ int MouseController::SendMouseCommand(RemoteAction remote_action,
case MouseFlag::right_down:
mouse_type = kCGEventRightMouseDown;
right_dragging_ = true;
click_state = BeginClick(right_click_tracker_, mouse_pos_x, mouse_pos_y);
click_state = BeginClick(right_click_tracker_, tracked_mouse_pos_x,
tracked_mouse_pos_y);
mouse_event = CGEventCreateMouseEvent(NULL, mouse_type, mouse_point,
kCGMouseButtonRight);
SetClickState(mouse_event, click_state);
@@ -133,21 +160,24 @@ int MouseController::SendMouseCommand(RemoteAction remote_action,
case MouseFlag::right_up:
mouse_type = kCGEventRightMouseUp;
right_dragging_ = false;
click_state = EndClick(right_click_tracker_, mouse_pos_x, mouse_pos_y);
click_state = EndClick(right_click_tracker_, tracked_mouse_pos_x,
tracked_mouse_pos_y);
mouse_event = CGEventCreateMouseEvent(NULL, mouse_type, mouse_point,
kCGMouseButtonRight);
SetClickState(mouse_event, click_state);
break;
case MouseFlag::middle_down:
mouse_type = kCGEventOtherMouseDown;
click_state = BeginClick(middle_click_tracker_, mouse_pos_x, mouse_pos_y);
click_state = BeginClick(middle_click_tracker_, tracked_mouse_pos_x,
tracked_mouse_pos_y);
mouse_event = CGEventCreateMouseEvent(NULL, mouse_type, mouse_point,
kCGMouseButtonCenter);
SetClickState(mouse_event, click_state);
break;
case MouseFlag::middle_up:
mouse_type = kCGEventOtherMouseUp;
click_state = EndClick(middle_click_tracker_, mouse_pos_x, mouse_pos_y);
click_state = EndClick(middle_click_tracker_, tracked_mouse_pos_x,
tracked_mouse_pos_y);
mouse_event = CGEventCreateMouseEvent(NULL, mouse_type, mouse_point,
kCGMouseButtonCenter);
SetClickState(mouse_event, click_state);
@@ -11,6 +11,7 @@
#include <vector>
#include "device_controller.h"
#include "display_info.h"
namespace crossdesk {
@@ -23,6 +24,8 @@ class MouseController : public DeviceController {
virtual int Init(std::vector<DisplayInfo> display_info_list);
virtual int Destroy();
virtual int SendMouseCommand(RemoteAction remote_action, int display_index);
void UpdateDisplayInfoList(
const std::vector<DisplayInfo>& display_info_list);
private:
struct ClickTracker {
@@ -10,6 +10,7 @@
#include <vector>
#include "device_controller.h"
#include "display_info.h"
namespace crossdesk {
@@ -27,4 +28,4 @@ class MouseController : public DeviceController {
std::vector<DisplayInfo> display_info_list_;
};
} // namespace crossdesk
#endif
#endif
+300
View File
@@ -0,0 +1,300 @@
#include "device_controller.h"
#include <algorithm>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <cmath>
#include <nlohmann/json.hpp>
namespace crossdesk {
namespace {
using json = nlohmann::json;
void ResetHostInfo(HostInfo& info) {
info.host_name[0] = '\0';
info.host_name_size = 0;
info.display_list = nullptr;
info.display_num = 0;
info.left = nullptr;
info.top = nullptr;
info.right = nullptr;
info.bottom = nullptr;
}
bool AllocateHostDisplays(HostInfo& info, std::size_t count) {
if (count == 0) return true;
info.display_list = static_cast<char**>(std::calloc(count, sizeof(char*)));
info.left = static_cast<int*>(std::malloc(count * sizeof(int)));
info.top = static_cast<int*>(std::malloc(count * sizeof(int)));
info.right = static_cast<int*>(std::malloc(count * sizeof(int)));
info.bottom = static_cast<int*>(std::malloc(count * sizeof(int)));
return info.display_list && info.left && info.top && info.right &&
info.bottom;
}
} // namespace
std::string RemoteAction::to_json() const { return ToJson(*this); }
bool RemoteAction::from_json(const std::string& json_string) {
RemoteAction temporary{};
if (!FromJson(json_string, temporary)) return false;
*this = temporary;
return true;
}
std::string RemoteAction::ToJson(const RemoteAction& action) {
if (action.type == ControlType::invalid) return {};
json object;
object["type"] = action.type;
switch (action.type) {
case ControlType::mouse:
object["mouse"] = {{"x", action.m.x},
{"y", action.m.y},
{"s", action.m.s},
{"flag", action.m.flag}};
break;
case ControlType::keyboard:
object["keyboard"] = {{"key_value", action.k.key_value},
{"scan_code", action.k.scan_code},
{"extended", action.k.extended},
{"flag", action.k.flag}};
break;
case ControlType::keyboard_state: {
json keys = json::array();
const std::size_t pressed_count =
std::min(action.ks.pressed_count, kMaxKeyboardStateKeys);
for (std::size_t index = 0; index < pressed_count; ++index) {
keys.push_back(
{{"key_value", action.ks.pressed_keys[index].key_value},
{"scan_code", action.ks.pressed_keys[index].scan_code},
{"extended", action.ks.pressed_keys[index].extended}});
}
object["keyboard_state"] =
{{"seq", action.ks.seq}, {"pressed_keys", keys}};
break;
}
case ControlType::cursor_state:
object["cursor_state"] =
{{"seq", action.cs.seq},
{"visible", action.cs.visible},
{"shape", static_cast<int>(action.cs.shape)},
{"position_valid", action.cs.position_valid},
{"x", action.cs.x},
{"y", action.cs.y},
{"visual_offset_x", action.cs.visual_offset_x},
{"visual_offset_y", action.cs.visual_offset_y},
{"display_id", action.cs.display_id},
{"position_update", action.cs.position_update}};
break;
case ControlType::audio_capture:
object["audio_capture"] = action.a;
break;
case ControlType::display_id:
object["display_id"] = action.d;
break;
case ControlType::service_status:
object["service_status"] =
{{"available", action.ss.available},
{"interactive_stage", action.ss.interactive_stage}};
break;
case ControlType::service_command:
object["service_command"] = {{"flag", action.c.flag}};
break;
case ControlType::host_infomation: {
json displays = json::array();
for (std::size_t index = 0; index < action.i.display_num; ++index) {
displays.push_back(
{{"name", action.i.display_list ? action.i.display_list[index]
: ""},
{"left", action.i.left ? action.i.left[index] : 0},
{"top", action.i.top ? action.i.top[index] : 0},
{"right", action.i.right ? action.i.right[index] : 0},
{"bottom", action.i.bottom ? action.i.bottom[index] : 0}});
}
object["host_info"] = {{"host_name", action.i.host_name},
{"display_num", action.i.display_num},
{"displays", displays}};
break;
}
case ControlType::invalid:
default:
return {};
}
return object.dump();
}
bool RemoteAction::FromJson(const std::string& json_string,
RemoteAction& output) {
bool owns_host_info = false;
try {
const json object = json::parse(json_string);
output.type = static_cast<ControlType>(object.at("type").get<int>());
switch (output.type) {
case ControlType::mouse:
output.m.x = object.at("mouse").at("x").get<float>();
output.m.y = object.at("mouse").at("y").get<float>();
output.m.s = object.at("mouse").at("s").get<int>();
output.m.flag = static_cast<MouseFlag>(
object.at("mouse").at("flag").get<int>());
break;
case ControlType::keyboard:
output.k.key_value =
object.at("keyboard").at("key_value").get<std::size_t>();
output.k.scan_code = object.at("keyboard").value("scan_code", 0u);
output.k.extended = object.at("keyboard").value("extended", false);
output.k.flag = static_cast<KeyFlag>(
object.at("keyboard").at("flag").get<int>());
break;
case ControlType::keyboard_state: {
const auto& keyboard_state_object = object.at("keyboard_state");
output.ks.seq = keyboard_state_object.value("seq", 0u);
output.ks.pressed_count = 0;
const auto keys =
keyboard_state_object.value("pressed_keys", json::array());
if (!keys.is_array()) break;
const std::size_t count =
std::min(keys.size(), kMaxKeyboardStateKeys);
for (std::size_t index = 0; index < count; ++index) {
output.ks.pressed_keys[index].key_value =
keys[index].at("key_value").get<std::size_t>();
output.ks.pressed_keys[index].scan_code =
keys[index].value("scan_code", 0u);
output.ks.pressed_keys[index].extended =
keys[index].value("extended", false);
}
output.ks.pressed_count = count;
break;
}
case ControlType::cursor_state: {
const auto& cursor_state_object = object.at("cursor_state");
const int shape = cursor_state_object.at("shape").get<int>();
if (shape < static_cast<int>(RemoteCursorShape::default_cursor) ||
shape > static_cast<int>(RemoteCursorShape::nwse_resize)) {
return false;
}
output.cs.seq = cursor_state_object.at("seq").get<uint32_t>();
output.cs.visible = cursor_state_object.at("visible").get<bool>();
output.cs.shape = static_cast<RemoteCursorShape>(shape);
output.cs.position_valid =
cursor_state_object.value("position_valid", false);
output.cs.x = cursor_state_object.value("x", 0.5f);
output.cs.y = cursor_state_object.value("y", 0.5f);
output.cs.visual_offset_x =
cursor_state_object.value("visual_offset_x", 0.0f);
output.cs.visual_offset_y =
cursor_state_object.value("visual_offset_y", 0.0f);
output.cs.display_id = cursor_state_object.value("display_id", -1);
// Cursor state messages predating position-only echo suppression
// always carried an authoritative position update.
output.cs.position_update =
cursor_state_object.value("position_update", true);
if (!std::isfinite(output.cs.x) || !std::isfinite(output.cs.y) ||
!std::isfinite(output.cs.visual_offset_x) ||
!std::isfinite(output.cs.visual_offset_y)) {
return false;
}
output.cs.x = std::clamp(output.cs.x, 0.0f, 1.0f);
output.cs.y = std::clamp(output.cs.y, 0.0f, 1.0f);
output.cs.visual_offset_x =
std::clamp(output.cs.visual_offset_x, -1.0f, 1.0f);
output.cs.visual_offset_y =
std::clamp(output.cs.visual_offset_y, -1.0f, 1.0f);
break;
}
case ControlType::audio_capture:
output.a = object.at("audio_capture").get<bool>();
break;
case ControlType::display_id:
output.d = object.at("display_id").get<int>();
break;
case ControlType::service_status: {
const auto& service_status_object = object.at("service_status");
output.ss.available = service_status_object.value("available", false);
const std::string stage = service_status_object.value(
"interactive_stage", std::string());
std::strncpy(output.ss.interactive_stage, stage.c_str(),
sizeof(output.ss.interactive_stage) - 1);
output.ss.interactive_stage[sizeof(output.ss.interactive_stage) - 1] =
'\0';
break;
}
case ControlType::service_command:
output.c.flag = static_cast<ServiceCommandFlag>(
object.at("service_command").at("flag").get<int>());
break;
case ControlType::host_infomation: {
ResetHostInfo(output.i);
owns_host_info = true;
const auto& host_info_object = object.at("host_info");
const std::string host_name =
host_info_object.at("host_name").get<std::string>();
std::strncpy(output.i.host_name, host_name.c_str(),
sizeof(output.i.host_name) - 1);
output.i.host_name[sizeof(output.i.host_name) - 1] = '\0';
output.i.host_name_size = std::strlen(output.i.host_name);
const auto& displays = host_info_object.at("displays");
if (!displays.is_array()) return false;
output.i.display_num =
host_info_object.at("display_num").get<std::size_t>();
if (output.i.display_num != displays.size()) return false;
if (!AllocateHostDisplays(output.i, output.i.display_num)) {
FreeRemoteAction(output);
return false;
}
for (std::size_t index = 0; index < output.i.display_num; ++index) {
const std::string name =
displays[index].at("name").get<std::string>();
output.i.display_list[index] =
static_cast<char*>(std::malloc(name.size() + 1));
if (!output.i.display_list[index]) {
FreeRemoteAction(output);
return false;
}
std::memcpy(output.i.display_list[index], name.c_str(),
name.size() + 1);
output.i.left[index] = displays[index].at("left").get<int>();
output.i.top[index] = displays[index].at("top").get<int>();
output.i.right[index] = displays[index].at("right").get<int>();
output.i.bottom[index] = displays[index].at("bottom").get<int>();
}
break;
}
default:
return false;
}
return true;
} catch (const std::exception& exception) {
if (owns_host_info) {
FreeRemoteAction(output);
}
std::fprintf(stderr, "Failed to parse RemoteAction JSON: %s\n",
exception.what());
return false;
}
}
void FreeRemoteAction(RemoteAction& action) {
if (action.type != ControlType::host_infomation) return;
if (action.i.display_list) {
for (std::size_t index = 0; index < action.i.display_num; ++index) {
std::free(action.i.display_list[index]);
}
}
std::free(action.i.display_list);
std::free(action.i.left);
std::free(action.i.top);
std::free(action.i.right);
std::free(action.i.bottom);
ResetHostInfo(action.i);
}
} // namespace crossdesk
+119 -36
View File
@@ -22,6 +22,7 @@
#include <string>
#include <string_view>
#include <unordered_map>
#include <unordered_set>
#include <utility>
#include <vector>
@@ -1807,60 +1808,142 @@ void GuiApplication::Tick() {
}
void GuiApplication::ShareLocalCursorState() {
constexpr auto kCursorEchoSuppressionInterval = 300ms;
constexpr auto kCursorStateHeartbeatInterval = 500ms;
constexpr auto kCursorPositionShareInterval = 16ms;
constexpr float kCursorPositionEpsilon = 0.00005f;
bool has_connected_controller = false;
std::vector<std::string> connected_controllers;
{
std::shared_lock lock(connection_status_mutex_);
has_connected_controller =
std::any_of(connection_status_.begin(), connection_status_.end(),
[](const auto& entry) {
return entry.second == ConnectionStatus::Connected;
});
connected_controllers.reserve(connection_status_.size());
for (const auto& [remote_id, status] : connection_status_) {
if (status == ConnectionStatus::Connected) {
connected_controllers.push_back(remote_id);
}
}
}
if (!is_server_mode_ || !peer_ || !has_connected_controller) {
has_shared_cursor_state_ = false;
last_cursor_state_share_time_ = {};
if (!is_server_mode_ || !peer_ || connected_controllers.empty()) {
cursor_delivery_states_.clear();
return;
}
CursorState sampled{};
if (!cursor_state_provider_.Sample(&sampled)) {
if (!cursor_state_provider_.Sample(devices_.display_info_list(),
selected_display_, &sampled)) {
return;
}
const auto now = std::chrono::steady_clock::now();
const bool changed =
!has_shared_cursor_state_ ||
sampled.visible != last_shared_cursor_state_.visible ||
sampled.shape != last_shared_cursor_state_.shape;
const bool heartbeat_due =
last_cursor_state_share_time_.time_since_epoch().count() == 0 ||
now - last_cursor_state_share_time_ >= kCursorStateHeartbeatInterval;
if (!changed && !heartbeat_due) {
return;
std::unordered_map<std::string, std::chrono::steady_clock::time_point>
last_input_times;
{
std::lock_guard lock(remote_pointer_input_mutex_);
for (const auto& remote_id : connected_controllers) {
const auto input_it = last_remote_pointer_input_time_.find(remote_id);
if (input_it != last_remote_pointer_input_time_.end()) {
last_input_times.emplace(remote_id, input_it->second);
}
}
}
std::unordered_set<std::string> connected_ids(connected_controllers.begin(),
connected_controllers.end());
std::erase_if(cursor_delivery_states_, [&](const auto& entry) {
return connected_ids.find(entry.first) == connected_ids.end();
});
struct CursorRecipient {
std::string remote_id;
bool include_position = true;
};
std::vector<CursorRecipient> recipients;
for (const auto& remote_id : connected_controllers) {
auto& delivery = cursor_delivery_states_[remote_id];
const auto input_it = last_input_times.find(remote_id);
const bool suppress_position =
input_it != last_input_times.end() &&
now - input_it->second < kCursorEchoSuppressionInterval;
delivery.feedback_pending =
delivery.feedback_pending || suppress_position;
const CursorState& previous = delivery.last_sent;
const bool shape_changed =
!delivery.has_sent || sampled.visible != previous.visible ||
sampled.shape != previous.shape ||
std::abs(sampled.visual_offset_x - previous.visual_offset_x) >
kCursorPositionEpsilon ||
std::abs(sampled.visual_offset_y - previous.visual_offset_y) >
kCursorPositionEpsilon;
// Preserve sub-point cursor motion. At 10x client zoom, the old roughly
// one-logical-pixel threshold became several visible phone points.
const bool position_changed =
!delivery.has_sent ||
sampled.position_valid != previous.position_valid ||
sampled.display_id != previous.display_id ||
(sampled.position_valid && previous.position_valid &&
(std::abs(sampled.x - previous.x) > kCursorPositionEpsilon ||
std::abs(sampled.y - previous.y) > kCursorPositionEpsilon));
const bool position_update_due =
position_changed &&
(delivery.last_sent_time.time_since_epoch().count() == 0 ||
now - delivery.last_sent_time >= kCursorPositionShareInterval);
const bool heartbeat_due =
delivery.last_sent_time.time_since_epoch().count() == 0 ||
now - delivery.last_sent_time >= kCursorStateHeartbeatInterval;
if (suppress_position) {
// Cursor appearance is independent of cursor position. Continue sending
// shape changes to every controller while withholding only the sampled
// position from the connection that originated recent input.
if (shape_changed || heartbeat_due) {
recipients.push_back({remote_id, false});
}
} else if (delivery.feedback_pending || shape_changed ||
position_update_due || heartbeat_due) {
recipients.push_back({remote_id, true});
}
}
if (recipients.empty()) return;
sampled.seq = ++cursor_state_sequence_;
RemoteAction action{};
action.type = ControlType::cursor_state;
action.cs = sampled;
const std::string message = action.to_json();
const int result = SendDataFrame(peer_, message.c_str(), message.size(),
mouse_label_.c_str());
if (result != 0) {
LOG_WARN("Send cursor state failed, ret={}", result);
return;
}
for (const auto& recipient : recipients) {
auto& delivery = cursor_delivery_states_[recipient.remote_id];
CursorState outgoing = sampled;
outgoing.position_update = recipient.include_position;
if (!recipient.include_position) {
// Keep legacy receivers at their last acknowledged position as well.
// New receivers honor position_update=false and leave their optimistic
// local position untouched.
outgoing.position_valid =
delivery.has_sent && delivery.last_sent.position_valid;
outgoing.x = delivery.has_sent ? delivery.last_sent.x : 0.5f;
outgoing.y = delivery.has_sent ? delivery.last_sent.y : 0.5f;
outgoing.display_id = delivery.has_sent
? delivery.last_sent.display_id
: -1;
}
if (changed) {
LOG_INFO("Sent cursor state: seq={}, visible={}, shape={}", sampled.seq,
sampled.visible, static_cast<int>(sampled.shape));
}
RemoteAction action{};
action.type = ControlType::cursor_state;
action.cs = outgoing;
const std::string message = action.to_json();
const int result = SendDataFrameToPeer(
peer_, message.c_str(), message.size(), mouse_label_.c_str(),
recipient.remote_id.c_str(), recipient.remote_id.size());
if (result != 0) {
LOG_WARN("Send cursor state to [{}] failed, ret={}",
recipient.remote_id, result);
continue;
}
last_shared_cursor_state_ = sampled;
has_shared_cursor_state_ = true;
last_cursor_state_share_time_ = now;
delivery.last_sent = outgoing;
delivery.has_sent = true;
if (recipient.include_position) {
delivery.feedback_pending = false;
}
delivery.last_sent_time = now;
}
}
void GuiApplication::HandlePasswordChangeResult() {
+12 -6
View File
@@ -1,9 +1,10 @@
#ifndef CROSSDESK_GUI_APPLICATION_H_
#define CROSSDESK_GUI_APPLICATION_H_
#ifndef _GUI_APPLICATION_H_
#define _GUI_APPLICATION_H_
#include <chrono>
#include <memory>
#include <string>
#include <unordered_map>
#include "runtime/cursor_state_provider.h"
#include "runtime/gui_runtime.h"
@@ -21,6 +22,12 @@ public:
private:
struct SlintUi;
struct CursorDeliveryState {
CursorState last_sent{};
bool has_sent = false;
bool feedback_pending = false;
std::chrono::steady_clock::time_point last_sent_time{};
};
void InitializeLogger();
void InitializeSettings();
@@ -73,10 +80,9 @@ private:
std::unique_ptr<SlintUi> ui_;
CursorStateProvider cursor_state_provider_;
CursorState last_shared_cursor_state_{};
bool has_shared_cursor_state_ = false;
std::unordered_map<std::string, CursorDeliveryState>
cursor_delivery_states_;
uint32_t cursor_state_sequence_ = 0;
std::chrono::steady_clock::time_point last_cursor_state_share_time_{};
std::chrono::steady_clock::time_point next_video_frame_time_{};
#if defined(__linux__) && !defined(__APPLE__)
bool use_xwayland_gui_ = false;
@@ -86,4 +92,4 @@ private:
} // namespace crossdesk
#endif // CROSSDESK_GUI_APPLICATION_H_
#endif
+9
View File
@@ -233,6 +233,15 @@ int GuiApplication::ProcessKeyboardEvent(const SDL_Event &event) {
int GuiApplication::ProcessMouseEvent(const SDL_Event &event) {
controlled_remote_id_ = "";
RemoteAction remote_action{};
if ((event.type == SDL_EVENT_MOUSE_BUTTON_DOWN ||
event.type == SDL_EVENT_MOUSE_BUTTON_UP) &&
event.button.button != SDL_BUTTON_LEFT &&
event.button.button != SDL_BUTTON_RIGHT &&
event.button.button != SDL_BUTTON_MIDDLE) {
return 0;
}
float cursor_x = last_mouse_event.motion.x;
float cursor_y = last_mouse_event.motion.y;
@@ -461,7 +461,7 @@ void SessionDeviceManager::UpdateInteractions() {
owner_.mouse_controller_is_started_ = false;
}
#if defined(__linux__) && !defined(__APPLE__)
#if defined(__linux__) || defined(__APPLE__)
if (owner_.screen_capturer_is_started_ && screen_capturer_ &&
mouse_controller_) {
const auto latest_display_info = screen_capturer_->GetDisplayInfoList();
@@ -2,7 +2,6 @@
#include <algorithm>
#include <chrono>
#include <cstring>
#include <filesystem>
#include <limits>
#include <memory>
@@ -236,15 +235,9 @@ void FileTransferManager::Unregister(uint32_t file_id, bool per_peer) {
}
void FileTransferManager::HandleAck(const char *data, size_t size) {
if (size < sizeof(FileTransferAck)) {
LOG_ERROR("FileTransferAck: buffer too small, size={}", size);
return;
}
FileTransferAck ack{};
std::memcpy(&ack, data, sizeof(ack));
if (ack.magic != kFileAckMagic) {
LOG_ERROR("FileTransferAck: invalid magic, got 0x{:08X}", ack.magic);
if (!protocol::DecodeFileTransferAck(data, size, &ack)) {
LOG_ERROR("FileTransferAck: invalid payload, size={}", size);
return;
}
+4
View File
@@ -123,6 +123,10 @@ void GuiRuntime::HandlePresenceProbeTimeout() {
void GuiRuntime::HandleServerControllerDisconnected(
const std::string& remote_id, const char* reason) {
keyboard_.ReleaseRemotePressedKeys(remote_id, reason);
{
std::lock_guard lock(remote_pointer_input_mutex_);
last_remote_pointer_input_time_.erase(remote_id);
}
bool has_connected_controller = false;
bool has_web_controller = false;
+66
View File
@@ -0,0 +1,66 @@
#ifndef _CURSOR_POSITION_H_
#define _CURSOR_POSITION_H_
#include <algorithm>
#include <vector>
#include "device_controller.h"
#include "display_info.h"
namespace crossdesk {
inline void ResetCursorPosition(CursorState* state) {
if (!state) return;
state->position_update = true;
state->position_valid = false;
state->x = 0.5f;
state->y = 0.5f;
state->visual_offset_x = 0.0f;
state->visual_offset_y = 0.0f;
state->display_id = -1;
}
inline bool NormalizeCursorPosition(
double screen_x, double screen_y,
const std::vector<DisplayInfo>& displays, int preferred_display,
CursorState* state) {
if (!state) return false;
ResetCursorPosition(state);
auto contains = [&](int index) {
if (index < 0 || index >= static_cast<int>(displays.size())) return false;
const auto& display = displays[index];
return display.width > 0 && display.height > 0 &&
screen_x >= display.left && screen_x < display.right &&
screen_y >= display.top && screen_y < display.bottom;
};
int display_id = contains(preferred_display) ? preferred_display : -1;
if (display_id < 0) {
for (int index = 0; index < static_cast<int>(displays.size()); ++index) {
if (contains(index)) {
display_id = index;
break;
}
}
}
if (display_id < 0) return false;
const auto& display = displays[display_id];
// Screen coordinates describe a continuous rectangle with an exclusive
// right/bottom edge. Use its full extent so feedback is the exact inverse of
// normalized input instead of accumulating a one-pixel edge convention.
const double horizontal_extent = std::max(display.width, 1);
const double vertical_extent = std::max(display.height, 1);
state->position_valid = true;
state->x = static_cast<float>(std::clamp(
(screen_x - display.left) / horizontal_extent, 0.0, 1.0));
state->y = static_cast<float>(std::clamp(
(screen_y - display.top) / vertical_extent, 0.0, 1.0));
state->display_id = display_id;
return true;
}
} // namespace crossdesk
#endif
+12 -2
View File
@@ -4,6 +4,8 @@
#include <windows.h>
#include "runtime/cursor_position.h"
namespace crossdesk {
namespace {
@@ -39,7 +41,8 @@ struct CursorStateProvider::Impl {};
CursorStateProvider::CursorStateProvider() : impl_(std::make_unique<Impl>()) {}
CursorStateProvider::~CursorStateProvider() = default;
bool CursorStateProvider::Sample(CursorState* state) {
bool CursorStateProvider::Sample(const std::vector<DisplayInfo>& displays,
int preferred_display, CursorState* state) {
if (!state) return false;
CURSORINFO info{};
@@ -50,6 +53,8 @@ bool CursorStateProvider::Sample(CursorState* state) {
state->visible = (info.flags & CURSOR_SHOWING) != 0;
state->shape = state->visible ? ShapeFromWindowsCursor(info.hCursor)
: RemoteCursorShape::none;
NormalizeCursorPosition(info.ptScreenPos.x, info.ptScreenPos.y, displays,
preferred_display, state);
return true;
}
@@ -65,6 +70,7 @@ bool CursorStateProvider::Sample(CursorState* state) {
#include "linux_cursor_shape.h"
#include "platform.h"
#include "shared_cursor_state.h"
#include "runtime/cursor_position.h"
namespace crossdesk {
namespace {
@@ -91,8 +97,10 @@ struct CursorStateProvider::Impl {
CursorStateProvider::CursorStateProvider() : impl_(std::make_unique<Impl>()) {}
CursorStateProvider::~CursorStateProvider() = default;
bool CursorStateProvider::Sample(CursorState* state) {
bool CursorStateProvider::Sample(const std::vector<DisplayInfo>& displays,
int preferred_display, CursorState* state) {
if (!state || !impl_) return false;
ResetCursorPosition(state);
if (IsWaylandSession()) {
SharedCursorState shared{};
@@ -115,6 +123,8 @@ bool CursorStateProvider::Sample(CursorState* state) {
state->visible = CursorHasVisiblePixel(*image);
state->shape = state->visible ? ShapeFromLinuxCursorName(name)
: RemoteCursorShape::none;
NormalizeCursorPosition(image->x, image->y, displays, preferred_display,
state);
XFree(image);
return true;
}
+7 -4
View File
@@ -1,9 +1,11 @@
#ifndef CROSSDESK_GUI_CURSOR_STATE_PROVIDER_H_
#define CROSSDESK_GUI_CURSOR_STATE_PROVIDER_H_
#ifndef _CURSOR_STATE_PROVIDER_H_
#define _CURSOR_STATE_PROVIDER_H_
#include <memory>
#include <vector>
#include "device_controller.h"
#include "display_info.h"
namespace crossdesk {
@@ -17,7 +19,8 @@ class CursorStateProvider {
CursorStateProvider(const CursorStateProvider&) = delete;
CursorStateProvider& operator=(const CursorStateProvider&) = delete;
bool Sample(CursorState* state);
bool Sample(const std::vector<DisplayInfo>& displays,
int preferred_display, CursorState* state);
private:
struct Impl;
@@ -26,4 +29,4 @@ class CursorStateProvider {
} // namespace crossdesk
#endif // CROSSDESK_GUI_CURSOR_STATE_PROVIDER_H_
#endif
+27 -1
View File
@@ -8,9 +8,17 @@
#include <cstdint>
#include <vector>
#include "runtime/cursor_position.h"
namespace crossdesk {
namespace {
// Quartz reports the arrow cursor's event hotspot. The visible apex in the
// current macOS system artwork is about 1.6 logical points above that hotspot
// (NSCursor.arrowCursor has a (5, 5) hotspot). Send this as presentation-only
// metadata so controllers can align their glyph without changing input.
constexpr double kDefaultArrowVisualTipYOffset = -1.6;
struct CursorFingerprint {
uint64_t pixel_hash = 0;
size_t width = 0;
@@ -144,7 +152,8 @@ struct CursorStateProvider::Impl {};
CursorStateProvider::CursorStateProvider() : impl_(std::make_unique<Impl>()) {}
CursorStateProvider::~CursorStateProvider() = default;
bool CursorStateProvider::Sample(CursorState* state) {
bool CursorStateProvider::Sample(const std::vector<DisplayInfo>& displays,
int preferred_display, CursorState* state) {
if (!state) return false;
#pragma clang diagnostic push
@@ -157,6 +166,23 @@ bool CursorStateProvider::Sample(CursorState* state) {
state->visible = visible && cursor != nil;
state->shape = state->visible ? ShapeFromMacCursor(cursor)
: RemoteCursorShape::none;
ResetCursorPosition(state);
CGEventRef event = CGEventCreate(nullptr);
if (event) {
const CGPoint location = CGEventGetLocation(event);
NormalizeCursorPosition(location.x, location.y, displays,
preferred_display, state);
if (state->position_valid && state->visible &&
state->shape == RemoteCursorShape::default_cursor &&
state->display_id >= 0 &&
state->display_id < static_cast<int>(displays.size())) {
const double display_height =
std::max(displays[state->display_id].height, 1);
state->visual_offset_y = static_cast<float>(
kDefaultArrowVisualTipYOffset / display_height);
}
CFRelease(event);
}
return true;
}
+3
View File
@@ -141,6 +141,9 @@ int GuiRuntime::CreateConnectionPeer() {
sizeof(params_.log_path) - 1);
params_.log_path[sizeof(params_.log_path) - 1] = '\0';
params_.hardware_acceleration = config_center_->IsHardwareVideoCodec();
// The Slint desktop renderer currently consumes packed CPU frames. Native
// renderers can opt into platform-native frame output independently.
params_.native_video_output = false;
params_.av1_encoding = config_center_->GetVideoEncodeFormat() ==
ConfigCenter::VIDEO_ENCODE_FORMAT::AV1
? true
+20 -5
View File
@@ -97,6 +97,7 @@ void PeerEventHandler::OnReceiveDataBuffer(
}
receiver.SetOnSendAck([runtime,
remote_user_id](const FileTransferAck &ack) -> int {
const auto encoded_ack = protocol::EncodeFileTransferAck(ack);
bool is_server_sending = remote_user_id.rfind("C-", 0) != 0;
if (is_server_sending) {
auto props =
@@ -104,14 +105,14 @@ void PeerEventHandler::OnReceiveDataBuffer(
if (props) {
PeerPtr *peer = props->peer_;
return SendReliableDataFrame(
peer, reinterpret_cast<const char *>(&ack),
sizeof(FileTransferAck), runtime->file_feedback_label_.c_str());
peer, encoded_ack.data(), encoded_ack.size(),
runtime->file_feedback_label_.c_str());
}
}
return SendReliableDataFrame(
runtime->peer_, reinterpret_cast<const char *>(&ack),
sizeof(FileTransferAck), runtime->file_feedback_label_.c_str());
runtime->peer_, encoded_ack.data(), encoded_ack.size(),
runtime->file_feedback_label_.c_str());
});
receiver.OnData(data, size);
@@ -187,7 +188,15 @@ void PeerEventHandler::OnReceiveDataBuffer(
!props->remote_cursor_state_received_ ||
props->remote_cursor_state_.visible != remote_action.cs.visible ||
props->remote_cursor_state_.shape != remote_action.cs.shape;
props->remote_cursor_state_ = remote_action.cs;
CursorState merged = remote_action.cs;
if (!remote_action.cs.position_update &&
props->remote_cursor_state_received_) {
merged.position_valid = props->remote_cursor_state_.position_valid;
merged.x = props->remote_cursor_state_.x;
merged.y = props->remote_cursor_state_.y;
merged.display_id = props->remote_cursor_state_.display_id;
}
props->remote_cursor_state_ = merged;
props->remote_cursor_state_received_ = true;
if (changed) {
LOG_INFO("Received cursor state: seq={}, visible={}, shape={}",
@@ -238,6 +247,12 @@ void PeerEventHandler::OnReceiveDataBuffer(
}
} else {
// remote
if (runtime->is_server_mode_ &&
remote_action.type == ControlType::mouse) {
std::lock_guard lock(runtime->remote_pointer_input_mutex_);
runtime->last_remote_pointer_input_time_[remote_id] =
std::chrono::steady_clock::now();
}
#if _WIN32
if (runtime->local_service_status_received_ &&
IsSecureDesktopInteractionRequired(runtime->local_interactive_stage_) &&
+12 -11
View File
@@ -1,5 +1,5 @@
#ifndef CROSSDESK_GUI_REMOTE_SESSION_H_
#define CROSSDESK_GUI_REMOTE_SESSION_H_
#ifndef _REMOTE_SESSION_H_
#define _REMOTE_SESSION_H_
#include <atomic>
#include <chrono>
@@ -13,6 +13,7 @@
#include <string>
#include <vector>
#include "stream_names.h"
#include "device_controller.h"
#include "display_info.h"
#include "minirtc.h"
@@ -60,14 +61,14 @@ struct FileTransferState {
struct RemoteSession {
Params params_;
PeerPtr* peer_ = nullptr;
std::string audio_label_ = "control_audio";
std::string data_label_ = "data";
std::string mouse_label_ = "mouse";
std::string keyboard_label_ = "keyboard";
std::string file_label_ = "file";
std::string control_data_label_ = "control_data";
std::string file_feedback_label_ = "file_feedback";
std::string clipboard_label_ = "clipboard";
std::string audio_label_ = protocol::kAudioStream;
std::string data_label_ = protocol::kDataStream;
std::string mouse_label_ = protocol::kMouseStream;
std::string keyboard_label_ = protocol::kKeyboardStream;
std::string file_label_ = protocol::kFileStream;
std::string control_data_label_ = protocol::kControlStream;
std::string file_feedback_label_ = protocol::kFileFeedbackStream;
std::string clipboard_label_ = protocol::kClipboardStream;
std::string local_id_;
std::string remote_id_;
bool exit_ = false;
@@ -173,4 +174,4 @@ using RemoteSessionPtr = std::shared_ptr<RemoteSession>;
} // namespace crossdesk::gui_detail
#endif // CROSSDESK_GUI_REMOTE_SESSION_H_
#endif
+16 -10
View File
@@ -1,5 +1,5 @@
#ifndef CROSSDESK_GUI_RUNTIME_STATE_H_
#define CROSSDESK_GUI_RUNTIME_STATE_H_
#ifndef _RUNTIME_STATE_H_
#define _RUNTIME_STATE_H_
#include <atomic>
#include <chrono>
@@ -57,14 +57,14 @@ struct PeerState {
std::string video_primary_label_ = "primary_display";
std::string video_secondary_label_ = "secondary_display";
std::string audio_label_ = "audio";
std::string data_label_ = "data";
std::string mouse_label_ = "mouse";
std::string keyboard_label_ = "keyboard";
std::string data_label_ = protocol::kDataStream;
std::string mouse_label_ = protocol::kMouseStream;
std::string keyboard_label_ = protocol::kKeyboardStream;
std::string info_label_ = "info";
std::string control_data_label_ = "control_data";
std::string file_label_ = "file";
std::string file_feedback_label_ = "file_feedback";
std::string clipboard_label_ = "clipboard";
std::string control_data_label_ = protocol::kControlStream;
std::string file_label_ = protocol::kFileStream;
std::string file_feedback_label_ = protocol::kFileFeedbackStream;
std::string clipboard_label_ = protocol::kClipboardStream;
Params params_;
};
@@ -180,6 +180,12 @@ struct ConnectionState {
std::shared_mutex connection_status_mutex_;
std::unordered_map<std::string, ConnectionStatus> connection_status_;
std::unordered_map<std::string, std::string> connection_host_names_;
// Cursor position is sampled asynchronously after remote mouse input has
// been injected. Remember the input source so the sampled position is not
// immediately echoed back to that same controller as stale feedback.
std::mutex remote_pointer_input_mutex_;
std::unordered_map<std::string, std::chrono::steady_clock::time_point>
last_remote_pointer_input_time_;
std::string selected_server_remote_id_;
std::string selected_server_remote_hostname_;
std::mutex pending_presence_probe_mutex_;
@@ -202,4 +208,4 @@ struct RuntimeState : InfrastructureState,
} // namespace crossdesk::gui_detail
#endif // CROSSDESK_GUI_RUNTIME_STATE_H_
#endif
+8 -63
View File
@@ -93,38 +93,8 @@ std::vector<char> FileSender::BuildChunk(uint32_t file_id, uint64_t offset,
uint32_t data_size,
const std::string* file_name,
bool is_first, bool is_last) {
FileChunkHeader header{};
header.magic = kFileChunkMagic;
header.file_id = file_id;
header.offset = offset;
header.total_size = total_size;
header.chunk_size = data_size;
header.name_len =
(file_name && is_first) ? static_cast<uint16_t>(file_name->size()) : 0;
header.flags = 0;
if (is_first) header.flags |= 0x01;
if (is_last) header.flags |= 0x02;
std::size_t total_size_bytes =
sizeof(FileChunkHeader) + header.name_len + header.chunk_size;
std::vector<char> buffer;
buffer.resize(total_size_bytes);
std::size_t offset_bytes = 0;
memcpy(buffer.data() + offset_bytes, &header, sizeof(FileChunkHeader));
offset_bytes += sizeof(FileChunkHeader);
if (header.name_len > 0 && file_name) {
memcpy(buffer.data() + offset_bytes, file_name->data(), header.name_len);
offset_bytes += header.name_len;
}
if (header.chunk_size > 0 && data) {
memcpy(buffer.data() + offset_bytes, data, header.chunk_size);
}
return buffer;
return protocol::EncodeFileChunk(file_id, offset, total_size, data,
data_size, file_name, is_first, is_last);
}
// ---------- FileReceiver ----------
@@ -167,40 +137,15 @@ std::filesystem::path FileReceiver::GetDefaultDesktopPath() {
}
bool FileReceiver::OnData(const char* data, size_t size) {
if (!data || size < sizeof(FileChunkHeader)) {
protocol::FileChunkView chunk;
if (!protocol::DecodeFileChunk(data, size, &chunk)) {
LOG_ERROR("FileReceiver::OnData: invalid buffer");
return false;
}
FileChunkHeader header{};
memcpy(&header, data, sizeof(FileChunkHeader));
if (header.magic != kFileChunkMagic) {
return false;
}
std::size_t header_and_name =
sizeof(FileChunkHeader) + static_cast<std::size_t>(header.name_len);
if (size < header_and_name ||
size < header_and_name + static_cast<std::size_t>(header.chunk_size)) {
LOG_ERROR("FileReceiver::OnData: buffer too small for header + payload");
return false;
}
const char* name_ptr = data + sizeof(FileChunkHeader);
std::string file_name;
const std::string* file_name_ptr = nullptr;
if (header.name_len > 0) {
file_name.assign(name_ptr,
name_ptr + static_cast<std::size_t>(header.name_len));
file_name_ptr = &file_name;
}
const char* payload = data + header_and_name;
std::size_t payload_size =
static_cast<std::size_t>(header.chunk_size); // may be 0
return HandleChunk(header, payload, payload_size, file_name_ptr);
const std::string* file_name =
chunk.file_name.empty() ? nullptr : &chunk.file_name;
return HandleChunk(chunk.header, chunk.payload, chunk.payload_size,
file_name);
}
bool FileReceiver::HandleChunk(const FileChunkHeader& header,
+6 -27
View File
@@ -7,7 +7,6 @@
#ifndef _FILE_TRANSFER_H_
#define _FILE_TRANSFER_H_
#include <cstdint>
#include <filesystem>
#include <fstream>
#include <functional>
@@ -15,32 +14,11 @@
#include <unordered_map>
#include <vector>
#include "file_transfer_protocol.h"
#include "stream_names.h"
namespace crossdesk {
// Magic constants for file transfer protocol
constexpr uint32_t kFileChunkMagic = 0x4A4E544D; // 'JNTM'
constexpr uint32_t kFileAckMagic = 0x4A4E5443; // 'JNTC'
#pragma pack(push, 1)
struct FileChunkHeader {
uint32_t magic; // magic to identify file-transfer chunks
uint32_t file_id; // unique id per file transfer
uint64_t offset; // offset in file
uint64_t total_size; // total file size
uint32_t chunk_size; // payload size in this chunk
uint16_t name_len; // filename length (bytes), only set on first chunk
uint8_t flags; // bit0: is_first, bit1: is_last, others reserved
};
struct FileTransferAck {
uint32_t magic; // magic to identify file-transfer ack
uint32_t file_id; // must match FileChunkHeader.file_id
uint64_t acked_offset; // received offset
uint64_t total_size; // total file size
uint32_t flags; // bit0: completed, bit1: error
};
#pragma pack(pop)
class FileSender {
public:
using SendFunc = std::function<int(const char* data, size_t size)>;
@@ -58,7 +36,8 @@ class FileSender {
// `file_id` : file id to use (0 means auto-generate).
// Return 0 on success, <0 on error.
int SendFile(const std::filesystem::path& path, const std::string& label,
const SendFunc& send, std::size_t chunk_size = 64 * 1024,
const SendFunc& send,
std::size_t chunk_size = protocol::kFileChunkSize,
uint32_t file_id = 0);
// build a single encoded chunk buffer according to FileChunkHeader protocol.
@@ -119,4 +98,4 @@ class FileReceiver {
} // namespace crossdesk
#endif
#endif
+81
View File
@@ -0,0 +1,81 @@
#include "file_transfer_protocol.h"
#include <cstring>
#include <limits>
namespace crossdesk::protocol {
std::vector<char> EncodeFileChunk(uint32_t file_id, uint64_t offset,
uint64_t total_size, const char* data,
uint32_t data_size,
const std::string* file_name, bool is_first,
bool is_last) {
const std::size_t name_size = file_name && is_first ? file_name->size() : 0;
if (name_size > std::numeric_limits<uint16_t>::max() ||
offset > total_size || data_size > total_size - offset ||
(data_size > 0 && data == nullptr)) {
return {};
}
FileChunkHeader header{};
header.magic = kFileChunkMagic;
header.file_id = file_id;
header.offset = offset;
header.total_size = total_size;
header.chunk_size = data_size;
header.name_len = static_cast<uint16_t>(name_size);
header.flags = (is_first ? 0x01 : 0) | (is_last ? 0x02 : 0);
std::vector<char> output(sizeof(header) + name_size + data_size);
std::memcpy(output.data(), &header, sizeof(header));
std::size_t cursor = sizeof(header);
if (name_size > 0) {
std::memcpy(output.data() + cursor, file_name->data(), name_size);
cursor += name_size;
}
if (data_size > 0) {
std::memcpy(output.data() + cursor, data, data_size);
}
return output;
}
bool DecodeFileChunk(const char* data, std::size_t size,
FileChunkView* output) {
if (!data || !output || size < sizeof(FileChunkHeader)) return false;
FileChunkHeader header{};
std::memcpy(&header, data, sizeof(header));
if (header.magic != kFileChunkMagic) return false;
const std::size_t name_size = header.name_len;
const std::size_t payload_size = header.chunk_size;
if (name_size > size - sizeof(header)) return false;
const std::size_t payload_offset = sizeof(header) + name_size;
if (payload_size > size - payload_offset || header.offset > header.total_size ||
payload_size > header.total_size - header.offset) {
return false;
}
output->header = header;
output->file_name.assign(data + sizeof(header), name_size);
output->payload = data + payload_offset;
output->payload_size = payload_size;
return true;
}
std::array<char, sizeof(FileTransferAck)> EncodeFileTransferAck(
const FileTransferAck& ack) {
std::array<char, sizeof(FileTransferAck)> output{};
std::memcpy(output.data(), &ack, sizeof(ack));
return output;
}
bool DecodeFileTransferAck(const char* data, std::size_t size,
FileTransferAck* output) {
if (!data || !output || size != sizeof(FileTransferAck)) return false;
std::memcpy(output, data, sizeof(*output));
return output->magic == kFileAckMagic &&
output->acked_offset <= output->total_size;
}
} // namespace crossdesk::protocol
+67
View File
@@ -0,0 +1,67 @@
#ifndef _FILE_TRANSFER_PROTOCOL_H_
#define _FILE_TRANSFER_PROTOCOL_H_
#include <array>
#include <cstddef>
#include <cstdint>
#include <string>
#include <vector>
namespace crossdesk {
inline constexpr uint32_t kFileChunkMagic = 0x4A4E544D; // 'JNTM'
inline constexpr uint32_t kFileAckMagic = 0x4A4E5443; // 'JNTC'
#pragma pack(push, 1)
struct FileChunkHeader {
uint32_t magic;
uint32_t file_id;
uint64_t offset;
uint64_t total_size;
uint32_t chunk_size;
uint16_t name_len;
uint8_t flags;
};
struct FileTransferAck {
uint32_t magic;
uint32_t file_id;
uint64_t acked_offset;
uint64_t total_size;
uint32_t flags;
};
#pragma pack(pop)
static_assert(sizeof(FileChunkHeader) == 31,
"FileChunkHeader wire layout must remain stable");
static_assert(sizeof(FileTransferAck) == 28,
"FileTransferAck wire layout must remain stable");
namespace protocol {
struct FileChunkView {
FileChunkHeader header{};
std::string file_name;
const char* payload = nullptr;
std::size_t payload_size = 0;
};
std::vector<char> EncodeFileChunk(uint32_t file_id, uint64_t offset,
uint64_t total_size, const char* data,
uint32_t data_size,
const std::string* file_name, bool is_first,
bool is_last);
bool DecodeFileChunk(const char* data, std::size_t size,
FileChunkView* output);
std::array<char, sizeof(FileTransferAck)> EncodeFileTransferAck(
const FileTransferAck& ack);
bool DecodeFileTransferAck(const char* data, std::size_t size,
FileTransferAck* output);
} // namespace protocol
} // namespace crossdesk
#endif
+179
View File
@@ -0,0 +1,179 @@
#include <cstring>
#include <iostream>
#include <string>
#include "device_controller.h"
#include "cursor_position.h"
#include "file_transfer_protocol.h"
#include "stream_names.h"
namespace {
bool Expect(bool condition, const char* message) {
if (condition) return true;
std::cerr << message << '\n';
return false;
}
} // namespace
int main() {
bool ok = true;
crossdesk::RemoteAction unset_action{};
ok &= Expect(unset_action.type == crossdesk::ControlType::invalid,
"default RemoteAction should be invalid");
ok &= Expect(unset_action.to_json().empty(),
"invalid RemoteAction should not be serialized");
crossdesk::RemoteAction parsed_invalid{};
ok &= Expect(!parsed_invalid.from_json("{\"type\":-1}"),
"invalid RemoteAction JSON should be rejected");
crossdesk::RemoteAction unknown_action{};
unknown_action.type = static_cast<crossdesk::ControlType>(999);
ok &= Expect(unknown_action.to_json().empty(),
"unknown RemoteAction type should not be serialized");
crossdesk::RemoteAction mouse{};
mouse.type = crossdesk::ControlType::mouse;
mouse.m = {0.25f, 0.75f, -1, crossdesk::MouseFlag::wheel_vertical};
crossdesk::RemoteAction parsed_mouse{};
ok &= Expect(parsed_mouse.from_json(mouse.to_json()),
"mouse JSON should decode");
ok &= Expect(parsed_mouse.type == crossdesk::ControlType::mouse &&
parsed_mouse.m.x == mouse.m.x &&
parsed_mouse.m.y == mouse.m.y &&
parsed_mouse.m.s == mouse.m.s &&
parsed_mouse.m.flag == mouse.m.flag,
"mouse JSON round trip changed fields");
crossdesk::RemoteAction cursor{};
cursor.type = crossdesk::ControlType::cursor_state;
cursor.cs = {42, true, crossdesk::RemoteCursorShape::pointer,
true, 0.25f, 0.75f, 0.001f, -0.002f, 1, true};
crossdesk::RemoteAction parsed_cursor{};
ok &= Expect(parsed_cursor.from_json(cursor.to_json()) &&
parsed_cursor.type == crossdesk::ControlType::cursor_state &&
parsed_cursor.cs.seq == cursor.cs.seq &&
parsed_cursor.cs.visible == cursor.cs.visible &&
parsed_cursor.cs.shape == cursor.cs.shape &&
parsed_cursor.cs.position_valid &&
parsed_cursor.cs.x == cursor.cs.x &&
parsed_cursor.cs.y == cursor.cs.y &&
parsed_cursor.cs.visual_offset_x ==
cursor.cs.visual_offset_x &&
parsed_cursor.cs.visual_offset_y ==
cursor.cs.visual_offset_y &&
parsed_cursor.cs.display_id == cursor.cs.display_id &&
parsed_cursor.cs.position_update,
"cursor state JSON round trip changed position fields");
crossdesk::RemoteAction legacy_cursor{};
ok &= Expect(legacy_cursor.from_json(
"{\"type\":8,\"cursor_state\":{\"seq\":1,"
"\"visible\":true,\"shape\":0}}") &&
!legacy_cursor.cs.position_valid &&
legacy_cursor.cs.position_update &&
legacy_cursor.cs.display_id == -1,
"legacy cursor state JSON should remain compatible");
crossdesk::RemoteAction shape_only_cursor{};
shape_only_cursor.type = crossdesk::ControlType::cursor_state;
shape_only_cursor.cs = cursor.cs;
shape_only_cursor.cs.position_update = false;
crossdesk::RemoteAction parsed_shape_only_cursor{};
ok &= Expect(parsed_shape_only_cursor.from_json(
shape_only_cursor.to_json()) &&
!parsed_shape_only_cursor.cs.position_update &&
parsed_shape_only_cursor.cs.shape == cursor.cs.shape,
"shape-only cursor state should preserve update semantics");
crossdesk::RemoteAction command{};
command.type = crossdesk::ControlType::service_command;
command.c.flag = crossdesk::ServiceCommandFlag::send_sas;
crossdesk::RemoteAction parsed_command{};
ok &= Expect(parsed_command.from_json(command.to_json()) &&
parsed_command.type ==
crossdesk::ControlType::service_command &&
parsed_command.c.flag ==
crossdesk::ServiceCommandFlag::send_sas,
"service command JSON round trip failed");
char display_name[] = "Built-in Display";
char* display_names[] = {display_name};
int left[] = {0};
int top[] = {0};
int right[] = {2560};
int bottom[] = {1600};
crossdesk::RemoteAction host{};
host.type = crossdesk::ControlType::host_infomation;
std::strcpy(host.i.host_name, "Test Mac");
host.i.host_name_size = std::strlen(host.i.host_name);
host.i.display_list = display_names;
host.i.display_num = 1;
host.i.left = left;
host.i.top = top;
host.i.right = right;
host.i.bottom = bottom;
crossdesk::RemoteAction parsed_host{};
ok &= Expect(parsed_host.from_json(host.to_json()) &&
parsed_host.i.display_num == 1 &&
std::string(parsed_host.i.host_name) == "Test Mac" &&
std::string(parsed_host.i.display_list[0]) ==
"Built-in Display" &&
parsed_host.i.right[0] == 2560 &&
parsed_host.i.bottom[0] == 1600,
"host information JSON round trip failed");
crossdesk::FreeRemoteAction(parsed_host);
const std::vector<crossdesk::DisplayInfo> displays = {
crossdesk::DisplayInfo("Test Display", 0, 0, 1920, 1080)};
crossdesk::CursorState normalized_cursor{};
ok &= Expect(crossdesk::NormalizeCursorPosition(
960.0, 540.0, displays, 0, &normalized_cursor) &&
normalized_cursor.position_valid &&
normalized_cursor.x == 0.5f &&
normalized_cursor.y == 0.5f,
"cursor position should use continuous display extents");
const std::string name = "example.txt";
const std::string payload = "CrossDesk protocol";
const auto encoded = crossdesk::protocol::EncodeFileChunk(
17, 0, payload.size(), payload.data(),
static_cast<uint32_t>(payload.size()), &name, true, true);
crossdesk::protocol::FileChunkView chunk;
ok &= Expect(crossdesk::protocol::DecodeFileChunk(
encoded.data(), encoded.size(), &chunk),
"file chunk should decode");
ok &= Expect(chunk.header.file_id == 17 && chunk.file_name == name &&
chunk.payload_size == payload.size() &&
std::memcmp(chunk.payload, payload.data(), payload.size()) ==
0,
"file chunk round trip changed fields");
ok &= Expect(!crossdesk::protocol::DecodeFileChunk(
encoded.data(), encoded.size() - 1, &chunk),
"truncated file chunk should be rejected");
crossdesk::FileTransferAck ack{};
ack.magic = crossdesk::kFileAckMagic;
ack.file_id = 17;
ack.acked_offset = payload.size();
ack.total_size = payload.size();
ack.flags = 1;
const auto encoded_ack = crossdesk::protocol::EncodeFileTransferAck(ack);
crossdesk::FileTransferAck decoded_ack{};
ok &= Expect(crossdesk::protocol::DecodeFileTransferAck(
encoded_ack.data(), encoded_ack.size(), &decoded_ack) &&
decoded_ack.file_id == ack.file_id &&
decoded_ack.acked_offset == ack.acked_offset &&
decoded_ack.flags == ack.flags,
"file acknowledgement round trip failed");
ok &= Expect(std::string(crossdesk::protocol::kControlStream) ==
"control_data" &&
std::string(crossdesk::protocol::kFileFeedbackStream) ==
"file_feedback",
"stream names changed");
return ok ? 0 : 1;
}
+15 -2
View File
@@ -66,8 +66,20 @@ function setup_targets()
target("keyboard_state_protocol_test")
set_kind("binary")
set_default(false)
add_packages("nlohmann_json")
add_includedirs("src/device_controller", "src/common")
add_files("tests/keyboard_state_protocol_test.cpp")
add_files("tests/keyboard_state_protocol_test.cpp",
"src/device_controller/remote_action.cpp")
target("crossdesk_protocol_test")
set_kind("binary")
set_default(false)
add_packages("nlohmann_json")
add_includedirs("src/device_controller", "src/common", "src/tools",
"src/gui/runtime")
add_files("tests/protocol_test.cpp",
"src/device_controller/remote_action.cpp",
"src/tools/file_transfer_protocol.cpp")
target("connection_status_protocol_test")
set_kind("binary")
@@ -177,6 +189,7 @@ function setup_targets()
set_kind("object")
add_deps("rd_log", "common")
add_includedirs("src/device_controller", {public = true})
add_files("src/device_controller/remote_action.cpp")
if is_os("windows") then
add_files("src/device_controller/mouse/windows/*.cpp",
"src/device_controller/keyboard/windows/*.cpp")
@@ -242,7 +255,7 @@ function setup_targets()
target("tools")
set_kind("object")
add_deps("rd_log")
add_deps("rd_log", "common")
add_files("src/tools/*.cpp")
if is_os("macosx") then
add_files("src/tools/*.mm")