mirror of
https://github.com/kunkundi/crossdesk.git
synced 2026-10-20 19:14:39 +08:00
[fix] require iOS privacy consent and simplify settings
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user