[fix] require iOS privacy consent and simplify settings

This commit is contained in:
dijunkun
2026-09-28 16:53:43 +08:00
parent 3992ea56db
commit a4baca222c
17 changed files with 1091 additions and 299 deletions
@@ -20,6 +20,10 @@
10000000000000000000000B /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 200000000000000000000010 /* PrivacyInfo.xcprivacy */; };
10000000000000000000000C /* OpenSSL.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 200000000000000000000011 /* OpenSSL.framework */; };
10000000000000000000000D /* OpenSSL.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 200000000000000000000011 /* OpenSSL.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; };
100000000000000000000012 /* PrivacyPreferences.swift in Sources */ = {isa = PBXBuildFile; fileRef = 200000000000000000000012 /* PrivacyPreferences.swift */; };
100000000000000000000013 /* ThumbnailFileStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 200000000000000000000013 /* ThumbnailFileStore.swift */; };
100000000000000000000014 /* PrivacyViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 200000000000000000000014 /* PrivacyViews.swift */; };
100000000000000000000015 /* PRIVACY.md in Resources */ = {isa = PBXBuildFile; fileRef = 200000000000000000000015 /* PRIVACY.md */; };
/* End PBXBuildFile section */
/* Begin PBXFileReference section */
@@ -40,6 +44,10 @@
20000000000000000000000F /* ThirdPartyLicenses.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = Resources/ThirdPartyLicenses.json; sourceTree = "<group>"; };
200000000000000000000010 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = PrivacyInfo.xcprivacy; sourceTree = "<group>"; };
200000000000000000000011 /* OpenSSL.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = OpenSSL.framework; sourceTree = BUILT_PRODUCTS_DIR; };
200000000000000000000012 /* PrivacyPreferences.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrivacyPreferences.swift; sourceTree = "<group>"; };
200000000000000000000013 /* ThumbnailFileStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThumbnailFileStore.swift; sourceTree = "<group>"; };
200000000000000000000014 /* PrivacyViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrivacyViews.swift; sourceTree = "<group>"; };
200000000000000000000015 /* PRIVACY.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = ../../../PRIVACY.md; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXCopyFilesBuildPhase section */
@@ -79,6 +87,10 @@
isa = PBXGroup;
children = (
200000000000000000000001 /* CrossDeskMobileApp.swift */,
200000000000000000000015 /* PRIVACY.md */,
200000000000000000000014 /* PrivacyViews.swift */,
200000000000000000000013 /* ThumbnailFileStore.swift */,
200000000000000000000012 /* PrivacyPreferences.swift */,
200000000000000000000002 /* RemoteSessionModel.swift */,
200000000000000000000003 /* ContentView.swift */,
20000000000000000000000E /* ThirdPartyLicensesView.swift */,
@@ -201,6 +213,7 @@
100000000000000000000008 /* Assets.xcassets in Resources */,
10000000000000000000000A /* ThirdPartyLicenses.json in Resources */,
10000000000000000000000B /* PrivacyInfo.xcprivacy in Resources */,
100000000000000000000015 /* PRIVACY.md in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -248,6 +261,9 @@
100000000000000000000005 /* CrossDeskRTCBridge.mm in Sources */,
100000000000000000000006 /* NativeMetalVideoView.swift in Sources */,
100000000000000000000007 /* RemoteTouchInputView.swift in Sources */,
100000000000000000000012 /* PrivacyPreferences.swift in Sources */,
100000000000000000000013 /* ThumbnailFileStore.swift in Sources */,
100000000000000000000014 /* PrivacyViews.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -117,6 +117,8 @@ typedef NS_ENUM(NSInteger, CrossDeskVideoAdaptationPolicy) {
- (void)connectToRemoteID:(NSString *)remoteID password:(NSString *)password;
- (void)disconnect;
// Close both session and signaling peers, including automatic reconnection.
- (void)stopNetworking;
- (void)requestKeyFrame;
- (void)sendPointerAtX:(float)x
@@ -489,6 +489,7 @@ Params MakeParams(const RTCState &state, const std::string &user_id,
std::atomic<uint64_t> _identityGeneration;
std::atomic<uint64_t> _signalGeneration;
std::atomic<uint64_t> _presenceGeneration;
std::atomic<uint64_t> _networkingGeneration;
}
+ (nullable NSString *)availableUpdateForAppVersion:(NSString *)appVersion
@@ -528,6 +529,7 @@ Params MakeParams(const RTCState &state, const std::string &user_id,
_identityGeneration.store(0);
_signalGeneration.store(0);
_presenceGeneration.store(0);
_networkingGeneration.store(0);
}
return self;
}
@@ -573,7 +575,9 @@ Params MakeParams(const RTCState &state, const std::string &user_id,
const char *log_c_string = logDirectory.UTF8String;
const std::string log_path = log_c_string ? log_c_string : "logs";
const uint64_t networking_generation = _networkingGeneration.load();
dispatch_async(_rtcQueue, ^{
if (networking_generation != self->_networkingGeneration.load()) return;
const bool unchanged = self->_state->signal_host == host_value &&
self->_state->signal_port == signalPort &&
self->_state->identity_peer != nullptr;
@@ -729,6 +733,24 @@ Params MakeParams(const RTCState &state, const std::string &user_id,
});
}
- (void)stopNetworking {
_networkingGeneration.fetch_add(1);
_controllerGenerationCounter.fetch_add(1);
_activeControllerGeneration.store(0);
_identityGeneration.fetch_add(1);
_signalGeneration.fetch_add(1);
_presenceGeneration.fetch_add(1);
dispatch_async(_rtcQueue, ^{
self->_state->pending_remote_id.clear();
self->_state->pending_password.clear();
[self destroyControllerPeer];
[self destroyIdentityPeer];
self->_state->signal_host.clear();
self->_state->identity_with_password.clear();
self->_state->identity_base.clear();
});
}
- (void)requestKeyFrame {
dispatch_async(_rtcQueue, ^{
if (self->_state->controller_peer) {
+182 -80
View File
@@ -6,12 +6,25 @@ struct ContentView: View {
@ObservedObject var session: RemoteSessionModel
var body: some View {
Group {
if session.sessionVisible {
RemoteSessionView(session: session)
ZStack {
Group {
if session.sessionVisible {
RemoteSessionView(session: session)
.transition(.opacity)
} else {
NavigationStack {
ConnectionHomeView(session: session)
.navigationTitle("首页")
.toolbar(.hidden, for: .navigationBar)
}
.transition(.opacity)
} else {
ConnectionHomeView(session: session)
}
}
.allowsHitTesting(!session.privacyNoticeVisible)
.accessibilityHidden(session.privacyNoticeVisible)
if session.privacyNoticeVisible {
PrivacyConsentView(session: session)
.transition(.opacity)
}
}
@@ -169,6 +182,7 @@ 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"
@@ -230,10 +244,8 @@ private struct ConnectionHomeView: View {
.presentationDetents([.height(340)])
.presentationDragIndicator(.visible)
}
.sheet(isPresented: $settingsVisible) {
.navigationDestination(isPresented: $settingsVisible) {
ServerSettingsView(session: session)
.presentationDetents([.fraction(0.80)])
.presentationDragIndicator(.visible)
}
.alert("设备离线", isPresented: Binding(
get: { session.deviceOfflineAlertVisible },
@@ -275,6 +287,7 @@ private struct ConnectionHomeView: View {
Spacer()
Button {
remoteIDFocused = false
settingsVisible = true
} label: {
Image(systemName: "gearshape")
@@ -292,6 +305,10 @@ private struct ConnectionHomeView: View {
private var remoteConnectionPanel: some View {
VStack(alignment: .leading, spacing: 16) {
if !session.hasNetworkConsent {
Button("查看联网说明并选择") { session.showPrivacyNotice() }
.font(.callout)
}
HStack {
Text("远程桌面")
.font(.title3.weight(.bold))
@@ -470,6 +487,11 @@ private struct ConnectionHomeView: View {
}
private func presentPasswordPrompt(for identifier: String) {
guard session.hasNetworkConsent else {
remoteIDFocused = false
session.showPrivacyNotice()
return
}
let trimmed = identifier.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else {
remoteIDFocused = true
@@ -670,88 +692,168 @@ private struct PasswordPromptView: View {
private struct ServerSettingsView: View {
@ObservedObject var session: RemoteSessionModel
@Environment(\.dismiss) private var dismiss
@State private var serverConfigurationExpanded = false
@State private var usesCustomServer: Bool
@State private var customServerHost: String
@State private var customServerPort: String
@State private var serverConfigurationError: String?
@FocusState private var focusedServerField: ServerField?
private enum ServerField: Hashable {
case host, port
}
init(session: RemoteSessionModel) {
self.session = session
let isCustom = !session.usesOfficialServer
_usesCustomServer = State(initialValue: isCustom)
_customServerHost = State(initialValue: isCustom ? session.signalHost : "")
_customServerPort = State(initialValue: isCustom ? session.signalPort : "")
}
var body: some View {
NavigationStack {
Form {
Section("鼠标控制") {
Picker("控制模式", selection: $session.mouseControlMode) {
ForEach(MouseControlMode.allCases) { mode in
Text(mode.title).tag(mode)
Form {
Section("鼠标控制") {
Picker("控制模式", selection: $session.mouseControlMode) {
ForEach(MouseControlMode.allCases) { mode in
Text(mode.title).tag(mode)
}
}
.pickerStyle(.segmented)
Text(session.mouseControlMode.detail)
.font(.footnote)
.foregroundStyle(.secondary)
}
Section("画面偏好") {
Picker("偏好模式", selection: $session.videoAdaptationPolicy) {
ForEach(VideoAdaptationPolicy.allCases) { policy in
Text(policy.title).tag(policy)
}
}
.pickerStyle(.segmented)
Text(session.videoAdaptationPolicy.detail)
.font(.footnote)
.foregroundStyle(.secondary)
}
Section {
DisclosureGroup(isExpanded: $serverConfigurationExpanded) {
Toggle("使用自定义服务器", isOn: $usesCustomServer)
if usesCustomServer {
LabeledContent("地址") {
TextField("填写服务器地址", text: $customServerHost)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.keyboardType(.URL)
.multilineTextAlignment(.trailing)
.focused($focusedServerField, equals: .host)
.accessibilityLabel("服务器地址")
}
LabeledContent("端口") {
TextField("填写端口", text: $customServerPort)
.keyboardType(.numberPad)
.multilineTextAlignment(.trailing)
.focused($focusedServerField, equals: .port)
.accessibilityLabel("服务器端口")
}
}
.pickerStyle(.segmented)
Text(session.mouseControlMode.detail)
.font(.footnote)
.foregroundStyle(.secondary)
if usesCustomServer {
Text("返回首页时生效。关闭开关可立即切回默认配置。")
.font(.footnote)
.foregroundStyle(.secondary)
}
} label: {
HStack {
Text("服务器")
Spacer()
Text(usesCustomServer ? "自定义" : "默认")
.foregroundStyle(.secondary)
}
}
Section("画面偏好") {
Picker("偏好模式", selection: $session.videoAdaptationPolicy) {
ForEach(VideoAdaptationPolicy.allCases) { policy in
Text(policy.title).tag(policy)
}
}
.pickerStyle(.segmented)
Text(session.videoAdaptationPolicy.detail)
.font(.footnote)
.foregroundStyle(.secondary)
Text("修改后从下一次连接开始生效。")
.font(.caption)
.foregroundStyle(.tertiary)
} footer: {
if let warning = session.identityStorageWarning {
Text(warning)
.foregroundStyle(.orange)
}
Section {
TextField("信令服务器", text: $session.signalHost)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
TextField("信令端口", text: $session.signalPort)
.keyboardType(.numberPad)
} header: {
Text("服务器")
} footer: {
if let warning = session.identityStorageWarning {
Text(warning)
.foregroundStyle(.orange)
}
Text(session.localIdentity.isEmpty
? "正在获取本机 ID…"
: "本机 ID \(session.localIdentity)")
.font(.caption2.monospacedDigit())
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, alignment: .center)
.padding(.top, 16)
.textSelection(.enabled)
}
Section {
if let source = SourceMetadata.bundled {
Link(destination: source.privacyPolicyURL) {
Label("隐私政策", systemImage: "hand.raised")
}
}
NavigationLink {
ThirdPartyLicensesView()
} label: {
Label("关于与开源许可", systemImage: "doc.text")
}
} footer: {
Text("本应用使用 OpenFEC,遵循 CeCILL-C 许可。版权声明、许可全文和源码入口见“关于与开源许可”。")
Text(!session.hasNetworkConsent ? "同意联网后才会登记本机身份" : session.localIdentity.isEmpty
? "正在获取本机 ID…"
: "本机 ID \(session.localIdentity)")
.font(.caption2.monospacedDigit())
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, alignment: .center)
.padding(.top, 16)
.textSelection(.enabled)
}
PrivacySettingsSection(session: session)
Section {
NavigationLink("关于") {
ThirdPartyLicensesView()
}
}
.navigationTitle("设置")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .confirmationAction) {
Button("应用") {
session.configureBridge()
dismiss()
}
}
ToolbarItem(placement: .cancellationAction) {
Button("取消") { dismiss() }
}
.navigationTitle("设置")
.navigationBarTitleDisplayMode(.inline)
.navigationBarBackButtonHidden(true)
.toolbar(.visible, for: .navigationBar)
.onChange(of: serverConfigurationExpanded) { expanded in
if !expanded { focusedServerField = nil }
}
.onChange(of: usesCustomServer) { isCustom in
if !isCustom { useDefaultServer() }
}
.alert("请检查服务器配置", isPresented: Binding(
get: { serverConfigurationError != nil },
set: { if !$0 { serverConfigurationError = nil } }
)) {
Button("确定", role: .cancel) { }
} message: {
Text(serverConfigurationError ?? "")
}
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button(action: returnToHome) {
Label("返回首页", systemImage: "chevron.left")
.labelStyle(.iconOnly)
}
}
}
}
private func useDefaultServer() {
focusedServerField = nil
customServerHost = ""
customServerPort = ""
guard !session.usesOfficialServer else { return }
session.restoreOfficialServerConfiguration()
session.configureBridge()
}
private func returnToHome() {
focusedServerField = nil
if usesCustomServer {
let host = customServerHost.trimmingCharacters(in: .whitespacesAndNewlines)
let port = customServerPort.trimmingCharacters(in: .whitespacesAndNewlines)
guard !host.isEmpty else {
serverConfigurationExpanded = true
serverConfigurationError = "请输入服务器地址,或关闭“使用自定义服务器”。"
return
}
guard let portNumber = Int(port), (1...65535).contains(portNumber) else {
serverConfigurationExpanded = true
serverConfigurationError = "请输入 1–65535 范围内的端口,或关闭“使用自定义服务器”。"
return
}
let normalizedPort = String(portNumber)
if session.signalHost != host || session.signalPort != normalizedPort {
session.signalHost = host
session.signalPort = normalizedPort
session.configureBridge()
}
} else {
useDefaultServer()
}
dismiss()
}
}
@@ -0,0 +1,28 @@
import Foundation
/// Consent is deliberately independent of existing device IDs and credentials.
/// A missing value (including an upgrade from older versions) is not consent.
struct PrivacyPreferences {
private let defaults: UserDefaults
private let networkKey = "crossdesk.mobile.network-consent.v1"
private let thumbnailsKey = "crossdesk.mobile.save-thumbnails.v1"
init(defaults: UserDefaults = .standard) {
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)
}
func setNetworkConsent(_ allowed: Bool) {
defaults.set(allowed, forKey: networkKey)
if !allowed { defaults.set(false, forKey: thumbnailsKey) }
}
func setSavesThumbnails(_ allowed: Bool) {
defaults.set(allowed && hasNetworkConsent, forKey: thumbnailsKey)
}
}
+245
View File
@@ -0,0 +1,245 @@
import SwiftUI
struct PrivacyConsentView: View {
@ObservedObject var session: RemoteSessionModel
@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)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
}
private struct ConsentCheckboxStyle: ToggleStyle {
func makeBody(configuration: Configuration) -> some View {
Button {
configuration.isOn.toggle()
} label: {
HStack(alignment: .center, spacing: 9) {
Image(systemName: configuration.isOn ? "checkmark.square.fill" : "square")
.font(.title3)
.foregroundStyle(configuration.isOn ? Color.accentColor : .secondary)
configuration.label
.foregroundStyle(.primary)
.multilineTextAlignment(.leading)
}
.frame(maxWidth: .infinity, minHeight: 36, alignment: .leading)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.accessibilityLabel("我已阅读并同意隐私政策")
.accessibilityValue(configuration.isOn ? "已勾选" : "未勾选")
}
}
struct PrivacySettingsSection: View {
@ObservedObject var session: RemoteSessionModel
@State private var confirmThumbnails = false
@State private var confirmClear = false
var body: some View {
Section {
NavigationLink {
PrivacyAuthorizationView(session: session)
} label: {
HStack {
Text("隐私与授权")
Spacer()
Text(session.hasNetworkConsent ? "已授权" : "未授权")
.foregroundStyle(.secondary)
}
}
Toggle("保存远程画面预览", isOn: Binding(
get: { session.savesConnectionThumbnails },
set: { enabled in
if enabled { confirmThumbnails = true }
else { session.setSavesConnectionThumbnails(false) }
}
))
.disabled(!session.hasNetworkConsent || session.thumbnailCleanupInProgress)
Button(session.thumbnailCleanupInProgress ? "正在清除…" : "清除预览图", role: .destructive) {
confirmClear = true
}
.disabled(session.thumbnailCleanupInProgress)
if let error = session.thumbnailCleanupError {
Text(error).font(.footnote).foregroundStyle(.secondary)
}
} header: {
Text("隐私")
} footer: {
Text("画面预览默认关闭。开启后,从下一次连接起保存一张远程画面到本机,显示在最近连接中,不上传且不纳入设备备份。关闭开关会清除已有预览;连接记录和接收的文件不受影响。")
}
.alert("保存远程画面预览?", isPresented: $confirmThumbnails) {
Button("开启保存") { session.setSavesConnectionThumbnails(true) }
Button("取消", role: .cancel) { }
} message: {
Text("预览可能包含远程电脑上的个人或工作信息。开启后,每次连接会自动将一张画面保存在本机并显示在最近连接中。你可以随时关闭并清除。")
}
.alert("清除预览图?", isPresented: $confirmClear) {
Button("清除", role: .destructive) { session.clearConnectionThumbnails() }
Button("取消", role: .cancel) { }
} message: {
Text(session.savesConnectionThumbnails
? "将删除本机保存的所有远程预览图,保留连接记录、密码和接收的文件。后续连接仍会保存新的预览图。"
: "将删除本机保存的所有远程预览图,保留连接记录、密码和接收的文件。")
}
}
}
private struct PrivacyAuthorizationView: View {
@ObservedObject var session: RemoteSessionModel
@State private var confirmWithdrawal = false
var body: some View {
Form {
Section {
LabeledContent("授权状态", value: session.hasNetworkConsent ? "已授权" : "未授权")
NavigationLink("隐私政策") { PrivacyPolicyView() }
} footer: {
if !session.hasNetworkConsent {
Text("授权后可使用远程连接。")
}
}
Section {
if session.hasNetworkConsent {
Button("撤回隐私授权", role: .destructive) {
confirmWithdrawal = true
}
} else {
Button("阅读并授权") { session.showPrivacyNotice() }
}
}
}
.navigationTitle("隐私与授权")
.navigationBarTitleDisplayMode(.inline)
.alert("是否撤回隐私授权?", isPresented: $confirmWithdrawal) {
Button("撤回授权", role: .destructive) { session.revokeNetworkConsent() }
Button("取消", role: .cancel) { }
} message: {
Text("将停止远程连接、关闭画面预览保存并清除已有预览。连接记录和接收文件会保留,已有服务端数据不会删除。您可随时重新授权。")
}
}
}
/// Bundled policy is readable before any network consent or external request.
private struct PrivacyPolicyDocument: View {
private static let text: String? = {
guard let url = Bundle.main.url(forResource: "PRIVACY", withExtension: "md"),
let text = try? String(contentsOf: url, encoding: .utf8),
!text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return nil }
// The app's interface is Chinese; show the complete Chinese translation.
if let start = text.range(of: "## 中文\n"),
let end = text.range(of: "## English", range: start.upperBound..<text.endIndex) {
return String(text[start.upperBound..<end.lowerBound])
}
return text
}()
static var isAvailable: Bool { text != nil }
private static let paragraphs = (text ?? "隐私政策暂时无法读取,请拒绝联网并重新安装应用。")
.components(separatedBy: "\n\n")
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty && $0 != "---" }
var body: some View {
VStack(alignment: .leading, spacing: 14) {
ForEach(Self.paragraphs.indices, id: \.self) { index in
let paragraph = Self.paragraphs[index]
if paragraph.hasPrefix("#") {
Text(paragraph.trimmingCharacters(in: CharacterSet(charactersIn: "# ")))
.font(.subheadline.bold())
.padding(.top, 4)
} else if paragraph.hasPrefix("更新日期:") {
Text(paragraph)
.font(.caption)
.foregroundStyle(.secondary)
} else {
Text((try? AttributedString(markdown: paragraph,
options: .init(interpretedSyntax: .inlineOnlyPreservingWhitespace)))
?? AttributedString(paragraph))
.font(.subheadline)
.lineSpacing(3)
}
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.textSelection(.enabled)
}
}
struct PrivacyPolicyView: View {
var body: some View {
ScrollView {
PrivacyPolicyDocument().padding()
}
.navigationTitle("隐私政策")
.navigationBarTitleDisplayMode(.inline)
}
}
+155 -89
View File
@@ -108,8 +108,9 @@ struct RecentConnection: Codable, Identifiable, Equatable {
private enum RecentConnectionStore {
private static let defaultsKey = "crossdesk.mobile.recent-connections.v1"
private static let thumbnailQueue =
DispatchQueue(label: "cn.crossdesk.mobile.thumbnails", qos: .utility)
private static let thumbnails = ThumbnailFileStore(directory:
FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
.appendingPathComponent("RecentConnectionThumbnails", isDirectory: true))
private static let context = CIContext(options: [.cacheIntermediates: false])
static func load() -> [RecentConnection] {
@@ -126,85 +127,54 @@ private enum RecentConnectionStore {
UserDefaults.standard.set(data, forKey: defaultsKey)
}
static func thumbnailURL(fileName: String) -> URL? {
guard let directory = thumbnailDirectory() else { return nil }
return directory.appendingPathComponent(fileName, isDirectory: false)
static func thumbnailURL(fileName: String) -> URL {
thumbnails.url(for: fileName)
}
private static func thumbnailFileName(for remoteID: String) -> String {
let safeID = remoteID.filter { $0.isLetter || $0.isNumber || $0 == "-" }
return "\(safeID.isEmpty ? "remote" : safeID).jpg"
}
static func captureThumbnail(from pixelBuffer: CVPixelBuffer,
remoteID: String,
completion: @escaping (String?) -> Void) {
thumbnailQueue.async {
let fileName: String? = autoreleasepool {
let image = CIImage(cvPixelBuffer: pixelBuffer)
let targetSize = CGSize(width: 640, height: 360)
guard image.extent.width > 0, image.extent.height > 0 else {
return nil
}
// Scale in Core Image before materializing a CGImage. Creating
// a full 4K bitmap and then drawing it into a thumbnail can add
// tens of megabytes to the first-frame memory peak.
let scale = max(targetSize.width / image.extent.width,
targetSize.height / image.extent.height)
let scaled = image.transformed(by: CGAffineTransform(
scaleX: scale,
y: scale
))
let cropRect = CGRect(
x: scaled.extent.midX - targetSize.width / 2,
y: scaled.extent.midY - targetSize.height / 2,
width: targetSize.width,
height: targetSize.height
).integral
guard let thumbnailImage = context.createCGImage(
scaled.cropped(to: cropRect),
from: cropRect
) else {
return nil
}
let safeID = remoteID.filter {
$0.isLetter || $0.isNumber || $0 == "-"
}
let name = "\(safeID.isEmpty ? "remote" : safeID).jpg"
guard let url = thumbnailURL(fileName: name),
let data = UIImage(cgImage: thumbnailImage)
.jpegData(compressionQuality: 0.78) else {
return nil
}
do {
try data.write(to: url, options: .atomic)
return name
} catch {
return nil
}
}
DispatchQueue.main.async { completion(fileName) }
let name = thumbnailFileName(for: remoteID)
thumbnails.save(fileName: name, makeData: {
let image = CIImage(cvPixelBuffer: pixelBuffer)
let targetSize = CGSize(width: 640, height: 360)
guard image.extent.width > 0, image.extent.height > 0 else { return nil }
// Scale before materializing a bitmap to avoid a full-size frame copy.
let scale = max(targetSize.width / image.extent.width,
targetSize.height / image.extent.height)
let scaled = image.transformed(by: CGAffineTransform(scaleX: scale, y: scale))
let cropRect = CGRect(x: scaled.extent.midX - targetSize.width / 2,
y: scaled.extent.midY - targetSize.height / 2,
width: targetSize.width, height: targetSize.height).integral
guard let thumbnailImage = context.createCGImage(scaled.cropped(to: cropRect),
from: cropRect) else { return nil }
return UIImage(cgImage: thumbnailImage).jpegData(compressionQuality: 0.78)
}) { name in
DispatchQueue.main.async { completion(name) }
}
}
static func removeThumbnail(fileName: String?) {
guard let fileName, let url = thumbnailURL(fileName: fileName) else { return }
try? FileManager.default.removeItem(at: url)
static func removeThumbnail(for connection: RecentConnection) {
// The first capture may still be queued, before its filename has been
// attached to the recent connection. Delete that pending file too.
let pendingFileName = thumbnailFileName(for: connection.remoteID)
thumbnails.remove(fileName: pendingFileName)
if let fileName = connection.thumbnailFileName, fileName != pendingFileName {
thumbnails.remove(fileName: fileName)
}
}
private static func thumbnailDirectory() -> URL? {
guard let base = FileManager.default.urls(for: .applicationSupportDirectory,
in: .userDomainMask).first else {
return nil
}
let directory = base.appendingPathComponent("RecentConnectionThumbnails",
isDirectory: true)
do {
try FileManager.default.createDirectory(at: directory,
withIntermediateDirectories: true)
return directory
} catch {
return nil
static func removeAllThumbnails(completion: @escaping (Bool) -> Void) {
thumbnails.removeAll { success in
DispatchQueue.main.async { completion(success) }
}
}
}
private enum ConnectionCredentialStore {
@@ -319,8 +289,19 @@ private final class RemoteAudioPlayer {
}
final class RemoteSessionModel: NSObject, ObservableObject, CrossDeskRTCBridgeDelegate {
@Published var signalHost = "api.crossdesk.cn"
@Published var signalPort = "9099"
static let officialSignalHost = "api.crossdesk.cn"
static let officialSignalPort = "9099"
private let privacyPreferences = PrivacyPreferences()
@Published private(set) var hasNetworkConsent = PrivacyPreferences().hasNetworkConsent
@Published private(set) var privacyNoticeVisible = !PrivacyPreferences().hasMadeNetworkChoice
@Published private(set) var savesConnectionThumbnails = PrivacyPreferences().savesThumbnails
@Published private(set) var thumbnailCleanupInProgress = false
@Published private(set) var thumbnailCleanupError: String?
private var thumbnailGeneration = UUID()
@Published var signalHost = RemoteSessionModel.officialSignalHost
@Published var signalPort = RemoteSessionModel.officialSignalPort
@Published var mouseControlMode = MouseControlMode.saved {
didSet { mouseControlMode.save() }
}
@@ -329,7 +310,7 @@ final class RemoteSessionModel: NSObject, ObservableObject, CrossDeskRTCBridgeDe
}
@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?
@@ -408,7 +389,8 @@ final class RemoteSessionModel: NSObject, ObservableObject, CrossDeskRTCBridgeDe
override init() {
super.init()
bridge.delegate = self
configureBridge()
if !savesConnectionThumbnails { clearConnectionThumbnails() }
if hasNetworkConsent { configureBridge() }
}
deinit {
@@ -416,8 +398,81 @@ final class RemoteSessionModel: NSObject, ObservableObject, CrossDeskRTCBridgeDe
remoteVersionCheckTask?.cancel()
}
func showPrivacyNotice() {
privacyNoticeVisible = true
}
func acceptNetworkConsent() {
privacyPreferences.setNetworkConsent(true)
hasNetworkConsent = true
privacyNoticeVisible = false
configureBridge()
}
func declineNetworkConsent() {
revokeNetworkConsent()
privacyNoticeVisible = false
}
func revokeNetworkConsent() {
privacyPreferences.setNetworkConsent(false)
hasNetworkConsent = false
signalConnected = false
stopPresenceMonitoring()
invalidatePresence()
resetConnection()
// disconnect() only closes the remote session. Consent withdrawal must
// also destroy the persistent signaling peer and its reconnect loop.
bridge.stopNetworking()
bridgeConfiguration = nil
localIdentity = ""
signalStatus = "尚未同意联网"
savesConnectionThumbnails = false
clearConnectionThumbnails()
}
func setSavesConnectionThumbnails(_ allowed: Bool) {
privacyPreferences.setSavesThumbnails(allowed)
savesConnectionThumbnails = privacyPreferences.savesThumbnails
if !savesConnectionThumbnails { clearConnectionThumbnails() }
// Enabling applies on the next connection, never to a frame captured
// before the user opted in.
}
func clearConnectionThumbnails() {
thumbnailGeneration = UUID()
shouldCaptureThumbnail = false
for index in recentConnections.indices {
recentConnections[index].thumbnailFileName = nil
}
RecentConnectionStore.save(recentConnections)
thumbnailCleanupInProgress = true
thumbnailCleanupError = nil
let generation = thumbnailGeneration
RecentConnectionStore.removeAllThumbnails { [weak self] success in
guard let self, self.thumbnailGeneration == generation else { return }
self.thumbnailCleanupInProgress = false
self.thumbnailCleanupError = success
? nil
: "部分预览图未能清除,请重试。"
}
}
var usesOfficialServer: Bool {
signalHost == Self.officialSignalHost && signalPort == Self.officialSignalPort
}
func restoreOfficialServerConfiguration() {
signalHost = Self.officialSignalHost
signalPort = Self.officialSignalPort
}
@discardableResult
func configureBridge() -> Bool {
guard hasNetworkConsent else {
signalStatus = "尚未同意联网"
return false
}
let host = signalHost.trimmingCharacters(in: .whitespacesAndNewlines)
guard let signal = Int(signalPort),
(1...65535).contains(signal),
@@ -449,6 +504,10 @@ final class RemoteSessionModel: NSObject, ObservableObject, CrossDeskRTCBridgeDe
}
func connect() {
guard hasNetworkConsent else {
showPrivacyNotice()
return
}
connectionFailureMessage = nil
let identifier = remoteID.trimmingCharacters(in: .whitespacesAndNewlines)
guard !identifier.isEmpty else {
@@ -475,7 +534,7 @@ final class RemoteSessionModel: NSObject, ObservableObject, CrossDeskRTCBridgeDe
activeDisplayName = ""
displaySizes = []
connectionRecorded = false
shouldCaptureThumbnail = true
shouldCaptureThumbnail = savesConnectionThumbnails
isConnecting = true
isConnected = false
sessionVisible = false
@@ -485,7 +544,7 @@ final class RemoteSessionModel: NSObject, ObservableObject, CrossDeskRTCBridgeDe
}
private func beginPresenceProbe(_ identifier: String) {
guard signalConnected, presenceIsForeground else {
guard hasNetworkConsent, signalConnected, presenceIsForeground else {
showDeviceOffline()
return
}
@@ -524,7 +583,7 @@ final class RemoteSessionModel: NSObject, ObservableObject, CrossDeskRTCBridgeDe
}
private func requestPresenceProbe(_ identifier: String) {
guard signalConnected, presenceIsForeground else { return }
guard hasNetworkConsent, signalConnected, presenceIsForeground else { return }
// Older servers ignore subscribe=false and replace the watched list.
var identifiers = recentConnections.map(\.remoteID)
if !identifiers.contains(identifier) { identifiers.append(identifier) }
@@ -607,7 +666,7 @@ final class RemoteSessionModel: NSObject, ObservableObject, CrossDeskRTCBridgeDe
}
private func checkRemoteVersionIfNeeded() {
guard isConnected, remoteHostInfoReceived,
guard hasNetworkConsent, isConnected, remoteHostInfoReceived,
!remoteVersionCheckAttempted else { return }
remoteVersionCheckAttempted = true
if remoteAppVersion.isEmpty {
@@ -656,11 +715,11 @@ final class RemoteSessionModel: NSObject, ObservableObject, CrossDeskRTCBridgeDe
}
func thumbnailImage(for connection: RecentConnection) -> UIImage? {
guard let fileName = connection.thumbnailFileName,
let url = RecentConnectionStore.thumbnailURL(fileName: fileName) else {
guard savesConnectionThumbnails,
let fileName = connection.thumbnailFileName else {
return nil
}
return UIImage(contentsOfFile: url.path)
return UIImage(contentsOfFile: RecentConnectionStore.thumbnailURL(fileName: fileName).path)
}
func isRecentConnectionOnline(_ connection: RecentConnection) -> Bool {
@@ -668,14 +727,14 @@ final class RemoteSessionModel: NSObject, ObservableObject, CrossDeskRTCBridgeDe
}
private func isDeviceOnline(_ identifier: String) -> Bool {
guard signalConnected, presenceIsForeground, !awaitingPresenceSnapshot,
guard hasNetworkConsent, signalConnected, presenceIsForeground, !awaitingPresenceSnapshot,
recentConnectionPresence[identifier] == true,
let updatedAt = presenceUpdatedAt[identifier] else { return false }
return updatedAt.duration(to: presenceClock.now) < Self.presenceMaxAge
}
private func refreshRecentConnectionPresence() {
guard signalConnected, presenceIsForeground else { return }
guard hasNetworkConsent, signalConnected, presenceIsForeground else { return }
lastPresenceRefreshAt = presenceClock.now
bridge.requestPresence(remoteIDs: recentConnections.map(\.remoteID),
subscribe: true)
@@ -690,7 +749,7 @@ final class RemoteSessionModel: NSObject, ObservableObject, CrossDeskRTCBridgeDe
}
private func startPresenceMonitoring() {
guard signalConnected, presenceIsForeground, presenceTimer == nil else { return }
guard hasNetworkConsent, signalConnected, presenceIsForeground, presenceTimer == nil else { return }
let timer = Timer(timeInterval: 1, repeats: true) { [weak self] _ in
self?.maintainPresence()
}
@@ -704,7 +763,7 @@ final class RemoteSessionModel: NSObject, ObservableObject, CrossDeskRTCBridgeDe
}
private func maintainPresence() {
guard signalConnected, presenceIsForeground else { return }
guard hasNetworkConsent, signalConnected, presenceIsForeground else { return }
let now = presenceClock.now
let expired = presenceUpdatedAt.compactMap { identifier, updatedAt in
updatedAt.duration(to: now) >= Self.presenceMaxAge ? identifier : nil
@@ -779,7 +838,7 @@ final class RemoteSessionModel: NSObject, ObservableObject, CrossDeskRTCBridgeDe
recentConnectionPresence.removeValue(forKey: connection.remoteID)
presenceUpdatedAt.removeValue(forKey: connection.remoteID)
RecentConnectionStore.save(recentConnections)
RecentConnectionStore.removeThumbnail(fileName: connection.thumbnailFileName)
RecentConnectionStore.removeThumbnail(for: connection)
ConnectionCredentialStore.removePassword(for: connection.remoteID)
refreshRecentConnectionPresence()
}
@@ -823,12 +882,15 @@ final class RemoteSessionModel: NSObject, ObservableObject, CrossDeskRTCBridgeDe
}
private func captureRecentThumbnailIfNeeded(_ pixelBuffer: CVPixelBuffer) {
guard shouldCaptureThumbnail, !activeRemoteID.isEmpty else { return }
guard hasNetworkConsent, savesConnectionThumbnails,
shouldCaptureThumbnail, !activeRemoteID.isEmpty else { return }
shouldCaptureThumbnail = false
let generation = thumbnailGeneration
let identifier = activeRemoteID
RecentConnectionStore.captureThumbnail(from: pixelBuffer,
remoteID: identifier) { [weak self] fileName in
guard let self, let fileName,
guard let self, let fileName, self.hasNetworkConsent,
self.savesConnectionThumbnails, self.thumbnailGeneration == generation,
let index = self.recentConnections.firstIndex(where: {
$0.remoteID == identifier
}) else { return }
@@ -898,6 +960,7 @@ final class RemoteSessionModel: NSObject, ObservableObject, CrossDeskRTCBridgeDe
func rtcBridge(_ bridge: CrossDeskRTCBridge,
didChange state: CrossDeskSignalState) {
guard hasNetworkConsent else { return }
signalConnected = state.rawValue == 1
invalidatePresence()
switch state.rawValue {
@@ -970,6 +1033,7 @@ final class RemoteSessionModel: NSObject, ObservableObject, CrossDeskRTCBridgeDe
}
func rtcBridge(_ bridge: CrossDeskRTCBridge, didProvisionIdentity identity: String) {
guard hasNetworkConsent else { return }
localIdentity = identity.split(separator: "@").first.map(String.init) ?? identity
refreshRecentConnectionPresence()
if let pendingPresenceRemoteID {
@@ -980,7 +1044,7 @@ final class RemoteSessionModel: NSObject, ObservableObject, CrossDeskRTCBridgeDe
func rtcBridge(_ bridge: CrossDeskRTCBridge,
didReceivePresence presence: [String: NSNumber],
snapshot: Bool) {
guard signalConnected, presenceIsForeground,
guard hasNetworkConsent, signalConnected, presenceIsForeground,
snapshot || !awaitingPresenceSnapshot else { return }
var updated = snapshot ? [:] : recentConnectionPresence
if snapshot {
@@ -1013,7 +1077,7 @@ final class RemoteSessionModel: NSObject, ObservableObject, CrossDeskRTCBridgeDe
didReceive pixelBuffer: CVPixelBuffer,
width: Int,
height: Int) {
guard !videoWasBackgrounded else { return }
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
@@ -1084,10 +1148,12 @@ final class RemoteSessionModel: NSObject, ObservableObject, CrossDeskRTCBridgeDe
}
func rtcBridge(_ bridge: CrossDeskRTCBridge, didReceiveAudioPCM pcmData: Data) {
guard hasNetworkConsent, isConnected else { return }
audioPlayer.enqueue(pcmData)
}
func rtcBridge(_ bridge: CrossDeskRTCBridge, didReceiveClipboardText text: String) {
guard hasNetworkConsent, isConnected else { return }
UIPasteboard.general.string = text
clipboardStatus = "已接收远端剪贴板"
}
@@ -147,6 +147,17 @@ 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() } }
@@ -41,7 +41,7 @@
},
{
"id": "crossdesk",
"name": "CrossDesk / crossdesk_wire",
"name": "CrossDesk",
"version": "application source",
"license": "GPL-3.0-only",
"sourceURL": "https://github.com/kunkundi/crossdesk",
@@ -45,7 +45,9 @@ private struct LicenseCatalog: Decodable {
throw CocoaError(.fileNoSuchFile)
}
let catalog = try JSONDecoder().decode(LicenseCatalog.self, from: Data(contentsOf: url))
guard catalog.schemaVersion == 1, catalog.components.contains(where: { $0.id == "openfec" }) else {
let requiredComponents: Set<String> = ["crossdesk", "openfec"]
guard catalog.schemaVersion == 1,
requiredComponents.isSubset(of: Set(catalog.components.map(\.id))) else {
throw CocoaError(.fileReadCorruptFile)
}
return catalog
@@ -80,53 +82,23 @@ struct ThirdPartyLicensesView: View {
switch LicenseCatalog.bundled {
case .success(let catalog):
List {
Section("本版本源码与构建说明") {
if let source = SourceMetadata.bundled {
Text(source.tag ?? String(source.revision.prefix(12)))
.textSelection(.enabled)
if source.isModified {
Text("开发版本包含尚未发布的修改,以下链接为基础版本源码。")
.font(.footnote)
.foregroundStyle(.secondary)
}
Link("CrossDesk 源码", destination: source.sourceURL)
Link("获取源码、修改与重新构建", destination: source.buildInstructionsURL)
Link("隐私政策", destination: source.privacyPolicyURL)
if let application = catalog.components.first(where: { $0.id == "crossdesk" }) {
Section {
LabeledContent("版本", value: application.displayVersion)
}
Text("各组件的使用、复制、修改和再分发权利以相应开源许可证为准。普通使用条款不限制这些许可证授予的权利。")
.font(.footnote)
.foregroundStyle(.secondary)
}
Section("OpenFEC 告知") {
Text("本应用使用 OpenFEC \(catalog.components.first(where: { $0.id == "openfec" })?.displayVersion ?? "") 提供前向纠错功能,遵循 CeCILL-C 1.0 许可。")
Text("(c) Copyright 2009 - 2012 INRIA - All rights reserved")
.font(.footnote)
Text("OpenFEC 按其许可提供有限保证,作者、权利人和后续许可人的责任亦受限制。完整声明、许可条款及源码见下方。")
.font(.footnote)
.foregroundStyle(.secondary)
if let openFEC = catalog.components.first(where: { $0.id == "openfec" }) {
NavigationLink("OpenFEC 许可与源码") {
ComponentLicenseView(component: openFEC)
Section {
NavigationLink("软件许可") {
ComponentLicenseView(component: application)
}
}
}
Section {
ForEach(catalog.components) { component in
NavigationLink {
ComponentLicenseView(component: component)
} label: {
VStack(alignment: .leading, spacing: 4) {
Text(component.name)
Text(component.displayVersion + " · " + component.license)
.font(.caption)
.foregroundStyle(.secondary)
}
NavigationLink("开源组件") {
OpenSourceComponentsView(catalog: catalog)
}
NavigationLink("源码与构建说明") {
SourceCodeView(application: application)
}
} footer: {
Text("许可与版权声明可离线查阅。")
}
} header: {
Text("开源组件")
} footer: {
Text("许可全文和版权声明可离线阅读;查看源码需要网络连接。")
}
}
case .failure:
@@ -142,7 +114,74 @@ struct ThirdPartyLicensesView: View {
.padding()
}
}
.navigationTitle("关于与开源许可")
.navigationTitle("关于 CrossDesk")
.navigationBarTitleDisplayMode(.inline)
}
}
private struct OpenSourceComponentsView: View {
let catalog: LicenseCatalog
var body: some View {
List {
if let openFEC = catalog.components.first(where: { $0.id == "openfec" }) {
Section("OpenFEC 告知") {
Text("本应用使用 OpenFEC 提供前向纠错功能,遵循 CeCILL-C 1.0 许可。该许可规定了有限保证及责任限制,详情见许可全文。")
.font(.footnote)
.foregroundStyle(.secondary)
Text("(c) Copyright 2009 - 2012 INRIA - All rights reserved")
.font(.footnote)
NavigationLink("OpenFEC 许可与源码") {
ComponentLicenseView(component: openFEC)
}
}
}
Section {
ForEach(catalog.components.filter { $0.id != "crossdesk" && $0.id != "openfec" }) { component in
NavigationLink {
ComponentLicenseView(component: component)
} label: {
VStack(alignment: .leading, spacing: 4) {
Text(component.name)
Text(component.license)
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
} footer: {
Text("各组件遵循所列开源许可证,其授予的权利不受本应用普通使用条款限制。查看源码需要网络连接。")
}
}
.navigationTitle("开源组件")
.navigationBarTitleDisplayMode(.inline)
}
}
private struct SourceCodeView: View {
let application: LicensedComponent
var body: some View {
List {
Section("本版本源码与构建说明") {
if let source = SourceMetadata.bundled {
LabeledContent("源码版本", value: source.tag ?? String(source.revision.prefix(12)))
.textSelection(.enabled)
if source.isModified {
Text("开发版本包含尚未发布的修改,以下链接为基础版本源码。")
.font(.footnote)
.foregroundStyle(.secondary)
}
Link("查看源码", destination: source.sourceURL)
Link("构建说明", destination: source.buildInstructionsURL)
} else {
Text("本版本源码信息暂不可用。")
.foregroundStyle(.secondary)
Link("项目主页", destination: application.sourceURL)
}
}
}
.navigationTitle("源码与构建说明")
.navigationBarTitleDisplayMode(.inline)
}
}
@@ -153,9 +192,9 @@ private struct ComponentLicenseView: View {
var body: some View {
List {
Section {
Text(component.name).font(.headline)
Text(component.displayVersion).textSelection(.enabled)
Text(component.license).foregroundStyle(.secondary)
LabeledContent("版本", value: component.displayVersion)
.textSelection(.enabled)
LabeledContent("许可", value: component.license)
Link("查看源码", destination: component.resolvedSourceURL)
if let buildSourceURL = component.resolvedBuildSourceURL {
Link("构建配方与项目补丁", destination: buildSourceURL)
@@ -165,11 +204,10 @@ private struct ComponentLicenseView: View {
ForEach(component.documents) { document in
NavigationLink {
LicenseTextView(text: document.text)
.navigationTitle(document.title)
.navigationTitle(documentTitle(document.title))
.navigationBarTitleDisplayMode(.inline)
} label: {
Text(document.title == "Source copyright and license notices"
? "源码版权与许可声明" : document.title)
Text(documentTitle(document.title))
}
}
}
@@ -177,6 +215,15 @@ private struct ComponentLicenseView: View {
.navigationTitle(component.name)
.navigationBarTitleDisplayMode(.inline)
}
private func documentTitle(_ title: String) -> String {
switch title {
case "Source copyright and license notices": return "版权与许可声明"
case "apps/ios/licenses/OPEN_SOURCE_NOTICE.txt": return "开源软件权利说明"
case "LICENSE" where component.id == "crossdesk": return "GNU GPL 第 3 版"
default: return title
}
}
}
// UITextView keeps long license documents selectable and efficient to scroll.
@@ -0,0 +1,61 @@
import Foundation
/// Serialize writes and deletions so an in-flight capture cannot recreate a
/// preview after a clear/revocation has finished.
final class ThumbnailFileStore {
let directory: URL
private let queue = DispatchQueue(label: "cn.crossdesk.mobile.thumbnails",
qos: .utility)
init(directory: URL) {
self.directory = directory
}
func url(for fileName: String) -> URL {
directory.appendingPathComponent(fileName, isDirectory: false)
}
func save(fileName: String, makeData: @escaping () -> Data?,
completion: @escaping (String?) -> Void) {
queue.async {
let saved: String? = autoreleasepool {
guard let data = makeData() else { return nil }
do {
try FileManager.default.createDirectory(at: self.directory,
withIntermediateDirectories: true)
var directory = self.directory
var values = URLResourceValues()
values.isExcludedFromBackup = true
try directory.setResourceValues(values)
#if os(iOS)
try data.write(to: self.url(for: fileName),
options: [.atomic, .completeFileProtection])
#else
try data.write(to: self.url(for: fileName), options: .atomic)
#endif
return fileName
} catch {
return nil
}
}
completion(saved)
}
}
func remove(fileName: String) {
queue.async { try? FileManager.default.removeItem(at: self.url(for: fileName)) }
}
func removeAll(completion: @escaping (Bool) -> Void) {
queue.async {
do {
if FileManager.default.fileExists(atPath: self.directory.path) {
try FileManager.default.removeItem(at: self.directory)
}
completion(true)
} catch {
completion(false)
}
}
}
}
+21 -4
View File
@@ -92,16 +92,18 @@ and rejects a build if they are present, including in cached archives.
## Third-party licenses
**设置 → 关于与开源许可** provides offline license texts, copyright notices,
and source links. OpenFEC is acknowledged both on the settings page and at the
top of the notices page, with its INRIA copyright, CeCILL-C terms and limited
**设置 → 关于** shows the app version and links to the application's
license, open-source components, and source/build information. `crossdesk_wire`
is part of CrossDesk's own source and is covered by its application license,
not a separate third-party entry. OpenFEC is acknowledged at the top of the
open-source components page, with its INRIA copyright, CeCILL-C terms and limited
warranty/liability notice. Its embedded BSD and CC-BY-SA notices are preserved
in the source-notices document as well.
`licenses/sources.json` pins the reviewed source archives (SHA-256), notice-review
Git revisions, selected legal files and source-notice extraction rules.
`CrossDeskMobile/Resources/ThirdPartyLicenses.json` is the generated resource
bundled by Xcode. The catalog covers 34 components for the default iOS build,
bundled by Xcode. The catalog covers CrossDesk and 33 dependency entries for the default iOS build,
including header-only dependencies, GLib's proxy-libintl, spdlog's bundled fmt,
WebRTC-derived code, inih, and the applicable patent notices. Host build tools,
OpenH264, desktop-only libraries, and the disabled libpsl built-in suffix list
@@ -172,6 +174,21 @@ set the `MINIRTC_ENABLE_AOM` environment variable (or Xcode user-defined build s
to `true`. This includes libaom in the merged native archive without changing the
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
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.
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
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.
## Physical-device test checklist
Run the app from Xcode on a physical device and connect to a current desktop
+2 -2
View File
@@ -56,7 +56,7 @@
},
{
"id": "crossdesk",
"name": "CrossDesk / crossdesk_wire",
"name": "CrossDesk",
"version": "application source",
"license": "GPL-3.0-only",
"source": {
@@ -898,7 +898,7 @@
]
}
],
"bundle_sha256": "58702163ac79d8821007b1ae44ef054cf1d9a3cec9d6334b852f0bbe99b5af01",
"bundle_sha256": "f54e5e2168bf6e91d81a413931a052d29f59e0c221df5e1de3f51bd27c662c3a",
"toolchain": {
"xmake_version": "3.1.1",
"xmake_repository_revision": "121109352da272cd1a3d4ed52f2dcda34f839955"
+2
View File
@@ -26,6 +26,8 @@ def verify(app, require_release=False):
if re.search(r"\b_of_(?:hweight\w*|hw8table|popcount_3)\b", defined):
raise ValueError("OpenFEC's optional CC-BY-SA-3.0 hamming-weight code requires a separate distribution review")
privacy = plistlib.loads((app / "PrivacyInfo.xcprivacy").read_bytes())
if not (app / "PRIVACY.md").read_text().strip():
raise ValueError("The offline privacy policy must be bundled for pre-consent access")
reasons = {entry["NSPrivacyAccessedAPIType"]: entry["NSPrivacyAccessedAPITypeReasons"]
for entry in privacy["NSPrivacyAccessedAPITypes"]}
required = {
+140
View File
@@ -0,0 +1,140 @@
"""Run the production privacy preferences and preview file store on macOS."""
from pathlib import Path
import subprocess
import sys
import tempfile
import unittest
@unittest.skipUnless(sys.platform == "darwin", "requires the Apple Swift runtime")
class PrivacyControlsTest(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.temp = tempfile.TemporaryDirectory(prefix="crossdesk-privacy-tests-")
cls.addClassCleanup(cls.temp.cleanup)
root = Path(cls.temp.name)
harness = root / "PrivacyTests.swift"
harness.write_text(r'''
import Foundation
func expect(_ condition: @autoclosure () -> Bool, _ message: String) {
if !condition() { fatalError(message) }
}
func wait(_ semaphore: DispatchSemaphore) {
expect(semaphore.wait(timeout: .now() + 10) == .success, "Operation timed out")
}
@main
enum PrivacyTests {
static func main() throws {
let test = CommandLine.arguments[1]
if test == "consent" || test == "upgrade" {
let suite = "crossdesk.privacy.tests.\(UUID().uuidString)"
let defaults = UserDefaults(suiteName: suite)!
defer { defaults.removePersistentDomain(forName: suite) }
if test == "upgrade" {
// Existing identity/history must not be interpreted as consent.
defaults.set("test-identity", forKey: "crossdesk.mobile.identity")
defaults.set(Data("legacy history".utf8),
forKey: "crossdesk.mobile.recent-connections.v1")
}
let preferences = PrivacyPreferences(defaults: defaults)
expect(!preferences.hasMadeNetworkChoice, "New/legacy install has no choice")
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")
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")
relaunched.setNetworkConsent(false)
expect(!relaunched.hasNetworkConsent && !relaunched.savesThumbnails,
"Withdrawal revokes both permissions")
relaunched.setNetworkConsent(true)
expect(!relaunched.savesThumbnails, "Re-consent must not re-enable screenshots")
} else {
let directory = FileManager.default.temporaryDirectory
.appendingPathComponent("crossdesk-thumbnails-\(UUID().uuidString)")
defer { try? FileManager.default.removeItem(at: directory) }
let store = ThumbnailFileStore(directory: directory)
let started = DispatchSemaphore(value: 0)
let finishCapture = DispatchSemaphore(value: 0)
let cleared = DispatchSemaphore(value: 0)
store.save(fileName: "remote.jpg", makeData: {
started.signal()
wait(finishCapture)
return Data("sensitive frame".utf8)
}) { fileName in
expect(fileName == "remote.jpg", "Capture must exercise the real write path")
}
wait(started)
if test == "clear" {
// Withdrawal/clear arrives while image conversion is still running.
store.removeAll { success in
expect(success, "Clear must succeed")
cleared.signal()
}
} else {
// Removing a recent connection must also win over its pending capture.
store.remove(fileName: "remote.jpg")
store.save(fileName: "barrier.jpg", makeData: { Data([1]) }) { _ in
cleared.signal()
}
}
finishCapture.signal()
wait(cleared)
expect(!FileManager.default.fileExists(atPath: store.url(for: "remote.jpg").path),
"A pending write must not resurrect a deleted preview")
// A later explicitly permitted capture can still be saved.
let saved = DispatchSemaphore(value: 0)
store.save(fileName: "new.jpg", makeData: { Data("new frame".utf8) }) { name in
expect(name == "new.jpg", "New captures must work after clearing")
saved.signal()
}
wait(saved)
let data = try Data(contentsOf: store.url(for: "new.jpg"))
expect(data == Data("new frame".utf8), "Saved preview must be intact")
let values = try directory.resourceValues(forKeys: [.isExcludedFromBackupKey])
expect(values.isExcludedFromBackup == true, "Previews must be excluded from backup")
}
}
}
''')
source = Path(__file__).resolve().parents[1] / "CrossDeskMobile"
cls.binary = root / "privacy-tests"
subprocess.run([
"xcrun", "swiftc", "-swift-version", "5",
str(source / "PrivacyPreferences.swift"),
str(source / "ThumbnailFileStore.swift"), str(harness),
"-o", str(cls.binary),
], check=True, capture_output=True, text=True)
def run_case(self, case):
result = subprocess.run([str(self.binary), case], capture_output=True,
text=True, timeout=30)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
def test_consent_persistence_and_withdrawal(self):
self.run_case("consent")
def test_upgrade_requires_fresh_choices(self):
self.run_case("upgrade")
def test_clear_wins_over_in_flight_capture(self):
self.run_case("clear")
def test_remove_connection_wins_over_in_flight_capture(self):
self.run_case("remove")
if __name__ == "__main__":
unittest.main()