mirror of
https://github.com/kunkundi/crossdesk.git
synced 2026-10-18 09:53:05 +08:00
[feat] expand iOS network status and refine privacy prompts
This commit is contained in:
@@ -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