[feat] expand iOS network status and refine privacy prompts

This commit is contained in:
dijunkun
2026-09-28 23:26:03 +08:00
parent 163506f81e
commit f8535a797b
14 changed files with 820 additions and 209 deletions
@@ -44,6 +44,29 @@ typedef NS_ENUM(NSInteger, CrossDeskVideoAdaptationPolicy) {
@class CrossDeskRTCBridge;
typedef NS_ENUM(NSInteger, CrossDeskTraversalMode) {
CrossDeskTraversalModeUnknown = 0,
CrossDeskTraversalModeDirect,
CrossDeskTraversalModeRelay,
};
typedef struct {
uint32_t inboundBitrate;
uint32_t outboundBitrate;
float lossRate;
} CrossDeskTrafficStats;
/// Value snapshot: no MiniRTC callback-owned memory crosses the main queue.
typedef struct {
CrossDeskTrafficStats video;
CrossDeskTrafficStats audio;
CrossDeskTrafficStats data;
CrossDeskTrafficStats total;
double rttMilliseconds;
BOOL srtpActive;
CrossDeskTraversalMode traversalMode;
} CrossDeskNetworkStats;
@protocol CrossDeskRTCBridgeDelegate <NSObject>
@optional
- (void)rtcBridge:(CrossDeskRTCBridge *)bridge
@@ -58,7 +81,8 @@ typedef NS_ENUM(NSInteger, CrossDeskVideoAdaptationPolicy) {
- (void)rtcBridge:(CrossDeskRTCBridge *)bridge
didReceivePixelBuffer:(CVPixelBufferRef)pixelBuffer
width:(NSInteger)width
height:(NSInteger)height;
height:(NSInteger)height
captureUptime:(NSTimeInterval)captureUptime;
- (void)rtcBridge:(CrossDeskRTCBridge *)bridge
didReceiveHostName:(NSString *)hostName
appVersion:(NSString *)appVersion
@@ -96,9 +120,8 @@ typedef NS_ENUM(NSInteger, CrossDeskVideoAdaptationPolicy) {
- (void)rtcBridge:(CrossDeskRTCBridge *)bridge
didReceiveFileAtURL:(NSURL *)fileURL;
- (void)rtcBridge:(CrossDeskRTCBridge *)bridge
didUpdateBitrate:(NSUInteger)bitsPerSecond
lossRate:(float)lossRate
usingTURN:(BOOL)usingTURN;
didUpdateNetworkStats:(CrossDeskNetworkStats)stats
NS_SWIFT_NAME(rtcBridge(_:didUpdateNetworkStats:));
@end
/// Objective-C++ boundary around the native MiniRTC C API.
@@ -325,7 +325,8 @@ void DispatchMain(dispatch_block_t block) {
sourceIDSize:(size_t)sourceIDSize;
- (void)enqueueVideoPixelBuffer:(CVPixelBufferRef)pixelBuffer
width:(NSInteger)width
height:(NSInteger)height;
height:(NSInteger)height
captureUptime:(NSTimeInterval)captureUptime;
- (void)resetVideoDelivery;
- (void)notifyVideoSettings:(crossdesk::VideoSettings)settings
generation:(uint64_t)generation;
@@ -335,7 +336,9 @@ void DispatchMain(dispatch_block_t block) {
sourceID:(const char *)sourceID
sourceIDSize:(size_t)sourceIDSize
generation:(uint64_t)generation;
- (void)handleStats:(const MiniRtcNetTrafficStats *)stats mode:(TraversalMode)mode;
- (void)handleStats:(const MiniRtcNetTrafficStats *)stats
mode:(TraversalMode)mode
generation:(uint64_t)generation;
- (void)sendMessage:(const std::string &)message
reliable:(BOOL)reliable
stream:(const char *)stream;
@@ -433,7 +436,7 @@ void OnNetworkStats(const char *peer_id, size_t peer_id_size,
generation:context->generation];
} else if (context->role == PeerRole::Controller && stats &&
[owner isControllerGenerationActive:context->generation]) {
[owner handleStats:stats mode:mode];
[owner handleStats:stats mode:mode generation:context->generation];
}
}
@@ -481,6 +484,7 @@ Params MakeParams(const RTCState &state, const std::string &user_id,
CVPixelBufferRef _pendingVideoBuffer;
NSInteger _pendingVideoWidth;
NSInteger _pendingVideoHeight;
NSTimeInterval _pendingVideoCaptureUptime;
bool _videoDeliveryScheduled;
uint64_t _videoDeliveryGeneration;
std::mutex _fileMutex;
@@ -522,6 +526,7 @@ Params MakeParams(const RTCState &state, const std::string &user_id,
_pendingVideoBuffer = nullptr;
_pendingVideoWidth = 0;
_pendingVideoHeight = 0;
_pendingVideoCaptureUptime = 0;
_videoDeliveryScheduled = false;
_videoDeliveryGeneration = 0;
_selectedDisplay.store(0);
@@ -1399,6 +1404,17 @@ Params MakeParams(const RTCState &state, const std::string &user_id,
static_cast<size_t>(_selectedDisplay.load()));
if (source != expected) return;
}
// Translate the transport's calibrated capture time once, while the peer is
// alive. Carry it with the exact pixels through coalescing and UI submission.
const NSTimeInterval now = NSProcessInfo.processInfo.systemUptime;
const int64_t peer_now = GetSystemTimeMicros(_state->controller_peer);
NSTimeInterval capture_uptime = 0;
if (frame->captured_timestamp > 0 && peer_now > 0 &&
frame->captured_timestamp <= static_cast<uint64_t>(peer_now) &&
static_cast<uint64_t>(peer_now) - frame->captured_timestamp <= 5'000'000) {
capture_uptime = now -
(static_cast<uint64_t>(peer_now) - frame->captured_timestamp) / 1'000'000.0;
}
const MiniRtcNativeVideoFrame *native_frame = frame->native_frame;
const bool has_native_pixel_buffer =
native_frame &&
@@ -1412,7 +1428,8 @@ Params MakeParams(const RTCState &state, const std::string &user_id,
native_frame->payload.cv_pixel_buffer);
[self enqueueVideoPixelBuffer:pixel_buffer
width:frame->width
height:frame->height];
height:frame->height
captureUptime:capture_uptime];
return;
}
@@ -1504,13 +1521,15 @@ Params MakeParams(const RTCState &state, const std::string &user_id,
[self enqueueVideoPixelBuffer:pixel_buffer
width:frame_width
height:frame_height];
height:frame_height
captureUptime:capture_uptime];
CVPixelBufferRelease(pixel_buffer);
}
- (void)enqueueVideoPixelBuffer:(CVPixelBufferRef)pixelBuffer
width:(NSInteger)width
height:(NSInteger)height {
height:(NSInteger)height
captureUptime:(NSTimeInterval)captureUptime {
if (!pixelBuffer) return;
CVPixelBufferRetain(pixelBuffer);
@@ -1524,6 +1543,7 @@ Params MakeParams(const RTCState &state, const std::string &user_id,
_pendingVideoBuffer = pixelBuffer;
_pendingVideoWidth = width;
_pendingVideoHeight = height;
_pendingVideoCaptureUptime = captureUptime;
generation = _videoDeliveryGeneration;
if (!_videoDeliveryScheduled) {
_videoDeliveryScheduled = true;
@@ -1536,15 +1556,18 @@ Params MakeParams(const RTCState &state, const std::string &user_id,
CVPixelBufferRef latest_buffer = nullptr;
NSInteger latest_width = 0;
NSInteger latest_height = 0;
NSTimeInterval latest_capture_uptime = 0;
{
std::lock_guard<std::mutex> lock(self->_pendingVideoMutex);
if (generation != self->_videoDeliveryGeneration) return;
latest_buffer = self->_pendingVideoBuffer;
latest_width = self->_pendingVideoWidth;
latest_height = self->_pendingVideoHeight;
latest_capture_uptime = self->_pendingVideoCaptureUptime;
self->_pendingVideoBuffer = nullptr;
self->_pendingVideoWidth = 0;
self->_pendingVideoHeight = 0;
self->_pendingVideoCaptureUptime = 0;
self->_videoDeliveryScheduled = false;
}
@@ -1557,11 +1580,12 @@ Params MakeParams(const RTCState &state, const std::string &user_id,
}
id<CrossDeskRTCBridgeDelegate> delegate = self.delegate;
if ([delegate respondsToSelector:
@selector(rtcBridge:didReceivePixelBuffer:width:height:)]) {
@selector(rtcBridge:didReceivePixelBuffer:width:height:captureUptime:)]) {
[delegate rtcBridge:self
didReceivePixelBuffer:latest_buffer
width:latest_width
height:latest_height];
height:latest_height
captureUptime:latest_capture_uptime];
}
CVPixelBufferRelease(latest_buffer);
});
@@ -1576,6 +1600,7 @@ Params MakeParams(const RTCState &state, const std::string &user_id,
_pendingVideoBuffer = nullptr;
_pendingVideoWidth = 0;
_pendingVideoHeight = 0;
_pendingVideoCaptureUptime = 0;
_videoDeliveryScheduled = false;
}
if (pending_buffer) CVPixelBufferRelease(pending_buffer);
@@ -1868,19 +1893,31 @@ Params MakeParams(const RTCState &state, const std::string &user_id,
}
}
- (void)handleStats:(const MiniRtcNetTrafficStats *)stats mode:(TraversalMode)mode {
- (void)handleStats:(const MiniRtcNetTrafficStats *)stats
mode:(TraversalMode)mode
generation:(uint64_t)generation {
if (!stats) return;
const NSUInteger bitrate = stats->total_inbound_stats.bitrate;
const float loss = stats->video_inbound_stats.loss_rate;
const BOOL using_turn = mode == TraversalMode::Relay;
const auto traffic = [](const MiniRtcInboundStats &inbound,
const MiniRtcOutboundStats &outbound) {
return CrossDeskTrafficStats{inbound.bitrate, outbound.bitrate,
inbound.loss_rate};
};
const CrossDeskNetworkStats snapshot{
traffic(stats->video_inbound_stats, stats->video_outbound_stats),
traffic(stats->audio_inbound_stats, stats->audio_outbound_stats),
traffic(stats->data_inbound_stats, stats->data_outbound_stats),
traffic(stats->total_inbound_stats, stats->total_outbound_stats),
stats->rtt_ms, static_cast<BOOL>(stats->srtp_active),
mode == TraversalMode::P2P ? CrossDeskTraversalModeDirect
: mode == TraversalMode::Relay ? CrossDeskTraversalModeRelay
: CrossDeskTraversalModeUnknown};
DispatchMain(^{
// Disconnect/reconnect can happen while this report waits on the UI queue.
if (![self isControllerGenerationActive:generation]) return;
id<CrossDeskRTCBridgeDelegate> delegate = self.delegate;
if ([delegate respondsToSelector:
@selector(rtcBridge:didUpdateBitrate:lossRate:usingTURN:)]) {
[delegate rtcBridge:self
didUpdateBitrate:bitrate
lossRate:loss
usingTURN:using_turn];
@selector(rtcBridge:didUpdateNetworkStats:)]) {
[delegate rtcBridge:self didUpdateNetworkStats:snapshot];
}
});
}
+54 -45
View File
@@ -6,30 +6,30 @@ struct ContentView: View {
@ObservedObject var session: RemoteSessionModel
var body: some View {
ZStack {
Group {
if session.sessionVisible {
RemoteSessionView(session: session)
.transition(.opacity)
} else {
NavigationStack {
ConnectionHomeView(session: session)
.navigationTitle("首页")
.toolbar(.hidden, for: .navigationBar)
}
Group {
if session.sessionVisible {
RemoteSessionView(session: session)
.transition(.opacity)
} else {
NavigationStack {
ConnectionHomeView(session: session)
.navigationTitle("首页")
.toolbar(.hidden, for: .navigationBar)
}
}
.allowsHitTesting(!session.privacyNoticeVisible)
.accessibilityHidden(session.privacyNoticeVisible)
if session.privacyNoticeVisible {
PrivacyConsentView(session: session)
.transition(.opacity)
.transition(.opacity)
}
}
.animation(.easeInOut(duration: 0.2), value: session.sessionVisible)
.preferredColorScheme(session.sessionVisible ? .dark : .light)
.sheet(isPresented: Binding(
get: { session.privacyNoticeVisible },
set: { if !$0 { session.dismissPrivacyNotice() } }
)) {
PrivacyConsentView(session: session)
.presentationDetents([.large])
.presentationDragIndicator(.hidden)
.interactiveDismissDisabled()
}
.alert("连接提示", isPresented: Binding(
get: { session.connectionFailureMessage != nil },
set: { if !$0 { session.dismissConnectionFailure() } }
@@ -43,17 +43,14 @@ struct ContentView: View {
AppOrientation.update(to: .portrait)
}
if scenePhase == .active {
session.refreshRecentConnectionPresenceAfterForeground()
session.resumeVideoAfterForeground()
session.applicationDidBecomeActive()
}
}
.onChange(of: scenePhase) { phase in
if phase == .active {
session.refreshRecentConnectionPresenceAfterForeground()
session.resumeVideoAfterForeground()
session.applicationDidBecomeActive()
} else if phase == .background {
session.suspendVideoForBackground()
session.suspendPresenceMonitoring()
session.applicationDidEnterBackground()
}
}
}
@@ -182,7 +179,6 @@ private struct ConnectionHomeView: View {
}
private var signalSymbol: String {
if !session.hasNetworkConsent { return "hand.raised" }
if signalIsConnected { return "checkmark.circle.fill" }
if signalHasError { return "exclamationmark.triangle.fill" }
return "arrow.triangle.2.circlepath"
@@ -267,21 +263,17 @@ private struct ConnectionHomeView: View {
private var header: some View {
HStack(spacing: 8) {
Label {
Text(session.signalStatus)
.font(.caption.weight(.semibold))
.lineLimit(1)
} icon: {
Image(systemName: signalSymbol)
.font(.caption.weight(.bold))
}
.foregroundStyle(signalTint)
.padding(.horizontal, 11)
.frame(height: 34)
.background(signalTint.opacity(0.11), in: Capsule())
.overlay {
Capsule()
.stroke(signalTint.opacity(0.22), lineWidth: 1)
if session.hasNetworkConsent {
signalStatusBadge
} else {
Button {
remoteIDFocused = false
session.showPrivacyNotice()
} label: {
signalStatusBadge
}
.buttonStyle(.plain)
.accessibilityHint("打开隐私政策")
}
Spacer()
@@ -303,12 +295,29 @@ private struct ConnectionHomeView: View {
.padding(.top, 8)
}
private var signalStatusBadge: some View {
HStack(spacing: 6) {
if session.hasNetworkConsent {
Image(systemName: signalSymbol)
.font(.caption.weight(.bold))
}
Text(session.signalStatus)
.font(.caption.weight(.semibold))
.lineLimit(1)
}
.foregroundStyle(signalTint)
.padding(.horizontal, 11)
.frame(height: 34)
.background(signalTint.opacity(0.11), in: Capsule())
.overlay {
Capsule()
.stroke(signalTint.opacity(0.22), lineWidth: 1)
}
.contentShape(Capsule())
}
private var remoteConnectionPanel: some View {
VStack(alignment: .leading, spacing: 16) {
if !session.hasNetworkConsent {
Button("查看联网说明并选择") { session.showPrivacyNotice() }
.font(.callout)
}
HStack {
Text("远程桌面")
.font(.title3.weight(.bold))
@@ -777,7 +786,7 @@ private struct ServerSettingsView: View {
Text(warning)
.foregroundStyle(.orange)
}
Text(!session.hasNetworkConsent ? "同意联网后才会登记本机身份" : session.localIdentity.isEmpty
Text(!session.hasNetworkConsent ? "完成隐私授权后登记本机身份" : session.localIdentity.isEmpty
? "正在获取本机 ID…"
: "本机 ID \(session.localIdentity)")
.font(.caption2.monospacedDigit())
@@ -11,7 +11,6 @@ struct PrivacyPreferences {
self.defaults = defaults
}
var hasMadeNetworkChoice: Bool { defaults.object(forKey: networkKey) != nil }
var hasNetworkConsent: Bool { defaults.bool(forKey: networkKey) }
var savesThumbnails: Bool {
hasNetworkConsent && defaults.bool(forKey: thumbnailsKey)
@@ -26,3 +25,32 @@ struct PrivacyPreferences {
defaults.set(allowed && hasNetworkConsent, forKey: thumbnailsKey)
}
}
/// Refusal dismisses the notice for this visit only. A background/foreground
/// round trip starts another visit; transient inactive states do not.
struct PrivacyNoticeState {
private(set) var isVisible: Bool
private var needsPresentationOnActivation = false
init(hasNetworkConsent: Bool) {
isVisible = !hasNetworkConsent
}
mutating func show() {
isVisible = true
}
mutating func dismiss() {
isVisible = false
}
mutating func didEnterBackground() {
needsPresentationOnActivation = true
}
mutating func didBecomeActive(hasNetworkConsent: Bool) {
guard needsPresentationOnActivation else { return }
needsPresentationOnActivation = false
isVisible = !hasNetworkConsent
}
}
+56 -71
View File
@@ -5,76 +5,61 @@ struct PrivacyConsentView: View {
@State private var hasReadPolicy = false
var body: some View {
GeometryReader { geometry in
ZStack {
Color.black.opacity(0.32).ignoresSafeArea()
VStack(spacing: 16) {
Text("隐私政策")
.font(.title3.bold())
Text("请阅读以下隐私政策。同意后将处理连接所需的设备与网络信息;画面预览保存需另行开启。")
.font(.footnote)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
ScrollView {
PrivacyPolicyDocument()
.padding(14)
}
.frame(height: min(220, max(100, geometry.size.height - 330)))
.background(Color(.secondarySystemGroupedBackground),
in: RoundedRectangle(cornerRadius: 12))
.overlay {
RoundedRectangle(cornerRadius: 12)
.stroke(Color(.separator).opacity(0.3), lineWidth: 0.5)
}
Toggle(isOn: $hasReadPolicy) {
Text("我已阅读并同意《隐私政策》")
.font(.footnote)
}
.toggleStyle(ConsentCheckboxStyle())
HStack(spacing: 12) {
Button {
session.declineNetworkConsent()
} label: {
Text("拒绝")
.frame(maxWidth: .infinity, minHeight: 44)
.background(Color(.secondarySystemGroupedBackground),
in: RoundedRectangle(cornerRadius: 10))
.contentShape(RoundedRectangle(cornerRadius: 10))
}
Button {
guard hasReadPolicy else { return }
session.acceptNetworkConsent()
} label: {
Text("同意并继续")
.frame(maxWidth: .infinity, minHeight: 44)
.foregroundStyle(.white)
.background(hasReadPolicy ? Color.accentColor : Color.gray.opacity(0.4),
in: RoundedRectangle(cornerRadius: 10))
.contentShape(RoundedRectangle(cornerRadius: 10))
}
.disabled(!hasReadPolicy || !PrivacyPolicyDocument.isAvailable)
}
.font(.subheadline.weight(.semibold))
.buttonStyle(.plain)
Text("拒绝不影响本地功能;同意后可在设置中撤回。")
.font(.caption2)
.foregroundStyle(.secondary)
}
.padding(20)
.frame(width: min(360, geometry.size.width - 40))
.background(Color(.systemBackground),
in: RoundedRectangle(cornerRadius: 22))
.compositingGroup()
.shadow(color: .black.opacity(0.16), radius: 24, y: 8)
.accessibilityElement(children: .contain)
.accessibilityAddTraits(.isModal)
NavigationStack {
ScrollView {
PrivacyPolicyDocument()
.padding(.horizontal, 20)
.padding(.vertical, 20)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color(.systemBackground))
.safeAreaInset(edge: .bottom, spacing: 0) {
consentActions
}
.navigationTitle("隐私政策")
.navigationBarTitleDisplayMode(.inline)
}
}
private var consentActions: some View {
VStack(spacing: 0) {
Divider()
VStack(spacing: 12) {
Toggle(isOn: $hasReadPolicy) {
Text("我已阅读并同意《隐私政策》")
.font(.subheadline)
}
.toggleStyle(ConsentCheckboxStyle())
Button {
guard hasReadPolicy else { return }
session.acceptNetworkConsent()
} label: {
Text("同意并继续")
.font(.body.weight(.semibold))
.frame(maxWidth: .infinity, minHeight: 24)
}
.buttonStyle(.borderedProminent)
.controlSize(.large)
.tint(.blue)
.disabled(!hasReadPolicy || !PrivacyPolicyDocument.isAvailable)
Button {
session.declineNetworkConsent()
} label: {
Text("暂不同意")
.font(.body)
.frame(maxWidth: .infinity, minHeight: 44)
.contentShape(Rectangle())
}
.foregroundStyle(.secondary)
.buttonStyle(.plain)
}
.padding(.horizontal, 20)
.padding(.vertical, 12)
}
.background(Color(.systemBackground))
}
}
private struct ConsentCheckboxStyle: ToggleStyle {
@@ -90,7 +75,7 @@ private struct ConsentCheckboxStyle: ToggleStyle {
.foregroundStyle(.primary)
.multilineTextAlignment(.leading)
}
.frame(maxWidth: .infinity, minHeight: 36, alignment: .leading)
.frame(maxWidth: .infinity, minHeight: 44, alignment: .leading)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
@@ -214,7 +199,7 @@ private struct PrivacyPolicyDocument: View {
let paragraph = Self.paragraphs[index]
if paragraph.hasPrefix("#") {
Text(paragraph.trimmingCharacters(in: CharacterSet(charactersIn: "# ")))
.font(.subheadline.bold())
.font(.headline)
.padding(.top, 4)
} else if paragraph.hasPrefix("更新日期:") {
Text(paragraph)
@@ -224,8 +209,8 @@ private struct PrivacyPolicyDocument: View {
Text((try? AttributedString(markdown: paragraph,
options: .init(interpretedSyntax: .inlineOnlyPreservingWhitespace)))
?? AttributedString(paragraph))
.font(.subheadline)
.lineSpacing(3)
.font(.body)
.lineSpacing(4)
}
}
}
@@ -0,0 +1,123 @@
import Foundation
struct RemoteTrafficStatistics {
var inboundBitrate: UInt32 = 0
var outboundBitrate: UInt32 = 0
var lossRate: Float = 0
}
struct RemoteNetworkReport {
enum Mode { case unknown, direct, relay }
var video = RemoteTrafficStatistics()
var audio = RemoteTrafficStatistics()
var data = RemoteTrafficStatistics()
var total = RemoteTrafficStatistics()
var mode: Mode = .unknown
var srtpActive = false
var rttMilliseconds: Double = -1
}
struct RemoteNetworkSnapshot {
var report: RemoteNetworkReport?
var framesPerSecond = 0
var videoLatencyMilliseconds: Double?
var rttMilliseconds: Double?
var connectionMode: String {
switch report?.mode {
case .direct: return "P2P 直连"
case .relay: return "TURN 中继"
default: return "—"
}
}
static func bitrate(_ value: UInt32?) -> String {
guard let value else { return "—" }
if value >= 1_000_000 {
return String(format: "%.1f Mbps", Double(value) / 1_000_000)
}
if value >= 1_000 {
return String(format: "%.0f Kbps", Double(value) / 1_000)
}
return "\(value) bps"
}
static func lossRate(_ value: Float?) -> String {
guard let value, value.isFinite, value >= 0 else { return "—" }
// MiniRTC's total is the sum of media fractions, as on desktop.
return String(format: "%.1f%%", value * 100)
}
static func latency(_ value: Double?) -> String {
guard let value, value.isFinite, value >= 0 else { return "—" }
return value < 1 ? "<1 ms" : String(format: "%.0f ms", value)
}
}
/// Main-thread presentation statistics. Time is monotonic and injectable so
/// missing reports, idle video and reconnections have deterministic behavior.
struct RemoteNetworkStatistics {
private var report: RemoteNetworkReport?
private var reportTime: TimeInterval = 0
private var averageRTT: Double?
private var rttTime: TimeInterval = 0
private var lastFrameID: UInt64 = 0
private var frames: [(time: TimeInterval, latency: Double?)] = []
private var displayedSnapshot = RemoteNetworkSnapshot()
private var displayTime: TimeInterval?
mutating func receive(_ report: RemoteNetworkReport, at now: TimeInterval) {
self.report = report
reportTime = now
let rtt = report.rttMilliseconds
guard rtt.isFinite, rtt >= 0, rtt <= 2_000 else { return }
if let previous = averageRTT, now - rttTime < 3 {
averageRTT = previous + 0.25 * (rtt - previous)
} else {
averageRTT = rtt
}
rttTime = now
}
mutating func recordSubmittedFrame(id: UInt64, captureUptime: TimeInterval,
at now: TimeInterval) {
// Cached redraws and foreground resubmission are not new video frames.
guard id > lastFrameID else { return }
lastFrameID = id
let latency = (now - captureUptime) * 1_000
let valid = captureUptime > 0 && latency.isFinite && (0...5_000).contains(latency)
frames.removeAll { now - $0.time >= 1 }
frames.append((now, valid ? latency : nil))
if frames.count > 240 { frames.removeFirst(frames.count - 240) }
}
mutating func resetVideo() {
frames.removeAll(keepingCapacity: true)
displayedSnapshot.framesPerSecond = 0
displayedSnapshot.videoLatencyMilliseconds = nil
}
/// SwiftUI also redraws this menu for every video frame. A periodic view
/// schedules updates but does not throttle those redraws, so freeze the
/// displayed measurements between one-second samples.
mutating func displaySnapshot(at now: TimeInterval) -> RemoteNetworkSnapshot {
if let displayTime, now - displayTime < 1 {
return displayedSnapshot
}
displayedSnapshot = snapshot(at: now)
displayTime = now
return displayedSnapshot
}
func snapshot(at now: TimeInterval) -> RemoteNetworkSnapshot {
let recentFrames = frames.filter { now - $0.time < 1 }
let delays = recentFrames.compactMap(\.latency)
return RemoteNetworkSnapshot(
report: now - reportTime < 3 ? report : nil,
framesPerSecond: recentFrames.count,
videoLatencyMilliseconds: delays.isEmpty ? nil : delays.reduce(0, +) / Double(delays.count),
rttMilliseconds: now - rttTime < 3 ? averageRTT : nil
)
}
}
@@ -94,6 +94,8 @@ enum VideoAdaptationPolicy: String, CaseIterable, Identifiable {
private struct RemoteVideoFrame {
let pixelBuffer: CVPixelBuffer
let encodedSize: CGSize
let id: UInt64
let captureUptime: TimeInterval
}
struct RecentConnection: Codable, Identifiable, Equatable {
@@ -294,7 +296,9 @@ final class RemoteSessionModel: NSObject, ObservableObject, CrossDeskRTCBridgeDe
private let privacyPreferences = PrivacyPreferences()
@Published private(set) var hasNetworkConsent = PrivacyPreferences().hasNetworkConsent
@Published private(set) var privacyNoticeVisible = !PrivacyPreferences().hasMadeNetworkChoice
@Published private var privacyNotice = PrivacyNoticeState(
hasNetworkConsent: PrivacyPreferences().hasNetworkConsent
)
@Published private(set) var savesConnectionThumbnails = PrivacyPreferences().savesThumbnails
@Published private(set) var thumbnailCleanupInProgress = false
@Published private(set) var thumbnailCleanupError: String?
@@ -311,7 +315,7 @@ final class RemoteSessionModel: NSObject, ObservableObject, CrossDeskRTCBridgeDe
@Published private(set) var videoSettings = RemoteVideoSettingsState()
@Published var remoteID = ""
@Published var password = ""
@Published private(set) var signalStatus = "尚未同意联网"
@Published private(set) var signalStatus = "等待隐私授权"
@Published private(set) var connectionStatus = "未连接"
@Published private(set) var localIdentity = ""
@Published private(set) var identityStorageWarning: String?
@@ -325,9 +329,8 @@ final class RemoteSessionModel: NSObject, ObservableObject, CrossDeskRTCBridgeDe
@Published private(set) var remoteCursorPositionRevision: UInt64 = 0
@Published private(set) var remoteCursorVisualOffset = CGPoint.zero
@Published private(set) var hasRemoteCursorState = false
@Published private(set) var bitrate: UInt = 0
@Published private(set) var lossRate: Float = 0
@Published private(set) var usingTURN = false
private var networkStatistics = RemoteNetworkStatistics()
private var videoFrameID: UInt64 = 0
@Published private(set) var displays: [String] = ["显示器 1"]
@Published private(set) var displaySizes: [CGSize] = []
@Published var selectedDisplay = 0
@@ -379,6 +382,8 @@ final class RemoteSessionModel: NSObject, ObservableObject, CrossDeskRTCBridgeDe
private var bridgeConfiguration: BridgeConfiguration?
var pixelBuffer: CVPixelBuffer? { videoFrame?.pixelBuffer }
var currentVideoFrameID: UInt64 { videoFrame?.id ?? 0 }
var frameCaptureUptime: TimeInterval { videoFrame?.captureUptime ?? 0 }
var frameSize: CGSize { videoFrame?.encodedSize ?? .zero }
var displayGeometrySize: CGSize {
guard displaySizes.indices.contains(selectedDisplay) else {
@@ -400,20 +405,38 @@ final class RemoteSessionModel: NSObject, ObservableObject, CrossDeskRTCBridgeDe
remoteVersionCheckTask?.cancel()
}
var privacyNoticeVisible: Bool { privacyNotice.isVisible }
func applicationDidBecomeActive() {
privacyNotice.didBecomeActive(hasNetworkConsent: hasNetworkConsent)
refreshRecentConnectionPresenceAfterForeground()
resumeVideoAfterForeground()
}
func applicationDidEnterBackground() {
privacyNotice.didEnterBackground()
suspendVideoForBackground()
suspendPresenceMonitoring()
}
func showPrivacyNotice() {
privacyNoticeVisible = true
privacyNotice.show()
}
func dismissPrivacyNotice() {
privacyNotice.dismiss()
}
func acceptNetworkConsent() {
privacyPreferences.setNetworkConsent(true)
hasNetworkConsent = true
privacyNoticeVisible = false
privacyNotice.dismiss()
configureBridge()
}
func declineNetworkConsent() {
revokeNetworkConsent()
privacyNoticeVisible = false
privacyNotice.dismiss()
}
func revokeNetworkConsent() {
@@ -428,7 +451,7 @@ final class RemoteSessionModel: NSObject, ObservableObject, CrossDeskRTCBridgeDe
bridge.stopNetworking()
bridgeConfiguration = nil
localIdentity = ""
signalStatus = "尚未同意联网"
signalStatus = "等待隐私授权"
savesConnectionThumbnails = false
clearConnectionThumbnails()
}
@@ -472,7 +495,7 @@ final class RemoteSessionModel: NSObject, ObservableObject, CrossDeskRTCBridgeDe
@discardableResult
func configureBridge() -> Bool {
guard hasNetworkConsent else {
signalStatus = "尚未同意联网"
signalStatus = "等待隐私授权"
return false
}
let host = signalHost.trimmingCharacters(in: .whitespacesAndNewlines)
@@ -529,6 +552,7 @@ final class RemoteSessionModel: NSObject, ObservableObject, CrossDeskRTCBridgeDe
}
private func beginRemoteConnection(_ identifier: String) {
networkStatistics = RemoteNetworkStatistics()
resetRemoteVersionCheck()
cancelVideoRecovery()
resetVideoSettings()
@@ -639,7 +663,7 @@ final class RemoteSessionModel: NSObject, ObservableObject, CrossDeskRTCBridgeDe
videoFrame = nil
resetRemoteCursorState()
frameCount = 0
bitrate = 0
networkStatistics = RemoteNetworkStatistics()
displays = ["显示器 1"]
displaySizes = []
selectedDisplay = 0
@@ -794,6 +818,7 @@ final class RemoteSessionModel: NSObject, ObservableObject, CrossDeskRTCBridgeDe
func suspendVideoForBackground() {
videoWasBackgrounded = true
networkStatistics.resetVideo()
cancelVideoRecovery()
}
@@ -906,6 +931,7 @@ final class RemoteSessionModel: NSObject, ObservableObject, CrossDeskRTCBridgeDe
func selectDisplay(_ index: Int) {
guard displays.indices.contains(index) else { return }
selectedDisplay = index
networkStatistics.resetVideo()
videoFrame = nil
resetRemoteCursorState()
frameCount = 0
@@ -1136,15 +1162,19 @@ final class RemoteSessionModel: NSObject, ObservableObject, CrossDeskRTCBridgeDe
func rtcBridge(_ bridge: CrossDeskRTCBridge,
didReceive pixelBuffer: CVPixelBuffer,
width: Int,
height: Int) {
height: Int,
captureUptime: TimeInterval) {
guard hasNetworkConsent, isConnected, !videoWasBackgrounded else { return }
cancelVideoRecovery()
// Buffer and encoded dimensions must be one observable value. Adaptive
// resolution changes must never expose a new frame with the previous
// frame's geometry to SwiftUI.
videoFrameID &+= 1
videoFrame = RemoteVideoFrame(
pixelBuffer: pixelBuffer,
encodedSize: CGSize(width: width, height: height)
encodedSize: CGSize(width: width, height: height),
id: videoFrameID,
captureUptime: captureUptime
)
frameCount &+= 1
captureRecentThumbnailIfNeeded(pixelBuffer)
@@ -1248,22 +1278,37 @@ final class RemoteSessionModel: NSObject, ObservableObject, CrossDeskRTCBridgeDe
}
func rtcBridge(_ bridge: CrossDeskRTCBridge,
didUpdateBitrate bitsPerSecond: UInt,
lossRate: Float,
usingTURN: Bool) {
bitrate = bitsPerSecond
self.lossRate = lossRate
self.usingTURN = usingTURN
didUpdateNetworkStats stats: CrossDeskNetworkStats) {
guard hasNetworkConsent, isConnected else { return }
func traffic(_ value: CrossDeskTrafficStats) -> RemoteTrafficStatistics {
RemoteTrafficStatistics(inboundBitrate: value.inboundBitrate,
outboundBitrate: value.outboundBitrate,
lossRate: value.lossRate)
}
let mode: RemoteNetworkReport.Mode
switch stats.traversalMode {
case .direct: mode = .direct
case .relay: mode = .relay
default: mode = .unknown
}
networkStatistics.receive(RemoteNetworkReport(
video: traffic(stats.video), audio: traffic(stats.audio),
data: traffic(stats.data), total: traffic(stats.total), mode: mode,
srtpActive: stats.srtpActive.boolValue, rttMilliseconds: stats.rttMilliseconds
), at: ProcessInfo.processInfo.systemUptime)
}
var formattedBitrate: String {
if bitrate >= 1_000_000 {
return String(format: "%.1f Mbps", Double(bitrate) / 1_000_000)
}
if bitrate >= 1_000 {
return String(format: "%.0f Kbps", Double(bitrate) / 1_000)
}
return "\(bitrate) bps"
// Called after the exact frame enters AVSampleBufferDisplayLayer. Keep this
// accumulator unpublished: submitting pixels occurs during a SwiftUI update.
func recordVideoFrameSubmission(id: UInt64, captureUptime: TimeInterval) {
guard isConnected, !videoWasBackgrounded, id == videoFrame?.id else { return }
networkStatistics.recordSubmittedFrame(id: id, captureUptime: captureUptime,
at: ProcessInfo.processInfo.systemUptime)
}
var networkSnapshot: RemoteNetworkSnapshot {
guard isConnected else { return RemoteNetworkSnapshot() }
return networkStatistics.displaySnapshot(at: ProcessInfo.processInfo.systemUptime)
}
var videoStatus: String {
+124 -24
View File
@@ -53,7 +53,12 @@ struct RemoteSessionView: View {
videoSize: session.displayGeometrySize
) {
ZStack(alignment: .topLeading) {
NativeVideoView(pixelBuffer: session.pixelBuffer)
NativeVideoView(
pixelBuffer: session.pixelBuffer,
frameID: session.currentVideoFrameID,
captureUptime: session.frameCaptureUptime,
onFrameSubmitted: session.recordVideoFrameSubmission
)
if let cursorPosition,
session.pixelBuffer != nil {
@@ -147,17 +152,6 @@ struct RemoteSessionView: View {
.zIndex(50)
}
}
.overlay(alignment: .top) {
if session.savesConnectionThumbnails {
Label("本地画面预览保存已开启", systemImage: "photo")
.font(.caption)
.foregroundStyle(.white)
.padding(8)
.background(.black.opacity(0.65), in: Capsule())
.padding(.top, 8)
.allowsHitTesting(false)
}
}
.alert("升级提示", isPresented: Binding(
get: { session.remoteUpdateMessage != nil },
set: { if !$0 { session.dismissRemoteUpdate() } }
@@ -1075,7 +1069,10 @@ private struct CrossDeskStatusOrb: View {
private struct FloatingSessionMenu: View {
@ObservedObject var session: RemoteSessionModel
@State private var showingVideoSettings = false
private enum Page { case controls, videoSettings, networkStats }
@State private var page: Page = .controls
@State private var displayedNetworkSnapshot = RemoteNetworkSnapshot()
@State private var displayedFrameSize = CGSize.zero
let showKeyboard: () -> Void
let chooseFile: () -> Void
let close: () -> Void
@@ -1087,16 +1084,16 @@ private struct FloatingSessionMenu: View {
var body: some View {
VStack(spacing: 9) {
HStack(spacing: 8) {
if showingVideoSettings {
if page != .controls {
Button {
showingVideoSettings = false
page = .controls
} label: {
Image(systemName: "chevron.left")
.frame(width: 28, height: 32)
}
.buttonStyle(.plain)
.accessibilityLabel("返回控制栏")
Text("画面设置")
Text(page == .videoSettings ? "画面设置" : "网络状态")
.font(.subheadline.weight(.semibold))
} else {
Circle()
@@ -1120,7 +1117,7 @@ private struct FloatingSessionMenu: View {
.accessibilityLabel("关闭控制栏")
}
if showingVideoSettings, !session.videoSettingsFeedback.isEmpty {
if page == .videoSettings, !session.videoSettingsFeedback.isEmpty {
Text(session.videoSettingsFeedback)
.font(.caption)
.foregroundStyle(.secondary)
@@ -1128,8 +1125,11 @@ private struct FloatingSessionMenu: View {
}
ScrollView {
if showingVideoSettings {
if page == .videoSettings {
FloatingVideoSettings(session: session)
} else if page == .networkStats {
FloatingNetworkStatistics(snapshot: displayedNetworkSnapshot,
frameSize: displayedFrameSize)
} else {
VStack(spacing: 9) {
LazyVGrid(columns: columns, spacing: 7) {
@@ -1154,7 +1154,7 @@ private struct FloatingSessionMenu: View {
.buttonStyle(.plain)
FloatingControlButton(title: "画面设置", symbol: "slider.horizontal.3") {
showingVideoSettings = true
page = .videoSettings
}
FloatingControlButton(
title: session.audioEnabled ? "声音" : "静音",
@@ -1172,6 +1172,9 @@ private struct FloatingSessionMenu: View {
FloatingControlButton(title: "发送文件",
symbol: "folder.badge.plus",
action: chooseFile)
FloatingControlButton(title: "网络状态", symbol: "chart.bar.xaxis") {
page = .networkStats
}
FloatingControlButton(title: "Ctrl+Alt+Del",
symbol: "lock.trianglebadge.exclamationmark",
action: session.bridge.sendSecureAttentionSequence)
@@ -1218,22 +1221,119 @@ private struct FloatingSessionMenu: View {
}
.shadow(color: .black.opacity(0.34), radius: 18, y: 7)
.environment(\.colorScheme, .light)
.task(id: session.selectedDisplay) {
// This task survives ordinary video-frame redraws and is cancelled
// when the menu closes. Rendering only reads the sampled values.
while !Task.isCancelled {
displayedNetworkSnapshot = session.networkSnapshot
displayedFrameSize = session.frameSize
do {
try await Task.sleep(nanoseconds: 1_000_000_000)
} catch {
return
}
}
}
}
private var statusDetail: String {
var components = [session.usingTURN ? "TURN" : "P2P"]
let frameSize = session.frameSize
let snapshot = displayedNetworkSnapshot
var components = [snapshot.connectionMode]
let frameSize = displayedFrameSize
if frameSize.width > 0 && frameSize.height > 0 {
components.append("\(Int(frameSize.width))×\(Int(frameSize.height))")
}
components.append(session.formattedBitrate)
if session.lossRate > 0 {
components.append(String(format: "丢包 %.1f%%", session.lossRate * 100))
components.append(RemoteNetworkSnapshot.bitrate(snapshot.report?.total.inboundBitrate))
if let loss = snapshot.report?.video.lossRate, loss > 0 {
components.append("丢包 \(RemoteNetworkSnapshot.lossRate(loss))")
}
return components.joined(separator: " · ")
}
}
private struct FloatingNetworkStatistics: View {
let snapshot: RemoteNetworkSnapshot
let frameSize: CGSize
var body: some View {
VStack(spacing: 10) {
VStack(spacing: 0) {
trafficRow("", inbound: "接收", outbound: "发送", loss: "丢包率")
.foregroundStyle(.secondary)
trafficRow("视频", statistics: snapshot.report?.video)
trafficRow("音频", statistics: snapshot.report?.audio)
trafficRow("数据", statistics: snapshot.report?.data)
trafficRow("合计", statistics: snapshot.report?.total)
.fontWeight(.semibold)
.background(Color.blue.opacity(0.07), in: RoundedRectangle(cornerRadius: 5))
}
.font(.caption2.monospacedDigit())
Divider()
VStack(spacing: 7) {
detail("帧率", value: "\(snapshot.framesPerSecond) FPS")
detail("分辨率", value: resolution)
detail("画面延时", value: RemoteNetworkSnapshot.latency(snapshot.videoLatencyMilliseconds))
detail("连接延时(RTT)", value: RemoteNetworkSnapshot.latency(snapshot.rttMilliseconds))
detail("连接方式", value: snapshot.connectionMode)
HStack {
Text("媒体加密")
.foregroundStyle(.secondary)
Spacer(minLength: 8)
if let report = snapshot.report {
Label(report.srtpActive ? "SRTP 已启用" : "未启用",
systemImage: report.srtpActive ? "lock.shield" : "lock.open")
.foregroundStyle(report.srtpActive ? Color.green : Color.secondary)
} else {
Text("—")
}
}
.accessibilityElement(children: .combine)
}
.font(.caption.monospacedDigit())
}
.padding(.vertical, 2)
}
private var resolution: String {
guard frameSize.width > 0, frameSize.height > 0 else { return "—" }
return "\(Int(frameSize.width)) × \(Int(frameSize.height))"
}
private func detail(_ title: String, value: String) -> some View {
HStack {
Text(title).foregroundStyle(.secondary)
Spacer(minLength: 8)
Text(value)
}
.accessibilityElement(children: .combine)
}
private func trafficRow(_ title: String, statistics: RemoteTrafficStatistics?) -> some View {
trafficRow(title,
inbound: RemoteNetworkSnapshot.bitrate(statistics?.inboundBitrate),
outbound: RemoteNetworkSnapshot.bitrate(statistics?.outboundBitrate),
loss: RemoteNetworkSnapshot.lossRate(statistics?.lossRate))
.accessibilityLabel("\(title),接收 \(RemoteNetworkSnapshot.bitrate(statistics?.inboundBitrate)),发送 \(RemoteNetworkSnapshot.bitrate(statistics?.outboundBitrate)),丢包率 \(RemoteNetworkSnapshot.lossRate(statistics?.lossRate))")
}
private func trafficRow(_ title: String, inbound: String,
outbound: String, loss: String) -> some View {
HStack(spacing: 4) {
Text(title).frame(width: 28, alignment: .leading)
Text(inbound).frame(maxWidth: .infinity, alignment: .trailing)
Text(outbound).frame(maxWidth: .infinity, alignment: .trailing)
Text(loss).frame(width: 46, alignment: .trailing)
}
.lineLimit(1)
.minimumScaleFactor(0.75)
.padding(.horizontal, 4)
.padding(.vertical, 5)
.accessibilityElement(children: .combine)
}
}
private struct FloatingVideoSettings: View {
@ObservedObject var session: RemoteSessionModel
@@ -12,13 +12,18 @@ import UIKit
struct NativeVideoView: UIViewRepresentable {
@Environment(\.scenePhase) private var scenePhase
let pixelBuffer: CVPixelBuffer?
let frameID: UInt64
let captureUptime: TimeInterval
let onFrameSubmitted: (UInt64, TimeInterval) -> Void
func makeUIView(context: Context) -> SampleBufferVideoView {
SampleBufferVideoView(frame: .zero)
}
func updateUIView(_ uiView: SampleBufferVideoView, context: Context) {
uiView.display(pixelBuffer, isActive: scenePhase == .active)
if uiView.display(pixelBuffer, isActive: scenePhase == .active) {
onFrameSubmitted(frameID, captureUptime)
}
}
}
@@ -57,7 +62,10 @@ final class SampleBufferVideoView: UIView {
videoLayer.videoGravity = .resize
}
func display(_ pixelBuffer: CVPixelBuffer?, isActive: Bool) {
/// True only when a new buffer was submitted; no presentation timestamp is
/// exposed by this display layer, so latency ends at submission like the
/// desktop renderer's submission fallback.
func display(_ pixelBuffer: CVPixelBuffer?, isActive: Bool) -> Bool {
if renderingActive != isActive {
renderingActive = isActive
// Backgrounding can invalidate the display layer without changing
@@ -72,9 +80,9 @@ final class SampleBufferVideoView: UIView {
submittedFrames = 0
droppedFrames = 0
videoLayer.flushAndRemoveImage()
return
return false
}
guard renderingActive else { return }
guard renderingActive else { return false }
// Check recovery before deduplicating frames: a failed layer may need
// to display the same buffer again even if no new decoded frame arrives.
@@ -87,7 +95,7 @@ final class SampleBufferVideoView: UIView {
// SwiftUI can refresh for unrelated status fields. Only deduplicate a
// frame after it was actually submitted to a healthy, active layer.
guard lastPixelBuffer !== pixelBuffer else { return }
guard lastPixelBuffer !== pixelBuffer else { return false }
// Never let AVSampleBufferDisplayLayer turn temporary rendering
// pressure into seconds of latency. Its queued buffers are already
@@ -101,7 +109,7 @@ final class SampleBufferVideoView: UIView {
droppedFrames)
}
}
guard videoLayer.isReadyForMoreMediaData else { return }
guard videoLayer.isReadyForMoreMediaData else { return false }
var formatDescription: CMVideoFormatDescription?
guard CMVideoFormatDescriptionCreateForImageBuffer(
@@ -110,7 +118,7 @@ final class SampleBufferVideoView: UIView {
formatDescriptionOut: &formatDescription
) == noErr, let formatDescription else {
NSLog("CrossDesk could not create a video format description")
return
return false
}
var timing = CMSampleTimingInfo(
@@ -127,7 +135,7 @@ final class SampleBufferVideoView: UIView {
sampleBufferOut: &sampleBuffer
) == noErr, let sampleBuffer else {
NSLog("CrossDesk could not create a video sample buffer")
return
return false
}
if let attachments = CMSampleBufferGetSampleAttachmentsArray(
@@ -162,5 +170,6 @@ final class SampleBufferVideoView: UIView {
self.videoLayer.error?.localizedDescription ?? "none")
}
}
return true
}
}