mirror of
https://github.com/kunkundi/crossdesk.git
synced 2026-10-16 00:33:22 +08:00
[feat] expand iOS network status and refine privacy prompts
This commit is contained in:
+2
-2
@@ -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];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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`
|
||||
|
||||
@@ -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()
|
||||
@@ -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")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user