mirror of
https://github.com/kunkundi/crossdesk.git
synced 2026-10-23 13:06:45 +08:00
[feat] expand iOS network status and refine privacy prompts
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user