From a4baca222c71d69b114398768951d34feac450b9 Mon Sep 17 00:00:00 2001 From: dijunkun Date: Mon, 28 Sep 2026 16:53:43 +0800 Subject: [PATCH] [fix] require iOS privacy consent and simplify settings --- .github/workflows/build.yml | 3 + PRIVACY.md | 174 +++++++----- .../CrossDeskMobile.xcodeproj/project.pbxproj | 16 ++ .../Bridge/CrossDeskRTCBridge.h | 2 + .../Bridge/CrossDeskRTCBridge.mm | 22 ++ apps/ios/CrossDeskMobile/ContentView.swift | 262 ++++++++++++------ .../CrossDeskMobile/PrivacyPreferences.swift | 28 ++ apps/ios/CrossDeskMobile/PrivacyViews.swift | 245 ++++++++++++++++ .../CrossDeskMobile/RemoteSessionModel.swift | 244 ++++++++++------ .../CrossDeskMobile/RemoteSessionView.swift | 11 + .../Resources/ThirdPartyLicenses.json | 2 +- .../ThirdPartyLicensesView.swift | 149 ++++++---- .../CrossDeskMobile/ThumbnailFileStore.swift | 61 ++++ apps/ios/README.md | 25 +- apps/ios/licenses/sources.json | 4 +- apps/ios/scripts/verify_app_bundle.py | 2 + apps/ios/tests/privacy_controls_test.py | 140 ++++++++++ 17 files changed, 1091 insertions(+), 299 deletions(-) create mode 100644 apps/ios/CrossDeskMobile/PrivacyPreferences.swift create mode 100644 apps/ios/CrossDeskMobile/PrivacyViews.swift create mode 100644 apps/ios/CrossDeskMobile/ThumbnailFileStore.swift create mode 100644 apps/ios/tests/privacy_controls_test.py diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 9908a777..27daf316 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -427,6 +427,9 @@ jobs: python3 -B -m unittest discover -s apps/ios/tests -p 'third_party_licenses_test.py' -v python3 -B apps/ios/scripts/generate_third_party_licenses.py + - name: Verify iOS privacy controls + run: python3 -B -m unittest discover -s apps/ios/tests -p 'privacy_controls_test.py' -v + - name: Install xmake uses: xmake-io/github-action-setup-xmake@v1 with: diff --git a/PRIVACY.md b/PRIVACY.md index 69bd2214..3949e268 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -6,112 +6,142 @@ Last updated: September 28, 2026 ## 中文 -### 适用范围 +更新日期:2026 年 9 月 28 日 -本隐私政策适用于 CrossDesk 桌面客户端、iOS 客户端及项目提供的默认网络服务。用户自行部署或指定的服务器由相应服务器运营者负责,其数据处理方式不在本政策控制范围内。 +CrossDesk 重视您的个人信息与隐私保护。本政策说明我们在提供远程桌面服务时如何处理相关信息,以及您可以如何管理授权、保存的内容和数据请求。请在使用前阅读并了解本政策。 -### 网络通信 +### 一、适用范围 -CrossDesk 是远程桌面软件,需要通过网络完成设备发现、连接协商及远程会话。客户端可能进行以下网络通信: +本政策适用于 CrossDesk 桌面客户端、iOS 客户端,以及由 CrossDesk 项目维护者(以下简称“我们”)提供的默认网络服务。 -- **默认服务:** 未启用自托管时,客户端会连接预配置的 CrossDesk 服务(`api.crossdesk.cn`),用于设备注册、信令、网络地址转换(NAT)穿透,并在无法建立直接连接时提供中继。 -- **版本检查:** 桌面客户端启动时会从 CrossDesk 版本服务(`version.crossdesk.cn`)获取最新版本信息,也会用这些信息提示升级旧版被控端。iOS 客户端在连接成功且收到被控端版本号后,会从同一服务获取最新桌面版本信息,以提示升级被控端。这些请求不携带设备 ID 或被控端版本号,启用自托管时仍会访问 CrossDesk 版本服务。 -- **远程会话:** 用户主动发起或接受远程连接后,CrossDesk 会根据使用的功能在设备之间传输屏幕画面、音频、键盘和鼠标输入、剪贴板文本以及用户选择传输的文件。剪贴板文本可能在远程会话期间自动同步。这些数据可能通过直接连接传输,也可能在必要时通过配置的中继服务器转发。 -- **自托管:** 用户可以在设置中指定自己的信令及中继服务器。自托管服务器的日志、数据保存和访问规则由其运营者决定。 +如果您选择自行部署或使用其他运营者提供的服务器,该服务器所处理的信息、保存期限及安全管理由相应运营者负责。请在使用前了解其隐私政策。本政策中有关客户端本地数据处理的说明仍适用。 -### 处理的信息 +### 二、我们处理的信息及用途 -为提供上述功能,CrossDesk 可能处理: +**设备连接与身份验证。** 为登记设备、验证连接权限、查询在线状态及建立远程连接,客户端与服务端会处理设备 ID、认证信息及在线状态。服务端还会保存设备身份及认证校验信息、在线与离线时间、客户端版本与平台、设备关联记录,并生成必要的运行和安全日志,用于维持服务、排查故障及防止滥用。 -- 连接所需的设备标识及认证信息; -- IP 地址、端口、连接时间、连接状态等必要的网络元数据; -- 版本检查请求产生的标准网络请求信息; -- 用户授权的远程会话中处理的屏幕、音频、控制指令、剪贴板及文件内容。 +**必要的网络信息。** 建立连接、网络地址转换穿透及中继转发需要处理 IP 地址、端口等网络信息。这些信息用于通信和网络诊断,不等同于读取设备的定位权限。连接服务、中继服务及网络请求可能产生相应日志。 -### 信息的使用与保存 +**远程会话内容。** 您发起或接受远程连接后,软件根据使用的功能在参与会话的设备之间传输画面、音频、键盘和鼠标操作、剪贴板文本及文件。会话内容可能包含个人或工作信息,请仅连接您拥有或已获授权使用的设备,并按需管理共享内容。 -- 设备标识、连接设置及用户偏好会保存在本地设备上,以便后续使用。 -- 通过官方信令和中继服务处理的信息仅用于建立、维持和转发 CrossDesk 连接,以及服务运行、安全防护和故障排查。 -- 中继传输的远程会话内容仅用于实时转发,不用于广告或用户画像。 -- CrossDesk 桌面及 iOS 客户端不包含广告功能,也不会主动上传诊断日志、崩溃报告或用户行为分析数据。 -- 自托管服务器可能保存的日志和数据由其运营者控制,请向相应运营者了解其隐私与保存政策。 +**版本检查。** 桌面客户端启动后会获取版本信息;iOS 客户端在连接成功并收到远端版本信息后,会获取最新桌面版本信息,用于提示升级。该请求不携带设备 ID 或远端版本号,但网络服务会处理完成请求所必需的网络信息。 -### 用户选择 +CrossDesk 未集成广告投放、广告追踪或用户行为分析功能。客户端不会主动上传诊断日志、崩溃报告或用户行为分析数据。您主动向我们提交问题或日志时,请先移除与问题无关的个人信息及连接凭据。 -用户可以: +### 三、数据传输与相关服务 -- 配置自托管服务器,以替代默认信令和中继服务; -- 决定是否发起或接受远程会话; -- 决定是否选择并传输文件; -- 结束远程会话,以停止会话数据传输; -- 删除 CrossDesk 的本地配置、缓存及日志文件。 +我们提供设备登记、连接协商、中继转发及版本检查服务。即使您使用自托管连接服务,客户端仍会按前述条件访问我们提供的版本服务。 -### iOS 客户端的本地数据与权限 +远程会话内容优先在参与会话的设备间直接传输,必要时通过配置的中继服务器转发。中继服务用于实时转发,不提供会话录像或文件归档。客户端本地保存的画面预览和接收文件,按本政策相关说明处理。 -- iOS 客户端作为控制端接收远程电脑的画面和音频,不采集本机屏幕、摄像头或麦克风。 -- 局域网权限用于发现和连接局域网中的设备。客户端也可通过信令及中继服务连接远程设备。 -- 服务器配置及最近连接记录保存在应用本地;最近连接记录可以包含远程桌面的预览缩略图。本机信令登录凭据和选择记住的远程连接密码保存在系统钥匙串中,不通过 iCloud 钥匙串同步。升级时会将旧版本保存在应用偏好中的本机登录凭据迁入钥匙串,保存成功后删除旧值。删除相应最近连接记录时,也会删除其缩略图和已保存的远程连接密码。 -- 当前 iOS 界面不读取或发送本机剪贴板文本;会话收到的远程剪贴板文本可以写入本机剪贴板。 -- 发送文件需通过系统文件选择器选择文件;接收的文件保存在应用的 `Documents/Received` 目录,可通过系统“文件”应用或共享功能管理。 -- 应用及通信库使用本地文件元数据、应用偏好和计时 API 提供文件传输、配置保存及网络计时功能,不使用这些信息制作设备指纹或进行广告跟踪。 +自托管服务器由您选择的运营者管理。更换连接服务器后,设备登记、连接协商及中继数据将由相应运营者处理。 -### 政策更新与联系方式 +### 四、信息保存与安全措施 -本政策可能随着 CrossDesk 功能或网络服务的变化而更新。更新内容将在本文件中发布。如有隐私相关问题,请联系 [junkun.di@hotmail.com](mailto:junkun.di@hotmail.com)。 +**客户端本地数据。** 软件会在本机保存连接设置、用户偏好、最近连接记录及必要的运行日志,以支持后续使用和故障排查。iOS 中的本机登录凭据及您选择记住的远程连接密码保存在系统钥匙串中,不通过 iCloud 钥匙串同步;旧版偏好中的本机登录凭据在成功迁入钥匙串后删除。 + +**远程画面预览。** iOS 默认不保存远程画面预览。只有您在“设置 → 隐私”单独开启并确认后,才会从下一次连接起,每次连接保存一张远程画面,用于最近连接预览;会话中会显示保存已开启的提示。预览仅保存在本机,不上传,并设置为不纳入设备备份。您可以单独清除预览;关闭保存或撤回隐私授权也会清除已有预览。旧版本未经此选择保存的预览会在升级后清理。 + +**接收的文件。** iOS 接收文件保存在本应用的文件目录中,您可通过系统“文件”应用管理或删除。清除画面预览不会删除接收文件或最近连接记录。 + +**服务端记录。** 为支持再次连接、服务运维和安全排查,设备身份、状态、关联记录及运行日志可能在连接结束后继续保存。保存范围和期限受服务运行需要、部署规则及适用的保留要求约束。您可以通过下述联系方式查询与您设备相关的信息及保存期限,或提出删除请求。撤回隐私授权、删除本地记录或卸载客户端,不会自动删除已产生的服务端记录;日志、备份和快照需分别核查和处理。 + +iOS 通过系统钥匙串和文件保护机制保护相应本地数据,默认信令连接进行 TLS 证书校验。请妥善保管设备和连接凭据,避免向无关人员共享访问密码。 + +### 五、iOS 权限与授权选择 + +**隐私授权。** 首次使用,或从未提供此选择的旧版升级时,您需阅读本政策、主动勾选并点击同意后,客户端才会登记本机身份、连接信令服务和查询设备在线状态。拒绝后仍可查看设置、离线隐私政策和本地连接记录,远程连接功能暂不可用。 + +**撤回同意。** 您可以在“设置 → 隐私 → 隐私与授权”中查看隐私政策、授权状态或撤回授权。客户端将停止远程会话、信令连接和版本检查,关闭并清除本地画面预览。再次使用连接功能前需重新同意;重新同意不会自动开启画面预览保存。撤回不影响撤回前已经发生的信息处理,也不等同于删除服务端数据。 + +**系统权限与会话功能。** iOS 客户端作为控制端接收远程电脑的画面与音频,不采集本机屏幕、摄像头或麦克风。局域网访问权限用于连接本地网络中的设备,您可在系统设置中管理。发送文件通过系统文件选择器选择,不要求开放整个文件库。当前 iOS 界面不读取或发送本机剪贴板文本;远端发送的剪贴板文本可在会话中写入本机剪贴板。 + +应用及通信组件使用本地文件元数据、应用偏好和计时接口实现文件传输、设置保存及网络计时,不使用这些信息制作设备指纹或进行广告追踪。 + +### 六、查询与删除请求 + +您可以管理本地最近连接记录、保存的画面预览及接收文件。删除某条最近连接记录时,其预览及已保存的远程连接密码也会删除。 + +如需查询、获取数据摘要或删除默认服务中与您设备相关的记录,请通过下述邮箱联系我们,说明设备 ID 和请求范围。为避免未经授权的披露或删除,我们会核实请求权限;请勿通过邮件发送访问密码、私钥或其他敏感凭据。 + +删除服务端设备身份后,原身份及对应凭据将失效,再次连接可能登记为新设备。数据库记录的删除不表示日志、备份或其他设备上已接收的内容同时删除。使用自托管服务时,请向相应服务器运营者提出服务端数据请求。 + +### 七、政策更新与联系方式 + +本政策可能随功能及服务方式调整而更新,更新后的内容和日期将在隐私政策中公布。您可随时在应用设置中查阅当前随应用提供的政策文本。 + +如对本政策或个人信息处理有疑问,请联系: + +邮箱:[junkun.di@hotmail.com](mailto:junkun.di@hotmail.com) --- ## English -### Scope +Updated: September 28, 2026 -This Privacy Policy applies to the CrossDesk desktop and iOS clients and the default network services provided by the project. Servers deployed or selected by users are operated by their respective administrators, and their data-processing practices are outside the control of this policy. +CrossDesk respects your privacy. This policy explains how information is processed when we provide remote desktop services and how you can manage consent, locally saved content and data requests. Please read this policy before using the software. -### Network communications +### 1. Scope -CrossDesk is remote desktop software and requires network communications for device discovery, connection negotiation, and remote sessions. The client may perform the following communications: +This policy applies to the CrossDesk desktop and iOS clients and the default network services provided by the CrossDesk project maintainers ("we" or "us"). -- **Default services:** Unless self-hosting is enabled, the client connects to preconfigured CrossDesk services (`api.crossdesk.cn`) for device registration, signaling, network address translation (NAT) traversal, and relay when a direct connection cannot be established. -- **Update checks:** On startup, the desktop client retrieves current release information from the CrossDesk version service (`version.crossdesk.cn`) and also uses it to suggest updating older remote hosts. After connecting and receiving a remote host's version, the iOS client retrieves the latest desktop release information from the same service to suggest updating that host. These requests do not include device IDs or the remote host's version. Both clients continue to use the CrossDesk version service when self-hosting is enabled. -- **Remote sessions:** After a user initiates or accepts a remote connection, CrossDesk transmits the data required by enabled features between devices. This may include screen content, audio, keyboard and mouse input, clipboard text, and files selected by the user. Clipboard text may be synchronized automatically during a remote session. Data may travel over a direct connection or through the configured relay server when necessary. -- **Self-hosting:** Users may configure their own signaling and relay servers. Logging, retention, and access policies for a self-hosted server are determined by its operator. +If you deploy your own server or choose a server provided by another operator, that operator is responsible for its data processing, retention and security practices. Please review its privacy policy before use. The descriptions of local client data processing in this policy still apply. -### Information processed +### 2. Information processed and its purposes -To provide these functions, CrossDesk may process: +**Device connections and authentication.** To register devices, verify connection permissions, query availability and establish remote connections, the clients and server process device IDs, authentication information and presence. The server also retains device identity and authentication verification records, online and offline times, client versions and platforms, and device associations. Necessary operational and security logs support service operation, troubleshooting and abuse prevention. -- device identifiers and authentication information required for connections; -- necessary network metadata, such as IP addresses, ports, connection times, and connection status; -- standard network request information generated by update checks; and -- screen, audio, control, clipboard, and file content processed during a remote session authorized by the user. +**Necessary network information.** Connections, network address translation traversal and relay forwarding require IP addresses, ports and other network information. This supports communication and network diagnostics and does not involve reading the device's location permission. Connection services, relay services and network requests may generate related logs. -### Use and storage of information +**Remote-session content.** After you initiate or accept a remote connection, the software transfers content between participating devices according to the features used. This includes screens, audio, keyboard and mouse input, clipboard text and files. Such content may contain personal or work information. Connect only to devices you own or are authorized to access, and manage shared content accordingly. -- Device identifiers, connection settings, and user preferences are stored locally for subsequent use. -- Information processed by the official signaling and relay services is used only to establish, maintain, and relay CrossDesk connections and for service operation, security, and troubleshooting. -- Remote-session content passing through a relay is used only for real-time forwarding and is not used for advertising or user profiling. -- The CrossDesk desktop and iOS clients do not contain advertising functionality and does not intentionally upload diagnostic logs, crash reports, or behavioral analytics. -- Logs and data that may be retained by self-hosted servers are controlled by their operators. Contact the relevant operator for its privacy and retention practices. +**Version checks.** The desktop client retrieves release information after startup. After connecting and receiving the remote host's version, the iOS client retrieves the latest desktop release information to suggest updates. These requests do not contain device IDs or the remote host's version, but the service processes network information necessary to complete the request. -### User choices +CrossDesk does not integrate advertising, advertising tracking or behavioral analytics. The clients do not proactively upload diagnostic logs, crash reports or behavioral analytics data. If you choose to submit an issue or logs, remove unrelated personal information and connection credentials first. -Users may: +### 3. Data transmission and related services -- configure a self-hosted server instead of the default signaling and relay services; -- choose whether to initiate or accept a remote session; -- choose whether to select and transfer files; -- end a remote session to stop session-data transmission; and -- remove local CrossDesk configuration, cache, and log files. +We provide device registration, connection negotiation, relay forwarding and version checks. Even when you use a self-hosted connection service, the client still accesses our version service under the conditions described above. -### iOS local data and permissions +Remote-session content is transmitted directly between participating devices where possible, or through the configured relay server when needed. The relay service forwards content in real time and does not provide session recording or file archiving. Locally saved previews and received files are handled as described in this policy. -- The iOS client controls remote computers and receives their screen content and audio. It does not capture the iPhone or iPad screen, camera or microphone. -- Local network permission supports discovering and connecting to local devices. Remote connections may also use signaling and relay services. -- Server settings and recent connections are stored locally. Recent connections may include remote desktop preview thumbnails. Local signaling credentials and remembered remote connection passwords are stored in the system Keychain without iCloud Keychain synchronization. On upgrade, legacy local login credentials stored in app preferences are migrated to the Keychain; the old values are removed only after saving succeeds. Removing a recent connection also removes its thumbnail and saved remote connection password. -- The current iOS interface does not read or send local clipboard text. Clipboard text received during a remote session may be written to the local clipboard. -- Sending a file requires selecting it using the system document picker. Received files are stored in the application's `Documents/Received` directory and can be managed through Files or the share action. -- The application and its communication libraries use local file metadata, app preferences and timing APIs for file transfer, settings and network timing. This information is not used for device fingerprinting or advertising tracking. +Self-hosted servers are managed by the operator you choose. If you change the connection server, the corresponding operator will process device registration, connection negotiation and relay data. -### Changes and contact +### 4. Storage and security measures -This policy may be updated as CrossDesk features or network services change. Updates will be published in this file. For privacy-related questions, contact [junkun.di@hotmail.com](mailto:junkun.di@hotmail.com). +**Local client data.** The software stores connection settings, preferences, recent connections and necessary operational logs on the device for subsequent use and troubleshooting. On iOS, local login credentials and remote connection passwords you choose to remember are stored in the system Keychain without iCloud Keychain synchronization. Legacy local login credentials in preferences are removed after successful migration to the Keychain. + +**Remote desktop previews.** Saving previews is off by default on iOS. Only after you separately enable and confirm it in Settings → Privacy will the app save one remote frame per connection, starting with the next connection, for recent-connection previews. An indicator appears during sessions while saving is enabled. Previews remain local, are not uploaded and are marked as excluded from device backups. You can clear them separately. Disabling saving or withdrawing network consent also clears them. Previews saved by older versions without this choice are cleared on upgrade. + +**Received files.** Received files on iOS are stored in the app's file directory and can be managed or deleted through Files. Clearing previews does not delete received files or recent-connection records. + +**Server records.** Device identities, presence, associations and operational logs may remain after a connection ends to support reconnection, service operation and security troubleshooting. Their scope and retention depend on operational needs, deployment rules and applicable retention requirements. You can contact us below to ask about records and retention periods related to your device or request deletion. Withdrawing network consent, deleting local records or uninstalling the client does not automatically delete existing server records. Logs, backups and snapshots require separate review and handling. + +On iOS, the system Keychain and file protection mechanisms protect the relevant local data, and default signaling connections validate TLS certificates. Keep your devices and connection credentials secure and avoid sharing access passwords with unrelated people. + +### 5. iOS permissions and consent choices + +**Privacy authorization.** On first use, or when upgrading from a version without this choice, you must read this policy, actively select the checkbox and agree before the client registers a local identity, connects to signaling or queries device presence. If you decline, settings, the offline policy and local connection history remain available, while remote connections are unavailable. + +**Withdrawal.** You can review the privacy policy and your authorization status or withdraw consent in Settings → Privacy → Privacy and Authorization. The client stops remote sessions, signaling and version checks, disables preview saving and clears local previews. Consent is required again before reconnecting. Agreeing again does not automatically enable preview saving. Withdrawal does not undo processing that already occurred or delete server data. + +**System permissions and session features.** The iOS client receives remote computer screens and audio as a controller; it does not capture the local screen, camera or microphone. Local network access supports connections to devices on your network and can be managed in system settings. File sending uses the system document picker without requiring access to the entire file library. The current iOS interface does not read or send local clipboard text. Text sent by the remote device may be written to the local clipboard during a session. + +The app and its communication components use local file metadata, preferences and timing APIs for file transfer, settings and network timing, not for device fingerprinting or advertising tracking. + +### 6. Access and deletion requests + +You can manage local recent connections, saved previews and received files. Removing a recent connection also removes its preview and remembered remote connection password. + +To request access to information, a data summary or deletion of records associated with your device in the default service, contact the email address below with the device ID and scope of your request. We verify the requester's authority to avoid unauthorized disclosure or deletion. Do not send access passwords, private keys or other sensitive credentials by email. + +Deleting a server-side device identity invalidates that identity and its credentials; reconnecting may register a new device. Deleting database records does not mean that logs, backups or content already received by other devices are also deleted. For a self-hosted service, direct server-side data requests to its operator. + +### 7. Policy changes and contact + +This policy may be updated as features and services change. Updated text and its date will be published in the privacy policy. You can review the policy bundled with your app in its settings at any time. + +For questions about this policy or the processing of personal information, contact: + +Email: [junkun.di@hotmail.com](mailto:junkun.di@hotmail.com) diff --git a/apps/ios/CrossDeskMobile.xcodeproj/project.pbxproj b/apps/ios/CrossDeskMobile.xcodeproj/project.pbxproj index b360f584..54e87f3f 100644 --- a/apps/ios/CrossDeskMobile.xcodeproj/project.pbxproj +++ b/apps/ios/CrossDeskMobile.xcodeproj/project.pbxproj @@ -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 = ""; }; 200000000000000000000010 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = PrivacyInfo.xcprivacy; sourceTree = ""; }; 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 = ""; }; + 200000000000000000000013 /* ThumbnailFileStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThumbnailFileStore.swift; sourceTree = ""; }; + 200000000000000000000014 /* PrivacyViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrivacyViews.swift; sourceTree = ""; }; + 200000000000000000000015 /* PRIVACY.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = ../../../PRIVACY.md; sourceTree = ""; }; /* 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; }; diff --git a/apps/ios/CrossDeskMobile/Bridge/CrossDeskRTCBridge.h b/apps/ios/CrossDeskMobile/Bridge/CrossDeskRTCBridge.h index 87d088fe..0cd39e1c 100644 --- a/apps/ios/CrossDeskMobile/Bridge/CrossDeskRTCBridge.h +++ b/apps/ios/CrossDeskMobile/Bridge/CrossDeskRTCBridge.h @@ -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 diff --git a/apps/ios/CrossDeskMobile/Bridge/CrossDeskRTCBridge.mm b/apps/ios/CrossDeskMobile/Bridge/CrossDeskRTCBridge.mm index 1bb6d146..adf45af5 100644 --- a/apps/ios/CrossDeskMobile/Bridge/CrossDeskRTCBridge.mm +++ b/apps/ios/CrossDeskMobile/Bridge/CrossDeskRTCBridge.mm @@ -489,6 +489,7 @@ Params MakeParams(const RTCState &state, const std::string &user_id, std::atomic _identityGeneration; std::atomic _signalGeneration; std::atomic _presenceGeneration; + std::atomic _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) { diff --git a/apps/ios/CrossDeskMobile/ContentView.swift b/apps/ios/CrossDeskMobile/ContentView.swift index bf8f6998..4d9bb28b 100644 --- a/apps/ios/CrossDeskMobile/ContentView.swift +++ b/apps/ios/CrossDeskMobile/ContentView.swift @@ -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() + } } diff --git a/apps/ios/CrossDeskMobile/PrivacyPreferences.swift b/apps/ios/CrossDeskMobile/PrivacyPreferences.swift new file mode 100644 index 00000000..ddbe2407 --- /dev/null +++ b/apps/ios/CrossDeskMobile/PrivacyPreferences.swift @@ -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) + } +} diff --git a/apps/ios/CrossDeskMobile/PrivacyViews.swift b/apps/ios/CrossDeskMobile/PrivacyViews.swift new file mode 100644 index 00000000..5b9a0325 --- /dev/null +++ b/apps/ios/CrossDeskMobile/PrivacyViews.swift @@ -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.. [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 = "已接收远端剪贴板" } diff --git a/apps/ios/CrossDeskMobile/RemoteSessionView.swift b/apps/ios/CrossDeskMobile/RemoteSessionView.swift index f1a5a3a3..a6a52bc3 100644 --- a/apps/ios/CrossDeskMobile/RemoteSessionView.swift +++ b/apps/ios/CrossDeskMobile/RemoteSessionView.swift @@ -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() } } diff --git a/apps/ios/CrossDeskMobile/Resources/ThirdPartyLicenses.json b/apps/ios/CrossDeskMobile/Resources/ThirdPartyLicenses.json index 15cce6d4..2664e92a 100644 --- a/apps/ios/CrossDeskMobile/Resources/ThirdPartyLicenses.json +++ b/apps/ios/CrossDeskMobile/Resources/ThirdPartyLicenses.json @@ -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", diff --git a/apps/ios/CrossDeskMobile/ThirdPartyLicensesView.swift b/apps/ios/CrossDeskMobile/ThirdPartyLicensesView.swift index 184b3cf3..6ad623e5 100644 --- a/apps/ios/CrossDeskMobile/ThirdPartyLicensesView.swift +++ b/apps/ios/CrossDeskMobile/ThirdPartyLicensesView.swift @@ -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 = ["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. diff --git a/apps/ios/CrossDeskMobile/ThumbnailFileStore.swift b/apps/ios/CrossDeskMobile/ThumbnailFileStore.swift new file mode 100644 index 00000000..8f11b41a --- /dev/null +++ b/apps/ios/CrossDeskMobile/ThumbnailFileStore.swift @@ -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) + } + } + } +} diff --git a/apps/ios/README.md b/apps/ios/README.md index 8eca7f12..15a812a6 100644 --- a/apps/ios/README.md +++ b/apps/ios/README.md @@ -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 diff --git a/apps/ios/licenses/sources.json b/apps/ios/licenses/sources.json index cfb42484..2cd7e570 100644 --- a/apps/ios/licenses/sources.json +++ b/apps/ios/licenses/sources.json @@ -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" diff --git a/apps/ios/scripts/verify_app_bundle.py b/apps/ios/scripts/verify_app_bundle.py index b4fc38f0..573859ba 100644 --- a/apps/ios/scripts/verify_app_bundle.py +++ b/apps/ios/scripts/verify_app_bundle.py @@ -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 = { diff --git a/apps/ios/tests/privacy_controls_test.py b/apps/ios/tests/privacy_controls_test.py new file mode 100644 index 00000000..d9af2131 --- /dev/null +++ b/apps/ios/tests/privacy_controls_test.py @@ -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()