mirror of
https://github.com/kunkundi/crossdesk.git
synced 2026-08-31 13:32:10 +08:00
Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fb0ab4f14e | ||
|
|
0e3f3cec00 | ||
|
|
72af49eb80 | ||
|
|
36f05834b5 | ||
|
|
1a190fbaf5 | ||
|
|
1fe236da76 | ||
|
|
3b989fb70d | ||
|
|
7595ab8bd8 |
+169
-1
@@ -295,6 +295,173 @@ jobs:
|
||||
mkdir -p release
|
||||
cp crossdesk-macos-${{ matrix.arch }}-${{ env.VERSION_NUM }}.pkg release/
|
||||
|
||||
# iOS
|
||||
build-ios:
|
||||
name: Build on iOS (arm64)
|
||||
runs-on: macos-26
|
||||
timeout-minutes: 120
|
||||
env:
|
||||
IOS_DEPLOYMENT_TARGET: "16.0"
|
||||
EXPECTED_IOS_SDK: "26.5"
|
||||
XMAKE_VERSION: "3.1.1"
|
||||
XMAKE_REPO_REVISION: "121109352da272cd1a3d4ed52f2dcda34f839955"
|
||||
|
||||
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${{ env.XMAKE_VERSION }}-deps-ios-arm64-xcode26.6-xrepo1211093-min16-${{ github.run_id }}"
|
||||
restore-keys: |
|
||||
${{ runner.os }}-xmake${{ env.XMAKE_VERSION }}-deps-ios-arm64-xcode26.6-xrepo1211093-min16-
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.13"
|
||||
|
||||
- name: Install xmake
|
||||
uses: xmake-io/github-action-setup-xmake@v1
|
||||
with:
|
||||
xmake-version: "${{ env.XMAKE_VERSION }}"
|
||||
|
||||
- name: Verify xmake version
|
||||
shell: bash
|
||||
run: |
|
||||
XMAKE_VERSION_OUTPUT="$(xmake --version)"
|
||||
echo "${XMAKE_VERSION_OUTPUT}"
|
||||
if [[ "${XMAKE_VERSION_OUTPUT}" != *"xmake v${XMAKE_VERSION}"* ]]; then
|
||||
echo "Unexpected xmake version: expected ${XMAKE_VERSION}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Pin xmake package repository
|
||||
shell: bash
|
||||
run: |
|
||||
xmake repo -u
|
||||
XMAKE_REPO_DIR="${HOME}/.xmake/repositories/xmake-repo"
|
||||
git -C "${XMAKE_REPO_DIR}" fetch --depth=1 origin "${XMAKE_REPO_REVISION}"
|
||||
git -C "${XMAKE_REPO_DIR}" checkout --detach "${XMAKE_REPO_REVISION}"
|
||||
test "$(git -C "${XMAKE_REPO_DIR}" rev-parse HEAD)" = "${XMAKE_REPO_REVISION}"
|
||||
|
||||
- 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
|
||||
|
||||
# Windows
|
||||
build-windows-x64:
|
||||
name: Build on Windows x64
|
||||
@@ -515,7 +682,7 @@ jobs:
|
||||
release:
|
||||
name: Publish Release
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
needs: [build-linux, build-macos, build-windows-x64]
|
||||
needs: [build-linux, build-macos, build-ios, build-windows-x64]
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
@@ -578,6 +745,7 @@ jobs:
|
||||
cp artifacts/crossdesk-macos-arm64-${{ steps.version.outputs.VERSION_WITH_V }}/* release/crossdesk-macos-arm64-${{ steps.version.outputs.VERSION_WITH_V }}.pkg
|
||||
cp artifacts/crossdesk-linux-amd64-${{ steps.version.outputs.VERSION_WITH_V }}/* release/crossdesk-linux-amd64-${{ steps.version.outputs.VERSION_WITH_V }}.deb
|
||||
cp artifacts/crossdesk-linux-arm64-${{ steps.version.outputs.VERSION_WITH_V }}/* release/crossdesk-linux-arm64-${{ steps.version.outputs.VERSION_WITH_V }}.deb
|
||||
cp artifacts/crossdesk-ios-arm64-unsigned-${{ steps.version.outputs.VERSION_WITH_V }}/* release/crossdesk-ios-arm64-unsigned-${{ steps.version.outputs.VERSION_WITH_V }}.zip
|
||||
cp artifacts/crossdesk-win-x64-${{ steps.version.outputs.VERSION_WITH_V }}/* release/crossdesk-win-x64-${{ steps.version.outputs.VERSION_WITH_V }}.exe
|
||||
cp artifacts/crossdesk-win-x64-portable-${{ steps.version.outputs.VERSION_WITH_V }}/* release/crossdesk-win-x64-portable-${{ steps.version.outputs.VERSION_WITH_V }}.zip
|
||||
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
# Xmake cache
|
||||
.xmake/
|
||||
build/
|
||||
ios/Vendor/
|
||||
ios/DerivedData/
|
||||
ios/.xmake/
|
||||
xcuserdata/
|
||||
*.xcuserstate
|
||||
|
||||
# MacOS Cache
|
||||
.DS_Store
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
Executable
+170
@@ -0,0 +1,170 @@
|
||||
#!/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
|
||||
|
||||
# Xcode build phases export the iOS SDK, compiler and linker settings into the
|
||||
# script environment. Letting Xmake inherit those variables also targets its
|
||||
# build-machine tools (for example NASM and Meson helpers) at iOS, so they
|
||||
# cannot run on the macOS build host. Give Xmake a clean host environment and
|
||||
# pass the iOS target exclusively through its command-line configuration.
|
||||
XMAKE_DEVELOPER_DIR="${DEVELOPER_DIR:-$(xcode-select -p)}"
|
||||
XMAKE_TOOLCHAIN_BIN="${XMAKE_DEVELOPER_DIR}/Toolchains/XcodeDefault.xctoolchain/usr/bin"
|
||||
run_xmake() {
|
||||
/usr/bin/env -i \
|
||||
HOME="${HOME}" \
|
||||
PATH="${XMAKE_TOOLCHAIN_BIN}:${PATH}" \
|
||||
TMPDIR="${TMPDIR:-/tmp}" \
|
||||
USER="${USER:-}" \
|
||||
LOGNAME="${LOGNAME:-${USER:-}}" \
|
||||
LANG="${LANG:-en_US.UTF-8}" \
|
||||
TERM="${TERM:-dumb}" \
|
||||
NO_COLOR="${NO_COLOR:-}" \
|
||||
DEVELOPER_DIR="${XMAKE_DEVELOPER_DIR}" \
|
||||
XMAKE_PKG_INSTALLDIR="${XMAKE_PKG_INSTALLDIR}" \
|
||||
"${XMAKE_BIN}" "$@"
|
||||
}
|
||||
|
||||
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}"
|
||||
run_xmake f -P "${MINIRTC_DIR}" -c -o "${MINIRTC_BUILD_DIR}" \
|
||||
-p iphoneos -a arm64 -m "${MODE}" \
|
||||
--as="${XMAKE_TOOLCHAIN_BIN}/clang" \
|
||||
--target_minver=16.0 --USE_CUDA=false -y
|
||||
run_xmake 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 \
|
||||
run_xmake 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}"
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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 {
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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_;
|
||||
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;
|
||||
}
|
||||
|
||||
RemoteAction action{};
|
||||
action.type = ControlType::cursor_state;
|
||||
action.cs = sampled;
|
||||
action.cs = outgoing;
|
||||
const std::string message = action.to_json();
|
||||
const int result = SendDataFrame(peer_, message.c_str(), message.size(),
|
||||
mouse_label_.c_str());
|
||||
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 failed, ret={}", result);
|
||||
return;
|
||||
LOG_WARN("Send cursor state to [{}] failed, ret={}",
|
||||
recipient.remote_id, result);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (changed) {
|
||||
LOG_INFO("Sent cursor state: seq={}, visible={}, shape={}", sampled.seq,
|
||||
sampled.visible, static_cast<int>(sampled.shape));
|
||||
delivery.last_sent = outgoing;
|
||||
delivery.has_sent = true;
|
||||
if (recipient.include_position) {
|
||||
delivery.feedback_pending = false;
|
||||
}
|
||||
delivery.last_sent_time = now;
|
||||
}
|
||||
|
||||
last_shared_cursor_state_ = sampled;
|
||||
has_shared_cursor_state_ = true;
|
||||
last_cursor_state_share_time_ = now;
|
||||
}
|
||||
|
||||
void GuiApplication::HandlePasswordChangeResult() {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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_) &&
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
+1
-1
Submodule submodules/minirtc updated: 3aa5fa5ed0...6424ecb77d
@@ -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
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user