mirror of
https://github.com/kunkundi/crossdesk.git
synced 2026-08-31 13:32:10 +08:00
[feat] add native iOS client support
This commit is contained in:
@@ -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
+147
@@ -0,0 +1,147 @@
|
||||
#!/bin/zsh
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="${0:A:h}"
|
||||
IOS_DIR="${SCRIPT_DIR:h}"
|
||||
REPO_DIR="${IOS_DIR:h}"
|
||||
MINIRTC_DIR="${REPO_DIR}/submodules/minirtc"
|
||||
CONFIG_NAME="${CONFIGURATION:-Debug}"
|
||||
MODE="${CONFIG_NAME:l}"
|
||||
|
||||
# Keep iOS dependencies isolated from desktop Xmake packages. Besides avoiding
|
||||
# cross-project cache collisions, this guarantees every archive is compiled
|
||||
# with the iOS 16 deployment target instead of the active SDK version.
|
||||
export XMAKE_PKG_INSTALLDIR="${IOS_DIR}/.xmake/packages"
|
||||
|
||||
if [[ "${MODE}" != "debug" && "${MODE}" != "release" ]]; then
|
||||
MODE="release"
|
||||
fi
|
||||
|
||||
ARCH_NAME="${CURRENT_ARCH:-arm64}"
|
||||
if [[ "${ARCH_NAME}" == "undefined_arch" ]]; then
|
||||
ARCH_NAME="arm64"
|
||||
fi
|
||||
if [[ "${ARCH_NAME}" != "arm64" ]]; then
|
||||
print -u2 "CrossDesk Mobile currently supports physical iOS arm64 builds only."
|
||||
exit 64
|
||||
fi
|
||||
|
||||
XMAKE_BIN="${XMAKE_BIN:-}"
|
||||
if [[ -z "${XMAKE_BIN}" ]]; then
|
||||
XMAKE_BIN="$(command -v xmake 2>/dev/null || true)"
|
||||
fi
|
||||
if [[ -z "${XMAKE_BIN}" ]]; then
|
||||
for candidate in /opt/homebrew/bin/xmake /usr/local/bin/xmake "${HOME}/.local/bin/xmake"; do
|
||||
if [[ -x "${candidate}" ]]; then
|
||||
XMAKE_BIN="${candidate}"
|
||||
break
|
||||
fi
|
||||
done
|
||||
fi
|
||||
if [[ -z "${XMAKE_BIN}" || ! -x "${XMAKE_BIN}" ]]; then
|
||||
print -u2 "xmake is required. Install it from https://xmake.io first."
|
||||
exit 69
|
||||
fi
|
||||
|
||||
OUTPUT_DIR="${IOS_DIR}/Vendor/iphoneos/${CONFIG_NAME}"
|
||||
OUTPUT_LIBRARY="${OUTPUT_DIR}/libCrossDeskMiniRTC.a"
|
||||
MINIRTC_BUILD_DIR="${IOS_DIR}/.xmake/minirtc-build"
|
||||
MINIRTC_LIBRARY="${MINIRTC_BUILD_DIR}/iphoneos/arm64/${MODE}/libminirtc.a"
|
||||
CROSSDESK_SOURCE_DIR="${REPO_DIR}/src"
|
||||
CROSSDESK_BUILD_DIR="${IOS_DIR}/.xmake/crossdesk-build/iphoneos/arm64/${MODE}"
|
||||
|
||||
mkdir -p "${OUTPUT_DIR}"
|
||||
|
||||
# Xmake stores the configured build directory relative to the process working
|
||||
# directory. Xcode does not guarantee that directory for build phases, so keep
|
||||
# configuration, compilation and inspection anchored to the MiniRTC project.
|
||||
(
|
||||
cd "${MINIRTC_DIR}"
|
||||
"${XMAKE_BIN}" f -P "${MINIRTC_DIR}" -c -o "${MINIRTC_BUILD_DIR}" \
|
||||
-p iphoneos -a arm64 -m "${MODE}" \
|
||||
--target_minver=16.0 --USE_CUDA=false -y
|
||||
"${XMAKE_BIN}" b -P "${MINIRTC_DIR}" minirtc
|
||||
)
|
||||
|
||||
if [[ ! -f "${MINIRTC_LIBRARY}" ]]; then
|
||||
print -u2 "MiniRTC archive was not produced at ${MINIRTC_LIBRARY}."
|
||||
exit 66
|
||||
fi
|
||||
|
||||
# Xmake owns the package hashes, so query its resolved target instead of
|
||||
# embedding machine-specific ~/.xmake paths in the Xcode project.
|
||||
TARGET_INFO="$(cd "${MINIRTC_DIR}" && TERM=dumb NO_COLOR=1 \
|
||||
"${XMAKE_BIN}" show -P "${MINIRTC_DIR}" -t minirtc)"
|
||||
CLEAN_INFO="$(print -r -- "${TARGET_INFO}" | sed $'s/\033\\[[0-9;]*[[:alpha:]]//g')"
|
||||
LINK_DIRS=("${(@f)$(print -r -- "${CLEAN_INFO}" | sed -nE 's|.*-> (/.*)/lib -> package.*|\1/lib|p' | sort -u)}")
|
||||
|
||||
REQUIRED_LINKS=(
|
||||
nice glib-2.0 gobject-2.0 gmodule-2.0 gio-2.0 gthread-2.0 intl
|
||||
ffi pcre2-8 pcre2-posix z ssl crypto srtp2 openfec opus yuv kcp
|
||||
datachannel usrsctp openh264 dav1d aom SvtAv1Enc
|
||||
)
|
||||
DEPENDENCY_ARCHIVES=()
|
||||
|
||||
for link_name in "${REQUIRED_LINKS[@]}"; do
|
||||
archive_path=""
|
||||
for link_dir in "${LINK_DIRS[@]}"; do
|
||||
candidate="${link_dir}/lib${link_name}.a"
|
||||
if [[ -f "${candidate}" ]]; then
|
||||
archive_path="${candidate}"
|
||||
break
|
||||
fi
|
||||
done
|
||||
if [[ -z "${archive_path}" ]]; then
|
||||
print -u2 "Unable to resolve static dependency lib${link_name}.a from Xmake."
|
||||
exit 66
|
||||
fi
|
||||
DEPENDENCY_ARCHIVES+=("${archive_path}")
|
||||
done
|
||||
|
||||
# Build the CrossDesk message and file-transfer codecs for the same iOS
|
||||
# architecture, then merge them into the aggregate archive consumed by Xcode.
|
||||
NLOHMANN_HEADER="$(find "${XMAKE_PKG_INSTALLDIR}/n/nlohmann_json" \
|
||||
-path '*/include/nlohmann/json.hpp' -print -quit 2>/dev/null || true)"
|
||||
if [[ -z "${NLOHMANN_HEADER}" ]]; then
|
||||
print -u2 "Unable to locate the nlohmann_json headers installed by Xmake."
|
||||
exit 66
|
||||
fi
|
||||
NLOHMANN_INCLUDE="${NLOHMANN_HEADER:h:h}"
|
||||
IOS_SDK_PATH="$(xcrun --sdk iphoneos --show-sdk-path)"
|
||||
mkdir -p "${CROSSDESK_BUILD_DIR}"
|
||||
|
||||
CROSSDESK_CXXFLAGS=(
|
||||
-std=c++17 -arch arm64 -isysroot "${IOS_SDK_PATH}"
|
||||
-miphoneos-version-min=16.0
|
||||
-I"${CROSSDESK_SOURCE_DIR}/device_controller"
|
||||
-I"${CROSSDESK_SOURCE_DIR}/common"
|
||||
-I"${CROSSDESK_SOURCE_DIR}/tools"
|
||||
-I"${NLOHMANN_INCLUDE}"
|
||||
)
|
||||
if [[ "${MODE}" == "debug" ]]; then
|
||||
CROSSDESK_CXXFLAGS+=(-O0 -g)
|
||||
else
|
||||
CROSSDESK_CXXFLAGS+=(-O2 -DNDEBUG)
|
||||
fi
|
||||
|
||||
CROSSDESK_SOURCES=(
|
||||
"${CROSSDESK_SOURCE_DIR}/device_controller/remote_action.cpp"
|
||||
"${CROSSDESK_SOURCE_DIR}/tools/file_transfer_protocol.cpp"
|
||||
)
|
||||
CROSSDESK_OBJECTS=()
|
||||
for source_path in "${CROSSDESK_SOURCES[@]}"; do
|
||||
object_path="${CROSSDESK_BUILD_DIR}/${source_path:t:r}.o"
|
||||
xcrun --sdk iphoneos clang++ "${CROSSDESK_CXXFLAGS[@]}" \
|
||||
-c "${source_path}" -o "${object_path}"
|
||||
CROSSDESK_OBJECTS+=("${object_path}")
|
||||
done
|
||||
|
||||
TEMP_LIBRARY="${OUTPUT_LIBRARY}.tmp"
|
||||
rm -f "${TEMP_LIBRARY}"
|
||||
/usr/bin/libtool -static -o "${TEMP_LIBRARY}" \
|
||||
"${MINIRTC_LIBRARY}" "${DEPENDENCY_ARCHIVES[@]}" \
|
||||
"${CROSSDESK_OBJECTS[@]}"
|
||||
mv -f "${TEMP_LIBRARY}" "${OUTPUT_LIBRARY}"
|
||||
|
||||
print "Created ${OUTPUT_LIBRARY}"
|
||||
Reference in New Issue
Block a user