[feat] expand iOS network status and refine privacy prompts

This commit is contained in:
dijunkun
2026-09-28 23:26:03 +08:00
parent 163506f81e
commit f8535a797b
14 changed files with 820 additions and 209 deletions
+2 -2
View File
@@ -40,7 +40,7 @@ CrossDesk 未集成广告投放、广告追踪或用户行为分析功能。客
**客户端本地数据。** 软件会在本机保存连接设置、用户偏好、最近连接记录及必要的运行日志,以支持后续使用和故障排查。iOS 中的本机登录凭据及您选择记住的远程连接密码保存在系统钥匙串中,不通过 iCloud 钥匙串同步;旧版偏好中的本机登录凭据在成功迁入钥匙串后删除。
**远程画面预览。** iOS 默认不保存远程画面预览。只有您在“设置 → 隐私”单独开启并确认后,才会从下一次连接起,每次连接保存一张远程画面,用于最近连接预览;会话中会显示保存已开启的提示。预览仅保存在本机,不上传,并设置为不纳入设备备份。您可以单独清除预览;关闭保存或撤回隐私授权也会清除已有预览。旧版本未经此选择保存的预览会在升级后清理。
**远程画面预览。** iOS 默认不保存远程画面预览。只有您在“设置 → 隐私”单独开启并确认后,才会从下一次连接起,每次连接保存一张远程画面,用于最近连接预览。预览仅保存在本机,不上传,并设置为不纳入设备备份。您可以单独清除预览;关闭保存或撤回隐私授权也会清除已有预览。旧版本未经此选择保存的预览会在升级后清理。
**接收的文件。** iOS 接收文件保存在本应用的文件目录中,您可通过系统“文件”应用管理或删除。清除画面预览不会删除接收文件或最近连接记录。
@@ -112,7 +112,7 @@ Self-hosted servers are managed by the operator you choose. If you change the co
**Local client data.** The software stores connection settings, preferences, recent connections and necessary operational logs on the device for subsequent use and troubleshooting. On iOS, local login credentials and remote connection passwords you choose to remember are stored in the system Keychain without iCloud Keychain synchronization. Legacy local login credentials in preferences are removed after successful migration to the Keychain.
**Remote desktop previews.** Saving previews is off by default on iOS. Only after you separately enable and confirm it in Settings → Privacy will the app save one remote frame per connection, starting with the next connection, for recent-connection previews. An indicator appears during sessions while saving is enabled. Previews remain local, are not uploaded and are marked as excluded from device backups. You can clear them separately. Disabling saving or withdrawing network consent also clears them. Previews saved by older versions without this choice are cleared on upgrade.
**Remote desktop previews.** Saving previews is off by default on iOS. Only after you separately enable and confirm it in Settings → Privacy will the app save one remote frame per connection, starting with the next connection, for recent-connection previews. Previews remain local, are not uploaded and are marked as excluded from device backups. You can clear them separately. Disabling saving or withdrawing network consent also clears them. Previews saved by older versions without this choice are cleared on upgrade.
**Received files.** Received files on iOS are stored in the app's file directory and can be managed or deleted through Files. Clearing previews does not delete received files or recent-connection records.
@@ -8,6 +8,7 @@
/* Begin PBXBuildFile section */
100000000000000000000016 /* RemoteVideoSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 200000000000000000000016 /* RemoteVideoSettings.swift */; };
100000000000000000000017 /* RemoteNetworkStatistics.swift in Sources */ = {isa = PBXBuildFile; fileRef = 200000000000000000000017 /* RemoteNetworkStatistics.swift */; };
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 */; };
@@ -29,6 +30,7 @@
/* Begin PBXFileReference section */
200000000000000000000016 /* RemoteVideoSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteVideoSettings.swift; sourceTree = "<group>"; };
200000000000000000000017 /* RemoteNetworkStatistics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteNetworkStatistics.swift; sourceTree = "<group>"; };
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>"; };
@@ -95,6 +97,7 @@
200000000000000000000012 /* PrivacyPreferences.swift */,
200000000000000000000002 /* RemoteSessionModel.swift */,
200000000000000000000016 /* RemoteVideoSettings.swift */,
200000000000000000000017 /* RemoteNetworkStatistics.swift */,
200000000000000000000003 /* ContentView.swift */,
20000000000000000000000E /* ThirdPartyLicensesView.swift */,
20000000000000000000000F /* ThirdPartyLicenses.json */,
@@ -259,6 +262,7 @@
100000000000000000000001 /* CrossDeskMobileApp.swift in Sources */,
100000000000000000000002 /* RemoteSessionModel.swift in Sources */,
100000000000000000000016 /* RemoteVideoSettings.swift in Sources */,
100000000000000000000017 /* RemoteNetworkStatistics.swift in Sources */,
100000000000000000000003 /* ContentView.swift in Sources */,
100000000000000000000009 /* ThirdPartyLicensesView.swift in Sources */,
100000000000000000000004 /* RemoteSessionView.swift in Sources */,
@@ -44,6 +44,29 @@ typedef NS_ENUM(NSInteger, CrossDeskVideoAdaptationPolicy) {
@class CrossDeskRTCBridge;
typedef NS_ENUM(NSInteger, CrossDeskTraversalMode) {
CrossDeskTraversalModeUnknown = 0,
CrossDeskTraversalModeDirect,
CrossDeskTraversalModeRelay,
};
typedef struct {
uint32_t inboundBitrate;
uint32_t outboundBitrate;
float lossRate;
} CrossDeskTrafficStats;
/// Value snapshot: no MiniRTC callback-owned memory crosses the main queue.
typedef struct {
CrossDeskTrafficStats video;
CrossDeskTrafficStats audio;
CrossDeskTrafficStats data;
CrossDeskTrafficStats total;
double rttMilliseconds;
BOOL srtpActive;
CrossDeskTraversalMode traversalMode;
} CrossDeskNetworkStats;
@protocol CrossDeskRTCBridgeDelegate <NSObject>
@optional
- (void)rtcBridge:(CrossDeskRTCBridge *)bridge
@@ -58,7 +81,8 @@ typedef NS_ENUM(NSInteger, CrossDeskVideoAdaptationPolicy) {
- (void)rtcBridge:(CrossDeskRTCBridge *)bridge
didReceivePixelBuffer:(CVPixelBufferRef)pixelBuffer
width:(NSInteger)width
height:(NSInteger)height;
height:(NSInteger)height
captureUptime:(NSTimeInterval)captureUptime;
- (void)rtcBridge:(CrossDeskRTCBridge *)bridge
didReceiveHostName:(NSString *)hostName
appVersion:(NSString *)appVersion
@@ -96,9 +120,8 @@ typedef NS_ENUM(NSInteger, CrossDeskVideoAdaptationPolicy) {
- (void)rtcBridge:(CrossDeskRTCBridge *)bridge
didReceiveFileAtURL:(NSURL *)fileURL;
- (void)rtcBridge:(CrossDeskRTCBridge *)bridge
didUpdateBitrate:(NSUInteger)bitsPerSecond
lossRate:(float)lossRate
usingTURN:(BOOL)usingTURN;
didUpdateNetworkStats:(CrossDeskNetworkStats)stats
NS_SWIFT_NAME(rtcBridge(_:didUpdateNetworkStats:));
@end
/// Objective-C++ boundary around the native MiniRTC C API.
@@ -325,7 +325,8 @@ void DispatchMain(dispatch_block_t block) {
sourceIDSize:(size_t)sourceIDSize;
- (void)enqueueVideoPixelBuffer:(CVPixelBufferRef)pixelBuffer
width:(NSInteger)width
height:(NSInteger)height;
height:(NSInteger)height
captureUptime:(NSTimeInterval)captureUptime;
- (void)resetVideoDelivery;
- (void)notifyVideoSettings:(crossdesk::VideoSettings)settings
generation:(uint64_t)generation;
@@ -335,7 +336,9 @@ void DispatchMain(dispatch_block_t block) {
sourceID:(const char *)sourceID
sourceIDSize:(size_t)sourceIDSize
generation:(uint64_t)generation;
- (void)handleStats:(const MiniRtcNetTrafficStats *)stats mode:(TraversalMode)mode;
- (void)handleStats:(const MiniRtcNetTrafficStats *)stats
mode:(TraversalMode)mode
generation:(uint64_t)generation;
- (void)sendMessage:(const std::string &)message
reliable:(BOOL)reliable
stream:(const char *)stream;
@@ -433,7 +436,7 @@ void OnNetworkStats(const char *peer_id, size_t peer_id_size,
generation:context->generation];
} else if (context->role == PeerRole::Controller && stats &&
[owner isControllerGenerationActive:context->generation]) {
[owner handleStats:stats mode:mode];
[owner handleStats:stats mode:mode generation:context->generation];
}
}
@@ -481,6 +484,7 @@ Params MakeParams(const RTCState &state, const std::string &user_id,
CVPixelBufferRef _pendingVideoBuffer;
NSInteger _pendingVideoWidth;
NSInteger _pendingVideoHeight;
NSTimeInterval _pendingVideoCaptureUptime;
bool _videoDeliveryScheduled;
uint64_t _videoDeliveryGeneration;
std::mutex _fileMutex;
@@ -522,6 +526,7 @@ Params MakeParams(const RTCState &state, const std::string &user_id,
_pendingVideoBuffer = nullptr;
_pendingVideoWidth = 0;
_pendingVideoHeight = 0;
_pendingVideoCaptureUptime = 0;
_videoDeliveryScheduled = false;
_videoDeliveryGeneration = 0;
_selectedDisplay.store(0);
@@ -1399,6 +1404,17 @@ Params MakeParams(const RTCState &state, const std::string &user_id,
static_cast<size_t>(_selectedDisplay.load()));
if (source != expected) return;
}
// Translate the transport's calibrated capture time once, while the peer is
// alive. Carry it with the exact pixels through coalescing and UI submission.
const NSTimeInterval now = NSProcessInfo.processInfo.systemUptime;
const int64_t peer_now = GetSystemTimeMicros(_state->controller_peer);
NSTimeInterval capture_uptime = 0;
if (frame->captured_timestamp > 0 && peer_now > 0 &&
frame->captured_timestamp <= static_cast<uint64_t>(peer_now) &&
static_cast<uint64_t>(peer_now) - frame->captured_timestamp <= 5'000'000) {
capture_uptime = now -
(static_cast<uint64_t>(peer_now) - frame->captured_timestamp) / 1'000'000.0;
}
const MiniRtcNativeVideoFrame *native_frame = frame->native_frame;
const bool has_native_pixel_buffer =
native_frame &&
@@ -1412,7 +1428,8 @@ Params MakeParams(const RTCState &state, const std::string &user_id,
native_frame->payload.cv_pixel_buffer);
[self enqueueVideoPixelBuffer:pixel_buffer
width:frame->width
height:frame->height];
height:frame->height
captureUptime:capture_uptime];
return;
}
@@ -1504,13 +1521,15 @@ Params MakeParams(const RTCState &state, const std::string &user_id,
[self enqueueVideoPixelBuffer:pixel_buffer
width:frame_width
height:frame_height];
height:frame_height
captureUptime:capture_uptime];
CVPixelBufferRelease(pixel_buffer);
}
- (void)enqueueVideoPixelBuffer:(CVPixelBufferRef)pixelBuffer
width:(NSInteger)width
height:(NSInteger)height {
height:(NSInteger)height
captureUptime:(NSTimeInterval)captureUptime {
if (!pixelBuffer) return;
CVPixelBufferRetain(pixelBuffer);
@@ -1524,6 +1543,7 @@ Params MakeParams(const RTCState &state, const std::string &user_id,
_pendingVideoBuffer = pixelBuffer;
_pendingVideoWidth = width;
_pendingVideoHeight = height;
_pendingVideoCaptureUptime = captureUptime;
generation = _videoDeliveryGeneration;
if (!_videoDeliveryScheduled) {
_videoDeliveryScheduled = true;
@@ -1536,15 +1556,18 @@ Params MakeParams(const RTCState &state, const std::string &user_id,
CVPixelBufferRef latest_buffer = nullptr;
NSInteger latest_width = 0;
NSInteger latest_height = 0;
NSTimeInterval latest_capture_uptime = 0;
{
std::lock_guard<std::mutex> lock(self->_pendingVideoMutex);
if (generation != self->_videoDeliveryGeneration) return;
latest_buffer = self->_pendingVideoBuffer;
latest_width = self->_pendingVideoWidth;
latest_height = self->_pendingVideoHeight;
latest_capture_uptime = self->_pendingVideoCaptureUptime;
self->_pendingVideoBuffer = nullptr;
self->_pendingVideoWidth = 0;
self->_pendingVideoHeight = 0;
self->_pendingVideoCaptureUptime = 0;
self->_videoDeliveryScheduled = false;
}
@@ -1557,11 +1580,12 @@ Params MakeParams(const RTCState &state, const std::string &user_id,
}
id<CrossDeskRTCBridgeDelegate> delegate = self.delegate;
if ([delegate respondsToSelector:
@selector(rtcBridge:didReceivePixelBuffer:width:height:)]) {
@selector(rtcBridge:didReceivePixelBuffer:width:height:captureUptime:)]) {
[delegate rtcBridge:self
didReceivePixelBuffer:latest_buffer
width:latest_width
height:latest_height];
height:latest_height
captureUptime:latest_capture_uptime];
}
CVPixelBufferRelease(latest_buffer);
});
@@ -1576,6 +1600,7 @@ Params MakeParams(const RTCState &state, const std::string &user_id,
_pendingVideoBuffer = nullptr;
_pendingVideoWidth = 0;
_pendingVideoHeight = 0;
_pendingVideoCaptureUptime = 0;
_videoDeliveryScheduled = false;
}
if (pending_buffer) CVPixelBufferRelease(pending_buffer);
@@ -1868,19 +1893,31 @@ Params MakeParams(const RTCState &state, const std::string &user_id,
}
}
- (void)handleStats:(const MiniRtcNetTrafficStats *)stats mode:(TraversalMode)mode {
- (void)handleStats:(const MiniRtcNetTrafficStats *)stats
mode:(TraversalMode)mode
generation:(uint64_t)generation {
if (!stats) return;
const NSUInteger bitrate = stats->total_inbound_stats.bitrate;
const float loss = stats->video_inbound_stats.loss_rate;
const BOOL using_turn = mode == TraversalMode::Relay;
const auto traffic = [](const MiniRtcInboundStats &inbound,
const MiniRtcOutboundStats &outbound) {
return CrossDeskTrafficStats{inbound.bitrate, outbound.bitrate,
inbound.loss_rate};
};
const CrossDeskNetworkStats snapshot{
traffic(stats->video_inbound_stats, stats->video_outbound_stats),
traffic(stats->audio_inbound_stats, stats->audio_outbound_stats),
traffic(stats->data_inbound_stats, stats->data_outbound_stats),
traffic(stats->total_inbound_stats, stats->total_outbound_stats),
stats->rtt_ms, static_cast<BOOL>(stats->srtp_active),
mode == TraversalMode::P2P ? CrossDeskTraversalModeDirect
: mode == TraversalMode::Relay ? CrossDeskTraversalModeRelay
: CrossDeskTraversalModeUnknown};
DispatchMain(^{
// Disconnect/reconnect can happen while this report waits on the UI queue.
if (![self isControllerGenerationActive:generation]) return;
id<CrossDeskRTCBridgeDelegate> delegate = self.delegate;
if ([delegate respondsToSelector:
@selector(rtcBridge:didUpdateBitrate:lossRate:usingTURN:)]) {
[delegate rtcBridge:self
didUpdateBitrate:bitrate
lossRate:loss
usingTURN:using_turn];
@selector(rtcBridge:didUpdateNetworkStats:)]) {
[delegate rtcBridge:self didUpdateNetworkStats:snapshot];
}
});
}
+54 -45
View File
@@ -6,30 +6,30 @@ struct ContentView: View {
@ObservedObject var session: RemoteSessionModel
var body: some View {
ZStack {
Group {
if session.sessionVisible {
RemoteSessionView(session: session)
.transition(.opacity)
} else {
NavigationStack {
ConnectionHomeView(session: session)
.navigationTitle("首页")
.toolbar(.hidden, for: .navigationBar)
}
Group {
if session.sessionVisible {
RemoteSessionView(session: session)
.transition(.opacity)
} else {
NavigationStack {
ConnectionHomeView(session: session)
.navigationTitle("首页")
.toolbar(.hidden, for: .navigationBar)
}
}
.allowsHitTesting(!session.privacyNoticeVisible)
.accessibilityHidden(session.privacyNoticeVisible)
if session.privacyNoticeVisible {
PrivacyConsentView(session: session)
.transition(.opacity)
.transition(.opacity)
}
}
.animation(.easeInOut(duration: 0.2), value: session.sessionVisible)
.preferredColorScheme(session.sessionVisible ? .dark : .light)
.sheet(isPresented: Binding(
get: { session.privacyNoticeVisible },
set: { if !$0 { session.dismissPrivacyNotice() } }
)) {
PrivacyConsentView(session: session)
.presentationDetents([.large])
.presentationDragIndicator(.hidden)
.interactiveDismissDisabled()
}
.alert("连接提示", isPresented: Binding(
get: { session.connectionFailureMessage != nil },
set: { if !$0 { session.dismissConnectionFailure() } }
@@ -43,17 +43,14 @@ struct ContentView: View {
AppOrientation.update(to: .portrait)
}
if scenePhase == .active {
session.refreshRecentConnectionPresenceAfterForeground()
session.resumeVideoAfterForeground()
session.applicationDidBecomeActive()
}
}
.onChange(of: scenePhase) { phase in
if phase == .active {
session.refreshRecentConnectionPresenceAfterForeground()
session.resumeVideoAfterForeground()
session.applicationDidBecomeActive()
} else if phase == .background {
session.suspendVideoForBackground()
session.suspendPresenceMonitoring()
session.applicationDidEnterBackground()
}
}
}
@@ -182,7 +179,6 @@ private struct ConnectionHomeView: View {
}
private var signalSymbol: String {
if !session.hasNetworkConsent { return "hand.raised" }
if signalIsConnected { return "checkmark.circle.fill" }
if signalHasError { return "exclamationmark.triangle.fill" }
return "arrow.triangle.2.circlepath"
@@ -267,21 +263,17 @@ private struct ConnectionHomeView: View {
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)
if session.hasNetworkConsent {
signalStatusBadge
} else {
Button {
remoteIDFocused = false
session.showPrivacyNotice()
} label: {
signalStatusBadge
}
.buttonStyle(.plain)
.accessibilityHint("打开隐私政策")
}
Spacer()
@@ -303,12 +295,29 @@ private struct ConnectionHomeView: View {
.padding(.top, 8)
}
private var signalStatusBadge: some View {
HStack(spacing: 6) {
if session.hasNetworkConsent {
Image(systemName: signalSymbol)
.font(.caption.weight(.bold))
}
Text(session.signalStatus)
.font(.caption.weight(.semibold))
.lineLimit(1)
}
.foregroundStyle(signalTint)
.padding(.horizontal, 11)
.frame(height: 34)
.background(signalTint.opacity(0.11), in: Capsule())
.overlay {
Capsule()
.stroke(signalTint.opacity(0.22), lineWidth: 1)
}
.contentShape(Capsule())
}
private var remoteConnectionPanel: some View {
VStack(alignment: .leading, spacing: 16) {
if !session.hasNetworkConsent {
Button("查看联网说明并选择") { session.showPrivacyNotice() }
.font(.callout)
}
HStack {
Text("远程桌面")
.font(.title3.weight(.bold))
@@ -777,7 +786,7 @@ private struct ServerSettingsView: View {
Text(warning)
.foregroundStyle(.orange)
}
Text(!session.hasNetworkConsent ? "同意联网后才会登记本机身份" : session.localIdentity.isEmpty
Text(!session.hasNetworkConsent ? "完成隐私授权后登记本机身份" : session.localIdentity.isEmpty
? "正在获取本机 ID…"
: "本机 ID \(session.localIdentity)")
.font(.caption2.monospacedDigit())
@@ -11,7 +11,6 @@ struct PrivacyPreferences {
self.defaults = defaults
}
var hasMadeNetworkChoice: Bool { defaults.object(forKey: networkKey) != nil }
var hasNetworkConsent: Bool { defaults.bool(forKey: networkKey) }
var savesThumbnails: Bool {
hasNetworkConsent && defaults.bool(forKey: thumbnailsKey)
@@ -26,3 +25,32 @@ struct PrivacyPreferences {
defaults.set(allowed && hasNetworkConsent, forKey: thumbnailsKey)
}
}
/// Refusal dismisses the notice for this visit only. A background/foreground
/// round trip starts another visit; transient inactive states do not.
struct PrivacyNoticeState {
private(set) var isVisible: Bool
private var needsPresentationOnActivation = false
init(hasNetworkConsent: Bool) {
isVisible = !hasNetworkConsent
}
mutating func show() {
isVisible = true
}
mutating func dismiss() {
isVisible = false
}
mutating func didEnterBackground() {
needsPresentationOnActivation = true
}
mutating func didBecomeActive(hasNetworkConsent: Bool) {
guard needsPresentationOnActivation else { return }
needsPresentationOnActivation = false
isVisible = !hasNetworkConsent
}
}
+56 -71
View File
@@ -5,76 +5,61 @@ struct PrivacyConsentView: View {
@State private var hasReadPolicy = false
var body: some View {
GeometryReader { geometry in
ZStack {
Color.black.opacity(0.32).ignoresSafeArea()
VStack(spacing: 16) {
Text("隐私政策")
.font(.title3.bold())
Text("请阅读以下隐私政策。同意后将处理连接所需的设备与网络信息;画面预览保存需另行开启。")
.font(.footnote)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
ScrollView {
PrivacyPolicyDocument()
.padding(14)
}
.frame(height: min(220, max(100, geometry.size.height - 330)))
.background(Color(.secondarySystemGroupedBackground),
in: RoundedRectangle(cornerRadius: 12))
.overlay {
RoundedRectangle(cornerRadius: 12)
.stroke(Color(.separator).opacity(0.3), lineWidth: 0.5)
}
Toggle(isOn: $hasReadPolicy) {
Text("我已阅读并同意《隐私政策》")
.font(.footnote)
}
.toggleStyle(ConsentCheckboxStyle())
HStack(spacing: 12) {
Button {
session.declineNetworkConsent()
} label: {
Text("拒绝")
.frame(maxWidth: .infinity, minHeight: 44)
.background(Color(.secondarySystemGroupedBackground),
in: RoundedRectangle(cornerRadius: 10))
.contentShape(RoundedRectangle(cornerRadius: 10))
}
Button {
guard hasReadPolicy else { return }
session.acceptNetworkConsent()
} label: {
Text("同意并继续")
.frame(maxWidth: .infinity, minHeight: 44)
.foregroundStyle(.white)
.background(hasReadPolicy ? Color.accentColor : Color.gray.opacity(0.4),
in: RoundedRectangle(cornerRadius: 10))
.contentShape(RoundedRectangle(cornerRadius: 10))
}
.disabled(!hasReadPolicy || !PrivacyPolicyDocument.isAvailable)
}
.font(.subheadline.weight(.semibold))
.buttonStyle(.plain)
Text("拒绝不影响本地功能;同意后可在设置中撤回。")
.font(.caption2)
.foregroundStyle(.secondary)
}
.padding(20)
.frame(width: min(360, geometry.size.width - 40))
.background(Color(.systemBackground),
in: RoundedRectangle(cornerRadius: 22))
.compositingGroup()
.shadow(color: .black.opacity(0.16), radius: 24, y: 8)
.accessibilityElement(children: .contain)
.accessibilityAddTraits(.isModal)
NavigationStack {
ScrollView {
PrivacyPolicyDocument()
.padding(.horizontal, 20)
.padding(.vertical, 20)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color(.systemBackground))
.safeAreaInset(edge: .bottom, spacing: 0) {
consentActions
}
.navigationTitle("隐私政策")
.navigationBarTitleDisplayMode(.inline)
}
}
private var consentActions: some View {
VStack(spacing: 0) {
Divider()
VStack(spacing: 12) {
Toggle(isOn: $hasReadPolicy) {
Text("我已阅读并同意《隐私政策》")
.font(.subheadline)
}
.toggleStyle(ConsentCheckboxStyle())
Button {
guard hasReadPolicy else { return }
session.acceptNetworkConsent()
} label: {
Text("同意并继续")
.font(.body.weight(.semibold))
.frame(maxWidth: .infinity, minHeight: 24)
}
.buttonStyle(.borderedProminent)
.controlSize(.large)
.tint(.blue)
.disabled(!hasReadPolicy || !PrivacyPolicyDocument.isAvailable)
Button {
session.declineNetworkConsent()
} label: {
Text("暂不同意")
.font(.body)
.frame(maxWidth: .infinity, minHeight: 44)
.contentShape(Rectangle())
}
.foregroundStyle(.secondary)
.buttonStyle(.plain)
}
.padding(.horizontal, 20)
.padding(.vertical, 12)
}
.background(Color(.systemBackground))
}
}
private struct ConsentCheckboxStyle: ToggleStyle {
@@ -90,7 +75,7 @@ private struct ConsentCheckboxStyle: ToggleStyle {
.foregroundStyle(.primary)
.multilineTextAlignment(.leading)
}
.frame(maxWidth: .infinity, minHeight: 36, alignment: .leading)
.frame(maxWidth: .infinity, minHeight: 44, alignment: .leading)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
@@ -214,7 +199,7 @@ private struct PrivacyPolicyDocument: View {
let paragraph = Self.paragraphs[index]
if paragraph.hasPrefix("#") {
Text(paragraph.trimmingCharacters(in: CharacterSet(charactersIn: "# ")))
.font(.subheadline.bold())
.font(.headline)
.padding(.top, 4)
} else if paragraph.hasPrefix("更新日期:") {
Text(paragraph)
@@ -224,8 +209,8 @@ private struct PrivacyPolicyDocument: View {
Text((try? AttributedString(markdown: paragraph,
options: .init(interpretedSyntax: .inlineOnlyPreservingWhitespace)))
?? AttributedString(paragraph))
.font(.subheadline)
.lineSpacing(3)
.font(.body)
.lineSpacing(4)
}
}
}
@@ -0,0 +1,123 @@
import Foundation
struct RemoteTrafficStatistics {
var inboundBitrate: UInt32 = 0
var outboundBitrate: UInt32 = 0
var lossRate: Float = 0
}
struct RemoteNetworkReport {
enum Mode { case unknown, direct, relay }
var video = RemoteTrafficStatistics()
var audio = RemoteTrafficStatistics()
var data = RemoteTrafficStatistics()
var total = RemoteTrafficStatistics()
var mode: Mode = .unknown
var srtpActive = false
var rttMilliseconds: Double = -1
}
struct RemoteNetworkSnapshot {
var report: RemoteNetworkReport?
var framesPerSecond = 0
var videoLatencyMilliseconds: Double?
var rttMilliseconds: Double?
var connectionMode: String {
switch report?.mode {
case .direct: return "P2P 直连"
case .relay: return "TURN 中继"
default: return "—"
}
}
static func bitrate(_ value: UInt32?) -> String {
guard let value else { return "—" }
if value >= 1_000_000 {
return String(format: "%.1f Mbps", Double(value) / 1_000_000)
}
if value >= 1_000 {
return String(format: "%.0f Kbps", Double(value) / 1_000)
}
return "\(value) bps"
}
static func lossRate(_ value: Float?) -> String {
guard let value, value.isFinite, value >= 0 else { return "—" }
// MiniRTC's total is the sum of media fractions, as on desktop.
return String(format: "%.1f%%", value * 100)
}
static func latency(_ value: Double?) -> String {
guard let value, value.isFinite, value >= 0 else { return "—" }
return value < 1 ? "<1 ms" : String(format: "%.0f ms", value)
}
}
/// Main-thread presentation statistics. Time is monotonic and injectable so
/// missing reports, idle video and reconnections have deterministic behavior.
struct RemoteNetworkStatistics {
private var report: RemoteNetworkReport?
private var reportTime: TimeInterval = 0
private var averageRTT: Double?
private var rttTime: TimeInterval = 0
private var lastFrameID: UInt64 = 0
private var frames: [(time: TimeInterval, latency: Double?)] = []
private var displayedSnapshot = RemoteNetworkSnapshot()
private var displayTime: TimeInterval?
mutating func receive(_ report: RemoteNetworkReport, at now: TimeInterval) {
self.report = report
reportTime = now
let rtt = report.rttMilliseconds
guard rtt.isFinite, rtt >= 0, rtt <= 2_000 else { return }
if let previous = averageRTT, now - rttTime < 3 {
averageRTT = previous + 0.25 * (rtt - previous)
} else {
averageRTT = rtt
}
rttTime = now
}
mutating func recordSubmittedFrame(id: UInt64, captureUptime: TimeInterval,
at now: TimeInterval) {
// Cached redraws and foreground resubmission are not new video frames.
guard id > lastFrameID else { return }
lastFrameID = id
let latency = (now - captureUptime) * 1_000
let valid = captureUptime > 0 && latency.isFinite && (0...5_000).contains(latency)
frames.removeAll { now - $0.time >= 1 }
frames.append((now, valid ? latency : nil))
if frames.count > 240 { frames.removeFirst(frames.count - 240) }
}
mutating func resetVideo() {
frames.removeAll(keepingCapacity: true)
displayedSnapshot.framesPerSecond = 0
displayedSnapshot.videoLatencyMilliseconds = nil
}
/// SwiftUI also redraws this menu for every video frame. A periodic view
/// schedules updates but does not throttle those redraws, so freeze the
/// displayed measurements between one-second samples.
mutating func displaySnapshot(at now: TimeInterval) -> RemoteNetworkSnapshot {
if let displayTime, now - displayTime < 1 {
return displayedSnapshot
}
displayedSnapshot = snapshot(at: now)
displayTime = now
return displayedSnapshot
}
func snapshot(at now: TimeInterval) -> RemoteNetworkSnapshot {
let recentFrames = frames.filter { now - $0.time < 1 }
let delays = recentFrames.compactMap(\.latency)
return RemoteNetworkSnapshot(
report: now - reportTime < 3 ? report : nil,
framesPerSecond: recentFrames.count,
videoLatencyMilliseconds: delays.isEmpty ? nil : delays.reduce(0, +) / Double(delays.count),
rttMilliseconds: now - rttTime < 3 ? averageRTT : nil
)
}
}
@@ -94,6 +94,8 @@ enum VideoAdaptationPolicy: String, CaseIterable, Identifiable {
private struct RemoteVideoFrame {
let pixelBuffer: CVPixelBuffer
let encodedSize: CGSize
let id: UInt64
let captureUptime: TimeInterval
}
struct RecentConnection: Codable, Identifiable, Equatable {
@@ -294,7 +296,9 @@ final class RemoteSessionModel: NSObject, ObservableObject, CrossDeskRTCBridgeDe
private let privacyPreferences = PrivacyPreferences()
@Published private(set) var hasNetworkConsent = PrivacyPreferences().hasNetworkConsent
@Published private(set) var privacyNoticeVisible = !PrivacyPreferences().hasMadeNetworkChoice
@Published private var privacyNotice = PrivacyNoticeState(
hasNetworkConsent: PrivacyPreferences().hasNetworkConsent
)
@Published private(set) var savesConnectionThumbnails = PrivacyPreferences().savesThumbnails
@Published private(set) var thumbnailCleanupInProgress = false
@Published private(set) var thumbnailCleanupError: String?
@@ -311,7 +315,7 @@ final class RemoteSessionModel: NSObject, ObservableObject, CrossDeskRTCBridgeDe
@Published private(set) var videoSettings = RemoteVideoSettingsState()
@Published var remoteID = ""
@Published var password = ""
@Published private(set) var signalStatus = "尚未同意联网"
@Published private(set) var signalStatus = "等待隐私授权"
@Published private(set) var connectionStatus = "未连接"
@Published private(set) var localIdentity = ""
@Published private(set) var identityStorageWarning: String?
@@ -325,9 +329,8 @@ final class RemoteSessionModel: NSObject, ObservableObject, CrossDeskRTCBridgeDe
@Published private(set) var remoteCursorPositionRevision: UInt64 = 0
@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
private var networkStatistics = RemoteNetworkStatistics()
private var videoFrameID: UInt64 = 0
@Published private(set) var displays: [String] = ["显示器 1"]
@Published private(set) var displaySizes: [CGSize] = []
@Published var selectedDisplay = 0
@@ -379,6 +382,8 @@ final class RemoteSessionModel: NSObject, ObservableObject, CrossDeskRTCBridgeDe
private var bridgeConfiguration: BridgeConfiguration?
var pixelBuffer: CVPixelBuffer? { videoFrame?.pixelBuffer }
var currentVideoFrameID: UInt64 { videoFrame?.id ?? 0 }
var frameCaptureUptime: TimeInterval { videoFrame?.captureUptime ?? 0 }
var frameSize: CGSize { videoFrame?.encodedSize ?? .zero }
var displayGeometrySize: CGSize {
guard displaySizes.indices.contains(selectedDisplay) else {
@@ -400,20 +405,38 @@ final class RemoteSessionModel: NSObject, ObservableObject, CrossDeskRTCBridgeDe
remoteVersionCheckTask?.cancel()
}
var privacyNoticeVisible: Bool { privacyNotice.isVisible }
func applicationDidBecomeActive() {
privacyNotice.didBecomeActive(hasNetworkConsent: hasNetworkConsent)
refreshRecentConnectionPresenceAfterForeground()
resumeVideoAfterForeground()
}
func applicationDidEnterBackground() {
privacyNotice.didEnterBackground()
suspendVideoForBackground()
suspendPresenceMonitoring()
}
func showPrivacyNotice() {
privacyNoticeVisible = true
privacyNotice.show()
}
func dismissPrivacyNotice() {
privacyNotice.dismiss()
}
func acceptNetworkConsent() {
privacyPreferences.setNetworkConsent(true)
hasNetworkConsent = true
privacyNoticeVisible = false
privacyNotice.dismiss()
configureBridge()
}
func declineNetworkConsent() {
revokeNetworkConsent()
privacyNoticeVisible = false
privacyNotice.dismiss()
}
func revokeNetworkConsent() {
@@ -428,7 +451,7 @@ final class RemoteSessionModel: NSObject, ObservableObject, CrossDeskRTCBridgeDe
bridge.stopNetworking()
bridgeConfiguration = nil
localIdentity = ""
signalStatus = "尚未同意联网"
signalStatus = "等待隐私授权"
savesConnectionThumbnails = false
clearConnectionThumbnails()
}
@@ -472,7 +495,7 @@ final class RemoteSessionModel: NSObject, ObservableObject, CrossDeskRTCBridgeDe
@discardableResult
func configureBridge() -> Bool {
guard hasNetworkConsent else {
signalStatus = "尚未同意联网"
signalStatus = "等待隐私授权"
return false
}
let host = signalHost.trimmingCharacters(in: .whitespacesAndNewlines)
@@ -529,6 +552,7 @@ final class RemoteSessionModel: NSObject, ObservableObject, CrossDeskRTCBridgeDe
}
private func beginRemoteConnection(_ identifier: String) {
networkStatistics = RemoteNetworkStatistics()
resetRemoteVersionCheck()
cancelVideoRecovery()
resetVideoSettings()
@@ -639,7 +663,7 @@ final class RemoteSessionModel: NSObject, ObservableObject, CrossDeskRTCBridgeDe
videoFrame = nil
resetRemoteCursorState()
frameCount = 0
bitrate = 0
networkStatistics = RemoteNetworkStatistics()
displays = ["显示器 1"]
displaySizes = []
selectedDisplay = 0
@@ -794,6 +818,7 @@ final class RemoteSessionModel: NSObject, ObservableObject, CrossDeskRTCBridgeDe
func suspendVideoForBackground() {
videoWasBackgrounded = true
networkStatistics.resetVideo()
cancelVideoRecovery()
}
@@ -906,6 +931,7 @@ final class RemoteSessionModel: NSObject, ObservableObject, CrossDeskRTCBridgeDe
func selectDisplay(_ index: Int) {
guard displays.indices.contains(index) else { return }
selectedDisplay = index
networkStatistics.resetVideo()
videoFrame = nil
resetRemoteCursorState()
frameCount = 0
@@ -1136,15 +1162,19 @@ final class RemoteSessionModel: NSObject, ObservableObject, CrossDeskRTCBridgeDe
func rtcBridge(_ bridge: CrossDeskRTCBridge,
didReceive pixelBuffer: CVPixelBuffer,
width: Int,
height: Int) {
height: Int,
captureUptime: TimeInterval) {
guard hasNetworkConsent, isConnected, !videoWasBackgrounded else { return }
cancelVideoRecovery()
// 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.
videoFrameID &+= 1
videoFrame = RemoteVideoFrame(
pixelBuffer: pixelBuffer,
encodedSize: CGSize(width: width, height: height)
encodedSize: CGSize(width: width, height: height),
id: videoFrameID,
captureUptime: captureUptime
)
frameCount &+= 1
captureRecentThumbnailIfNeeded(pixelBuffer)
@@ -1248,22 +1278,37 @@ final class RemoteSessionModel: NSObject, ObservableObject, CrossDeskRTCBridgeDe
}
func rtcBridge(_ bridge: CrossDeskRTCBridge,
didUpdateBitrate bitsPerSecond: UInt,
lossRate: Float,
usingTURN: Bool) {
bitrate = bitsPerSecond
self.lossRate = lossRate
self.usingTURN = usingTURN
didUpdateNetworkStats stats: CrossDeskNetworkStats) {
guard hasNetworkConsent, isConnected else { return }
func traffic(_ value: CrossDeskTrafficStats) -> RemoteTrafficStatistics {
RemoteTrafficStatistics(inboundBitrate: value.inboundBitrate,
outboundBitrate: value.outboundBitrate,
lossRate: value.lossRate)
}
let mode: RemoteNetworkReport.Mode
switch stats.traversalMode {
case .direct: mode = .direct
case .relay: mode = .relay
default: mode = .unknown
}
networkStatistics.receive(RemoteNetworkReport(
video: traffic(stats.video), audio: traffic(stats.audio),
data: traffic(stats.data), total: traffic(stats.total), mode: mode,
srtpActive: stats.srtpActive.boolValue, rttMilliseconds: stats.rttMilliseconds
), at: ProcessInfo.processInfo.systemUptime)
}
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"
// Called after the exact frame enters AVSampleBufferDisplayLayer. Keep this
// accumulator unpublished: submitting pixels occurs during a SwiftUI update.
func recordVideoFrameSubmission(id: UInt64, captureUptime: TimeInterval) {
guard isConnected, !videoWasBackgrounded, id == videoFrame?.id else { return }
networkStatistics.recordSubmittedFrame(id: id, captureUptime: captureUptime,
at: ProcessInfo.processInfo.systemUptime)
}
var networkSnapshot: RemoteNetworkSnapshot {
guard isConnected else { return RemoteNetworkSnapshot() }
return networkStatistics.displaySnapshot(at: ProcessInfo.processInfo.systemUptime)
}
var videoStatus: String {
+124 -24
View File
@@ -53,7 +53,12 @@ struct RemoteSessionView: View {
videoSize: session.displayGeometrySize
) {
ZStack(alignment: .topLeading) {
NativeVideoView(pixelBuffer: session.pixelBuffer)
NativeVideoView(
pixelBuffer: session.pixelBuffer,
frameID: session.currentVideoFrameID,
captureUptime: session.frameCaptureUptime,
onFrameSubmitted: session.recordVideoFrameSubmission
)
if let cursorPosition,
session.pixelBuffer != nil {
@@ -147,17 +152,6 @@ struct RemoteSessionView: View {
.zIndex(50)
}
}
.overlay(alignment: .top) {
if session.savesConnectionThumbnails {
Label("本地画面预览保存已开启", systemImage: "photo")
.font(.caption)
.foregroundStyle(.white)
.padding(8)
.background(.black.opacity(0.65), in: Capsule())
.padding(.top, 8)
.allowsHitTesting(false)
}
}
.alert("升级提示", isPresented: Binding(
get: { session.remoteUpdateMessage != nil },
set: { if !$0 { session.dismissRemoteUpdate() } }
@@ -1075,7 +1069,10 @@ private struct CrossDeskStatusOrb: View {
private struct FloatingSessionMenu: View {
@ObservedObject var session: RemoteSessionModel
@State private var showingVideoSettings = false
private enum Page { case controls, videoSettings, networkStats }
@State private var page: Page = .controls
@State private var displayedNetworkSnapshot = RemoteNetworkSnapshot()
@State private var displayedFrameSize = CGSize.zero
let showKeyboard: () -> Void
let chooseFile: () -> Void
let close: () -> Void
@@ -1087,16 +1084,16 @@ private struct FloatingSessionMenu: View {
var body: some View {
VStack(spacing: 9) {
HStack(spacing: 8) {
if showingVideoSettings {
if page != .controls {
Button {
showingVideoSettings = false
page = .controls
} label: {
Image(systemName: "chevron.left")
.frame(width: 28, height: 32)
}
.buttonStyle(.plain)
.accessibilityLabel("返回控制栏")
Text("画面设置")
Text(page == .videoSettings ? "画面设置" : "网络状态")
.font(.subheadline.weight(.semibold))
} else {
Circle()
@@ -1120,7 +1117,7 @@ private struct FloatingSessionMenu: View {
.accessibilityLabel("关闭控制栏")
}
if showingVideoSettings, !session.videoSettingsFeedback.isEmpty {
if page == .videoSettings, !session.videoSettingsFeedback.isEmpty {
Text(session.videoSettingsFeedback)
.font(.caption)
.foregroundStyle(.secondary)
@@ -1128,8 +1125,11 @@ private struct FloatingSessionMenu: View {
}
ScrollView {
if showingVideoSettings {
if page == .videoSettings {
FloatingVideoSettings(session: session)
} else if page == .networkStats {
FloatingNetworkStatistics(snapshot: displayedNetworkSnapshot,
frameSize: displayedFrameSize)
} else {
VStack(spacing: 9) {
LazyVGrid(columns: columns, spacing: 7) {
@@ -1154,7 +1154,7 @@ private struct FloatingSessionMenu: View {
.buttonStyle(.plain)
FloatingControlButton(title: "画面设置", symbol: "slider.horizontal.3") {
showingVideoSettings = true
page = .videoSettings
}
FloatingControlButton(
title: session.audioEnabled ? "声音" : "静音",
@@ -1172,6 +1172,9 @@ private struct FloatingSessionMenu: View {
FloatingControlButton(title: "发送文件",
symbol: "folder.badge.plus",
action: chooseFile)
FloatingControlButton(title: "网络状态", symbol: "chart.bar.xaxis") {
page = .networkStats
}
FloatingControlButton(title: "Ctrl+Alt+Del",
symbol: "lock.trianglebadge.exclamationmark",
action: session.bridge.sendSecureAttentionSequence)
@@ -1218,22 +1221,119 @@ private struct FloatingSessionMenu: View {
}
.shadow(color: .black.opacity(0.34), radius: 18, y: 7)
.environment(\.colorScheme, .light)
.task(id: session.selectedDisplay) {
// This task survives ordinary video-frame redraws and is cancelled
// when the menu closes. Rendering only reads the sampled values.
while !Task.isCancelled {
displayedNetworkSnapshot = session.networkSnapshot
displayedFrameSize = session.frameSize
do {
try await Task.sleep(nanoseconds: 1_000_000_000)
} catch {
return
}
}
}
}
private var statusDetail: String {
var components = [session.usingTURN ? "TURN" : "P2P"]
let frameSize = session.frameSize
let snapshot = displayedNetworkSnapshot
var components = [snapshot.connectionMode]
let frameSize = displayedFrameSize
if frameSize.width > 0 && frameSize.height > 0 {
components.append("\(Int(frameSize.width))×\(Int(frameSize.height))")
}
components.append(session.formattedBitrate)
if session.lossRate > 0 {
components.append(String(format: "丢包 %.1f%%", session.lossRate * 100))
components.append(RemoteNetworkSnapshot.bitrate(snapshot.report?.total.inboundBitrate))
if let loss = snapshot.report?.video.lossRate, loss > 0 {
components.append("丢包 \(RemoteNetworkSnapshot.lossRate(loss))")
}
return components.joined(separator: " · ")
}
}
private struct FloatingNetworkStatistics: View {
let snapshot: RemoteNetworkSnapshot
let frameSize: CGSize
var body: some View {
VStack(spacing: 10) {
VStack(spacing: 0) {
trafficRow("", inbound: "接收", outbound: "发送", loss: "丢包率")
.foregroundStyle(.secondary)
trafficRow("视频", statistics: snapshot.report?.video)
trafficRow("音频", statistics: snapshot.report?.audio)
trafficRow("数据", statistics: snapshot.report?.data)
trafficRow("合计", statistics: snapshot.report?.total)
.fontWeight(.semibold)
.background(Color.blue.opacity(0.07), in: RoundedRectangle(cornerRadius: 5))
}
.font(.caption2.monospacedDigit())
Divider()
VStack(spacing: 7) {
detail("帧率", value: "\(snapshot.framesPerSecond) FPS")
detail("分辨率", value: resolution)
detail("画面延时", value: RemoteNetworkSnapshot.latency(snapshot.videoLatencyMilliseconds))
detail("连接延时(RTT)", value: RemoteNetworkSnapshot.latency(snapshot.rttMilliseconds))
detail("连接方式", value: snapshot.connectionMode)
HStack {
Text("媒体加密")
.foregroundStyle(.secondary)
Spacer(minLength: 8)
if let report = snapshot.report {
Label(report.srtpActive ? "SRTP 已启用" : "未启用",
systemImage: report.srtpActive ? "lock.shield" : "lock.open")
.foregroundStyle(report.srtpActive ? Color.green : Color.secondary)
} else {
Text("—")
}
}
.accessibilityElement(children: .combine)
}
.font(.caption.monospacedDigit())
}
.padding(.vertical, 2)
}
private var resolution: String {
guard frameSize.width > 0, frameSize.height > 0 else { return "—" }
return "\(Int(frameSize.width)) × \(Int(frameSize.height))"
}
private func detail(_ title: String, value: String) -> some View {
HStack {
Text(title).foregroundStyle(.secondary)
Spacer(minLength: 8)
Text(value)
}
.accessibilityElement(children: .combine)
}
private func trafficRow(_ title: String, statistics: RemoteTrafficStatistics?) -> some View {
trafficRow(title,
inbound: RemoteNetworkSnapshot.bitrate(statistics?.inboundBitrate),
outbound: RemoteNetworkSnapshot.bitrate(statistics?.outboundBitrate),
loss: RemoteNetworkSnapshot.lossRate(statistics?.lossRate))
.accessibilityLabel("\(title),接收 \(RemoteNetworkSnapshot.bitrate(statistics?.inboundBitrate)),发送 \(RemoteNetworkSnapshot.bitrate(statistics?.outboundBitrate)),丢包率 \(RemoteNetworkSnapshot.lossRate(statistics?.lossRate))")
}
private func trafficRow(_ title: String, inbound: String,
outbound: String, loss: String) -> some View {
HStack(spacing: 4) {
Text(title).frame(width: 28, alignment: .leading)
Text(inbound).frame(maxWidth: .infinity, alignment: .trailing)
Text(outbound).frame(maxWidth: .infinity, alignment: .trailing)
Text(loss).frame(width: 46, alignment: .trailing)
}
.lineLimit(1)
.minimumScaleFactor(0.75)
.padding(.horizontal, 4)
.padding(.vertical, 5)
.accessibilityElement(children: .combine)
}
}
private struct FloatingVideoSettings: View {
@ObservedObject var session: RemoteSessionModel
@@ -12,13 +12,18 @@ import UIKit
struct NativeVideoView: UIViewRepresentable {
@Environment(\.scenePhase) private var scenePhase
let pixelBuffer: CVPixelBuffer?
let frameID: UInt64
let captureUptime: TimeInterval
let onFrameSubmitted: (UInt64, TimeInterval) -> Void
func makeUIView(context: Context) -> SampleBufferVideoView {
SampleBufferVideoView(frame: .zero)
}
func updateUIView(_ uiView: SampleBufferVideoView, context: Context) {
uiView.display(pixelBuffer, isActive: scenePhase == .active)
if uiView.display(pixelBuffer, isActive: scenePhase == .active) {
onFrameSubmitted(frameID, captureUptime)
}
}
}
@@ -57,7 +62,10 @@ final class SampleBufferVideoView: UIView {
videoLayer.videoGravity = .resize
}
func display(_ pixelBuffer: CVPixelBuffer?, isActive: Bool) {
/// True only when a new buffer was submitted; no presentation timestamp is
/// exposed by this display layer, so latency ends at submission like the
/// desktop renderer's submission fallback.
func display(_ pixelBuffer: CVPixelBuffer?, isActive: Bool) -> Bool {
if renderingActive != isActive {
renderingActive = isActive
// Backgrounding can invalidate the display layer without changing
@@ -72,9 +80,9 @@ final class SampleBufferVideoView: UIView {
submittedFrames = 0
droppedFrames = 0
videoLayer.flushAndRemoveImage()
return
return false
}
guard renderingActive else { return }
guard renderingActive else { return false }
// Check recovery before deduplicating frames: a failed layer may need
// to display the same buffer again even if no new decoded frame arrives.
@@ -87,7 +95,7 @@ final class SampleBufferVideoView: UIView {
// SwiftUI can refresh for unrelated status fields. Only deduplicate a
// frame after it was actually submitted to a healthy, active layer.
guard lastPixelBuffer !== pixelBuffer else { return }
guard lastPixelBuffer !== pixelBuffer else { return false }
// Never let AVSampleBufferDisplayLayer turn temporary rendering
// pressure into seconds of latency. Its queued buffers are already
@@ -101,7 +109,7 @@ final class SampleBufferVideoView: UIView {
droppedFrames)
}
}
guard videoLayer.isReadyForMoreMediaData else { return }
guard videoLayer.isReadyForMoreMediaData else { return false }
var formatDescription: CMVideoFormatDescription?
guard CMVideoFormatDescriptionCreateForImageBuffer(
@@ -110,7 +118,7 @@ final class SampleBufferVideoView: UIView {
formatDescriptionOut: &formatDescription
) == noErr, let formatDescription else {
NSLog("CrossDesk could not create a video format description")
return
return false
}
var timing = CMSampleTimingInfo(
@@ -127,7 +135,7 @@ final class SampleBufferVideoView: UIView {
sampleBufferOut: &sampleBuffer
) == noErr, let sampleBuffer else {
NSLog("CrossDesk could not create a video sample buffer")
return
return false
}
if let attachments = CMSampleBufferGetSampleAttachmentsArray(
@@ -162,5 +170,6 @@ final class SampleBufferVideoView: UIView {
self.videoLayer.error?.localizedDescription ?? "none")
}
}
return true
}
}
+30 -8
View File
@@ -176,15 +176,22 @@ AV1 factories' choice of SVT-AV1 and dav1d.
## Privacy choices
A fresh install or an upgrade without a prior choice shows the privacy notice
before signaling registration. Declining leaves settings, local history and the
bundled privacy policy available. Settings → Privacy → Privacy and Authorization shows consent status and can
A launch without stored consent shows the privacy notice before signaling
registration, including after a previous refusal or withdrawal. Returning from
the background also shows it until consent is granted. Declining dismisses it
for the current visit and leaves settings, local history and the bundled
privacy policy available. Settings → Privacy → Privacy and Authorization shows consent status and can
withdraw consent to stop both the persistent identity peer and remote session. Consent is required
again before reconnecting; foregrounding or relaunching does not grant it.
The notice opens as a native large sheet with a scrolling document above a fixed
consent area. The agreement checkbox starts unchecked; **同意并继续** stays
disabled until it is selected and the bundled policy is available.
**暂不同意** dismisses the sheet for this visit. Swipe-to-dismiss is disabled
so either action is explicit.
Remote desktop previews default to off, including on upgrade. Enabling and
confirming saving in Settings → Privacy applies from the next connection. A
session indicator remains visible while saving is enabled. Previews stay local
confirming saving in Settings → Privacy applies from the next connection. Previews stay local
and are excluded from device backups. Disabling saving or withdrawing consent
clears them; clearing is serialized after pending writes to prevent resurrection.
Old previews saved without this choice are removed on upgrade.
@@ -196,9 +203,11 @@ build. Test the features in this order so a media problem is not confused with
a data-channel problem:
1. **Video:** after the session connects, the waiting panel should be replaced
by the remote desktop. Tap the floating icon to see the connection state,
P2P/TURN mode, decoded resolution, and bitrate. The current menu does not
display a frame counter.
by the remote desktop. Tap the floating icon, then **网络状态** (Network
status), to see video/audio/data/total receive and send bitrates, receive
loss rates, submitted FPS, decoded resolution, video delay, connection RTT,
P2P/TURN mode, and the actual SRTP encryption state. Scroll the panel in
either orientation to see all metrics.
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`.
@@ -215,6 +224,19 @@ a data-channel problem:
app's `Documents/Received` directory and can be exported with the share
button beside the transfer status or Finder's Files tab.
Network status samples and caches displayed measurements once per second while
the panel is open; video-frame redraws reuse that snapshot. Traffic and connection RTT
expire after three seconds without a valid report; RTT uses the desktop's
0.25-weight smoothing. FPS counts distinct frames submitted to the native
display layer during the last second. Video delay averages calibrated capture
time through display submission over that same window; it is an estimate, not
a measurement of physical screen presentation. Missing calibration or stale
samples show **—**, and idle video reaches zero FPS. Switching displays or
backgrounding clears video samples; disconnecting clears all statistics.
Receive loss rates are fractions converted to percentages. As on desktop,
MiniRTC's total loss is the sum of the media loss rates, not a packet-weighted
overall loss rate.
For black-screen diagnosis, keep Xcode's debug console open and look for
`CrossDesk` and `VideoToolbox` messages. With VideoToolbox H.264 decoding selected, look for
`VideoToolbox decoded frame` from MiniRTC and `CrossDesk delivered latest frame`
+193
View File
@@ -0,0 +1,193 @@
"""Exercise production statistics with deterministic monotonic time."""
from pathlib import Path
import subprocess
import sys
import tempfile
import unittest
@unittest.skipUnless(sys.platform == "darwin", "requires the Apple Swift runtime")
class NetworkStatisticsTest(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.temp = tempfile.TemporaryDirectory(prefix="crossdesk-network-statistics-")
cls.addClassCleanup(cls.temp.cleanup)
root = Path(cls.temp.name)
harness = root / "NetworkStatisticsTests.swift"
harness.write_text(r'''
import Foundation
func expect(_ condition: @autoclosure () -> Bool, _ message: String) {
if !condition() { fatalError(message) }
}
func near(_ value: Double?, _ expected: Double) -> Bool {
guard let value else { return false }
return abs(value - expected) < 0.0001
}
@main
enum NetworkStatisticsTests {
static func main() {
var state = RemoteNetworkStatistics()
var report = RemoteNetworkReport(
video: RemoteTrafficStatistics(inboundBitrate: 8_000_000, outboundBitrate: 0, lossRate: 0.02),
audio: RemoteTrafficStatistics(inboundBitrate: 64_000, outboundBitrate: 0, lossRate: 0.01),
data: RemoteTrafficStatistics(inboundBitrate: 2_000, outboundBitrate: 3_000, lossRate: 0.03),
total: RemoteTrafficStatistics(inboundBitrate: 8_066_000, outboundBitrate: 3_000, lossRate: 0.06),
mode: .relay, srtpActive: true, rttMilliseconds: 20
)
let test = CommandLine.arguments[1]
if test == "report" {
expect(state.snapshot(at: 10).report == nil, "No measurements before a report")
expect(state.snapshot(at: 10).connectionMode == "—", "Unknown is not P2P")
state.receive(report, at: 10)
let snapshot = state.snapshot(at: 10)
expect(snapshot.report?.video.inboundBitrate == 8_000_000, "Video rate survives")
expect(snapshot.report?.audio.inboundBitrate == 64_000, "Audio rate survives")
expect(snapshot.report?.data.outboundBitrate == 3_000, "Data upload survives")
expect(snapshot.report?.total.inboundBitrate == 8_066_000, "Total is transport-provided")
expect(snapshot.report?.audio.lossRate == 0.01, "Media loss stays separate")
expect(snapshot.report?.srtpActive == true && snapshot.connectionMode == "TURN 中继", "Actual security and route")
report.mode = .direct
report.srtpActive = false
state.receive(report, at: 11)
expect(state.snapshot(at: 11).connectionMode == "P2P 直连", "Route updates")
expect(state.snapshot(at: 11).report?.srtpActive == false, "Security is not inferred from connection")
} else if test == "expiry" {
state.receive(report, at: 10)
expect(state.snapshot(at: 12.99).report != nil, "One missed report does not flicker")
let expired = state.snapshot(at: 13)
expect(expired.report == nil && expired.rttMilliseconds == nil, "Stalled reports expire")
expect(expired.connectionMode == "—", "Stale route expires")
state = RemoteNetworkStatistics()
expect(state.snapshot(at: 13).report == nil, "Reconnect clears prior peer")
} else if test == "rtt" {
state.receive(report, at: 10)
report.rttMilliseconds = 100
state.receive(report, at: 11)
expect(near(state.snapshot(at: 11).rttMilliseconds, 40), "Desktop EWMA alpha is 0.25")
for invalid in [-1.0, Double.nan, Double.infinity, 2_001] {
report.rttMilliseconds = invalid
state.receive(report, at: 12)
expect(near(state.snapshot(at: 12).rttMilliseconds, 40), "Invalid RTT cannot contaminate average")
}
expect(state.snapshot(at: 14).rttMilliseconds == nil, "Invalid reports cannot keep RTT alive")
expect(state.snapshot(at: 14).report != nil, "Traffic and RTT expire independently")
report.rttMilliseconds = 0
state.receive(report, at: 15)
expect(near(state.snapshot(at: 15).rttMilliseconds, 0), "Zero RTT is valid; expired average is replaced")
} else if test == "frames" {
state.recordSubmittedFrame(id: 1, captureUptime: 9.9, at: 10)
state.recordSubmittedFrame(id: 1, captureUptime: 9.9, at: 10.1)
state.recordSubmittedFrame(id: 2, captureUptime: 10, at: 10.2)
let snapshot = state.snapshot(at: 10.3)
expect(snapshot.framesPerSecond == 2, "Redrawing a frame is not FPS")
expect(near(snapshot.videoLatencyMilliseconds, 150), "Average includes time through display submission")
expect(state.snapshot(at: 11).framesPerSecond == 1, "One-second rolling window")
expect(state.snapshot(at: 11.3).framesPerSecond == 0, "Idle video must reach zero FPS")
expect(state.snapshot(at: 11.3).videoLatencyMilliseconds == nil, "Idle latency expires")
} else if test == "video_reset" {
state.receive(report, at: 10)
state.recordSubmittedFrame(id: 1, captureUptime: 9.9, at: 10)
state.resetVideo()
state.recordSubmittedFrame(id: 1, captureUptime: 9.9, at: 10.1)
expect(state.snapshot(at: 10.1).framesPerSecond == 0, "Foreground cached frame is not counted again")
expect(state.snapshot(at: 10.1).report != nil, "Switching displays preserves transport stats")
state.recordSubmittedFrame(id: 2, captureUptime: 0, at: 10.2)
state.recordSubmittedFrame(id: 3, captureUptime: 12, at: 10.3)
state.recordSubmittedFrame(id: 4, captureUptime: 1, at: 10.4)
state.recordSubmittedFrame(id: 5, captureUptime: .nan, at: 10.5)
expect(state.snapshot(at: 10.5).framesPerSecond == 4, "Uncalibrated frames still count for FPS")
expect(state.snapshot(at: 10.5).videoLatencyMilliseconds == nil, "Invalid capture estimates are unavailable")
state.recordSubmittedFrame(id: 6, captureUptime: 10.5, at: 10.6)
expect(near(state.snapshot(at: 10.6).videoLatencyMilliseconds, 100), "Calibration recovers without invalid samples")
} else if test == "display_cadence" {
state.receive(report, at: 10)
state.recordSubmittedFrame(id: 1, captureUptime: 9.9, at: 10)
let first = state.displaySnapshot(at: 10)
expect(near(first.videoLatencyMilliseconds, 100), "Initial display sample")
for frame in 1...59 {
let now = 10 + Double(frame) / 60
let delay = frame.isMultiple(of: 2) ? 0.3 : 0.2
state.recordSubmittedFrame(id: UInt64(frame + 1), captureUptime: now - delay, at: now)
report.rttMilliseconds = 100
report.total.inboundBitrate = 9_000_000
state.receive(report, at: now)
let displayed = state.displaySnapshot(at: now)
expect(near(displayed.videoLatencyMilliseconds, 100), "60 FPS callbacks cannot refresh the displayed delay")
expect(displayed.framesPerSecond == first.framesPerSecond, "FPS is sampled with delay")
expect(near(displayed.rttMilliseconds, 20), "RTT is frozen between ticks")
expect(displayed.report?.total.inboundBitrate == 8_066_000, "Reports are sampled with video stats")
}
let next = state.displaySnapshot(at: 11)
expect(next.framesPerSecond == 59, "The next tick samples all recent frames")
expect((next.videoLatencyMilliseconds ?? 0) > 200, "Delay updates at the one-second boundary")
expect(next.report?.total.inboundBitrate == 9_000_000, "The next tick sees current traffic")
let idle = state.displaySnapshot(at: 12)
expect(idle.framesPerSecond == 0 && idle.videoLatencyMilliseconds == nil, "Timer ticks expire idle video without new frames")
let expired = state.displaySnapshot(at: 14)
expect(expired.report == nil && expired.rttMilliseconds == nil, "Timer ticks expire missing reports")
} else if test == "display_reset" {
state.receive(report, at: 10)
state.recordSubmittedFrame(id: 1, captureUptime: 9.9, at: 10)
_ = state.displaySnapshot(at: 10)
state.resetVideo()
let switched = state.displaySnapshot(at: 10.1)
expect(switched.framesPerSecond == 0 && switched.videoLatencyMilliseconds == nil, "Switching displays clears cached video immediately")
expect(switched.report != nil, "Video reset keeps transport state")
state = RemoteNetworkStatistics()
let reconnected = state.displaySnapshot(at: 10.2)
expect(reconnected.report == nil && reconnected.videoLatencyMilliseconds == nil, "A new session cannot reuse the old display cache")
} else if test == "format" {
expect(RemoteNetworkSnapshot.bitrate(nil) == "—", "Unavailable is not zero")
expect(RemoteNetworkSnapshot.bitrate(0) == "0 bps", "Zero traffic is valid")
expect(RemoteNetworkSnapshot.bitrate(64_000) == "64 Kbps", "Bitrate units")
expect(RemoteNetworkSnapshot.bitrate(8_000_000) == "8.0 Mbps", "Mbps units")
expect(RemoteNetworkSnapshot.latency(0) == "<1 ms", "Sub-millisecond RTT")
expect(RemoteNetworkSnapshot.latency(nil) == "—", "Missing calibration")
expect(RemoteNetworkSnapshot.lossRate(0.005) == "0.5%", "Small losses remain visible")
expect(RemoteNetworkSnapshot.lossRate(1.2) == "120.0%", "Desktop aggregate is not silently clamped")
expect(RemoteNetworkSnapshot.lossRate(.nan) == "—", "Nonfinite loss is unavailable")
} else {
fatalError("Unknown test")
}
}
}
''')
source = Path(__file__).resolve().parents[1] / "CrossDeskMobile/RemoteNetworkStatistics.swift"
cls.binary = root / "network-statistics-tests"
subprocess.run(["xcrun", "swiftc", str(source), str(harness), "-o", str(cls.binary)],
check=True, capture_output=True, text=True)
def test_channel_and_security_reports(self):
self.run_case("report")
def test_missing_reports_and_reconnect(self):
self.run_case("expiry")
def test_rtt_smoothing_and_recovery(self):
self.run_case("rtt")
def test_frame_submission_and_idle_video(self):
self.run_case("frames")
def test_display_switch_and_invalid_capture_time(self):
self.run_case("video_reset")
def test_units_and_unavailable_values(self):
self.run_case("format")
def test_display_refresh_is_independent_of_video_frames(self):
self.run_case("display_cadence")
def test_display_cache_resets_with_video_and_session(self):
self.run_case("display_reset")
def run_case(self, name):
subprocess.run([str(self.binary), name], check=True, capture_output=True, text=True)
if __name__ == "__main__":
unittest.main()
+35 -2
View File
@@ -41,26 +41,56 @@ enum PrivacyTests {
forKey: "crossdesk.mobile.recent-connections.v1")
}
let preferences = PrivacyPreferences(defaults: defaults)
expect(!preferences.hasMadeNetworkChoice, "New/legacy install has no choice")
expect(PrivacyNoticeState(hasNetworkConsent: preferences.hasNetworkConsent).isVisible,
"New/legacy install must show the policy")
expect(!preferences.hasNetworkConsent, "Must not start networking")
expect(!preferences.savesThumbnails, "Saving must default to off")
preferences.setSavesThumbnails(true)
expect(!preferences.savesThumbnails, "Cannot enable captures without consent")
preferences.setNetworkConsent(false)
let declined = PrivacyPreferences(defaults: defaults)
expect(declined.hasMadeNetworkChoice, "Decline must persist across launches")
expect(!declined.hasNetworkConsent, "Decline must not allow networking")
expect(PrivacyNoticeState(hasNetworkConsent: declined.hasNetworkConsent).isVisible,
"Relaunch after refusal must show the policy again")
declined.setNetworkConsent(true)
expect(!declined.savesThumbnails, "Network consent is not screenshot consent")
declined.setSavesThumbnails(true)
let relaunched = PrivacyPreferences(defaults: defaults)
expect(relaunched.hasNetworkConsent && relaunched.savesThumbnails,
"Explicit choices must survive relaunch")
expect(!PrivacyNoticeState(hasNetworkConsent: relaunched.hasNetworkConsent).isVisible,
"Relaunch after agreement must not prompt again")
relaunched.setNetworkConsent(false)
expect(!relaunched.hasNetworkConsent && !relaunched.savesThumbnails,
"Withdrawal revokes both permissions")
expect(PrivacyNoticeState(hasNetworkConsent: relaunched.hasNetworkConsent).isVisible,
"Relaunch after withdrawal must show the policy")
relaunched.setNetworkConsent(true)
expect(!relaunched.savesThumbnails, "Re-consent must not re-enable screenshots")
} else if test == "notice_lifecycle" {
var notice = PrivacyNoticeState(hasNetworkConsent: false)
expect(notice.isVisible, "No consent must prompt on cold launch")
notice.didBecomeActive(hasNetworkConsent: false)
notice.dismiss()
notice.didBecomeActive(hasNetworkConsent: false)
expect(!notice.isVisible, "Refusal must not immediately reopen the notice")
notice.didBecomeActive(hasNetworkConsent: false)
expect(!notice.isVisible, "Transient inactivity or duplicate active callbacks are not another visit")
for _ in 0..<3 {
notice.didEnterBackground()
notice.didBecomeActive(hasNetworkConsent: false)
expect(notice.isVisible, "Every background/foreground round trip must prompt without consent")
notice.dismiss()
}
notice.show()
expect(notice.isVisible, "The status button can still open the policy manually")
notice.dismiss()
notice.didEnterBackground()
notice.didBecomeActive(hasNetworkConsent: true)
expect(!notice.isVisible, "Granting consent stops automatic prompts")
notice.didEnterBackground()
notice.didBecomeActive(hasNetworkConsent: false)
expect(notice.isVisible, "Withdrawal restores the next-visit prompt")
} else {
let directory = FileManager.default.temporaryDirectory
.appendingPathComponent("crossdesk-thumbnails-\(UUID().uuidString)")
@@ -129,6 +159,9 @@ enum PrivacyTests {
def test_upgrade_requires_fresh_choices(self):
self.run_case("upgrade")
def test_notice_on_each_unconsented_visit(self):
self.run_case("notice_lifecycle")
def test_clear_wins_over_in_flight_capture(self):
self.run_case("clear")