[feat] add native iOS client support

This commit is contained in:
dijunkun
2026-08-31 02:14:27 +08:00
parent 126f455681
commit 7595ab8bd8
50 changed files with 7391 additions and 432 deletions
@@ -0,0 +1,388 @@
// !$*UTF8*$!
{
archiveVersion = 1;
classes = {
};
objectVersion = 60;
objects = {
/* Begin PBXBuildFile section */
100000000000000000000001 /* CrossDeskMobileApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 200000000000000000000001 /* CrossDeskMobileApp.swift */; };
100000000000000000000002 /* RemoteSessionModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 200000000000000000000002 /* RemoteSessionModel.swift */; };
100000000000000000000003 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 200000000000000000000003 /* ContentView.swift */; };
100000000000000000000004 /* RemoteSessionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 200000000000000000000004 /* RemoteSessionView.swift */; };
100000000000000000000005 /* CrossDeskRTCBridge.mm in Sources */ = {isa = PBXBuildFile; fileRef = 200000000000000000000006 /* CrossDeskRTCBridge.mm */; };
100000000000000000000006 /* NativeMetalVideoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 200000000000000000000009 /* NativeMetalVideoView.swift */; };
100000000000000000000007 /* RemoteTouchInputView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20000000000000000000000A /* RemoteTouchInputView.swift */; };
100000000000000000000008 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 20000000000000000000000D /* Assets.xcassets */; };
/* End PBXBuildFile section */
/* Begin PBXFileReference section */
200000000000000000000001 /* CrossDeskMobileApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CrossDeskMobileApp.swift; sourceTree = "<group>"; };
200000000000000000000002 /* RemoteSessionModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteSessionModel.swift; sourceTree = "<group>"; };
200000000000000000000003 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
200000000000000000000004 /* RemoteSessionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteSessionView.swift; sourceTree = "<group>"; };
200000000000000000000005 /* CrossDeskRTCBridge.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = CrossDeskRTCBridge.h; sourceTree = "<group>"; };
200000000000000000000006 /* CrossDeskRTCBridge.mm */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.objcpp; path = CrossDeskRTCBridge.mm; sourceTree = "<group>"; };
200000000000000000000007 /* CrossDeskMobile-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "CrossDeskMobile-Bridging-Header.h"; sourceTree = "<group>"; };
200000000000000000000008 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
200000000000000000000009 /* NativeMetalVideoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeMetalVideoView.swift; sourceTree = "<group>"; };
20000000000000000000000A /* RemoteTouchInputView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteTouchInputView.swift; sourceTree = "<group>"; };
20000000000000000000000B /* CrossDeskMobile.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = CrossDeskMobile.app; sourceTree = BUILT_PRODUCTS_DIR; };
20000000000000000000000C /* build_minirtc_ios.sh */ = {isa = PBXFileReference; lastKnownFileType = text.script.sh; path = build_minirtc_ios.sh; sourceTree = "<group>"; };
20000000000000000000000D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
300000000000000000000001 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
400000000000000000000001 = {
isa = PBXGroup;
children = (
400000000000000000000002 /* CrossDeskMobile */,
400000000000000000000006 /* scripts */,
400000000000000000000007 /* Products */,
);
sourceTree = "<group>";
};
400000000000000000000002 /* CrossDeskMobile */ = {
isa = PBXGroup;
children = (
200000000000000000000001 /* CrossDeskMobileApp.swift */,
200000000000000000000002 /* RemoteSessionModel.swift */,
200000000000000000000003 /* ContentView.swift */,
200000000000000000000004 /* RemoteSessionView.swift */,
400000000000000000000003 /* Bridge */,
400000000000000000000004 /* Rendering */,
400000000000000000000005 /* Input */,
20000000000000000000000D /* Assets.xcassets */,
200000000000000000000007 /* CrossDeskMobile-Bridging-Header.h */,
200000000000000000000008 /* Info.plist */,
);
path = CrossDeskMobile;
sourceTree = "<group>";
};
400000000000000000000003 /* Bridge */ = {
isa = PBXGroup;
children = (
200000000000000000000005 /* CrossDeskRTCBridge.h */,
200000000000000000000006 /* CrossDeskRTCBridge.mm */,
);
path = Bridge;
sourceTree = "<group>";
};
400000000000000000000004 /* Rendering */ = {
isa = PBXGroup;
children = (
200000000000000000000009 /* NativeMetalVideoView.swift */,
);
path = Rendering;
sourceTree = "<group>";
};
400000000000000000000005 /* Input */ = {
isa = PBXGroup;
children = (
20000000000000000000000A /* RemoteTouchInputView.swift */,
);
path = Input;
sourceTree = "<group>";
};
400000000000000000000006 /* scripts */ = {
isa = PBXGroup;
children = (
20000000000000000000000C /* build_minirtc_ios.sh */,
);
path = scripts;
sourceTree = "<group>";
};
400000000000000000000007 /* Products */ = {
isa = PBXGroup;
children = (
20000000000000000000000B /* CrossDeskMobile.app */,
);
name = Products;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
500000000000000000000001 /* CrossDeskMobile */ = {
isa = PBXNativeTarget;
buildConfigurationList = 900000000000000000000002 /* Build configuration list for PBXNativeTarget "CrossDeskMobile" */;
buildPhases = (
700000000000000000000001 /* Build native MiniRTC */,
600000000000000000000001 /* Sources */,
300000000000000000000001 /* Frameworks */,
600000000000000000000002 /* Resources */,
);
buildRules = (
);
dependencies = (
);
name = CrossDeskMobile;
productName = CrossDeskMobile;
productReference = 20000000000000000000000B /* CrossDeskMobile.app */;
productType = "com.apple.product-type.application";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
800000000000000000000001 /* Project object */ = {
isa = PBXProject;
attributes = {
BuildIndependentTargetsInParallel = 1;
LastSwiftUpdateCheck = 2660;
LastUpgradeCheck = 2660;
TargetAttributes = {
500000000000000000000001 = {
CreatedOnToolsVersion = 26.6;
};
};
};
buildConfigurationList = 900000000000000000000001 /* Build configuration list for PBXProject "CrossDeskMobile" */;
compatibilityVersion = "Xcode 15.0";
developmentRegion = zh_CN;
hasScannedForEncodings = 0;
knownRegions = (
Base,
en,
zh_CN,
);
mainGroup = 400000000000000000000001;
productRefGroup = 400000000000000000000007 /* Products */;
projectDirPath = "";
projectRoot = "";
targets = (
500000000000000000000001 /* CrossDeskMobile */,
);
};
/* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */
600000000000000000000002 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
100000000000000000000008 /* Assets.xcassets in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXShellScriptBuildPhase section */
700000000000000000000001 /* Build native MiniRTC */ = {
isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
);
inputPaths = (
);
name = "Build native MiniRTC";
outputFileListPaths = (
);
outputPaths = (
"$(PROJECT_DIR)/Vendor/iphoneos/$(CONFIGURATION)/libCrossDeskMiniRTC.a",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/zsh;
shellScript = "\"${PROJECT_DIR}/scripts/build_minirtc_ios.sh\"\n";
};
/* End PBXShellScriptBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
600000000000000000000001 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
100000000000000000000001 /* CrossDeskMobileApp.swift in Sources */,
100000000000000000000002 /* RemoteSessionModel.swift in Sources */,
100000000000000000000003 /* ContentView.swift in Sources */,
100000000000000000000004 /* RemoteSessionView.swift in Sources */,
100000000000000000000005 /* CrossDeskRTCBridge.mm in Sources */,
100000000000000000000006 /* NativeMetalVideoView.swift in Sources */,
100000000000000000000007 /* RemoteTouchInputView.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin XCBuildConfiguration section */
A00000000000000000000001 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
CLANG_ANALYZER_NONNULL = YES;
CLANG_CXX_LANGUAGE_STANDARD = "c++17";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
DEBUG_INFORMATION_FORMAT = dwarf;
ENABLE_TESTABILITY = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
GCC_OPTIMIZATION_LEVEL = 0;
GCC_PREPROCESSOR_DEFINITIONS = (
"DEBUG=1",
"$(inherited)",
);
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = iphoneos;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
};
name = Debug;
};
A00000000000000000000002 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
CLANG_ANALYZER_NONNULL = YES;
CLANG_CXX_LANGUAGE_STANDARD = "c++17";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
GCC_C_LANGUAGE_STANDARD = gnu17;
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = iphoneos;
SWIFT_COMPILATION_MODE = wholemodule;
VALIDATE_PRODUCT = YES;
};
name = Release;
};
A00000000000000000000003 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ARCHS = arm64;
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = 8DNC3SZ86S;
ENABLE_BITCODE = NO;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
GENERATE_INFOPLIST_FILE = NO;
HEADER_SEARCH_PATHS = (
"$(inherited)",
"$(PROJECT_DIR)/../submodules/minirtc/src/api",
"$(PROJECT_DIR)/../src/device_controller",
"$(PROJECT_DIR)/../src/common",
"$(PROJECT_DIR)/../src/tools",
);
INFOPLIST_FILE = CrossDeskMobile/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
MARKETING_VERSION = 1.0;
OTHER_LDFLAGS = (
"$(inherited)",
"$(PROJECT_DIR)/Vendor/iphoneos/$(CONFIGURATION)/libCrossDeskMiniRTC.a",
"-liconv",
"-lresolv",
"-framework",
VideoToolbox,
"-framework",
CoreMedia,
"-framework",
CoreVideo,
"-framework",
Security,
"-framework",
SystemConfiguration,
"-framework",
CoreFoundation,
"-framework",
Foundation,
);
PRODUCT_BUNDLE_IDENTIFIER = cn.crossdesk.mobile;
PRODUCT_NAME = "$(TARGET_NAME)";
SUPPORTED_PLATFORMS = iphoneos;
SWIFT_OBJC_BRIDGING_HEADER = "CrossDeskMobile/CrossDeskMobile-Bridging-Header.h";
SWIFT_STRICT_CONCURRENCY = minimal;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Debug;
};
A00000000000000000000004 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ARCHS = arm64;
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = 8DNC3SZ86S;
ENABLE_BITCODE = NO;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
GENERATE_INFOPLIST_FILE = NO;
HEADER_SEARCH_PATHS = (
"$(inherited)",
"$(PROJECT_DIR)/../submodules/minirtc/src/api",
"$(PROJECT_DIR)/../src/device_controller",
"$(PROJECT_DIR)/../src/common",
"$(PROJECT_DIR)/../src/tools",
);
INFOPLIST_FILE = CrossDeskMobile/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
MARKETING_VERSION = 1.0;
OTHER_LDFLAGS = (
"$(inherited)",
"$(PROJECT_DIR)/Vendor/iphoneos/$(CONFIGURATION)/libCrossDeskMiniRTC.a",
"-liconv",
"-lresolv",
"-framework",
VideoToolbox,
"-framework",
CoreMedia,
"-framework",
CoreVideo,
"-framework",
Security,
"-framework",
SystemConfiguration,
"-framework",
CoreFoundation,
"-framework",
Foundation,
);
PRODUCT_BUNDLE_IDENTIFIER = cn.crossdesk.mobile;
PRODUCT_NAME = "$(TARGET_NAME)";
SUPPORTED_PLATFORMS = iphoneos;
SWIFT_OBJC_BRIDGING_HEADER = "CrossDeskMobile/CrossDeskMobile-Bridging-Header.h";
SWIFT_STRICT_CONCURRENCY = minimal;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Release;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
900000000000000000000001 /* Build configuration list for PBXProject "CrossDeskMobile" */ = {
isa = XCConfigurationList;
buildConfigurations = (
A00000000000000000000001 /* Debug */,
A00000000000000000000002 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
900000000000000000000002 /* Build configuration list for PBXNativeTarget "CrossDeskMobile" */ = {
isa = XCConfigurationList;
buildConfigurations = (
A00000000000000000000003 /* Debug */,
A00000000000000000000004 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
};
rootObject = 800000000000000000000001 /* Project object */;
}
@@ -0,0 +1,76 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "2660"
version = "1.7">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES"
buildArchitectures = "Automatic">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "500000000000000000000001"
BuildableName = "CrossDeskMobile.app"
BlueprintName = "CrossDeskMobile"
ReferencedContainer = "container:CrossDeskMobile.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES">
<Testables>
</Testables>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
<BuildableProductRunnable runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "500000000000000000000001"
BuildableName = "CrossDeskMobile.app"
BlueprintName = "CrossDeskMobile"
ReferencedContainer = "container:CrossDeskMobile.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "500000000000000000000001"
BuildableName = "CrossDeskMobile.app"
BlueprintName = "CrossDeskMobile"
ReferencedContainer = "container:CrossDeskMobile.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>
@@ -0,0 +1,14 @@
{
"images" : [
{
"filename" : "CrossDeskAppIcon-1024.png",
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 217 KiB

@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -0,0 +1,121 @@
#import <CoreVideo/CoreVideo.h>
#import <Foundation/Foundation.h>
NS_ASSUME_NONNULL_BEGIN
typedef NS_ENUM(NSInteger, CrossDeskSignalState) {
CrossDeskSignalStateConnecting = 0,
CrossDeskSignalStateConnected,
CrossDeskSignalStateFailed,
CrossDeskSignalStateClosed,
CrossDeskSignalStateReconnecting,
CrossDeskSignalStateServerClosed,
CrossDeskSignalStateTLSCertificateError,
};
typedef NS_ENUM(NSInteger, CrossDeskConnectionState) {
CrossDeskConnectionStateConnecting = 0,
CrossDeskConnectionStateConnected,
CrossDeskConnectionStateGathering,
CrossDeskConnectionStateDisconnected,
CrossDeskConnectionStateFailed,
CrossDeskConnectionStateClosed,
CrossDeskConnectionStateIncorrectPassword,
CrossDeskConnectionStateNoSuchID,
CrossDeskConnectionStateRemoteUnavailable,
};
typedef NS_ENUM(NSInteger, CrossDeskPointerAction) {
CrossDeskPointerActionMove = 0,
CrossDeskPointerActionLeftDown,
CrossDeskPointerActionLeftUp,
CrossDeskPointerActionRightDown,
CrossDeskPointerActionRightUp,
CrossDeskPointerActionMiddleDown,
CrossDeskPointerActionMiddleUp,
};
@class CrossDeskRTCBridge;
@protocol CrossDeskRTCBridgeDelegate <NSObject>
@optional
- (void)rtcBridge:(CrossDeskRTCBridge *)bridge
didChangeSignalState:(CrossDeskSignalState)state;
- (void)rtcBridge:(CrossDeskRTCBridge *)bridge
didChangeConnectionState:(CrossDeskConnectionState)state
remoteID:(NSString *)remoteID;
- (void)rtcBridge:(CrossDeskRTCBridge *)bridge
didProvisionIdentity:(NSString *)identity;
- (void)rtcBridge:(CrossDeskRTCBridge *)bridge
didReceivePixelBuffer:(CVPixelBufferRef)pixelBuffer
width:(NSInteger)width
height:(NSInteger)height;
- (void)rtcBridge:(CrossDeskRTCBridge *)bridge
didReceiveHostName:(NSString *)hostName
displayNames:(NSArray<NSString *> *)displayNames
displaySizes:(NSArray<NSValue *> *)displaySizes;
- (void)rtcBridge:(CrossDeskRTCBridge *)bridge
didReceivePresence:(NSDictionary<NSString *, NSNumber *> *)presence;
- (void)rtcBridge:(CrossDeskRTCBridge *)bridge
didReceiveCursorVisible:(BOOL)visible
shape:(NSInteger)shape
positionUpdate:(BOOL)positionUpdate
positionValid:(BOOL)positionValid
x:(float)x
y:(float)y
visualOffsetX:(float)visualOffsetX
visualOffsetY:(float)visualOffsetY
displayIndex:(NSInteger)displayIndex
sequence:(uint32_t)sequence;
- (void)rtcBridge:(CrossDeskRTCBridge *)bridge
didReceiveAudioPCM:(NSData *)pcmData;
- (void)rtcBridge:(CrossDeskRTCBridge *)bridge
didReceiveClipboardText:(NSString *)text;
- (void)rtcBridge:(CrossDeskRTCBridge *)bridge
didUpdateFileTransfer:(NSString *)fileName
progress:(double)progress
sending:(BOOL)sending;
- (void)rtcBridge:(CrossDeskRTCBridge *)bridge
didReceiveFileAtURL:(NSURL *)fileURL;
- (void)rtcBridge:(CrossDeskRTCBridge *)bridge
didUpdateBitrate:(NSUInteger)bitsPerSecond
lossRate:(float)lossRate
usingTURN:(BOOL)usingTURN;
@end
/// Objective-C++ boundary around the native MiniRTC C API.
///
/// All MiniRTC ownership and calls are serialized internally. Delegate methods
/// are delivered on the main queue so SwiftUI never touches callback threads.
@interface CrossDeskRTCBridge : NSObject
@property(nonatomic, weak, nullable) id<CrossDeskRTCBridgeDelegate> delegate;
- (void)configureWithSignalHost:(NSString *)host
signalPort:(NSInteger)signalPort
turnPort:(NSInteger)turnPort
enableSRTP:(BOOL)enableSRTP;
- (void)setHardwareAccelerationEnabled:(BOOL)enabled;
- (void)requestPresenceForRemoteIDs:(NSArray<NSString *> *)remoteIDs
NS_SWIFT_NAME(requestPresence(remoteIDs:));
- (void)connectToRemoteID:(NSString *)remoteID password:(NSString *)password;
- (void)disconnect;
- (void)sendPointerAtX:(float)x
y:(float)y
action:(CrossDeskPointerAction)action
NS_SWIFT_NAME(sendPointer(x:y:action:));
- (void)sendScrollX:(float)x y:(float)y deltaX:(NSInteger)deltaX
deltaY:(NSInteger)deltaY;
- (void)sendWindowsKeyCode:(NSUInteger)keyCode isDown:(BOOL)isDown;
- (void)sendText:(NSString *)text;
- (void)switchToDisplay:(NSInteger)displayIndex;
- (void)setAudioEnabled:(BOOL)enabled;
- (void)sendClipboardText:(NSString *)text;
- (void)sendFileAtURL:(NSURL *)fileURL;
- (void)sendSecureAttentionSequence;
@end
NS_ASSUME_NONNULL_END
File diff suppressed because it is too large Load Diff
+731
View File
@@ -0,0 +1,731 @@
import SwiftUI
import UIKit
struct ContentView: View {
@Environment(\.scenePhase) private var scenePhase
@ObservedObject var session: RemoteSessionModel
var body: some View {
Group {
if session.sessionVisible {
RemoteSessionView(session: session)
.transition(.opacity)
} else {
ConnectionHomeView(session: session)
.transition(.opacity)
}
}
.animation(.easeInOut(duration: 0.2), value: session.sessionVisible)
.preferredColorScheme(session.sessionVisible ? .dark : .light)
.onAppear {
if !session.sessionVisible {
AppOrientation.update(to: .portrait)
}
if scenePhase == .active {
session.refreshRecentConnectionPresenceAfterForeground()
}
}
.onChange(of: scenePhase) { phase in
if phase == .active {
session.refreshRecentConnectionPresenceAfterForeground()
}
}
}
}
private struct KeyboardDismissTapView: UIViewRepresentable {
let onDismiss: () -> Void
func makeCoordinator() -> Coordinator {
Coordinator(onDismiss: onDismiss)
}
func makeUIView(context: Context) -> UIView {
let view = UIView(frame: .zero)
view.backgroundColor = .clear
view.isUserInteractionEnabled = false
installRecognizer(for: view, coordinator: context.coordinator)
return view
}
func updateUIView(_ view: UIView, context: Context) {
context.coordinator.onDismiss = onDismiss
installRecognizer(for: view, coordinator: context.coordinator)
}
static func dismantleUIView(_ view: UIView, coordinator: Coordinator) {
coordinator.uninstall()
}
private func installRecognizer(for view: UIView, coordinator: Coordinator) {
DispatchQueue.main.async { [weak view, weak coordinator] in
guard let window = view?.window else { return }
coordinator?.install(in: window)
}
}
final class Coordinator: NSObject, UIGestureRecognizerDelegate {
var onDismiss: () -> Void
private weak var window: UIWindow?
private var recognizer: UITapGestureRecognizer?
init(onDismiss: @escaping () -> Void) {
self.onDismiss = onDismiss
}
func install(in window: UIWindow) {
guard self.window !== window || recognizer == nil else { return }
uninstall()
let recognizer = UITapGestureRecognizer(target: self,
action: #selector(didTapOutsideInput))
recognizer.cancelsTouchesInView = false
recognizer.delegate = self
window.addGestureRecognizer(recognizer)
self.window = window
self.recognizer = recognizer
}
func uninstall() {
if let recognizer {
window?.removeGestureRecognizer(recognizer)
}
recognizer = nil
window = nil
}
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer,
shouldReceive touch: UITouch) -> Bool {
var touchedView = touch.view
while let view = touchedView {
if view is UITextField || view is UITextView {
return false
}
touchedView = view.superview
}
return true
}
@objc private func didTapOutsideInput() {
onDismiss()
window?.endEditing(true)
}
}
}
private struct ConnectionHomeView: View {
@ObservedObject var session: RemoteSessionModel
@FocusState private var remoteIDFocused: Bool
@State private var passwordPromptVisible = false
@State private var settingsVisible = false
@State private var promptRemoteID = ""
@State private var promptPassword = ""
@State private var promptRememberPassword = false
private let connectionAccent = Color(red: 0.18, green: 0.48, blue: 0.86)
private let connectionAccentEnd = Color(red: 0.12, green: 0.38, blue: 0.78)
private var trimmedRemoteID: String {
session.remoteID.trimmingCharacters(in: .whitespacesAndNewlines)
}
private var groupedRemoteID: Binding<String> {
Binding(
get: { formatRemoteID(session.remoteID) },
set: { value in
session.remoteID = String(value.filter { $0.isNumber }.prefix(9))
}
)
}
private var signalIsConnected: Bool {
session.signalStatus == "已连接服务器"
}
private var signalHasError: Bool {
session.signalStatus.contains("失败") ||
session.signalStatus.contains("无效") ||
session.signalStatus.contains("关闭") ||
session.signalStatus.contains("未知")
}
private var signalTint: Color {
if signalIsConnected { return .green }
if signalHasError { return .red }
return .orange
}
private var signalSymbol: String {
if signalIsConnected { return "checkmark.circle.fill" }
if signalHasError { return "exclamationmark.triangle.fill" }
return "arrow.triangle.2.circlepath"
}
private var recentConnectionColumns: [GridItem] {
[
GridItem(.flexible(), spacing: 12, alignment: .top),
GridItem(.flexible(), spacing: 12, alignment: .top)
]
}
private var orderedRecentConnections: [RecentConnection] {
session.recentConnections.enumerated()
.sorted { lhs, rhs in
let lhsOnline = session.isRecentConnectionOnline(lhs.element)
let rhsOnline = session.isRecentConnectionOnline(rhs.element)
if lhsOnline != rhsOnline { return lhsOnline }
return lhs.offset < rhs.offset
}
.map(\.element)
}
var body: some View {
ZStack {
Color(.systemGroupedBackground).ignoresSafeArea()
VStack(spacing: 0) {
header
remoteConnectionPanel
.padding(.top, 24)
recentConnectionsPanel
.padding(.top, 20)
.frame(maxHeight: .infinity)
}
.padding(.horizontal, 20)
.padding(.bottom, 12)
if session.isConnecting {
connectionProgressOverlay
}
}
.background {
KeyboardDismissTapView {
remoteIDFocused = false
}
.allowsHitTesting(false)
}
.sheet(isPresented: $passwordPromptVisible) {
PasswordPromptView(remoteID: promptRemoteID,
password: $promptPassword,
rememberPassword: $promptRememberPassword) {
passwordPromptVisible = false
session.remoteID = promptRemoteID
session.connect(password: promptPassword,
rememberPassword: promptRememberPassword)
}
.presentationDetents([.height(340)])
.presentationDragIndicator(.visible)
}
.sheet(isPresented: $settingsVisible) {
ServerSettingsView(session: session)
.presentationDetents([.fraction(0.80)])
.presentationDragIndicator(.visible)
}
.alert("设备离线", isPresented: Binding(
get: { session.deviceOfflineAlertVisible },
set: { visible in
if !visible { session.dismissDeviceOfflineAlert() }
}
)) {
Button("确定") {
session.dismissDeviceOfflineAlert()
}
}
.onChange(of: session.remoteID) { value in
let formatted = String(value.filter { $0.isNumber }.prefix(9))
if formatted != value {
session.remoteID = formatted
}
}
}
private var header: some View {
HStack(spacing: 8) {
Label {
Text(session.signalStatus)
.font(.caption.weight(.semibold))
.lineLimit(1)
} icon: {
Image(systemName: signalSymbol)
.font(.caption.weight(.bold))
}
.foregroundStyle(signalTint)
.padding(.horizontal, 11)
.frame(height: 34)
.background(signalTint.opacity(0.11), in: Capsule())
.overlay {
Capsule()
.stroke(signalTint.opacity(0.22), lineWidth: 1)
}
Spacer()
Button {
settingsVisible = true
} label: {
Image(systemName: "gearshape")
.font(.system(size: 18, weight: .semibold))
.frame(width: 40, height: 40)
.background(Color(.secondarySystemGroupedBackground),
in: RoundedRectangle(cornerRadius: 10,
style: .continuous))
}
.foregroundStyle(.primary)
.accessibilityLabel("设置")
}
.padding(.top, 8)
}
private var remoteConnectionPanel: some View {
VStack(alignment: .leading, spacing: 16) {
HStack {
Text("远程桌面")
.font(.title3.weight(.bold))
Spacer()
}
HStack(spacing: 10) {
TextField("对端 ID", text: groupedRemoteID)
.keyboardType(.numberPad)
.textContentType(.username)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.font(.system(.title3, design: .rounded).weight(.semibold))
.monospacedDigit()
.focused($remoteIDFocused)
.padding(.horizontal, 15)
.frame(height: 54)
.background(Color(.systemGroupedBackground),
in: RoundedRectangle(cornerRadius: 12,
style: .continuous))
.overlay {
RoundedRectangle(cornerRadius: 12, style: .continuous)
.stroke(remoteIDFocused
? connectionAccent
: Color(.separator).opacity(0.4),
lineWidth: remoteIDFocused ? 2 : 1)
}
.shadow(color: remoteIDFocused
? connectionAccent.opacity(0.12) : .clear,
radius: 8, y: 2)
Button {
presentPasswordPrompt(for: trimmedRemoteID)
} label: {
HStack(spacing: 6) {
Text("连接")
Image(systemName: "arrow.right")
}
.font(.system(size: 16, weight: .bold))
.frame(width: 96, height: 54)
.foregroundStyle(.white)
.background(LinearGradient(colors: [
connectionAccent,
connectionAccentEnd
], startPoint: .topLeading, endPoint: .bottomTrailing))
.clipShape(RoundedRectangle(cornerRadius: 12,
style: .continuous))
.shadow(color: connectionAccent.opacity(0.24),
radius: 8, y: 4)
}
.buttonStyle(.plain)
.disabled(trimmedRemoteID.isEmpty || session.isConnecting)
.opacity(trimmedRemoteID.isEmpty || session.isConnecting ? 0.45 : 1)
.accessibilityLabel("连接")
}
}
.padding(18)
.background(Color(.secondarySystemGroupedBackground),
in: RoundedRectangle(cornerRadius: 20, style: .continuous))
.overlay {
RoundedRectangle(cornerRadius: 20, style: .continuous)
.stroke(Color(.separator).opacity(0.28), lineWidth: 1)
}
.shadow(color: .black.opacity(0.055), radius: 14, y: 5)
}
@ViewBuilder
private var recentConnectionsPanel: some View {
VStack(alignment: .leading, spacing: 14) {
HStack(alignment: .firstTextBaseline) {
Text("最近连接")
.font(.title3.bold())
Spacer()
if !session.recentConnections.isEmpty {
Text("\(session.recentConnections.count) 台设备")
.font(.caption)
.foregroundStyle(.secondary)
}
}
if session.recentConnections.isEmpty {
VStack(spacing: 10) {
Image(systemName: "clock.arrow.circlepath")
.font(.system(size: 30, weight: .medium))
.foregroundStyle(.tertiary)
Text("还没有连接记录")
.font(.headline)
Text("成功连接后,这里会显示远端桌面的缩略图。")
.font(.footnote)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else {
ScrollView(.vertical) {
LazyVGrid(columns: recentConnectionColumns, spacing: 12) {
ForEach(orderedRecentConnections) { connection in
RecentConnectionCard(
connection: connection,
thumbnail: session.thumbnailImage(for: connection),
online: session.isRecentConnectionOnline(connection),
connect: {
connectRecent(connection)
},
delete: {
session.removeRecentConnection(connection)
}
)
}
}
.padding(.bottom, 2)
}
}
}
.padding(16)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
.background(Color(.secondarySystemGroupedBackground),
in: RoundedRectangle(cornerRadius: 18, style: .continuous))
.overlay {
RoundedRectangle(cornerRadius: 18, style: .continuous)
.stroke(Color(.separator).opacity(0.35), lineWidth: 1)
}
}
private var connectionProgressOverlay: some View {
ZStack {
Color.clear
.ignoresSafeArea()
.contentShape(Rectangle())
.onTapGesture { }
VStack(spacing: 0) {
VStack(spacing: 14) {
ProgressView()
.controlSize(.large)
.tint(connectionAccent)
VStack(spacing: 6) {
Text("正在连接")
.font(.title3.weight(.semibold))
.foregroundStyle(.primary)
Text(session.connectionStatus)
.font(.subheadline)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
.lineLimit(2)
.frame(minHeight: 36)
}
}
.padding(.horizontal, 26)
.padding(.top, 24)
.padding(.bottom, 20)
Divider()
Button(role: .cancel, action: session.disconnect) {
Text("取消连接")
.font(.subheadline.weight(.semibold))
.foregroundStyle(Color.red)
.frame(maxWidth: .infinity)
.frame(height: 46)
}
.buttonStyle(.plain)
}
.frame(width: 292)
.background(Color.white,
in: RoundedRectangle(cornerRadius: 24,
style: .continuous))
.overlay {
RoundedRectangle(cornerRadius: 24, style: .continuous)
.stroke(Color(.separator).opacity(0.24), lineWidth: 0.8)
}
.shadow(color: .black.opacity(0.22), radius: 28, y: 10)
}
.transition(.opacity.combined(with: .scale(scale: 0.96)))
}
private func presentPasswordPrompt(for identifier: String) {
let trimmed = identifier.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else {
remoteIDFocused = true
return
}
remoteIDFocused = false
promptRemoteID = trimmed
promptPassword = session.savedPassword(for: trimmed)
promptRememberPassword = session.remembersPassword(for: trimmed)
passwordPromptVisible = true
}
private func formatRemoteID(_ value: String) -> String {
let digits = Array(value.filter { $0.isNumber }.prefix(9))
return stride(from: 0, to: digits.count, by: 3)
.map { start in
String(digits[start..<min(start + 3, digits.count)])
}
.joined(separator: " ")
}
private func connectRecent(_ connection: RecentConnection) {
session.remoteID = connection.remoteID
remoteIDFocused = false
if connection.remembersPassword,
let savedPassword = session.savedCredential(for: connection.remoteID) {
session.connect(password: savedPassword, rememberPassword: true)
} else {
presentPasswordPrompt(for: connection.remoteID)
}
}
}
private struct RecentConnectionCard: View {
let connection: RecentConnection
let thumbnail: UIImage?
let online: Bool
let connect: () -> Void
let delete: () -> Void
var body: some View {
Button(action: connect) {
VStack(alignment: .leading, spacing: 0) {
ZStack {
if let thumbnail {
Image(uiImage: thumbnail)
.resizable()
.scaledToFill()
} else {
LinearGradient(colors: [
Color(red: 0.18, green: 0.48, blue: 0.86),
Color(red: 0.38, green: 0.68, blue: 0.96)
], startPoint: .topLeading, endPoint: .bottomTrailing)
Image(systemName: "display")
.font(.system(size: 34, weight: .medium))
.foregroundStyle(.white.opacity(0.9))
}
}
.frame(maxWidth: .infinity)
.aspectRatio(16 / 9, contentMode: .fit)
.clipped()
VStack(alignment: .leading, spacing: 3) {
Text(connection.displayName)
.font(.caption.weight(.semibold))
.foregroundStyle(.primary)
.lineLimit(1)
HStack(spacing: 5) {
Text("ID \(connection.remoteID)")
.font(.caption2.monospacedDigit())
.foregroundStyle(.secondary)
.lineLimit(1)
Spacer(minLength: 4)
if connection.remembersPassword {
Image(systemName: "key.fill")
.font(.system(size: 9, weight: .semibold))
.foregroundStyle(.secondary)
.accessibilityLabel("已保存密码")
}
Text(online ? "在线" : "离线")
.font(.caption2.weight(.semibold))
.foregroundStyle(online ? Color.green : Color.secondary)
.accessibilityLabel(online ? "在线" : "离线")
}
}
.padding(9)
}
.background(Color(.secondarySystemGroupedBackground))
.clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
.overlay {
RoundedRectangle(cornerRadius: 14, style: .continuous)
.stroke(Color(.separator).opacity(0.35), lineWidth: 1)
}
}
.buttonStyle(.plain)
.contextMenu {
Button(action: connect) {
Label("连接", systemImage: "arrow.right.circle")
}
Button(role: .destructive, action: delete) {
Label("删除记录", systemImage: "trash")
}
}
}
}
private struct PasswordPromptView: View {
let remoteID: String
@Binding var password: String
@Binding var rememberPassword: Bool
let connect: () -> Void
@Environment(\.dismiss) private var dismiss
@FocusState private var passwordFocused: Bool
@State private var passwordVisible = false
var body: some View {
VStack(alignment: .leading, spacing: 18) {
HStack {
VStack(alignment: .leading, spacing: 4) {
Text("连接远程桌面")
.font(.title2.bold())
Text("对端 ID \(remoteID)")
.font(.subheadline.monospacedDigit())
.foregroundStyle(.secondary)
}
Spacer()
Button {
dismiss()
} label: {
Image(systemName: "xmark.circle.fill")
.font(.title2)
.foregroundStyle(.secondary)
}
.accessibilityLabel("关闭")
}
HStack(spacing: 10) {
Group {
if passwordVisible {
TextField("访问密码", text: $password)
} else {
SecureField("访问密码", text: $password)
}
}
.keyboardType(.numberPad)
.textContentType(.password)
.focused($passwordFocused)
Button {
passwordVisible.toggle()
} label: {
Image(systemName: passwordVisible ? "eye.slash" : "eye")
.foregroundStyle(.secondary)
}
.accessibilityLabel(passwordVisible ? "隐藏密码" : "显示密码")
}
.padding(.horizontal, 13)
.frame(height: 50)
.background(Color(.secondarySystemGroupedBackground),
in: RoundedRectangle(cornerRadius: 10,
style: .continuous))
.overlay {
RoundedRectangle(cornerRadius: 10, style: .continuous)
.stroke(Color(.separator).opacity(0.5), lineWidth: 1)
}
Toggle(isOn: $rememberPassword) {
VStack(alignment: .leading, spacing: 2) {
Text("保存密码")
.font(.subheadline.weight(.semibold))
Text("密码将安全保存在本机 Keychain 中")
.font(.caption)
.foregroundStyle(.secondary)
}
}
Button(action: connect) {
Label("连接", systemImage: "arrow.right")
.font(.headline)
.frame(maxWidth: .infinity)
.frame(height: 48)
}
.buttonStyle(.borderedProminent)
.buttonBorderShape(.roundedRectangle(radius: 10))
}
.padding(22)
.onAppear {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) {
passwordFocused = true
}
}
}
}
private struct ServerSettingsView: View {
@ObservedObject var session: RemoteSessionModel
@Environment(\.dismiss) private var dismiss
var body: some View {
NavigationStack {
Form {
Section("鼠标控制") {
Picker("控制模式", selection: $session.mouseControlMode) {
ForEach(MouseControlMode.allCases) { mode in
Text(mode.title).tag(mode)
}
}
.pickerStyle(.segmented)
Text(session.mouseControlMode.detail)
.font(.footnote)
.foregroundStyle(.secondary)
}
Section("视频编解码") {
Picker("处理方式", selection: $session.videoCodecMode) {
ForEach(VideoCodecMode.allCases) { mode in
Text(mode.title).tag(mode)
}
}
.pickerStyle(.segmented)
Text(session.videoCodecMode.detail)
.font(.footnote)
.foregroundStyle(.secondary)
Text("修改后从下一次连接开始生效。")
.font(.caption)
.foregroundStyle(.tertiary)
}
Section("服务器") {
TextField("信令服务器", text: $session.signalHost)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
TextField("信令端口", text: $session.signalPort)
.keyboardType(.numberPad)
TextField("STUN/TURN 端口", text: $session.turnPort)
.keyboardType(.numberPad)
}
Section {
Toggle("启用 SRTP", isOn: $session.enableSRTP)
} header: {
Text("传输")
} footer: {
Text(session.localIdentity.isEmpty
? "正在获取本机 ID…"
: "本机 ID \(session.localIdentity)")
.font(.caption2.monospacedDigit())
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, alignment: .center)
.padding(.top, 16)
.textSelection(.enabled)
}
}
.navigationTitle("设置")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .confirmationAction) {
Button("应用") {
session.configureBridge()
dismiss()
}
}
ToolbarItem(placement: .cancellationAction) {
Button("取消") { dismiss() }
}
}
}
}
}
@@ -0,0 +1 @@
#import "Bridge/CrossDeskRTCBridge.h"
@@ -0,0 +1,43 @@
import SwiftUI
import UIKit
final class CrossDeskAppDelegate: NSObject, UIApplicationDelegate {
static var supportedOrientations: UIInterfaceOrientationMask = .portrait
func application(_ application: UIApplication,
supportedInterfaceOrientationsFor window: UIWindow?)
-> UIInterfaceOrientationMask {
Self.supportedOrientations
}
}
enum AppOrientation {
static func update(to orientations: UIInterfaceOrientationMask) {
DispatchQueue.main.async {
CrossDeskAppDelegate.supportedOrientations = orientations
for case let scene as UIWindowScene in UIApplication.shared.connectedScenes {
scene.windows.forEach {
$0.rootViewController?.setNeedsUpdateOfSupportedInterfaceOrientations()
}
scene.requestGeometryUpdate(
.iOS(interfaceOrientations: orientations)
) { error in
NSLog("CrossDesk orientation update failed: %@", error.localizedDescription)
}
}
}
}
}
@main
struct CrossDeskMobileApp: App {
@UIApplicationDelegateAdaptor(CrossDeskAppDelegate.self) private var appDelegate
@StateObject private var session = RemoteSessionModel()
var body: some Scene {
WindowGroup {
ContentView(session: session)
}
}
}
+53
View File
@@ -0,0 +1,53 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDisplayName</key>
<string>CrossDesk</string>
<key>CFBundleDevelopmentRegion</key>
<string>zh_CN</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleVersion</key>
<string>2</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>NSLocalNetworkUsageDescription</key>
<string>CrossDesk 使用局域网发现并建立原生 P2P 远程控制连接。</string>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
<key>UIFileSharingEnabled</key>
<true/>
<key>LSSupportsOpeningDocumentsInPlace</key>
<true/>
<key>UILaunchScreen</key>
<dict/>
<key>UIRequiredDeviceCapabilities</key>
<array>
<string>arm64</string>
</array>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
</dict>
</plist>
@@ -0,0 +1,502 @@
import SwiftUI
import UIKit
enum RemoteVideoGeometry {
private static func pixelAligned(_ rect: CGRect) -> CGRect {
let scale = max(UIScreen.main.scale, 1)
let minX = (rect.minX * scale).rounded() / scale
let minY = (rect.minY * scale).rounded() / scale
let maxX = (rect.maxX * scale).rounded() / scale
let maxY = (rect.maxY * scale).rounded() / scale
return CGRect(x: minX, y: minY,
width: max(0, maxX - minX),
height: max(0, maxY - minY))
}
static func aspectFitRect(containerSize: CGSize,
videoSize: CGSize) -> CGRect? {
guard containerSize.width > 0, containerSize.height > 0,
videoSize.width > 0, videoSize.height > 0 else { return nil }
let videoAspect = videoSize.width / videoSize.height
let containerAspect = containerSize.width / containerSize.height
if videoAspect > containerAspect {
let height = containerSize.width / videoAspect
return pixelAligned(
CGRect(x: 0, y: (containerSize.height - height) / 2,
width: containerSize.width, height: height)
)
}
let width = containerSize.height * videoAspect
return pixelAligned(
CGRect(x: (containerSize.width - width) / 2, y: 0,
width: width, height: containerSize.height)
)
}
static func transformedRect(containerSize: CGSize,
videoSize: CGSize,
scale: CGFloat,
offset: CGSize) -> CGRect? {
guard let baseRect = aspectFitRect(containerSize: containerSize,
videoSize: videoSize) else {
return nil
}
// SwiftUI scales the video around the center of its own pixel-aligned
// frame. That center can differ fractionally from the container center
// after edge alignment, and the difference is amplified at 10x.
let center = CGPoint(x: baseRect.midX, y: baseRect.midY)
return CGRect(
x: center.x + (baseRect.minX - center.x) * scale + offset.width,
y: center.y + (baseRect.minY - center.y) * scale + offset.height,
width: baseRect.width * scale,
height: baseRect.height * scale
)
}
}
struct RemoteTouchInputView: UIViewRepresentable {
let videoSize: CGSize
let controlMode: MouseControlMode
let remoteCursorPosition: CGPoint?
let viewportScale: CGFloat
let viewportOffset: CGSize
let viewportChanged: (CGFloat, CGSize) -> Void
let move: (Float, Float) -> Void
let leftDown: (Float, Float) -> Void
let leftUp: (Float, Float) -> Void
let rightClick: (Float, Float) -> Void
let scroll: (Float, Float, Int, Int) -> Void
func makeUIView(context: Context) -> RemoteTouchSurface {
RemoteTouchSurface()
}
func updateUIView(_ view: RemoteTouchSurface, context: Context) {
view.videoSize = videoSize
view.controlMode = controlMode
view.setViewport(scale: viewportScale, offset: viewportOffset)
view.onViewportChanged = viewportChanged
view.onMove = move
view.onLeftDown = leftDown
view.onLeftUp = leftUp
view.onRightClick = rightClick
view.onScroll = scroll
view.synchronizeRemoteCursor(remoteCursorPosition)
}
}
final class RemoteTouchSurface: UIView, UIGestureRecognizerDelegate {
var videoSize = CGSize.zero {
didSet { setNeedsLayout() }
}
var controlMode: MouseControlMode = .absolute {
didSet {
guard oldValue != controlMode else { return }
relativeCursorPoint = lastRemoteCursorPoint ?? (0.5, 0.5)
relativePanActive = false
heldDragPoint = nil
heldDragLastLocation = nil
hoverLastLocation = nil
}
}
var onMove: ((Float, Float) -> Void)?
var onLeftDown: ((Float, Float) -> Void)?
var onLeftUp: ((Float, Float) -> Void)?
var onRightClick: ((Float, Float) -> Void)?
var onScroll: ((Float, Float, Int, Int) -> Void)?
var onViewportChanged: ((CGFloat, CGSize) -> Void)?
private var relativeCursorPoint: (Float, Float) = (0.5, 0.5)
private var lastRemoteCursorPoint: (Float, Float)?
private var relativePanActive = false
private var heldDragPoint: (Float, Float)?
private var heldDragLastLocation: CGPoint?
private var hoverLastLocation: CGPoint?
private var viewportScale: CGFloat = 1
private var viewportOffset = CGSize.zero
private var pinchActive = false
private var viewportPanActive = false
private let relativePointerSpeed: CGFloat = 1.35
private let maximumViewportScale: CGFloat = 10
override init(frame: CGRect) {
super.init(frame: frame)
backgroundColor = .clear
isMultipleTouchEnabled = true
let tap = UITapGestureRecognizer(target: self, action: #selector(handleTap(_:)))
tap.numberOfTouchesRequired = 1
addGestureRecognizer(tap)
let rightTap = UITapGestureRecognizer(target: self, action: #selector(handleRightTap(_:)))
rightTap.numberOfTouchesRequired = 2
tap.require(toFail: rightTap)
addGestureRecognizer(rightTap)
let drag = UIPanGestureRecognizer(target: self, action: #selector(handleDrag(_:)))
drag.minimumNumberOfTouches = 1
drag.maximumNumberOfTouches = 1
drag.delegate = self
addGestureRecognizer(drag)
let heldDrag = UILongPressGestureRecognizer(
target: self,
action: #selector(handleHeldDrag(_:))
)
heldDrag.minimumPressDuration = 0.35
heldDrag.allowableMovement = 14
heldDrag.numberOfTouchesRequired = 1
heldDrag.delegate = self
addGestureRecognizer(heldDrag)
let pinch = UIPinchGestureRecognizer(target: self,
action: #selector(handlePinch(_:)))
pinch.delegate = self
addGestureRecognizer(pinch)
let wheel = UIPanGestureRecognizer(target: self, action: #selector(handleWheel(_:)))
wheel.minimumNumberOfTouches = 2
wheel.maximumNumberOfTouches = 2
wheel.delegate = self
addGestureRecognizer(wheel)
let hover = UIHoverGestureRecognizer(target: self,
action: #selector(handleHover(_:)))
addGestureRecognizer(hover)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func layoutSubviews() {
super.layoutSubviews()
let constrained = constrainedViewportOffset(viewportOffset,
scale: viewportScale)
guard constrained != viewportOffset else { return }
viewportOffset = constrained
publishViewport()
}
func setViewport(scale: CGFloat, offset: CGSize) {
let nextScale = min(max(scale, 1), maximumViewportScale)
viewportScale = nextScale
viewportOffset = constrainedViewportOffset(offset, scale: nextScale)
}
func synchronizeRemoteCursor(_ position: CGPoint?) {
guard let position else {
lastRemoteCursorPoint = nil
return
}
let point = (
Float(min(max(position.x, 0), 1)),
Float(min(max(position.y, 0), 1))
)
lastRemoteCursorPoint = point
applyRemoteCursorIfIdle()
}
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer,
shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
let recognizers = [gestureRecognizer, otherGestureRecognizer]
let hasPinch = recognizers.contains { $0 is UIPinchGestureRecognizer }
let hasTwoFingerPan = recognizers.contains { recognizer in
guard let pan = recognizer as? UIPanGestureRecognizer else {
return false
}
return pan.minimumNumberOfTouches == 2
}
return hasPinch && hasTwoFingerPan
}
@objc private func handleTap(_ recognizer: UITapGestureRecognizer) {
let location = recognizer.location(in: self)
guard recognizer.state == .ended,
let point = pointerPoint(for: location) else { return }
#if DEBUG
NSLog("CrossDesk tap location=%@ normalized=(%.5f, %.5f) " +
"render=%@ scale=%.4f offset=%@ mode=%@",
String(describing: location), point.0, point.1,
renderRect().map { String(describing: $0) } ?? "nil",
viewportScale, String(describing: viewportOffset),
controlMode == .relative ? "relative" : "absolute")
#endif
onMove?(point.0, point.1)
onLeftDown?(point.0, point.1)
onLeftUp?(point.0, point.1)
}
@objc private func handleRightTap(_ recognizer: UITapGestureRecognizer) {
guard recognizer.state == .ended,
let point = pointerPoint(for: recognizer.location(in: self)) else { return }
onMove?(point.0, point.1)
onRightClick?(point.0, point.1)
}
@objc private func handleDrag(_ recognizer: UIPanGestureRecognizer) {
if controlMode == .relative {
handleRelativeDrag(recognizer)
return
}
switch recognizer.state {
case .began, .changed:
guard let point = normalized(recognizer.location(in: self)) else { return }
onMove?(point.0, point.1)
default:
break
}
}
@objc private func handleHeldDrag(_ recognizer: UILongPressGestureRecognizer) {
if controlMode == .relative {
handleRelativeHeldDrag(recognizer)
return
}
let point = normalized(recognizer.location(in: self)) ?? heldDragPoint
switch recognizer.state {
case .began:
guard let point else { return }
heldDragPoint = point
onMove?(point.0, point.1)
onLeftDown?(point.0, point.1)
case .changed:
guard let point else { return }
heldDragPoint = point
onMove?(point.0, point.1)
case .ended, .cancelled, .failed:
if let point {
onLeftUp?(point.0, point.1)
}
heldDragPoint = nil
default:
break
}
}
@objc private func handlePinch(_ recognizer: UIPinchGestureRecognizer) {
let focus = recognizer.location(in: self)
switch recognizer.state {
case .began:
pinchActive = renderRect()?.contains(focus) == true
recognizer.scale = 1
case .changed:
guard pinchActive else { return }
let oldScale = viewportScale
let newScale = min(max(oldScale * recognizer.scale, 1),
maximumViewportScale)
recognizer.scale = 1
guard abs(newScale - oldScale) > 0.0001 else { return }
let center = CGPoint(x: bounds.midX, y: bounds.midY)
let ratio = newScale / oldScale
let proposedOffset = CGSize(
width: focus.x - center.x -
(focus.x - center.x - viewportOffset.width) * ratio,
height: focus.y - center.y -
(focus.y - center.y - viewportOffset.height) * ratio
)
viewportScale = newScale
viewportOffset = constrainedViewportOffset(proposedOffset,
scale: newScale)
publishViewport()
case .ended, .cancelled, .failed:
pinchActive = false
if viewportScale <= 1.001 {
viewportScale = 1
viewportOffset = .zero
publishViewport()
}
default:
break
}
}
@objc private func handleWheel(_ recognizer: UIPanGestureRecognizer) {
if viewportScale > 1.001 || pinchActive {
switch recognizer.state {
case .began:
viewportPanActive = renderRect()?.contains(
recognizer.location(in: self)
) == true
recognizer.setTranslation(.zero, in: self)
case .changed:
if !viewportPanActive {
viewportPanActive = renderRect()?.contains(
recognizer.location(in: self)
) == true
}
guard viewportPanActive else { return }
let translation = recognizer.translation(in: self)
recognizer.setTranslation(.zero, in: self)
let proposedOffset = CGSize(
width: viewportOffset.width + translation.x,
height: viewportOffset.height + translation.y
)
viewportOffset = constrainedViewportOffset(proposedOffset,
scale: viewportScale)
publishViewport()
default:
viewportPanActive = false
}
return
}
guard let point = pointerPoint(for: recognizer.location(in: self)) else { return }
let translation = recognizer.translation(in: self)
let deltaX = Int(-translation.x / 12)
let deltaY = Int(-translation.y / 12)
if deltaX != 0 || deltaY != 0 {
onScroll?(point.0, point.1, deltaX, deltaY)
recognizer.setTranslation(.zero, in: self)
}
}
@objc private func handleHover(_ recognizer: UIHoverGestureRecognizer) {
let location = recognizer.location(in: self)
if controlMode == .relative {
switch recognizer.state {
case .began:
guard renderRect()?.contains(location) == true else { return }
hoverLastLocation = location
onMove?(relativeCursorPoint.0, relativeCursorPoint.1)
case .changed:
guard let previous = hoverLastLocation else { return }
hoverLastLocation = location
if let point = updateRelativeCursor(
by: CGPoint(x: location.x - previous.x,
y: location.y - previous.y)
) {
onMove?(point.0, point.1)
}
default:
hoverLastLocation = nil
}
return
}
guard recognizer.state == .began || recognizer.state == .changed,
let point = normalized(location) else { return }
onMove?(point.0, point.1)
}
private func handleRelativeDrag(_ recognizer: UIPanGestureRecognizer) {
switch recognizer.state {
case .began:
let location = recognizer.location(in: self)
relativePanActive = renderRect()?.contains(location) == true
recognizer.setTranslation(.zero, in: self)
if relativePanActive {
onMove?(relativeCursorPoint.0, relativeCursorPoint.1)
}
case .changed:
guard relativePanActive else { return }
let translation = recognizer.translation(in: self)
recognizer.setTranslation(.zero, in: self)
if let point = updateRelativeCursor(by: translation) {
onMove?(point.0, point.1)
}
default:
relativePanActive = false
applyRemoteCursorIfIdle()
}
}
private func handleRelativeHeldDrag(_ recognizer: UILongPressGestureRecognizer) {
let location = recognizer.location(in: self)
switch recognizer.state {
case .began:
guard renderRect()?.contains(location) == true else { return }
heldDragLastLocation = location
heldDragPoint = relativeCursorPoint
onMove?(relativeCursorPoint.0, relativeCursorPoint.1)
onLeftDown?(relativeCursorPoint.0, relativeCursorPoint.1)
case .changed:
guard let previous = heldDragLastLocation else { return }
heldDragLastLocation = location
if let point = updateRelativeCursor(
by: CGPoint(x: location.x - previous.x,
y: location.y - previous.y)
) {
heldDragPoint = point
onMove?(point.0, point.1)
}
case .ended, .cancelled, .failed:
if let point = heldDragPoint {
onLeftUp?(point.0, point.1)
}
heldDragPoint = nil
heldDragLastLocation = nil
applyRemoteCursorIfIdle()
default:
break
}
}
private func pointerPoint(for location: CGPoint) -> (Float, Float)? {
if controlMode == .relative {
guard renderRect()?.contains(location) == true else { return nil }
return relativeCursorPoint
}
return normalized(location)
}
private func updateRelativeCursor(by translation: CGPoint) -> (Float, Float)? {
guard let renderRect = renderRect(),
renderRect.width > 0, renderRect.height > 0 else { return nil }
let nextX = CGFloat(relativeCursorPoint.0) +
translation.x / renderRect.width * relativePointerSpeed
let nextY = CGFloat(relativeCursorPoint.1) +
translation.y / renderRect.height * relativePointerSpeed
relativeCursorPoint = (
Float(min(max(nextX, 0), 1)),
Float(min(max(nextY, 0), 1))
)
return relativeCursorPoint
}
private func applyRemoteCursorIfIdle() {
guard controlMode == .relative,
!relativePanActive,
heldDragPoint == nil,
let point = lastRemoteCursorPoint else { return }
relativeCursorPoint = point
}
private func normalized(_ point: CGPoint) -> (Float, Float)? {
guard let renderRect = renderRect(), renderRect.contains(point) else { return nil }
return (Float((point.x - renderRect.minX) / renderRect.width),
Float((point.y - renderRect.minY) / renderRect.height))
}
private func renderRect() -> CGRect? {
RemoteVideoGeometry.transformedRect(
containerSize: bounds.size,
videoSize: videoSize,
scale: viewportScale,
offset: viewportOffset
)
}
private func baseRenderRect() -> CGRect? {
RemoteVideoGeometry.aspectFitRect(containerSize: bounds.size,
videoSize: videoSize)
}
private func constrainedViewportOffset(_ offset: CGSize,
scale: CGFloat) -> CGSize {
guard let baseRect = baseRenderRect() else { return offset }
let maximumX = max(0, baseRect.width * (scale - 1) / 2)
let maximumY = max(0, baseRect.height * (scale - 1) / 2)
return CGSize(
width: min(max(offset.width, -maximumX), maximumX),
height: min(max(offset.height, -maximumY), maximumY)
)
}
private func publishViewport() {
onViewportChanged?(viewportScale, viewportOffset)
}
}
@@ -0,0 +1,875 @@
import AVFoundation
import CoreImage
import CoreVideo
import Foundation
import Security
import UIKit
enum MouseControlMode: String, CaseIterable, Identifiable {
case relative
case absolute
private static let defaultsKey = "crossdesk.mobile.mouse-control-mode"
var id: String { rawValue }
var title: String {
switch self {
case .absolute: return "绝对位置"
case .relative: return "相对位置"
}
}
var detail: String {
switch self {
case .absolute:
return "触摸位置直接对应远端屏幕位置,适合快速定位。"
case .relative:
return "像触控板一样滑动光标,点击时操作当前光标位置。"
}
}
static var saved: MouseControlMode {
guard let rawValue = UserDefaults.standard.string(forKey: defaultsKey),
let mode = MouseControlMode(rawValue: rawValue) else {
return .relative
}
return mode
}
func save() {
UserDefaults.standard.set(rawValue, forKey: Self.defaultsKey)
}
}
enum VideoCodecMode: String, CaseIterable, Identifiable {
case hardware
case software
private static let defaultsKey = "crossdesk.mobile.video-codec-mode"
var id: String { rawValue }
var title: String {
switch self {
case .hardware: return "硬件"
case .software: return "软件"
}
}
var detail: String {
switch self {
case .hardware:
return "使用 VideoToolbox,延迟和耗电更低,推荐日常使用。"
case .software:
return "使用 OpenH264 软件编解码,适合兼容性测试。"
}
}
static var saved: VideoCodecMode {
guard let rawValue = UserDefaults.standard.string(forKey: defaultsKey),
let mode = VideoCodecMode(rawValue: rawValue) else {
return .hardware
}
return mode
}
func save() {
UserDefaults.standard.set(rawValue, forKey: Self.defaultsKey)
}
}
private struct RemoteVideoFrame {
let pixelBuffer: CVPixelBuffer
let encodedSize: CGSize
}
struct RecentConnection: Codable, Identifiable, Equatable {
let remoteID: String
var displayName: String
var lastConnectedAt: Date
var remembersPassword: Bool
var thumbnailFileName: String?
var id: String { remoteID }
}
private enum RecentConnectionStore {
private static let defaultsKey = "crossdesk.mobile.recent-connections.v1"
private static let thumbnailQueue =
DispatchQueue(label: "cn.crossdesk.mobile.thumbnails", qos: .utility)
private static let context = CIContext(options: [.cacheIntermediates: false])
static func load() -> [RecentConnection] {
guard let data = UserDefaults.standard.data(forKey: defaultsKey),
let connections = try? JSONDecoder().decode([RecentConnection].self,
from: data) else {
return []
}
return connections.sorted { $0.lastConnectedAt > $1.lastConnectedAt }
}
static func save(_ connections: [RecentConnection]) {
guard let data = try? JSONEncoder().encode(connections) else { return }
UserDefaults.standard.set(data, forKey: defaultsKey)
}
static func thumbnailURL(fileName: String) -> URL? {
guard let directory = thumbnailDirectory() else { return nil }
return directory.appendingPathComponent(fileName, isDirectory: false)
}
static func captureThumbnail(from pixelBuffer: CVPixelBuffer,
remoteID: String,
completion: @escaping (String?) -> Void) {
thumbnailQueue.async {
let fileName: String? = autoreleasepool {
let image = CIImage(cvPixelBuffer: pixelBuffer)
let targetSize = CGSize(width: 640, height: 360)
guard image.extent.width > 0, image.extent.height > 0 else {
return nil
}
// Scale in Core Image before materializing a CGImage. Creating
// a full 4K bitmap and then drawing it into a thumbnail can add
// tens of megabytes to the first-frame memory peak.
let scale = max(targetSize.width / image.extent.width,
targetSize.height / image.extent.height)
let scaled = image.transformed(by: CGAffineTransform(
scaleX: scale,
y: scale
))
let cropRect = CGRect(
x: scaled.extent.midX - targetSize.width / 2,
y: scaled.extent.midY - targetSize.height / 2,
width: targetSize.width,
height: targetSize.height
).integral
guard let thumbnailImage = context.createCGImage(
scaled.cropped(to: cropRect),
from: cropRect
) else {
return nil
}
let safeID = remoteID.filter {
$0.isLetter || $0.isNumber || $0 == "-"
}
let name = "\(safeID.isEmpty ? "remote" : safeID).jpg"
guard let url = thumbnailURL(fileName: name),
let data = UIImage(cgImage: thumbnailImage)
.jpegData(compressionQuality: 0.78) else {
return nil
}
do {
try data.write(to: url, options: .atomic)
return name
} catch {
return nil
}
}
DispatchQueue.main.async { completion(fileName) }
}
}
static func removeThumbnail(fileName: String?) {
guard let fileName, let url = thumbnailURL(fileName: fileName) else { return }
try? FileManager.default.removeItem(at: url)
}
private static func thumbnailDirectory() -> URL? {
guard let base = FileManager.default.urls(for: .applicationSupportDirectory,
in: .userDomainMask).first else {
return nil
}
let directory = base.appendingPathComponent("RecentConnectionThumbnails",
isDirectory: true)
do {
try FileManager.default.createDirectory(at: directory,
withIntermediateDirectories: true)
return directory
} catch {
return nil
}
}
}
private enum ConnectionCredentialStore {
private static let service = "cn.crossdesk.mobile.saved-password"
static func password(for remoteID: String) -> String? {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: remoteID,
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne
]
var result: CFTypeRef?
guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess,
let data = result as? Data else {
return nil
}
return String(data: data, encoding: .utf8)
}
static func save(password: String, for remoteID: String) {
removePassword(for: remoteID)
let attributes: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: remoteID,
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly,
kSecValueData as String: Data(password.utf8)
]
SecItemAdd(attributes as CFDictionary, nil)
}
static func removePassword(for remoteID: String) {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: remoteID
]
SecItemDelete(query as CFDictionary)
}
}
private final class RemoteAudioPlayer {
private let engine = AVAudioEngine()
private let player = AVAudioPlayerNode()
private let queue = DispatchQueue(label: "cn.crossdesk.mobile.audio")
private let format = AVAudioFormat(commonFormat: .pcmFormatInt16,
sampleRate: 48_000,
channels: 1,
interleaved: false)!
private var enabled = true
private var queuedBuffers = 0
init() {
engine.attach(player)
engine.connect(player, to: engine.mainMixerNode, format: format)
}
func setEnabled(_ value: Bool) {
queue.async {
self.enabled = value
if value {
self.startIfNeeded()
} else {
self.player.stop()
self.engine.stop()
self.queuedBuffers = 0
}
}
}
func enqueue(_ data: Data) {
guard !data.isEmpty, data.count.isMultiple(of: MemoryLayout<Int16>.size) else { return }
queue.async {
guard self.enabled, self.queuedBuffers < 80 else { return }
self.startIfNeeded()
guard self.engine.isRunning,
let buffer = AVAudioPCMBuffer(pcmFormat: self.format,
frameCapacity: AVAudioFrameCount(data.count / 2)),
let samples = buffer.int16ChannelData?[0] else { return }
buffer.frameLength = buffer.frameCapacity
data.withUnsafeBytes { bytes in
guard let source = bytes.baseAddress else { return }
memcpy(samples, source, data.count)
}
self.queuedBuffers += 1
self.player.scheduleBuffer(buffer) {
self.queue.async {
self.queuedBuffers = max(0, self.queuedBuffers - 1)
}
}
if !self.player.isPlaying {
self.player.play()
}
}
}
private func startIfNeeded() {
guard enabled, !engine.isRunning else { return }
do {
let session = AVAudioSession.sharedInstance()
try session.setCategory(.playback, mode: .moviePlayback,
options: [.mixWithOthers])
try session.setActive(true)
try engine.start()
player.play()
} catch {
// The next PCM packet retries activation after route changes.
}
}
}
final class RemoteSessionModel: NSObject, ObservableObject, CrossDeskRTCBridgeDelegate {
@Published var signalHost = "api.crossdesk.cn"
@Published var signalPort = "9099"
@Published var turnPort = "3478"
@Published var enableSRTP = false
@Published var mouseControlMode = MouseControlMode.saved {
didSet { mouseControlMode.save() }
}
@Published var videoCodecMode = VideoCodecMode.saved {
didSet { videoCodecMode.save() }
}
@Published var remoteID = ""
@Published var password = ""
@Published private(set) var signalStatus = "正在连接信令服务"
@Published private(set) var connectionStatus = "未连接"
@Published private(set) var localIdentity = ""
@Published private(set) var isConnecting = false
@Published private(set) var isConnected = false
@Published private(set) var sessionVisible = false
@Published private var videoFrame: RemoteVideoFrame?
@Published private(set) var remoteCursorVisible = true
@Published private(set) var remoteCursorShape = 0
@Published private(set) var remoteCursorPosition: CGPoint?
@Published private(set) var remoteCursorVisualOffset = CGPoint.zero
@Published private(set) var hasRemoteCursorState = false
@Published private(set) var bitrate: UInt = 0
@Published private(set) var lossRate: Float = 0
@Published private(set) var usingTURN = false
@Published private(set) var displays: [String] = ["显示器 1"]
@Published private(set) var displaySizes: [CGSize] = []
@Published var selectedDisplay = 0
@Published var audioEnabled = true
@Published private(set) var clipboardStatus = ""
@Published private(set) var transferStatus = ""
@Published private(set) var transferProgress = 0.0
@Published private(set) var receivedFileURL: URL?
@Published private(set) var frameCount: UInt64 = 0
@Published private(set) var recentConnections = RecentConnectionStore.load()
@Published private(set) var recentConnectionPresence: [String: Bool] = [:]
@Published private(set) var deviceOfflineAlertVisible = false
let bridge = CrossDeskRTCBridge()
private let audioPlayer = RemoteAudioPlayer()
private var activeRemoteID = ""
private var pendingRememberPassword = false
private var connectionRecorded = false
private var shouldCaptureThumbnail = false
private var activeDisplayName = ""
private var remoteCursorSequence: UInt32?
private var pendingPresenceRemoteID: String?
private var presenceProbeGeneration: UInt64 = 0
var pixelBuffer: CVPixelBuffer? { videoFrame?.pixelBuffer }
var frameSize: CGSize { videoFrame?.encodedSize ?? .zero }
var displayGeometrySize: CGSize {
guard displaySizes.indices.contains(selectedDisplay) else {
return frameSize
}
let size = displaySizes[selectedDisplay]
return size.width > 0 && size.height > 0 ? size : frameSize
}
override init() {
super.init()
bridge.delegate = self
configureBridge()
}
func configureBridge() {
guard let signal = Int(signalPort), let turn = Int(turnPort),
!signalHost.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
signalStatus = "服务器配置无效"
return
}
bridge.setHardwareAccelerationEnabled(videoCodecMode == .hardware)
bridge.configure(withSignalHost: signalHost,
signalPort: signal,
turnPort: turn,
enableSRTP: enableSRTP)
}
func connect(password: String, rememberPassword: Bool) {
self.password = password
pendingRememberPassword = rememberPassword
connect()
}
func connect() {
let identifier = remoteID.trimmingCharacters(in: .whitespacesAndNewlines)
guard !identifier.isEmpty else {
connectionStatus = "请输入远程设备 ID"
return
}
remoteID = identifier
configureBridge()
if recentConnectionPresence[identifier] == true {
beginRemoteConnection(identifier)
} else {
beginPresenceProbe(identifier)
}
}
private func beginRemoteConnection(_ identifier: String) {
cancelPresenceProbe()
activeRemoteID = identifier
activeDisplayName = ""
displaySizes = []
connectionRecorded = false
shouldCaptureThumbnail = true
isConnecting = true
isConnected = false
sessionVisible = false
resetRemoteCursorState()
connectionStatus = "正在准备连接…"
bridge.connect(toRemoteID: identifier, password: password)
}
private func beginPresenceProbe(_ identifier: String) {
guard signalStatus == "已连接服务器" else {
showDeviceOffline()
return
}
presenceProbeGeneration &+= 1
let generation = presenceProbeGeneration
pendingPresenceRemoteID = identifier
isConnecting = true
isConnected = false
sessionVisible = false
connectionStatus = "正在确认设备状态…"
bridge.requestPresence(remoteIDs: [identifier])
DispatchQueue.main.asyncAfter(deadline: .now() + 5) { [weak self] in
guard let self,
self.presenceProbeGeneration == generation,
self.pendingPresenceRemoteID == identifier else {
return
}
self.cancelPresenceProbe()
self.refreshRecentConnectionPresence()
self.showDeviceOffline()
}
}
private func cancelPresenceProbe() {
presenceProbeGeneration &+= 1
pendingPresenceRemoteID = nil
}
private func showDeviceOffline() {
isConnecting = false
isConnected = false
sessionVisible = false
connectionStatus = "设备离线"
deviceOfflineAlertVisible = true
}
func dismissDeviceOfflineAlert() {
deviceOfflineAlertVisible = false
}
func disconnect() {
let wasCheckingPresence = pendingPresenceRemoteID != nil
cancelPresenceProbe()
if wasCheckingPresence {
refreshRecentConnectionPresence()
}
bridge.disconnect()
isConnecting = false
isConnected = false
sessionVisible = false
videoFrame = nil
resetRemoteCursorState()
frameCount = 0
bitrate = 0
displays = ["显示器 1"]
displaySizes = []
selectedDisplay = 0
connectionStatus = "未连接"
audioPlayer.setEnabled(false)
AppOrientation.update(to: .portrait)
}
func retry() {
bridge.disconnect()
connect()
}
func savedPassword(for remoteID: String) -> String {
ConnectionCredentialStore.password(for: remoteID) ?? ""
}
func savedCredential(for remoteID: String) -> String? {
ConnectionCredentialStore.password(for: remoteID)
}
func remembersPassword(for remoteID: String) -> Bool {
recentConnections.first(where: { $0.remoteID == remoteID })?
.remembersPassword == true
}
func thumbnailImage(for connection: RecentConnection) -> UIImage? {
guard let fileName = connection.thumbnailFileName,
let url = RecentConnectionStore.thumbnailURL(fileName: fileName) else {
return nil
}
return UIImage(contentsOfFile: url.path)
}
func isRecentConnectionOnline(_ connection: RecentConnection) -> Bool {
recentConnectionPresence[connection.remoteID] == true
}
private func refreshRecentConnectionPresence() {
bridge.requestPresence(remoteIDs: recentConnections.map(\.remoteID))
}
func refreshRecentConnectionPresenceAfterForeground() {
// iOS can suspend the process while it is in the background, so presence
// updates received during that time are missed. Treat cached values as
// unknown (and therefore offline, like the desktop client) until the
// signaling server returns a fresh snapshot.
recentConnectionPresence = [:]
if let pendingPresenceRemoteID {
bridge.requestPresence(remoteIDs: [pendingPresenceRemoteID])
} else {
refreshRecentConnectionPresence()
}
}
func removeRecentConnection(_ connection: RecentConnection) {
recentConnections.removeAll { $0.remoteID == connection.remoteID }
recentConnectionPresence.removeValue(forKey: connection.remoteID)
RecentConnectionStore.save(recentConnections)
RecentConnectionStore.removeThumbnail(fileName: connection.thumbnailFileName)
ConnectionCredentialStore.removePassword(for: connection.remoteID)
refreshRecentConnectionPresence()
}
private func recordSuccessfulConnectionIfNeeded() {
guard !connectionRecorded, !activeRemoteID.isEmpty else { return }
connectionRecorded = true
if pendingRememberPassword {
ConnectionCredentialStore.save(password: password, for: activeRemoteID)
} else {
ConnectionCredentialStore.removePassword(for: activeRemoteID)
}
let previous = recentConnections.first { $0.remoteID == activeRemoteID }
let title = activeDisplayName.isEmpty
? (previous?.displayName ?? activeRemoteID)
: activeDisplayName
let connection = RecentConnection(
remoteID: activeRemoteID,
displayName: title,
lastConnectedAt: Date(),
remembersPassword: pendingRememberPassword,
thumbnailFileName: previous?.thumbnailFileName
)
recentConnections.removeAll { $0.remoteID == activeRemoteID }
recentConnections.insert(connection, at: 0)
recentConnectionPresence[activeRemoteID] = true
RecentConnectionStore.save(recentConnections)
refreshRecentConnectionPresence()
}
private func updateActiveConnectionName(_ name: String) {
let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty, !activeRemoteID.isEmpty else { return }
activeDisplayName = trimmed
guard let index = recentConnections.firstIndex(where: {
$0.remoteID == activeRemoteID
}) else { return }
recentConnections[index].displayName = trimmed
RecentConnectionStore.save(recentConnections)
}
private func captureRecentThumbnailIfNeeded(_ pixelBuffer: CVPixelBuffer) {
guard shouldCaptureThumbnail, !activeRemoteID.isEmpty else { return }
shouldCaptureThumbnail = false
let identifier = activeRemoteID
RecentConnectionStore.captureThumbnail(from: pixelBuffer,
remoteID: identifier) { [weak self] fileName in
guard let self, let fileName,
let index = self.recentConnections.firstIndex(where: {
$0.remoteID == identifier
}) else { return }
self.recentConnections[index].thumbnailFileName = fileName
RecentConnectionStore.save(self.recentConnections)
}
}
func selectDisplay(_ index: Int) {
guard displays.indices.contains(index) else { return }
selectedDisplay = index
videoFrame = nil
resetRemoteCursorState()
frameCount = 0
bridge.switch(toDisplay: index)
}
private func resetRemoteCursorState() {
remoteCursorVisible = true
remoteCursorShape = 0
remoteCursorPosition = nil
remoteCursorVisualOffset = .zero
hasRemoteCursorState = false
remoteCursorSequence = nil
}
func toggleAudio() {
audioEnabled.toggle()
audioPlayer.setEnabled(audioEnabled)
bridge.setAudioEnabled(audioEnabled)
}
func sendClipboard() {
guard let text = UIPasteboard.general.string, !text.isEmpty else {
clipboardStatus = "剪贴板中没有文本"
return
}
guard text.lengthOfBytes(using: .utf8) <= 128 * 1024 else {
clipboardStatus = "剪贴板文本超过 128 KiB"
return
}
bridge.sendClipboardText(text)
clipboardStatus = "已发送本机剪贴板"
}
func sendFile(_ url: URL) {
transferStatus = "正在发送 \(url.lastPathComponent)"
transferProgress = 0
bridge.sendFile(at: url)
}
func sendKeyStroke(_ keyCode: UInt) {
bridge.sendWindowsKeyCode(keyCode, isDown: true)
bridge.sendWindowsKeyCode(keyCode, isDown: false)
}
func sendKeyState(_ keyCode: UInt, isDown: Bool) {
bridge.sendWindowsKeyCode(keyCode, isDown: isDown)
}
func rtcBridge(_ bridge: CrossDeskRTCBridge,
didChange state: CrossDeskSignalState) {
switch state.rawValue {
case 0: signalStatus = "正在连接信令服务"
case 1: signalStatus = "已连接服务器"
case 2: signalStatus = "信令连接失败"
case 3: signalStatus = "信令连接已关闭"
case 4: signalStatus = "信令服务重连中"
case 5: signalStatus = "信令服务器已关闭连接"
case 6: signalStatus = "TLS 证书校验失败"
default: signalStatus = "未知信令状态"
}
if state.rawValue == 1 {
if let pendingPresenceRemoteID {
bridge.requestPresence(remoteIDs: [pendingPresenceRemoteID])
} else {
refreshRecentConnectionPresence()
}
}
}
func rtcBridge(_ bridge: CrossDeskRTCBridge,
didChange state: CrossDeskConnectionState,
remoteID: String) {
isConnected = state.rawValue == 1
isConnecting = [0, 2].contains(state.rawValue)
switch state.rawValue {
case 0: connectionStatus = "正在建立远程连接"
case 1:
connectionStatus = "已连接"
if !remoteID.isEmpty {
recentConnectionPresence[remoteID] = true
}
sessionVisible = true
recordSuccessfulConnectionIfNeeded()
audioPlayer.setEnabled(audioEnabled)
bridge.setAudioEnabled(audioEnabled)
AppOrientation.update(to: .allButUpsideDown)
case 2: connectionStatus = "正在收集 ICE 候选"
case 3: connectionStatus = "网络连接已中断"
case 4:
connectionStatus = "P2P/TURN 建链失败"
sessionVisible = false
AppOrientation.update(to: .portrait)
case 5:
connectionStatus = "未连接"
sessionVisible = false
AppOrientation.update(to: .portrait)
case 6:
connectionStatus = "访问密码错误"
sessionVisible = false
AppOrientation.update(to: .portrait)
case 7:
connectionStatus = "远程设备 ID 不存在"
if !remoteID.isEmpty {
recentConnectionPresence[remoteID] = false
}
sessionVisible = false
AppOrientation.update(to: .portrait)
case 8:
connectionStatus = "远程设备当前不可用"
if !remoteID.isEmpty {
recentConnectionPresence[remoteID] = false
}
sessionVisible = false
AppOrientation.update(to: .portrait)
default: connectionStatus = "未知连接状态"
}
}
func rtcBridge(_ bridge: CrossDeskRTCBridge, didProvisionIdentity identity: String) {
localIdentity = identity.split(separator: "@").first.map(String.init) ?? identity
if let pendingPresenceRemoteID {
bridge.requestPresence(remoteIDs: [pendingPresenceRemoteID])
} else {
refreshRecentConnectionPresence()
}
}
func rtcBridge(_ bridge: CrossDeskRTCBridge,
didReceivePresence presence: [String: NSNumber]) {
var updated = recentConnectionPresence
for (remoteID, online) in presence {
updated[remoteID] = online.boolValue
}
recentConnectionPresence = updated
guard let pendingRemoteID = pendingPresenceRemoteID,
let online = presence[pendingRemoteID]?.boolValue else {
return
}
cancelPresenceProbe()
refreshRecentConnectionPresence()
if online {
beginRemoteConnection(pendingRemoteID)
} else {
showDeviceOffline()
}
}
func rtcBridge(_ bridge: CrossDeskRTCBridge,
didReceive pixelBuffer: CVPixelBuffer,
width: Int,
height: Int) {
// Buffer and encoded dimensions must be one observable value. Adaptive
// resolution changes must never expose a new frame with the previous
// frame's geometry to SwiftUI.
videoFrame = RemoteVideoFrame(
pixelBuffer: pixelBuffer,
encodedSize: CGSize(width: width, height: height)
)
frameCount &+= 1
captureRecentThumbnailIfNeeded(pixelBuffer)
}
func rtcBridge(_ bridge: CrossDeskRTCBridge,
didReceiveHostName hostName: String,
displayNames remoteDisplayNames: [String],
displaySizes remoteDisplaySizes: [NSValue]) {
updateActiveConnectionName(hostName)
let names = remoteDisplayNames.enumerated().map { index, displayName in
let name = displayName.trimmingCharacters(in: .whitespacesAndNewlines)
return name.isEmpty ? "显示器 \(index + 1)" : name
}
if !names.isEmpty {
displays = names
displaySizes = remoteDisplaySizes.map(\.cgSizeValue)
selectedDisplay = min(selectedDisplay, names.count - 1)
bridge.switch(toDisplay: selectedDisplay)
}
}
func rtcBridge(_ bridge: CrossDeskRTCBridge,
didReceiveCursorVisible visible: Bool,
shape: Int,
positionUpdate: Bool,
positionValid: Bool,
x: Float,
y: Float,
visualOffsetX: Float,
visualOffsetY: Float,
display displayIndex: Int,
sequence: UInt32) {
if let previous = remoteCursorSequence,
sequence != 0,
Int32(bitPattern: sequence &- previous) <= 0 {
return
}
remoteCursorSequence = sequence
remoteCursorVisible = visible
remoteCursorShape = min(max(shape, 0), 28)
remoteCursorVisualOffset = CGPoint(x: CGFloat(visualOffsetX),
y: CGFloat(visualOffsetY))
if positionUpdate {
if positionValid, displayIndex == selectedDisplay {
remoteCursorPosition = CGPoint(
x: CGFloat(min(max(x, 0), 1)),
y: CGFloat(min(max(y, 0), 1))
)
} else {
remoteCursorPosition = nil
}
}
hasRemoteCursorState = true
}
func rtcBridge(_ bridge: CrossDeskRTCBridge, didReceiveAudioPCM pcmData: Data) {
audioPlayer.enqueue(pcmData)
}
func rtcBridge(_ bridge: CrossDeskRTCBridge, didReceiveClipboardText text: String) {
UIPasteboard.general.string = text
clipboardStatus = "已接收远端剪贴板"
}
func rtcBridge(_ bridge: CrossDeskRTCBridge,
didUpdateFileTransfer fileName: String,
progress: Double,
sending: Bool) {
if progress < 0 {
transferStatus = "\(fileName) 传输失败"
transferProgress = 0
return
}
transferProgress = progress
let percent = Int((progress * 100).rounded())
transferStatus = "\(sending ? "发送" : "接收") \(fileName) · \(percent)%"
}
func rtcBridge(_ bridge: CrossDeskRTCBridge, didReceiveFileAt fileURL: URL) {
receivedFileURL = fileURL
transferStatus = "已接收 \(fileURL.lastPathComponent)"
transferProgress = 1
}
func rtcBridge(_ bridge: CrossDeskRTCBridge,
didUpdateBitrate bitsPerSecond: UInt,
lossRate: Float,
usingTURN: Bool) {
bitrate = bitsPerSecond
self.lossRate = lossRate
self.usingTURN = usingTURN
}
var formattedBitrate: String {
if bitrate >= 1_000_000 {
return String(format: "%.1f Mbps", Double(bitrate) / 1_000_000)
}
if bitrate >= 1_000 {
return String(format: "%.0f Kbps", Double(bitrate) / 1_000)
}
return "\(bitrate) bps"
}
var videoStatus: String {
if frameCount > 0 {
return "视频 \(Int(frameSize.width))×\(Int(frameSize.height)) · \(frameCount)"
}
return isConnected ? "正在取回远程画面…" : "等待连接"
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,151 @@
import AVFoundation
import CoreMedia
import CoreVideo
import SwiftUI
import UIKit
/// Presents decoded NV12 frames through iOS's native video display path.
///
/// `AVSampleBufferDisplayLayer` handles YUV conversion and drawable scheduling
/// itself. This avoids the Core Image -> MTKView path, which can remain black
/// on a physical device even though VideoToolbox is producing valid frames.
struct NativeVideoView: UIViewRepresentable {
let pixelBuffer: CVPixelBuffer?
func makeUIView(context: Context) -> SampleBufferVideoView {
SampleBufferVideoView(frame: .zero)
}
func updateUIView(_ uiView: SampleBufferVideoView, context: Context) {
uiView.display(pixelBuffer)
}
}
final class SampleBufferVideoView: UIView {
override class var layerClass: AnyClass {
AVSampleBufferDisplayLayer.self
}
private var videoLayer: AVSampleBufferDisplayLayer {
layer as! AVSampleBufferDisplayLayer
}
private var lastPixelBuffer: CVPixelBuffer?
private var submittedFrames: UInt64 = 0
private var droppedFrames: UInt64 = 0
override init(frame: CGRect) {
super.init(frame: frame)
configureLayer()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
configureLayer()
}
private func configureLayer() {
backgroundColor = .black
isOpaque = true
videoLayer.backgroundColor = UIColor.black.cgColor
// The SwiftUI host gives this view the exact aspect-fit video rect.
// Filling that rect avoids a second, independently rounded aspect-fit
// calculation inside AVSampleBufferDisplayLayer. That rounding becomes
// visibly amplified when the remote desktop is zoomed up to 10x.
videoLayer.videoGravity = .resize
}
func display(_ pixelBuffer: CVPixelBuffer?) {
guard let pixelBuffer else {
lastPixelBuffer = nil
submittedFrames = 0
droppedFrames = 0
videoLayer.flushAndRemoveImage()
return
}
// SwiftUI can refresh for unrelated status fields. Do not enqueue the
// same retained CVPixelBuffer more than once.
guard lastPixelBuffer !== pixelBuffer else { return }
lastPixelBuffer = pixelBuffer
if videoLayer.status == .failed {
NSLog("CrossDesk video layer failed: %@",
videoLayer.error?.localizedDescription ?? "unknown error")
videoLayer.flush()
}
// Never let AVSampleBufferDisplayLayer turn temporary rendering
// pressure into seconds of latency. Its queued buffers are already
// stale by the time it stops accepting more data, so discard them and
// present the newest decoded IOSurface instead.
if !videoLayer.isReadyForMoreMediaData {
droppedFrames &+= 1
videoLayer.flush()
if droppedFrames == 1 || droppedFrames.isMultiple(of: 300) {
NSLog("CrossDesk dropped stale display frames: %llu",
droppedFrames)
}
}
guard videoLayer.isReadyForMoreMediaData else { return }
var formatDescription: CMVideoFormatDescription?
guard CMVideoFormatDescriptionCreateForImageBuffer(
allocator: kCFAllocatorDefault,
imageBuffer: pixelBuffer,
formatDescriptionOut: &formatDescription
) == noErr, let formatDescription else {
NSLog("CrossDesk could not create a video format description")
return
}
var timing = CMSampleTimingInfo(
duration: .invalid,
presentationTimeStamp: .invalid,
decodeTimeStamp: .invalid
)
var sampleBuffer: CMSampleBuffer?
guard CMSampleBufferCreateReadyWithImageBuffer(
allocator: kCFAllocatorDefault,
imageBuffer: pixelBuffer,
formatDescription: formatDescription,
sampleTiming: &timing,
sampleBufferOut: &sampleBuffer
) == noErr, let sampleBuffer else {
NSLog("CrossDesk could not create a video sample buffer")
return
}
if let attachments = CMSampleBufferGetSampleAttachmentsArray(
sampleBuffer,
createIfNecessary: true
), CFArrayGetCount(attachments) > 0 {
let attachment = unsafeBitCast(
CFArrayGetValueAtIndex(attachments, 0),
to: CFMutableDictionary.self
)
CFDictionarySetValue(
attachment,
Unmanaged.passUnretained(kCMSampleAttachmentKey_DisplayImmediately).toOpaque(),
Unmanaged.passUnretained(kCFBooleanTrue).toOpaque()
)
}
videoLayer.enqueue(sampleBuffer)
submittedFrames &+= 1
if submittedFrames == 1 || submittedFrames.isMultiple(of: 300) {
NSLog("CrossDesk presented video frame %llu (%zu x %zu)",
submittedFrames,
CVPixelBufferGetWidth(pixelBuffer),
CVPixelBufferGetHeight(pixelBuffer))
}
if submittedFrames == 1 {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { [weak self] in
guard let self else { return }
NSLog("CrossDesk video layer status=%ld error=%@",
self.videoLayer.status.rawValue,
self.videoLayer.error?.localizedDescription ?? "none")
}
}
}
}
+70
View File
@@ -0,0 +1,70 @@
# CrossDesk Mobile (native iOS)
This target is a native MiniRTC controller. It uses the same WebSocket
signaling, libnice ICE, SRTP/RTP and data-stream protocol as the desktop app.
There is no `WKWebView` or browser runtime.
## Requirements
- Xcode 16 or newer
- Xmake available on `PATH`
- A physical arm64 iPhone or iPad running iOS 16 or newer
## Build
Open `CrossDeskMobile.xcodeproj`, select the `CrossDeskMobile` scheme and a
physical device, then build. The first build phase compiles MiniRTC and the
shared CrossDesk protocol, then merges their iPhoneOS static dependencies into
a local archive under `Vendor/`.
You can also verify the target without code signing:
```sh
xcodebuild -project ios/CrossDeskMobile.xcodeproj \
-scheme CrossDeskMobile \
-configuration Debug \
-destination 'generic/platform=iOS' \
CODE_SIGNING_ALLOWED=NO build
```
The first connection to a signaling server provisions and stores an identity
for that server. Remote control sessions then log in as `C-<identity>` and use
the desktop-compatible `DisplayN`, `control_audio`, `mouse`, `keyboard`,
`control_data`, `clipboard`, `file`, and `file_feedback` streams.
## Video codecs
Every iOS build includes VideoToolbox, OpenH264, dav1d, libaom, and SVT-AV1. H.264 uses
VideoToolbox by default; the in-app **Settings > Video Codec** selector switches
H.264 software processing to OpenH264. AV1 encoding uses SVT-AV1 and AV1 decoding
uses dav1d; VideoToolbox AV1 hardware decoding is currently unsupported.
## Physical-device test checklist
Run the app from Xcode on a physical device and connect to a current desktop
build. Test the features in this order so a media problem is not confused with
a data-channel problem:
1. **Video:** after the connection turns green, the waiting panel should be
replaced by the remote desktop. The status bar reports the decoded size and
frame count.
2. **Audio:** play continuous sound on the remote computer, then toggle the
speaker button. Audio is Opus-decoded by MiniRTC and played as 48 kHz mono
16-bit PCM through `AVAudioEngine`.
3. **Clipboard:** copy a short text value on the phone and choose **Send local
clipboard**. Copy a different value on the desktop and confirm that it is
written to the iOS pasteboard. Text is limited to 128 KiB.
4. **Displays:** open the display menu, switch every listed monitor, and check
that the frame counter restarts and the selected monitor appears after a
new key frame.
5. **Files:** use the folder button to pick a small file, then send a file back
from the desktop. Progress is ACK-driven. Received files are stored in the
app's `Documents/Received` directory and can be exported with the share
button or Finder's Files tab.
For black-screen diagnosis, keep Xcode's device console open and filter for
`CrossDesk`. A working pipeline prints both `VideoToolbox decoded frame` from
MiniRTC and `CrossDesk received decoded frame` from the iOS bridge. If only the
first message appears, verify that the selected stream is named `Display1`,
`Display2`, and so on. If neither appears, update and rebuild the desktop side
and request a key frame.
+147
View File
@@ -0,0 +1,147 @@
#!/bin/zsh
set -euo pipefail
SCRIPT_DIR="${0:A:h}"
IOS_DIR="${SCRIPT_DIR:h}"
REPO_DIR="${IOS_DIR:h}"
MINIRTC_DIR="${REPO_DIR}/submodules/minirtc"
CONFIG_NAME="${CONFIGURATION:-Debug}"
MODE="${CONFIG_NAME:l}"
# Keep iOS dependencies isolated from desktop Xmake packages. Besides avoiding
# cross-project cache collisions, this guarantees every archive is compiled
# with the iOS 16 deployment target instead of the active SDK version.
export XMAKE_PKG_INSTALLDIR="${IOS_DIR}/.xmake/packages"
if [[ "${MODE}" != "debug" && "${MODE}" != "release" ]]; then
MODE="release"
fi
ARCH_NAME="${CURRENT_ARCH:-arm64}"
if [[ "${ARCH_NAME}" == "undefined_arch" ]]; then
ARCH_NAME="arm64"
fi
if [[ "${ARCH_NAME}" != "arm64" ]]; then
print -u2 "CrossDesk Mobile currently supports physical iOS arm64 builds only."
exit 64
fi
XMAKE_BIN="${XMAKE_BIN:-}"
if [[ -z "${XMAKE_BIN}" ]]; then
XMAKE_BIN="$(command -v xmake 2>/dev/null || true)"
fi
if [[ -z "${XMAKE_BIN}" ]]; then
for candidate in /opt/homebrew/bin/xmake /usr/local/bin/xmake "${HOME}/.local/bin/xmake"; do
if [[ -x "${candidate}" ]]; then
XMAKE_BIN="${candidate}"
break
fi
done
fi
if [[ -z "${XMAKE_BIN}" || ! -x "${XMAKE_BIN}" ]]; then
print -u2 "xmake is required. Install it from https://xmake.io first."
exit 69
fi
OUTPUT_DIR="${IOS_DIR}/Vendor/iphoneos/${CONFIG_NAME}"
OUTPUT_LIBRARY="${OUTPUT_DIR}/libCrossDeskMiniRTC.a"
MINIRTC_BUILD_DIR="${IOS_DIR}/.xmake/minirtc-build"
MINIRTC_LIBRARY="${MINIRTC_BUILD_DIR}/iphoneos/arm64/${MODE}/libminirtc.a"
CROSSDESK_SOURCE_DIR="${REPO_DIR}/src"
CROSSDESK_BUILD_DIR="${IOS_DIR}/.xmake/crossdesk-build/iphoneos/arm64/${MODE}"
mkdir -p "${OUTPUT_DIR}"
# Xmake stores the configured build directory relative to the process working
# directory. Xcode does not guarantee that directory for build phases, so keep
# configuration, compilation and inspection anchored to the MiniRTC project.
(
cd "${MINIRTC_DIR}"
"${XMAKE_BIN}" f -P "${MINIRTC_DIR}" -c -o "${MINIRTC_BUILD_DIR}" \
-p iphoneos -a arm64 -m "${MODE}" \
--target_minver=16.0 --USE_CUDA=false -y
"${XMAKE_BIN}" b -P "${MINIRTC_DIR}" minirtc
)
if [[ ! -f "${MINIRTC_LIBRARY}" ]]; then
print -u2 "MiniRTC archive was not produced at ${MINIRTC_LIBRARY}."
exit 66
fi
# Xmake owns the package hashes, so query its resolved target instead of
# embedding machine-specific ~/.xmake paths in the Xcode project.
TARGET_INFO="$(cd "${MINIRTC_DIR}" && TERM=dumb NO_COLOR=1 \
"${XMAKE_BIN}" show -P "${MINIRTC_DIR}" -t minirtc)"
CLEAN_INFO="$(print -r -- "${TARGET_INFO}" | sed $'s/\033\\[[0-9;]*[[:alpha:]]//g')"
LINK_DIRS=("${(@f)$(print -r -- "${CLEAN_INFO}" | sed -nE 's|.*-> (/.*)/lib -> package.*|\1/lib|p' | sort -u)}")
REQUIRED_LINKS=(
nice glib-2.0 gobject-2.0 gmodule-2.0 gio-2.0 gthread-2.0 intl
ffi pcre2-8 pcre2-posix z ssl crypto srtp2 openfec opus yuv kcp
datachannel usrsctp openh264 dav1d aom SvtAv1Enc
)
DEPENDENCY_ARCHIVES=()
for link_name in "${REQUIRED_LINKS[@]}"; do
archive_path=""
for link_dir in "${LINK_DIRS[@]}"; do
candidate="${link_dir}/lib${link_name}.a"
if [[ -f "${candidate}" ]]; then
archive_path="${candidate}"
break
fi
done
if [[ -z "${archive_path}" ]]; then
print -u2 "Unable to resolve static dependency lib${link_name}.a from Xmake."
exit 66
fi
DEPENDENCY_ARCHIVES+=("${archive_path}")
done
# Build the CrossDesk message and file-transfer codecs for the same iOS
# architecture, then merge them into the aggregate archive consumed by Xcode.
NLOHMANN_HEADER="$(find "${XMAKE_PKG_INSTALLDIR}/n/nlohmann_json" \
-path '*/include/nlohmann/json.hpp' -print -quit 2>/dev/null || true)"
if [[ -z "${NLOHMANN_HEADER}" ]]; then
print -u2 "Unable to locate the nlohmann_json headers installed by Xmake."
exit 66
fi
NLOHMANN_INCLUDE="${NLOHMANN_HEADER:h:h}"
IOS_SDK_PATH="$(xcrun --sdk iphoneos --show-sdk-path)"
mkdir -p "${CROSSDESK_BUILD_DIR}"
CROSSDESK_CXXFLAGS=(
-std=c++17 -arch arm64 -isysroot "${IOS_SDK_PATH}"
-miphoneos-version-min=16.0
-I"${CROSSDESK_SOURCE_DIR}/device_controller"
-I"${CROSSDESK_SOURCE_DIR}/common"
-I"${CROSSDESK_SOURCE_DIR}/tools"
-I"${NLOHMANN_INCLUDE}"
)
if [[ "${MODE}" == "debug" ]]; then
CROSSDESK_CXXFLAGS+=(-O0 -g)
else
CROSSDESK_CXXFLAGS+=(-O2 -DNDEBUG)
fi
CROSSDESK_SOURCES=(
"${CROSSDESK_SOURCE_DIR}/device_controller/remote_action.cpp"
"${CROSSDESK_SOURCE_DIR}/tools/file_transfer_protocol.cpp"
)
CROSSDESK_OBJECTS=()
for source_path in "${CROSSDESK_SOURCES[@]}"; do
object_path="${CROSSDESK_BUILD_DIR}/${source_path:t:r}.o"
xcrun --sdk iphoneos clang++ "${CROSSDESK_CXXFLAGS[@]}" \
-c "${source_path}" -o "${object_path}"
CROSSDESK_OBJECTS+=("${object_path}")
done
TEMP_LIBRARY="${OUTPUT_LIBRARY}.tmp"
rm -f "${TEMP_LIBRARY}"
/usr/bin/libtool -static -o "${TEMP_LIBRARY}" \
"${MINIRTC_LIBRARY}" "${DEPENDENCY_ARCHIVES[@]}" \
"${CROSSDESK_OBJECTS[@]}"
mv -f "${TEMP_LIBRARY}" "${OUTPUT_LIBRARY}"
print "Created ${OUTPUT_LIBRARY}"