mirror of
https://github.com/kunkundi/crossdesk.git
synced 2026-10-15 08:19:44 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
149d20cbd1 | ||
|
|
b935df917f | ||
|
|
fab7edeff1 | ||
|
|
85ca13aa75 | ||
|
|
ff7bda6988 | ||
|
|
380a2a225c | ||
|
|
a535ded76d | ||
|
|
fc67a1543c | ||
|
|
31acb6b2f0 | ||
|
|
2d1241c0a0 | ||
|
|
0031c411e5 | ||
|
|
bc8dbcbf68 | ||
|
|
2d49024bd6 | ||
|
|
597c1dd2c9 | ||
|
|
a83b6badd0 | ||
|
|
6a71a3173a | ||
|
|
c345c69e81 | ||
|
|
f8535a797b | ||
|
|
163506f81e | ||
|
|
a4baca222c | ||
|
|
3992ea56db | ||
|
|
51d392ef48 | ||
|
|
f347fc2387 | ||
|
|
0b28d1bc99 | ||
|
|
e894f4a763 | ||
|
|
e65a30e969 | ||
|
|
52f6f23f93 | ||
|
|
3d2235ad4e | ||
|
|
0b2eb5b106 | ||
|
|
94760b2a3c | ||
|
|
bbbcfb9d6f | ||
|
|
9ef64cc00f | ||
|
|
b8389a8aa0 | ||
|
|
7ac8163931 | ||
|
|
704938a157 | ||
|
|
572a018a72 | ||
|
|
41cab6fa0b | ||
|
|
f73a57baaa | ||
|
|
e62d473b64 | ||
|
|
3c5b3f8e38 | ||
|
|
191ba26772 | ||
|
|
c661319890 | ||
|
|
e0cd4c0567 |
+45
-17
@@ -149,6 +149,11 @@ jobs:
|
||||
xmake run --root crossdesk_wire_test
|
||||
xmake run --root keyboard_state_wire_test
|
||||
|
||||
- name: Install Linux headless runtime dependencies
|
||||
run: |
|
||||
apt-get update
|
||||
apt-get install -y --no-install-recommends xvfb xfce4 dbus
|
||||
|
||||
- name: Package
|
||||
run: |
|
||||
chmod +x ${{ matrix.package_script }}
|
||||
@@ -370,6 +375,16 @@ jobs:
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Select corresponding source tag
|
||||
shell: bash
|
||||
run: |
|
||||
if [[ "${GITHUB_REF_TYPE}" == "tag" ]]; then
|
||||
echo "CROSSDESK_SOURCE_TAG=${GITHUB_REF_NAME}" >> "${GITHUB_ENV}"
|
||||
echo "CROSSDESK_RELEASE_BUILD=YES" >> "${GITHUB_ENV}"
|
||||
fi
|
||||
|
||||
- name: Read independent iOS version
|
||||
id: ios_version
|
||||
@@ -398,15 +413,23 @@ jobs:
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: apps/ios/.xmake/packages
|
||||
key: "${{ runner.os }}-xmake${{ env.XMAKE_VERSION }}-deps-ios-arm64-xcode26.6-xrepo1211093-min16-${{ github.run_id }}"
|
||||
key: "${{ runner.os }}-xmake${{ env.XMAKE_VERSION }}-deps-ios-arm64-source-v1-xcode26.6-xrepo1211093-min16-${{ github.run_id }}"
|
||||
restore-keys: |
|
||||
${{ runner.os }}-xmake${{ env.XMAKE_VERSION }}-deps-ios-arm64-xcode26.6-xrepo1211093-min16-
|
||||
${{ runner.os }}-xmake${{ env.XMAKE_VERSION }}-deps-ios-arm64-source-v1-xcode26.6-xrepo1211093-min16-
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.13"
|
||||
|
||||
- name: Verify iOS license catalog
|
||||
run: |
|
||||
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:
|
||||
@@ -472,6 +495,11 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
|
||||
VERIFY_FLAGS=()
|
||||
if [[ "${CROSSDESK_RELEASE_BUILD:-NO}" == "YES" ]]; then
|
||||
VERIFY_FLAGS+=(--require-release)
|
||||
fi
|
||||
python3 apps/ios/scripts/verify_app_bundle.py "${APP_PATH}" "${VERIFY_FLAGS[@]}"
|
||||
xcrun lipo -info "${EXECUTABLE_PATH}"
|
||||
echo "Minimum iOS version: ${MINIMUM_OS}"
|
||||
|
||||
@@ -484,6 +512,9 @@ jobs:
|
||||
|
||||
mkdir -p "${PACKAGE_DIR}"
|
||||
cp -R "${APP_PATH}" "${PACKAGE_DIR}/"
|
||||
cp apps/ios/licenses/sources.json "${PACKAGE_DIR}/ThirdPartySources.json"
|
||||
cp apps/ios/licenses/OPEN_SOURCE_NOTICE.txt "${PACKAGE_DIR}/OPEN_SOURCE_NOTICE.txt"
|
||||
cp "${APP_PATH}/SourceMetadata.json" "${PACKAGE_DIR}/SourceMetadata.json"
|
||||
|
||||
DSYM_PATH="${DERIVED_DATA}/Build/Products/Release-iphoneos/CrossDeskMobile.app.dSYM"
|
||||
if [[ -d "${DSYM_PATH}" ]]; then
|
||||
@@ -684,7 +715,8 @@ jobs:
|
||||
@("crossdesk_service.exe", "crossdesk_service.exe"),
|
||||
@("crossdesk_session_helper.exe", "crossdesk_session_helper.exe"),
|
||||
@("crossdesk_privacy_cursor_helper.exe", "crossdesk_privacy_cursor_helper.exe"),
|
||||
@("crossdesk_privacy_window.dll", "crossdesk_privacy_window.dll")
|
||||
@("crossdesk_privacy_window.dll", "crossdesk_privacy_window.dll"),
|
||||
@("slint_cpp.dll", "slint_cpp.dll")
|
||||
)
|
||||
|
||||
foreach ($file in $portableFiles) {
|
||||
@@ -786,23 +818,19 @@ jobs:
|
||||
echo "PATCH_NUMBER=${PATCH_NUMBER}" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Prepare release notes
|
||||
env:
|
||||
RELEASE_TAG: ${{ steps.version.outputs.VERSION_WITH_V }}
|
||||
VERSION_BASE: ${{ steps.version.outputs.VERSION_BASE }}
|
||||
run: |
|
||||
if [[ -f "docs/releases/${RELEASE_TAG}.md" ]]; then
|
||||
cp "docs/releases/${RELEASE_TAG}.md" release-notes.md
|
||||
elif [[ -f "docs/releases/v${VERSION_BASE}.md" ]]; then
|
||||
cp "docs/releases/v${VERSION_BASE}.md" release-notes.md
|
||||
else
|
||||
cat > release-notes.md << 'EOF'
|
||||
Binary release only. Source code is not included.
|
||||
cat > "$RUNNER_TEMP/release-notes.md" << EOF
|
||||
## Corresponding source
|
||||
|
||||
Source for these binaries: [${GITHUB_REF_NAME}](https://github.com/${GITHUB_REPOSITORY}/tree/${GITHUB_SHA}).
|
||||
Clone that version with recursive submodules. The repository includes the build scripts and dependency patches.
|
||||
[iOS source, modification and rebuild instructions](https://github.com/${GITHUB_REPOSITORY}/blob/${GITHUB_SHA}/apps/ios/README.md).
|
||||
The iOS archive also contains the exact source revision and third-party source catalog with archive checksums.
|
||||
|
||||
## Code signing policy
|
||||
|
||||
Free code signing provided by [SignPath.io](https://signpath.io/), certificate by [SignPath Foundation](https://signpath.org/).
|
||||
EOF
|
||||
fi
|
||||
|
||||
- name: Rename artifacts
|
||||
run: |
|
||||
@@ -828,7 +856,7 @@ jobs:
|
||||
prerelease: false
|
||||
files: release/*
|
||||
generate_release_notes: false
|
||||
body_path: release-notes.md
|
||||
body_path: ${{ runner.temp }}/release-notes.md
|
||||
|
||||
- name: Create or update 'latest' tag
|
||||
run: |
|
||||
@@ -846,7 +874,7 @@ jobs:
|
||||
prerelease: false
|
||||
files: release/*
|
||||
generate_release_notes: false
|
||||
body_path: release-notes.md
|
||||
body_path: ${{ runner.temp }}/release-notes.md
|
||||
|
||||
- name: Upload artifacts to server
|
||||
uses: burnett01/rsync-deployments@5.2
|
||||
@@ -898,7 +926,7 @@ jobs:
|
||||
}
|
||||
EOF
|
||||
jq --arg release_name "Release ${{ steps.version.outputs.VERSION_WITH_V }}" \
|
||||
--rawfile release_notes release-notes.md \
|
||||
--rawfile release_notes "$RUNNER_TEMP/release-notes.md" \
|
||||
'.releaseName = $release_name | .releaseNotes = $release_notes' \
|
||||
version.json > version-with-notes.json
|
||||
mv version-with-notes.json version.json
|
||||
|
||||
+107
-59
@@ -1,99 +1,147 @@
|
||||
# CrossDesk Privacy Policy
|
||||
|
||||
Last updated: August 8, 2026
|
||||
Last updated: September 28, 2026
|
||||
|
||||
[中文](#中文) | [English](#english)
|
||||
|
||||
## 中文
|
||||
|
||||
### 适用范围
|
||||
更新日期:2026 年 9 月 28 日
|
||||
|
||||
本隐私政策适用于 CrossDesk 桌面客户端及项目提供的默认网络服务。用户自行部署或指定的服务器由相应服务器运营者负责,其数据处理方式不在本政策控制范围内。
|
||||
CrossDesk 重视您的个人信息与隐私保护。本政策说明我们在提供远程桌面服务时如何处理相关信息,以及您可以如何管理授权、保存的内容和数据请求。请在使用前阅读并了解本政策。
|
||||
|
||||
### 网络通信
|
||||
### 一、适用范围
|
||||
|
||||
CrossDesk 是远程桌面软件,需要通过网络完成设备发现、连接协商及远程会话。客户端可能进行以下网络通信:
|
||||
本政策适用于 CrossDesk 桌面客户端、iOS 客户端,以及由 CrossDesk 项目维护者(以下简称“我们”)提供的默认网络服务。
|
||||
|
||||
- **默认服务:** 未启用自托管时,客户端会连接预配置的 CrossDesk 服务(`api.crossdesk.cn`),用于设备注册、信令、网络地址转换(NAT)穿透,并在无法建立直接连接时提供中继。
|
||||
- **版本检查:** 客户端启动时会从 CrossDesk 版本服务(`version.crossdesk.cn`)获取最新版本信息。当前即使启用自托管,版本检查仍会访问 CrossDesk 版本服务。
|
||||
- **远程会话:** 用户主动发起或接受远程连接后,CrossDesk 会根据使用的功能在设备之间传输屏幕画面、音频、键盘和鼠标输入、剪贴板文本以及用户选择传输的文件。剪贴板文本可能在远程会话期间自动同步。这些数据可能通过直接连接传输,也可能在必要时通过配置的中继服务器转发。
|
||||
- **自托管:** 用户可以在设置中指定自己的信令及中继服务器。自托管服务器的日志、数据保存和访问规则由其运营者决定。
|
||||
如果您选择自行部署或使用其他运营者提供的服务器,该服务器所处理的信息、保存期限及安全管理由相应运营者负责。请在使用前了解其隐私政策。本政策中有关客户端本地数据处理的说明仍适用。
|
||||
|
||||
### 处理的信息
|
||||
### 二、我们处理的信息及用途
|
||||
|
||||
为提供上述功能,CrossDesk 可能处理:
|
||||
**设备连接与身份验证。** 为登记设备、验证连接权限、查询在线状态及建立远程连接,客户端与服务端会处理设备 ID、认证信息及在线状态。服务端还会保存设备身份及认证校验信息、在线与离线时间、客户端版本与平台、设备关联记录,并生成必要的运行和安全日志,用于维持服务、排查故障及防止滥用。
|
||||
|
||||
- 连接所需的设备标识及认证信息;
|
||||
- IP 地址、端口、连接时间、连接状态等必要的网络元数据;
|
||||
- 版本检查请求产生的标准网络请求信息;
|
||||
- 用户授权的远程会话中处理的屏幕、音频、控制指令、剪贴板及文件内容。
|
||||
**必要的网络信息。** 建立连接、网络地址转换穿透及中继转发需要处理 IP 地址、端口等网络信息。这些信息用于通信和网络诊断,不等同于读取设备的定位权限。连接服务、中继服务及网络请求可能产生相应日志。
|
||||
|
||||
### 信息的使用与保存
|
||||
**远程会话内容。** 您发起或接受远程连接后,软件根据使用的功能在参与会话的设备之间传输画面、音频、键盘和鼠标操作、剪贴板文本及文件。会话内容可能包含个人或工作信息,请仅连接您拥有或已获授权使用的设备,并按需管理共享内容。
|
||||
|
||||
- 设备标识、连接设置及用户偏好会保存在本地设备上,以便后续使用。
|
||||
- 通过官方信令和中继服务处理的信息仅用于建立、维持和转发 CrossDesk 连接,以及服务运行、安全防护和故障排查。
|
||||
- 中继传输的远程会话内容仅用于实时转发,不用于广告或用户画像。
|
||||
- CrossDesk 桌面客户端不包含广告功能,也不会主动上传诊断日志、崩溃报告或用户行为分析数据。
|
||||
- 自托管服务器可能保存的日志和数据由其运营者控制,请向相应运营者了解其隐私与保存政策。
|
||||
**版本检查。** 桌面客户端启动后会获取版本信息;iOS 客户端在连接成功并收到远端版本信息后,会获取最新桌面版本信息,用于提示升级。该请求不携带设备 ID 或远端版本号,但网络服务会处理完成请求所必需的网络信息。
|
||||
|
||||
### 用户选择
|
||||
CrossDesk 未集成广告投放、广告追踪或用户行为分析功能。客户端不会主动上传诊断日志、崩溃报告或用户行为分析数据。您主动向我们提交问题或日志时,请先移除与问题无关的个人信息及连接凭据。
|
||||
|
||||
用户可以:
|
||||
### 三、数据传输与相关服务
|
||||
|
||||
- 配置自托管服务器,以替代默认信令和中继服务;
|
||||
- 决定是否发起或接受远程会话;
|
||||
- 决定是否选择并传输文件;
|
||||
- 结束远程会话,以停止会话数据传输;
|
||||
- 删除 CrossDesk 的本地配置、缓存及日志文件。
|
||||
我们提供设备登记、连接协商、中继转发及版本检查服务。即使您使用自托管连接服务,客户端仍会按前述条件访问我们提供的版本服务。
|
||||
|
||||
### 政策更新与联系方式
|
||||
远程会话内容优先在参与会话的设备间直接传输,必要时通过配置的中继服务器转发。中继服务用于实时转发,不提供会话录像或文件归档。客户端本地保存的画面预览和接收文件,按本政策相关说明处理。
|
||||
|
||||
本政策可能随着 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 client 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 client retrieves current release information from the CrossDesk version service (`version.crossdesk.cn`). Update checks currently continue to use the CrossDesk version service even 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 client does 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.
|
||||
|
||||
### Changes and contact
|
||||
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.
|
||||
|
||||
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).
|
||||
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.
|
||||
|
||||
### 4. Storage and security measures
|
||||
|
||||
**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. 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)
|
||||
|
||||
@@ -48,11 +48,13 @@ CrossDesk 支持 Windows、macOS、Linux 之间的远程访问,也可从浏览
|
||||
| 能力 | 当前支持 |
|
||||
| --- | --- |
|
||||
| 跨平台控制 | Windows / macOS / Linux 桌面互控;浏览器与原生 iOS 作为控制端 |
|
||||
| 实时画面与声音 | H.264 / AV1、30 / 60 fps 设置、硬件编解码选项、远端声音播放;实际能力取决于设备与构建配置 |
|
||||
| 实时画面与声音 | H.264 / AV1、硬件编解码、远端声音播放;会话中可调整画质、30 / 60 fps 与画面偏好 |
|
||||
| 多设备与多显示器 | 近期连接、设备别名、会话标签与远端显示器切换 |
|
||||
| 输入与协作 | 键鼠输入、远端光标同步、组合键、文本剪贴板同步、文件传输 |
|
||||
| 网络与部署 | P2P 直连、TURN 中继、SRTP 加密选项、自托管信令与中继服务 |
|
||||
| 网络与部署 | P2P 直连、TURN 中继与强制中继、SRTP 媒体加密、自托管信令与中继服务 |
|
||||
| 隐私屏 | 被连接时自动开启,或在会话工具栏切换;可用性取决于被控端系统与采集方式 |
|
||||
| Windows 锁屏控制 | 通过 CrossDesk Service 支持锁屏、登录界面及安全桌面的输入转发 |
|
||||
| Linux 无头运行 | 复用已有桌面和应用,或创建独立 Xvfb 虚拟桌面;终端控制台可查看状态、修改密码和设置,见 [运行说明](docs/HEADLESS.md) |
|
||||
|
||||
各客户端的入口和能力有所不同;桌面会话操作见下文,iOS 的具体功能见 [开发说明](apps/ios/README.md)。
|
||||
|
||||
@@ -89,11 +91,11 @@ sudo apt install "./crossdesk-linux-amd64-<version>.deb"
|
||||
3. **完成密码验证。** 按弹窗输入被控电脑当前的连接密码,确认后等待远程画面出现。需要保存密码时勾选“记住密码”。
|
||||
4. **再次连接。** 成功连接过的设备会出现在“近期连接”中,可通过卡片重连,也可编辑别名或删除记录。
|
||||
|
||||
本机密码旁的眼睛按钮用于显示 / 隐藏密码,铅笔按钮用于修改密码。修改时使用 **6 位数字或英文字母**,并保持连接服务器;等待修改成功、客户端重新连接后,再复制当前密码。已保存旧密码的控制端需重新输入。
|
||||
本机密码旁的眼睛按钮用于显示 / 隐藏密码,刷新按钮用于生成新的随机密码,铅笔按钮用于手动修改。密码统一为 **6 位数字或英文字母**。修改或刷新时需保持连接服务器;等待修改成功、客户端重新连接后,再复制当前密码。已保存旧密码的控制端需重新输入。
|
||||
|
||||
### 从浏览器连接
|
||||
|
||||
1. 保持被控电脑上的 CrossDesk 运行,并确认已连接服务器;在被控端“设置”中保持 **启用 SRTP** 开启。
|
||||
1. 保持被控电脑上的 CrossDesk 运行,并确认已连接服务器。
|
||||
2. 打开 [Web 客户端](https://web.crossdesk.cn/),输入 **远程设备 ID** 和 **密码**,点击“连接”。
|
||||
3. 连接后使用页面提供的显示器、鼠标模式和键盘控件操作远端;手机和平板也可通过浏览器访问。
|
||||
|
||||
@@ -112,33 +114,53 @@ sudo apt install "./crossdesk-linux-amd64-<version>.deb"
|
||||
| 鼠标 | 控制 / 释放远端鼠标 |
|
||||
| 扬声器 | 播放声音 / 静音 |
|
||||
| 文件夹 | 选择文件发送到远端,并查看传输进度 |
|
||||
| 网络状态 | 查看流量、丢包率、帧率、分辨率及直连 / 中继状态 |
|
||||
| 画面设置 | 在当前会话中调整画面质量、采集帧率与画面偏好 |
|
||||
| 网络状态 | 查看流量、丢包率、帧率、分辨率、直连 / 中继及加密状态 |
|
||||
| 隐私屏 | 开启或关闭被控电脑的隐私屏;悬停查看当前状态与可用操作 |
|
||||
| 全屏 | 进入 / 退出全屏 |
|
||||
| 断开连接 | 结束当前远程会话 |
|
||||
|
||||
桌面端可同步文本剪贴板。接收文件的位置可在“设置 → 文件保存路径”调整。关闭主窗口会隐藏窗口并保持程序运行;需要完全退出时使用系统托盘或菜单栏中的退出入口。
|
||||
|
||||
<a id="settings"></a>
|
||||
### 在会话中调整画面
|
||||
|
||||
## 调整画面与连接设置
|
||||
|
||||
点击主窗口右上角 **☰ → 设置**,修改后点击“确认”保存。建议在建立连接前调整;会话进行中部分选项不可修改。设置列表可向下滚动,查看自托管、启动方式与文件保存路径。
|
||||
连接成功后,点击工具栏中的 **画面设置**(滑杆图标),直接选择低 / 中 / 高画质、**30 / 60 fps**,以及帧率优先 / 画质优先 / 平衡。选项会发送给当前被控端,无需断开重连;实际画面效果取决于网络、设备与编码器能力。旧版被控端不支持时,菜单会提示并禁用选项。
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/images/desktop-settings-zh.png" width="720" alt="当前设置界面:画质、帧率、画面偏好、编解码、中继与 SRTP" />
|
||||
<img src="docs/images/desktop-stream-video-settings-zh.png" width="800" alt="当前远程会话的画面设置菜单:画面质量、采集帧率与画面偏好;背景为公开页面演示图" />
|
||||
</p>
|
||||
|
||||
<a id="settings"></a>
|
||||
|
||||
## 客户端设置与更新
|
||||
|
||||
点击主窗口右上角 **☰ → 设置**,修改后点击“确认”保存。编码与连接选项建议在建立连接前调整;会话进行中部分选项不可修改。设置列表可向下滚动,查看自托管、启动方式与文件保存路径。画质、帧率和画面偏好在远程会话工具栏中调整。
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/images/desktop-settings-zh.png" width="720" alt="当前客户端设置:编码格式、硬件编解码、中继、强制中继与自动隐私屏" />
|
||||
</p>
|
||||
|
||||
| 设置 | 说明 |
|
||||
| --- | --- |
|
||||
| 画面质量 / 帧率 | 选择低、中、高画质与 30 / 60 fps;实际帧率取决于网络和设备 |
|
||||
| 画面偏好 | 在帧率优先、画质优先、平衡之间选择 |
|
||||
| 语言 | 中文、English、Русский |
|
||||
| 采集方式(Windows) | 自动 / DXGI / WGC / GDI;无显示器主机使用“自动”启用自动适配 |
|
||||
| 视频编码格式 | H.264 / AV1;硬件编解码是否可用取决于平台、硬件与构建选项 |
|
||||
| 启用中继服务 | 允许借助 TURN 中继建立连接;P2P 连接失败时先检查此项 |
|
||||
| 启用 SRTP | 媒体传输加密选项,两端配置需兼容 |
|
||||
| 自托管配置 | 设置服务器地址、信令端口与中继端口,并勾选启用 |
|
||||
| 强制中继连接 | 启用中继服务后可勾选,让连接通过中继建立;关闭中继服务会同时关闭此项 |
|
||||
| 保存远程画面预览 | 默认开启,保存远程画面预览;关闭后缩略图区域显示被控端平台,并清除已有预览,保留连接记录和密码 |
|
||||
| 被连接时开启隐私屏 | 设置本机被远程连接时是否自动启用隐私屏;会话中可从控制端工具栏切换 |
|
||||
| 自托管配置 | 填写服务器地址和信令服务端口,并勾选启用 |
|
||||
| 开机自启 / 启用守护进程 | 调整启动与进程守护行为,按界面提示重启后生效 |
|
||||
| 文件保存路径 | 选择接收文件的目录 |
|
||||
|
||||
### 检查更新
|
||||
|
||||
客户端启动后会在后台检查版本。发现新版本时,主窗口菜单入口会显示更新提示;打开 **☰ → 关于** 可查看版本,也可点击 **检查更新** 手动检查。发现更新后可查看更新说明并前往下载页面。
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/images/desktop-about-zh.png" width="720" alt="关于窗口与检查更新按钮,版本号和检查结果为演示数据" />
|
||||
</p>
|
||||
|
||||
<a id="ios"></a>
|
||||
|
||||
## 原生 iOS / iPadOS 客户端
|
||||
@@ -205,8 +227,6 @@ sudo apt install "./crossdesk-linux-amd64-<version>.deb"
|
||||
|
||||
CrossDesk 使用 [GPL-3.0](LICENSE) 许可。隐私说明见 [隐私政策](PRIVACY.md#中文)。
|
||||
|
||||
Windows 安装包内置 [Amyuni Technologies](https://www.amyuni.com/) 的 USB Mobile Monitor 虚拟显示器驱动(usbmmidd_v2),用于无显示器主机;驱动未作任何修改,其许可见安装目录 `usbmmidd_v2\License.txt`。
|
||||
|
||||
### Code signing policy
|
||||
|
||||
CrossDesk 使用 SignPath.io 为本仓库构建的官方 Windows 发布包提供代码签名。
|
||||
|
||||
+36
-16
@@ -48,11 +48,13 @@ See the [screenshot notes](docs/images/README.md) for image sources and refresh
|
||||
| Capability | Current support |
|
||||
| --- | --- |
|
||||
| Cross-platform control | Windows / macOS / Linux desktop control; browser and native iOS controllers |
|
||||
| Live video and audio | H.264 / AV1, 30 / 60 fps settings, hardware codec options, and remote audio; availability depends on hardware and build configuration |
|
||||
| Live video and audio | H.264 / AV1, hardware codecs, and remote audio; adjust quality, 30 / 60 fps, and video preference during a session |
|
||||
| Devices and displays | Recent connections, device aliases, session tabs, and remote display switching |
|
||||
| Input and sharing | Keyboard/mouse input, remote cursor synchronization, shortcuts, text clipboard synchronization, and file transfer |
|
||||
| Networking and deployment | Direct P2P connections, TURN relay, an SRTP encryption option, and self-hosted signaling/relay services |
|
||||
| Networking and deployment | Direct P2P connections, TURN relay and forced relay, SRTP media encryption, and self-hosted signaling/relay services |
|
||||
| Privacy screen | Enable automatically when connected to, or toggle from the session toolbar; availability depends on the host OS and capture method |
|
||||
| Windows protected desktops | CrossDesk Service forwards input on lock screens, sign-in screens, and secure desktops |
|
||||
| Linux headless operation | Reuse existing desktops and applications or create separate Xvfb desktops; use the terminal console to check status and change passwords or settings; see [setup](docs/HEADLESS_EN.md) |
|
||||
|
||||
Controls and capabilities vary by client. Desktop controls are described below; see the [iOS guide](apps/ios/README.md) for native mobile features.
|
||||
|
||||
@@ -89,11 +91,11 @@ sudo apt install "./crossdesk-linux-amd64-<version>.deb"
|
||||
3. **Verify the password.** Enter the host's current connection password when prompted and confirm. Select **Remember password** if you want to save it.
|
||||
4. **Reconnect later.** Previously connected devices appear under **Recent Connections**. Reconnect from a card, edit its alias, or remove its record.
|
||||
|
||||
The eye button shows or hides the local password; the pencil button changes it. Use **6 ASCII letters or digits** and stay connected to the server while changing it. Wait for the change to succeed and the client to reconnect, then copy the current password. Controllers that saved the old password must enter it again.
|
||||
The eye button shows or hides the local password, the refresh button generates a new random password, and the pencil button lets you edit it. Passwords use **6 ASCII letters or digits**. Stay connected to the server when editing or refreshing a password. Wait for the change to succeed and the client to reconnect, then copy the current password. Controllers that saved the old password must enter it again.
|
||||
|
||||
### Connect from a browser
|
||||
|
||||
1. Keep CrossDesk running and connected to the server on the host computer, with **Enable SRTP** enabled in its Settings.
|
||||
1. Keep CrossDesk running and connected to the server on the host computer.
|
||||
2. Open the [Web Client](https://web.crossdesk.cn/), enter the **Remote Device ID** and **Password**, and click **Connect**.
|
||||
3. Use the page's display, mouse-mode, and keyboard controls during the session. Phone and tablet browsers can also connect.
|
||||
|
||||
@@ -112,33 +114,53 @@ Switch devices using session tabs. Expand the control bar if it is collapsed, an
|
||||
| Mouse | Enable / release remote mouse control |
|
||||
| Speaker | Play / mute remote audio |
|
||||
| Folder | Select a file to send and view transfer progress |
|
||||
| Network statistics | Inspect traffic, packet loss, frame rate, resolution, and direct / relay mode |
|
||||
| Video settings | Adjust video quality, capture frame rate, and video preference during the current session |
|
||||
| Network statistics | Inspect traffic, packet loss, frame rate, resolution, direct / relay mode, and encryption status |
|
||||
| Privacy screen | Enable or disable the host's privacy screen; hover to see its status and available actions |
|
||||
| Fullscreen | Enter / exit fullscreen |
|
||||
| Disconnect | End the current remote session |
|
||||
|
||||
Desktop clients synchronize text clipboard contents. Configure the receiving directory under **Settings → File Save Path**. Closing the main window hides it and keeps the app running; use the system tray or menu-bar exit command to quit completely.
|
||||
|
||||
<a id="settings"></a>
|
||||
### Adjust video during a session
|
||||
|
||||
## Video and connection settings
|
||||
|
||||
Open **☰ → Settings** in the top-right corner and click **OK** to save. Configure the session before connecting; some settings are disabled during an active session. Scroll down for self-hosting, startup behavior, and the file save path.
|
||||
After connecting, open **Video settings** (the sliders icon) in the toolbar. Choose low / medium / high quality, **30 / 60 fps**, and frame-rate priority / quality priority / balanced adaptation. Changes are sent to the current host without reconnecting; actual results depend on the network, device, and encoder. For older hosts that do not support live video settings, the menu explains the limitation and disables the options.
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/images/desktop-settings-en.png" width="720" alt="Current settings for quality, frame rate, video preference, codecs, relay, and SRTP" />
|
||||
<img src="docs/images/desktop-stream-video-settings-en.png" width="800" alt="Current session video menu with quality, capture frame rate, and video preference; the background is a public page used for demonstration" />
|
||||
</p>
|
||||
|
||||
<a id="settings"></a>
|
||||
|
||||
## Client settings and updates
|
||||
|
||||
Open **☰ → Settings** in the top-right corner and click **OK** to save. Configure codecs and connections before connecting; some settings are disabled during an active session. Scroll down for self-hosting, startup behavior, and the file save path. Adjust quality, frame rate, and video preference from the remote session toolbar.
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/images/desktop-settings-en.png" width="720" alt="Current client settings with codec, hardware codecs, TURN, forced relay, and automatic privacy screen options" />
|
||||
</p>
|
||||
|
||||
| Setting | Purpose |
|
||||
| --- | --- |
|
||||
| Video Quality / Video Capture Frame Rate | Low, medium, or high quality and 30 / 60 fps; actual performance depends on the connection and device |
|
||||
| Video Preference | Choose frame-rate priority, quality priority, or balanced adaptation |
|
||||
| Language | 中文, English, Русский |
|
||||
| Capture Method (Windows) | Auto / DXGI / WGC / GDI; choose Auto for automatic headless adaptation |
|
||||
| Video Encode Format | H.264 / AV1; hardware codec availability depends on the platform, device, and build options |
|
||||
| Enable TURN Service | Allow relay-assisted connections; check this setting if P2P fails |
|
||||
| SRTP | Media encryption option; configurations on both ends must be compatible |
|
||||
| Self-Hosted Config | Set the host, signaling port, and TURN port, then enable the checkbox |
|
||||
| Force Relay | Require relay connections when TURN is enabled; turning TURN off also disables this option |
|
||||
| Save remote screen previews | On by default. When disabled, the thumbnail area shows the remote device’s platform and existing previews are cleared; connection records and passwords are preserved |
|
||||
| Privacy screen on connect | Automatically enable privacy when this computer is controlled remotely; the controller can toggle it from the session toolbar |
|
||||
| Self-Hosted Config | Set the server address and signaling port, then enable the checkbox |
|
||||
| Auto Start / Enable Daemon | Configure startup and process supervision; restart as indicated by the UI |
|
||||
| File Save Path | Choose where received files are saved |
|
||||
|
||||
### Check for updates
|
||||
|
||||
The client checks for updates in the background after launch. A new version adds an update indicator to the main window menu. Open **☰ → About** to view the version or click **Check for updates** to check manually. When an update is available, you can view its release notes and open the download page.
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/images/desktop-about-en.png" width="720" alt="About dialog with the Check for updates button; version and check result are demonstration data" />
|
||||
</p>
|
||||
|
||||
<a id="ios"></a>
|
||||
|
||||
## Native iOS / iPadOS client
|
||||
@@ -205,8 +227,6 @@ Thanks to [HelloGitHub](https://hellogithub.com/), [Ruanyf Weekly](https://githu
|
||||
|
||||
CrossDesk is licensed under [GPL-3.0](LICENSE). See the [Privacy Policy](PRIVACY.md#english).
|
||||
|
||||
The Windows packages bundle the unmodified USB Mobile Monitor virtual display driver (usbmmidd_v2) by [Amyuni Technologies](https://www.amyuni.com/) for hosts without a monitor; its license is in `usbmmidd_v2\License.txt` in the install directory.
|
||||
|
||||
### Code signing policy
|
||||
|
||||
CrossDesk uses SignPath.io to sign official Windows releases built from this repository.
|
||||
|
||||
@@ -21,6 +21,10 @@ DESCRIPTION="A simple cross-platform remote desktop client."
|
||||
ALSA_RUNTIME_DEP="libasound2 | libasound2t64"
|
||||
PORTAL_RUNTIME_RECOMMENDS="xdg-desktop-portal, xdg-desktop-portal-gtk | xdg-desktop-portal-kde | xdg-desktop-portal-wlr"
|
||||
TRAY_RUNTIME_RECOMMENDS="libayatana-appindicator3-1 | libappindicator3-1"
|
||||
# Every published package includes the prerequisites for all Linux headless
|
||||
# paths, including a complete independent desktop. Older Ubuntu releases ship
|
||||
# dbus-run-session in dbus; newer releases split it into dbus-daemon.
|
||||
HEADLESS_RUNTIME_DEPS="xvfb, xfce4, dbus-daemon | dbus"
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../../../.." && pwd)"
|
||||
@@ -167,7 +171,8 @@ Depends: libc6 (>= 2.31), libstdc++6 (>= 10), libx11-6, libxext6,
|
||||
libxrender1, libxft2, libxrandr2, libxfixes3, libxcursor1, libxi6, libxcb1, libxcb-randr0,
|
||||
libxcb-xtest0, libxcb-xinerama0, libxcb-shape0, libxcb-xkb1,
|
||||
libxcb-xfixes0, libxv1, libxtst6, $ALSA_RUNTIME_DEP, libsndio7.0,
|
||||
libxcb-shm0, libpulse0, libdrm2, libdbus-1-3, libgl1
|
||||
libxcb-shm0, libpulse0, libdrm2, libdbus-1-3, libgl1,
|
||||
$HEADLESS_RUNTIME_DEPS
|
||||
Recommends: $RECOMMENDS
|
||||
Priority: optional
|
||||
Section: utils
|
||||
|
||||
@@ -90,8 +90,10 @@ installApp:
|
||||
File "..\..\..\..\build\windows\x64\release\crossdesk_session_helper.exe"
|
||||
File "..\..\..\..\build\windows\x64\release\crossdesk_privacy_cursor_helper.exe"
|
||||
File "..\..\..\..\build\windows\x64\release\crossdesk_privacy_window.dll"
|
||||
; Bundle runtime DLLs from the release output directory
|
||||
File /x crossdesk_privacy_window.dll "..\..\..\..\build\windows\x64\release\*.dll"
|
||||
; Require the Slint runtime so an incomplete build cannot be packaged.
|
||||
File "..\..\..\..\build\windows\x64\release\slint_cpp.dll"
|
||||
; Bundle the remaining runtime DLLs from the release output directory
|
||||
File /x crossdesk_privacy_window.dll /x slint_cpp.dll "..\..\..\..\build\windows\x64\release\*.dll"
|
||||
; Amyuni usbmmidd_v2 virtual display driver for headless hosts. xmake
|
||||
; copies apps\desktop\resources\windows\usbmmidd_v2 into the release
|
||||
; output directory after building crossdesk; its License.txt ships with it.
|
||||
|
||||
+117
-37
@@ -6,6 +6,7 @@
|
||||
#endif
|
||||
#endif
|
||||
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include <filesystem>
|
||||
#include <iostream>
|
||||
@@ -22,10 +23,14 @@
|
||||
#include "config_center.h"
|
||||
#include "daemon.h"
|
||||
#include "path_manager.h"
|
||||
#include "platform/single_instance.h"
|
||||
#include "rd_log.h"
|
||||
#include "render.h"
|
||||
|
||||
#ifdef __linux__
|
||||
#include "SimpleIni.h"
|
||||
#include "platform/linux/headless/headless_console.h"
|
||||
#include "platform/linux/headless/headless_session.h"
|
||||
#include "platform/linux/privacy/privacy_guard.h"
|
||||
#endif
|
||||
|
||||
@@ -179,47 +184,37 @@ int HandleServiceCliCommand(const std::string& command) {
|
||||
} // namespace
|
||||
#endif
|
||||
|
||||
int main(int argc, char* argv[]) {
|
||||
#ifdef __linux__
|
||||
// Run before GUI, configuration, logging or daemon initialization. The
|
||||
// guard owns native privacy resources and restores them if this app exits.
|
||||
if (argc == 2 &&
|
||||
std::strcmp(argv[1], crossdesk::kLinuxPrivacyGuardArgument) == 0) {
|
||||
return crossdesk::RunLinuxPrivacyGuard();
|
||||
static bool IsDaemonChild(int argc, char* argv[]) {
|
||||
for (int i = 1; i < argc; ++i) {
|
||||
if (std::strcmp(argv[i], "--child") == 0) return true;
|
||||
}
|
||||
#endif
|
||||
#ifdef _WIN32
|
||||
if (argc == 2 &&
|
||||
std::strcmp(argv[1], crossdesk::kSlintRendererProbeArgument) == 0) {
|
||||
return crossdesk::RunSlintRendererProbe();
|
||||
}
|
||||
#endif
|
||||
return false;
|
||||
}
|
||||
|
||||
// Service operations and config migration can log before the GUI starts.
|
||||
// Configure the directory first, including CLI and daemon child paths.
|
||||
static crossdesk::platform::InstanceResult AcquireInstance(
|
||||
crossdesk::platform::SingleInstanceGuard& guard,
|
||||
crossdesk::platform::InstanceRole role) {
|
||||
std::string error;
|
||||
const auto result = guard.TryAcquire(role, error);
|
||||
if (result == crossdesk::platform::InstanceResult::kAlreadyRunning) {
|
||||
std::cerr << "CrossDesk is already running for this user.\n";
|
||||
} else if (result == crossdesk::platform::InstanceResult::kError) {
|
||||
std::cerr << "CrossDesk cannot start: " << error << '\n';
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
static int RunApplication(
|
||||
int argc, char* argv[],
|
||||
crossdesk::platform::SingleInstanceGuard& client_instance) {
|
||||
// The instance guards are held before any configuration or logger is opened.
|
||||
auto path_manager = std::make_unique<crossdesk::PathManager>("CrossDesk");
|
||||
crossdesk::InitLogger(path_manager->GetLogPath().string());
|
||||
|
||||
#ifdef _WIN32
|
||||
if (argc > 1 && IsServiceCliCommand(argv[1])) {
|
||||
return HandleServiceCliCommand(argv[1]);
|
||||
}
|
||||
#endif
|
||||
|
||||
// check if running as child process
|
||||
bool is_child = false;
|
||||
for (int i = 1; i < argc; i++) {
|
||||
if (strcmp(argv[i], "--child") == 0) {
|
||||
is_child = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (is_child) {
|
||||
if (IsDaemonChild(argc, argv)) {
|
||||
// child process: run render directly
|
||||
crossdesk::Render render;
|
||||
render.Run();
|
||||
return 0;
|
||||
return render.Run();
|
||||
}
|
||||
|
||||
#ifdef _WIN32
|
||||
@@ -233,12 +228,32 @@ int main(int argc, char* argv[]) {
|
||||
enable_daemon = config_center.IsEnableDaemon();
|
||||
}
|
||||
|
||||
#ifdef __linux__
|
||||
// The headless supervisor owns the display and application lifetime. A
|
||||
// detached daemon would outlive its display; systemd can restart the whole
|
||||
// session instead.
|
||||
if (std::getenv("CROSSDESK_HEADLESS_ACTIVE") ||
|
||||
crossdesk::HeadlessConsole::Instance().active()) enable_daemon = false;
|
||||
#endif
|
||||
|
||||
if (enable_daemon) {
|
||||
// The launcher lease stays in main for the supervisor's entire lifetime.
|
||||
// Each --child must acquire its own client lease, so it is neither blocked
|
||||
// by its supervisor nor able to bypass the single-client restriction.
|
||||
client_instance.Release();
|
||||
// start daemon with restart monitoring
|
||||
Daemon daemon("CrossDesk");
|
||||
|
||||
// define main loop function: run render and stop daemon on normal exit
|
||||
Daemon::MainLoopFunc main_loop = [&daemon]() {
|
||||
Daemon::MainLoopFunc main_loop = [&daemon, &client_instance]() {
|
||||
// Executable discovery can fail and make the supervisor run the GUI in
|
||||
// process. This fallback must retain the same client ownership rule.
|
||||
if (AcquireInstance(client_instance,
|
||||
crossdesk::platform::InstanceRole::kClient) !=
|
||||
crossdesk::platform::InstanceResult::kAcquired) {
|
||||
daemon.stop();
|
||||
return;
|
||||
}
|
||||
crossdesk::Render render;
|
||||
render.Run();
|
||||
daemon.stop();
|
||||
@@ -251,6 +266,71 @@ int main(int argc, char* argv[]) {
|
||||
|
||||
// run without daemon: direct execution
|
||||
crossdesk::Render render;
|
||||
render.Run();
|
||||
return 0;
|
||||
return render.Run();
|
||||
}
|
||||
|
||||
int main(int argc, char* argv[]) {
|
||||
#ifdef __linux__
|
||||
// Run before GUI, configuration, logging or display initialization.
|
||||
if (argc == 2 &&
|
||||
std::strcmp(argv[1], crossdesk::kLinuxPrivacyGuardArgument) == 0) {
|
||||
return crossdesk::RunLinuxPrivacyGuard();
|
||||
}
|
||||
auto& console = crossdesk::HeadlessConsole::Instance();
|
||||
if (argc == 2 && std::strcmp(argv[1], "--headless-help") == 0) {
|
||||
crossdesk::PathManager paths("CrossDesk");
|
||||
CSimpleIniA config;
|
||||
config.SetUnicode(true);
|
||||
config.LoadFile((paths.GetCachePath() / "config.ini").string().c_str());
|
||||
console.SetLanguage(static_cast<int>(config.GetLongValue("Settings", "language", 0)));
|
||||
std::cout << console.Text("console_cli_help");
|
||||
return 0;
|
||||
}
|
||||
#endif
|
||||
#ifdef _WIN32
|
||||
if (argc == 2 &&
|
||||
std::strcmp(argv[1], crossdesk::kSlintRendererProbeArgument) == 0) {
|
||||
return crossdesk::RunSlintRendererProbe();
|
||||
}
|
||||
if (argc > 1 && IsServiceCliCommand(argv[1])) {
|
||||
EnsureConsoleForCli();
|
||||
crossdesk::platform::SingleInstanceGuard cli_instance;
|
||||
if (AcquireInstance(cli_instance, crossdesk::platform::InstanceRole::kServiceCli) !=
|
||||
crossdesk::platform::InstanceResult::kAcquired) return 1;
|
||||
crossdesk::PathManager paths("CrossDesk");
|
||||
crossdesk::InitLogger(paths.GetLogPath().string(), "crossdesk-service-cli");
|
||||
return HandleServiceCliCommand(argv[1]);
|
||||
}
|
||||
#endif
|
||||
|
||||
using crossdesk::platform::InstanceResult;
|
||||
using crossdesk::platform::InstanceRole;
|
||||
crossdesk::platform::SingleInstanceGuard launcher_instance;
|
||||
crossdesk::platform::SingleInstanceGuard client_instance;
|
||||
if (!IsDaemonChild(argc, argv)) {
|
||||
const auto result = AcquireInstance(launcher_instance, InstanceRole::kLauncher);
|
||||
if (result != InstanceResult::kAcquired)
|
||||
return result == InstanceResult::kAlreadyRunning ? 0 : 1;
|
||||
}
|
||||
const auto result = AcquireInstance(client_instance, InstanceRole::kClient);
|
||||
if (result != InstanceResult::kAcquired)
|
||||
return result == InstanceResult::kAlreadyRunning ? 0 : 1;
|
||||
|
||||
#ifdef __linux__
|
||||
const int exit_code = crossdesk::RunWithLinuxDisplay(
|
||||
argc, argv, [&] { return RunApplication(argc, argv, client_instance); }, [&] {
|
||||
crossdesk::PathManager paths("CrossDesk");
|
||||
if (console.Enable(paths.GetLogPath(), paths.GetCachePath() / "config.ini")) return true;
|
||||
std::cerr << "无法初始化日志或控制台,启动失败。\n";
|
||||
return false;
|
||||
});
|
||||
if (console.active()) {
|
||||
console.Notify(exit_code == 0 ? console.Text("console_exited")
|
||||
: console.Text("console_stopped") + " " + std::to_string(exit_code) +
|
||||
"; " + console.Text("console_log_file") + ": " + console.diagnostic_path());
|
||||
}
|
||||
return exit_code;
|
||||
#else
|
||||
return RunApplication(argc, argv, client_instance);
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
#include "task_queue_lock_free.h"
|
||||
|
||||
#include <concurrentqueue.h>
|
||||
|
||||
#include <exception>
|
||||
#include <new>
|
||||
#include <stdexcept>
|
||||
@@ -7,8 +9,15 @@
|
||||
|
||||
namespace crossdesk {
|
||||
|
||||
struct TaskQueueLockFree::QueueStorage {
|
||||
moodycamel::ConcurrentQueue<TaskItem> tasks;
|
||||
// All submissions share one producer so different caller threads keep FIFO.
|
||||
moodycamel::ProducerToken producer{tasks};
|
||||
};
|
||||
|
||||
TaskQueueLockFree::TaskQueueLockFree()
|
||||
: producer_(task_queue_), worker_([this] { WorkerThread(); }) {}
|
||||
: queue_(std::make_unique<QueueStorage>()),
|
||||
worker_([this] { WorkerThread(); }) {}
|
||||
|
||||
TaskQueueLockFree::~TaskQueueLockFree() { Stop(); }
|
||||
|
||||
@@ -24,7 +33,7 @@ std::future<void> TaskQueueLockFree::PostTask(std::function<void()> task) {
|
||||
|
||||
// Count before publishing: the worker may dequeue as soon as enqueue returns.
|
||||
++pending_tasks_;
|
||||
if (!task_queue_.enqueue(producer_, std::move(item))) {
|
||||
if (!queue_->tasks.enqueue(queue_->producer, std::move(item))) {
|
||||
--pending_tasks_;
|
||||
throw std::bad_alloc();
|
||||
}
|
||||
@@ -48,7 +57,7 @@ void TaskQueueLockFree::Stop() {
|
||||
void TaskQueueLockFree::WorkerThread() {
|
||||
while (true) {
|
||||
TaskItem item;
|
||||
while (task_queue_.try_dequeue(item)) {
|
||||
while (queue_->tasks.try_dequeue(item)) {
|
||||
--pending_tasks_;
|
||||
|
||||
std::exception_ptr error;
|
||||
|
||||
@@ -12,11 +12,10 @@
|
||||
#include <cstddef>
|
||||
#include <functional>
|
||||
#include <future>
|
||||
#include <memory>
|
||||
#include <mutex>
|
||||
#include <thread>
|
||||
|
||||
#include <concurrentqueue.h>
|
||||
|
||||
namespace crossdesk {
|
||||
|
||||
// A single worker executes tasks in submission order. The concurrent queue
|
||||
@@ -41,9 +40,8 @@ class TaskQueueLockFree {
|
||||
|
||||
void WorkerThread();
|
||||
|
||||
moodycamel::ConcurrentQueue<TaskItem> task_queue_;
|
||||
// All submissions share one producer so different caller threads keep FIFO.
|
||||
moodycamel::ProducerToken producer_;
|
||||
struct QueueStorage;
|
||||
std::unique_ptr<QueueStorage> queue_;
|
||||
std::atomic<std::size_t> pending_tasks_{0};
|
||||
std::mutex mutex_;
|
||||
std::condition_variable wake_;
|
||||
|
||||
@@ -1,10 +1,20 @@
|
||||
#include "config_center.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <chrono>
|
||||
#include <cstring>
|
||||
#include <filesystem>
|
||||
#include <optional>
|
||||
#include <vector>
|
||||
|
||||
#include "SimpleIni.h"
|
||||
#include "autostart.h"
|
||||
#include "rd_log.h"
|
||||
|
||||
namespace crossdesk {
|
||||
|
||||
struct ConfigCenter::IniStorage : CSimpleIniA {};
|
||||
|
||||
namespace {
|
||||
|
||||
bool IsValidTurnModeValue(long value) {
|
||||
@@ -12,39 +22,38 @@ bool IsValidTurnModeValue(long value) {
|
||||
value <= static_cast<long>(ConfigCenter::TURN_MODE::FORCE_TCP);
|
||||
}
|
||||
|
||||
bool IsValidVideoAdaptationPolicyValue(long value) {
|
||||
using Policy = ConfigCenter::VIDEO_ADAPTATION_POLICY;
|
||||
return value >= static_cast<long>(Policy::FRAME_RATE_PRIORITY) &&
|
||||
value <= static_cast<long>(Policy::BALANCED);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
ConfigCenter::ConfigCenter(const std::string& config_path)
|
||||
: config_path_(config_path) {
|
||||
ini_.SetUnicode(true);
|
||||
: config_path_(config_path), ini_(std::make_unique<IniStorage>()) {
|
||||
ini_->SetUnicode(true);
|
||||
Load();
|
||||
}
|
||||
|
||||
ConfigCenter::~ConfigCenter() {}
|
||||
|
||||
int ConfigCenter::Load() {
|
||||
SI_Error rc = ini_.LoadFile(config_path_.c_str());
|
||||
SI_Error rc = ini_->LoadFile(config_path_.c_str());
|
||||
if (rc < 0) {
|
||||
Save();
|
||||
return -1;
|
||||
}
|
||||
|
||||
bool persist_config_migration = false;
|
||||
persist_config_migration |= ini_.Delete(section_, "video_content_type");
|
||||
persist_config_migration |= ini_.Delete(section_, "screen_content");
|
||||
// These options now belong only to the active remote connection. Discard
|
||||
// legacy local defaults so they cannot affect this host or new connections.
|
||||
persist_config_migration |= ini_->Delete(section_, "video_quality");
|
||||
persist_config_migration |= ini_->Delete(section_, "video_frame_rate");
|
||||
persist_config_migration |= ini_->Delete(section_, "video_adaptation_policy");
|
||||
persist_config_migration |= ini_->Delete(section_, "video_content_type");
|
||||
persist_config_migration |= ini_->Delete(section_, "screen_content");
|
||||
persist_config_migration |=
|
||||
ini_.Delete(section_, "enable_desktop_quality_optimization");
|
||||
ini_->Delete(section_, "enable_desktop_quality_optimization");
|
||||
persist_config_migration |=
|
||||
ini_.Delete(section_, "enable_minimize_to_tray");
|
||||
ini_->Delete(section_, "enable_minimize_to_tray");
|
||||
|
||||
const long language_value =
|
||||
ini_.GetLongValue(section_, "language", static_cast<long>(language_));
|
||||
ini_->GetLongValue(section_, "language", static_cast<long>(language_));
|
||||
if (language_value < static_cast<long>(LANGUAGE::CHINESE) ||
|
||||
language_value > static_cast<long>(LANGUAGE::RUSSIAN)) {
|
||||
language_ = LANGUAGE::ENGLISH;
|
||||
@@ -52,13 +61,7 @@ int ConfigCenter::Load() {
|
||||
language_ = static_cast<LANGUAGE>(language_value);
|
||||
}
|
||||
|
||||
video_quality_ = static_cast<VIDEO_QUALITY>(ini_.GetLongValue(
|
||||
section_, "video_quality", static_cast<long>(video_quality_)));
|
||||
|
||||
video_frame_rate_ = static_cast<VIDEO_FRAME_RATE>(ini_.GetLongValue(
|
||||
section_, "video_frame_rate", static_cast<long>(video_frame_rate_)));
|
||||
|
||||
const long screen_capture_method_value = ini_.GetLongValue(
|
||||
const long screen_capture_method_value = ini_->GetLongValue(
|
||||
section_, "screen_capture_method",
|
||||
static_cast<long>(ScreenCaptureMethod::Auto));
|
||||
screen_capture_method_ = IsValidScreenCaptureMethod(screen_capture_method_value)
|
||||
@@ -66,27 +69,15 @@ int ConfigCenter::Load() {
|
||||
screen_capture_method_value)
|
||||
: ScreenCaptureMethod::Auto;
|
||||
|
||||
const long video_adaptation_policy_value = ini_.GetLongValue(
|
||||
section_, "video_adaptation_policy",
|
||||
static_cast<long>(video_adaptation_policy_));
|
||||
if (IsValidVideoAdaptationPolicyValue(video_adaptation_policy_value)) {
|
||||
video_adaptation_policy_ =
|
||||
static_cast<VIDEO_ADAPTATION_POLICY>(video_adaptation_policy_value);
|
||||
} else {
|
||||
LOG_WARN("Invalid video adaptation policy [{}], using quality priority",
|
||||
video_adaptation_policy_value);
|
||||
video_adaptation_policy_ = VIDEO_ADAPTATION_POLICY::QUALITY_PRIORITY;
|
||||
}
|
||||
|
||||
video_encode_format_ = static_cast<VIDEO_ENCODE_FORMAT>(
|
||||
ini_.GetLongValue(section_, "video_encode_format",
|
||||
ini_->GetLongValue(section_, "video_encode_format",
|
||||
static_cast<long>(video_encode_format_)));
|
||||
|
||||
hardware_video_codec_ = ini_.GetBoolValue(section_, "hardware_video_codec",
|
||||
hardware_video_codec_ = ini_->GetBoolValue(section_, "hardware_video_codec",
|
||||
hardware_video_codec_);
|
||||
const char* turn_mode_value = ini_.GetValue(section_, "turn_mode", nullptr);
|
||||
const char* turn_mode_value = ini_->GetValue(section_, "turn_mode", nullptr);
|
||||
if (turn_mode_value != nullptr && strlen(turn_mode_value) > 0) {
|
||||
const long parsed_turn_mode = ini_.GetLongValue(
|
||||
const long parsed_turn_mode = ini_->GetLongValue(
|
||||
section_, "turn_mode", static_cast<long>(turn_mode_));
|
||||
if (IsValidTurnModeValue(parsed_turn_mode)) {
|
||||
turn_mode_ = static_cast<TURN_MODE>(parsed_turn_mode);
|
||||
@@ -96,24 +87,24 @@ int ConfigCenter::Load() {
|
||||
turn_mode_ = TURN_MODE::AUTO_UDP_TCP;
|
||||
}
|
||||
} else {
|
||||
const bool legacy_enable_turn = ini_.GetBoolValue(
|
||||
const bool legacy_enable_turn = ini_->GetBoolValue(
|
||||
section_, "enable_turn", turn_mode_ != TURN_MODE::DISABLED);
|
||||
turn_mode_ = legacy_enable_turn ? TURN_MODE::AUTO_UDP_TCP
|
||||
: TURN_MODE::DISABLED;
|
||||
ini_.SetLongValue(section_, "turn_mode", static_cast<long>(turn_mode_));
|
||||
ini_->SetLongValue(section_, "turn_mode", static_cast<long>(turn_mode_));
|
||||
persist_config_migration = true;
|
||||
}
|
||||
// Migrate the former user switch. Keep true in the file for older builds,
|
||||
// while the current application always requests encrypted native media.
|
||||
if (!ini_.GetBoolValue(section_, "enable_srtp", false)) {
|
||||
ini_.SetBoolValue(section_, "enable_srtp", true);
|
||||
if (!ini_->GetBoolValue(section_, "enable_srtp", false)) {
|
||||
ini_->SetBoolValue(section_, "enable_srtp", true);
|
||||
persist_config_migration = true;
|
||||
}
|
||||
enable_self_hosted_ =
|
||||
ini_.GetBoolValue(section_, "enable_self_hosted", enable_self_hosted_);
|
||||
ini_->GetBoolValue(section_, "enable_self_hosted", enable_self_hosted_);
|
||||
|
||||
const char* signal_server_host_value =
|
||||
ini_.GetValue(section_, "signal_server_host", nullptr);
|
||||
ini_->GetValue(section_, "signal_server_host", nullptr);
|
||||
if (signal_server_host_value != nullptr &&
|
||||
strlen(signal_server_host_value) > 0) {
|
||||
signal_server_host_ = signal_server_host_value;
|
||||
@@ -121,25 +112,27 @@ int ConfigCenter::Load() {
|
||||
signal_server_host_ = "";
|
||||
}
|
||||
const char* signal_server_port_value =
|
||||
ini_.GetValue(section_, "signal_server_port", nullptr);
|
||||
ini_->GetValue(section_, "signal_server_port", nullptr);
|
||||
if (signal_server_port_value != nullptr &&
|
||||
strlen(signal_server_port_value) > 0) {
|
||||
signal_server_port_ =
|
||||
static_cast<int>(ini_.GetLongValue(section_, "signal_server_port", 0));
|
||||
static_cast<int>(ini_->GetLongValue(section_, "signal_server_port", 0));
|
||||
} else {
|
||||
signal_server_port_ = 0;
|
||||
}
|
||||
enable_autostart_ =
|
||||
ini_.GetBoolValue(section_, "enable_autostart", enable_autostart_);
|
||||
enable_daemon_ = ini_.GetBoolValue(section_, "enable_daemon", enable_daemon_);
|
||||
ini_->GetBoolValue(section_, "enable_autostart", enable_autostart_);
|
||||
enable_daemon_ = ini_->GetBoolValue(section_, "enable_daemon", enable_daemon_);
|
||||
enable_privacy_screen_.store(
|
||||
ini_.GetBoolValue(section_, "enable_privacy_screen", true));
|
||||
ini_->GetBoolValue(section_, "enable_privacy_screen", true));
|
||||
save_remote_previews_.store(
|
||||
ini_->GetBoolValue(section_, "save_remote_previews", true));
|
||||
portable_service_prompt_suppressed_ =
|
||||
ini_.GetBoolValue(section_, "portable_service_prompt_suppressed",
|
||||
ini_->GetBoolValue(section_, "portable_service_prompt_suppressed",
|
||||
portable_service_prompt_suppressed_);
|
||||
|
||||
const char* file_transfer_save_path_value =
|
||||
ini_.GetValue(section_, "file_transfer_save_path", nullptr);
|
||||
ini_->GetValue(section_, "file_transfer_save_path", nullptr);
|
||||
if (file_transfer_save_path_value != nullptr &&
|
||||
strlen(file_transfer_save_path_value) > 0) {
|
||||
file_transfer_save_path_ = file_transfer_save_path_value;
|
||||
@@ -147,50 +140,91 @@ int ConfigCenter::Load() {
|
||||
file_transfer_save_path_ = "";
|
||||
}
|
||||
|
||||
if (persist_config_migration && ini_.SaveFile(config_path_.c_str()) < 0) {
|
||||
if (persist_config_migration && CommitIni() < 0) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
int ConfigCenter::CommitIni() {
|
||||
// Write beside the destination and rename only after a complete save. A
|
||||
// failed write must not truncate the last usable configuration.
|
||||
static std::atomic<unsigned long> sequence{0};
|
||||
const std::string temporary = config_path_ + ".tmp-" +
|
||||
std::to_string(std::chrono::steady_clock::now().time_since_epoch().count()) +
|
||||
"-" + std::to_string(sequence++);
|
||||
std::error_code error;
|
||||
if (ini_->SaveFile(temporary.c_str()) < 0) {
|
||||
LOG_ERROR("Failed to write configuration: {}", config_path_);
|
||||
std::filesystem::remove(temporary, error);
|
||||
return -1;
|
||||
}
|
||||
const auto previous = std::filesystem::status(config_path_, error);
|
||||
if (!error && std::filesystem::is_regular_file(previous)) {
|
||||
std::filesystem::permissions(temporary, previous.permissions(), error);
|
||||
if (error) {
|
||||
LOG_ERROR("Failed to preserve configuration permissions: {}", error.message());
|
||||
std::filesystem::remove(temporary, error);
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
std::filesystem::rename(temporary, config_path_, error);
|
||||
if (!error) return 0;
|
||||
LOG_ERROR("Failed to replace configuration {}: {}", config_path_, error.message());
|
||||
std::filesystem::remove(temporary, error);
|
||||
return -1;
|
||||
}
|
||||
|
||||
int ConfigCenter::StoreValues(
|
||||
std::initializer_list<std::pair<const char*, std::string>> values) {
|
||||
std::vector<std::pair<const char*, std::optional<std::string>>> previous;
|
||||
for (const auto& value : values) {
|
||||
const char* old = ini_->GetValue(section_, value.first, nullptr);
|
||||
previous.emplace_back(value.first, old ? std::optional<std::string>(old) : std::nullopt);
|
||||
ini_->SetValue(section_, value.first, value.second.c_str());
|
||||
}
|
||||
if (CommitIni() == 0) return 0;
|
||||
for (const auto& value : previous) {
|
||||
if (value.second) ini_->SetValue(section_, value.first, value.second->c_str());
|
||||
else ini_->Delete(section_, value.first);
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
int ConfigCenter::Save() {
|
||||
ini_.SetLongValue(section_, "language", static_cast<long>(language_));
|
||||
ini_.SetLongValue(section_, "video_quality",
|
||||
static_cast<long>(video_quality_));
|
||||
ini_.SetLongValue(section_, "video_frame_rate",
|
||||
static_cast<long>(video_frame_rate_));
|
||||
ini_.SetLongValue(section_, "screen_capture_method",
|
||||
ini_->SetLongValue(section_, "language", static_cast<long>(language_));
|
||||
ini_->SetLongValue(section_, "screen_capture_method",
|
||||
static_cast<long>(screen_capture_method_));
|
||||
ini_.SetLongValue(section_, "video_adaptation_policy",
|
||||
static_cast<long>(video_adaptation_policy_));
|
||||
ini_.SetLongValue(section_, "video_encode_format",
|
||||
ini_->SetLongValue(section_, "video_encode_format",
|
||||
static_cast<long>(video_encode_format_));
|
||||
ini_.SetBoolValue(section_, "hardware_video_codec", hardware_video_codec_);
|
||||
ini_.SetLongValue(section_, "turn_mode", static_cast<long>(turn_mode_));
|
||||
ini_.SetBoolValue(section_, "enable_turn",
|
||||
ini_->SetBoolValue(section_, "hardware_video_codec", hardware_video_codec_);
|
||||
ini_->SetLongValue(section_, "turn_mode", static_cast<long>(turn_mode_));
|
||||
ini_->SetBoolValue(section_, "enable_turn",
|
||||
turn_mode_ != TURN_MODE::DISABLED);
|
||||
ini_.SetBoolValue(section_, "enable_srtp", true);
|
||||
ini_.SetBoolValue(section_, "enable_self_hosted", enable_self_hosted_);
|
||||
ini_->SetBoolValue(section_, "enable_srtp", true);
|
||||
ini_->SetBoolValue(section_, "enable_self_hosted", enable_self_hosted_);
|
||||
|
||||
// only save when self hosted
|
||||
if (enable_self_hosted_) {
|
||||
ini_.SetValue(section_, "signal_server_host", signal_server_host_.c_str());
|
||||
ini_.SetLongValue(section_, "signal_server_port",
|
||||
ini_->SetValue(section_, "signal_server_host", signal_server_host_.c_str());
|
||||
ini_->SetLongValue(section_, "signal_server_port",
|
||||
static_cast<long>(signal_server_port_));
|
||||
}
|
||||
|
||||
ini_.SetBoolValue(section_, "enable_autostart", enable_autostart_);
|
||||
ini_.SetBoolValue(section_, "enable_daemon", enable_daemon_);
|
||||
ini_.SetBoolValue(section_, "enable_privacy_screen",
|
||||
ini_->SetBoolValue(section_, "enable_autostart", enable_autostart_);
|
||||
ini_->SetBoolValue(section_, "enable_daemon", enable_daemon_);
|
||||
ini_->SetBoolValue(section_, "enable_privacy_screen",
|
||||
enable_privacy_screen_.load());
|
||||
ini_.SetBoolValue(section_, "portable_service_prompt_suppressed",
|
||||
ini_->SetBoolValue(section_, "save_remote_previews",
|
||||
save_remote_previews_.load());
|
||||
ini_->SetBoolValue(section_, "portable_service_prompt_suppressed",
|
||||
portable_service_prompt_suppressed_);
|
||||
|
||||
ini_.SetValue(section_, "file_transfer_save_path",
|
||||
ini_->SetValue(section_, "file_transfer_save_path",
|
||||
file_transfer_save_path_.c_str());
|
||||
|
||||
SI_Error rc = ini_.SaveFile(config_path_.c_str());
|
||||
SI_Error rc = CommitIni();
|
||||
if (rc < 0) {
|
||||
return -1;
|
||||
}
|
||||
@@ -201,34 +235,9 @@ int ConfigCenter::Save() {
|
||||
// setters
|
||||
|
||||
int ConfigCenter::SetLanguage(LANGUAGE language) {
|
||||
const int value = static_cast<int>(language);
|
||||
if (value < 0 || value > 2 || StoreValues({{"language", std::to_string(value)}}) != 0) return -1;
|
||||
language_ = language;
|
||||
ini_.SetLongValue(section_, "language", static_cast<long>(language_));
|
||||
SI_Error rc = ini_.SaveFile(config_path_.c_str());
|
||||
if (rc < 0) {
|
||||
return -1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
int ConfigCenter::SetVideoQuality(VIDEO_QUALITY video_quality) {
|
||||
video_quality_ = video_quality;
|
||||
ini_.SetLongValue(section_, "video_quality",
|
||||
static_cast<long>(video_quality_));
|
||||
SI_Error rc = ini_.SaveFile(config_path_.c_str());
|
||||
if (rc < 0) {
|
||||
return -1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
int ConfigCenter::SetVideoFrameRate(VIDEO_FRAME_RATE video_frame_rate) {
|
||||
video_frame_rate_ = video_frame_rate;
|
||||
ini_.SetLongValue(section_, "video_frame_rate",
|
||||
static_cast<long>(video_frame_rate_));
|
||||
SI_Error rc = ini_.SaveFile(config_path_.c_str());
|
||||
if (rc < 0) {
|
||||
return -1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -236,9 +245,9 @@ int ConfigCenter::SetScreenCaptureMethod(ScreenCaptureMethod method) {
|
||||
if (!IsValidScreenCaptureMethod(static_cast<long>(method))) {
|
||||
return -1;
|
||||
}
|
||||
ini_.SetLongValue(section_, "screen_capture_method", static_cast<long>(method));
|
||||
if (ini_.SaveFile(config_path_.c_str()) < 0) {
|
||||
ini_.SetLongValue(section_, "screen_capture_method",
|
||||
ini_->SetLongValue(section_, "screen_capture_method", static_cast<long>(method));
|
||||
if (CommitIni() < 0) {
|
||||
ini_->SetLongValue(section_, "screen_capture_method",
|
||||
static_cast<long>(screen_capture_method_));
|
||||
return -1;
|
||||
}
|
||||
@@ -246,53 +255,25 @@ int ConfigCenter::SetScreenCaptureMethod(ScreenCaptureMethod method) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
int ConfigCenter::SetVideoAdaptationPolicy(
|
||||
VIDEO_ADAPTATION_POLICY policy) {
|
||||
video_adaptation_policy_ = policy;
|
||||
ini_.SetLongValue(section_, "video_adaptation_policy",
|
||||
static_cast<long>(video_adaptation_policy_));
|
||||
SI_Error rc = ini_.SaveFile(config_path_.c_str());
|
||||
if (rc < 0) {
|
||||
return -1;
|
||||
}
|
||||
int ConfigCenter::SetVideoEncodeFormat(VIDEO_ENCODE_FORMAT format) {
|
||||
if (format != VIDEO_ENCODE_FORMAT::H264 && format != VIDEO_ENCODE_FORMAT::AV1) return -1;
|
||||
if (StoreValues({{"video_encode_format", std::to_string(static_cast<int>(format))}}) != 0) return -1;
|
||||
video_encode_format_ = format;
|
||||
return 0;
|
||||
}
|
||||
|
||||
int ConfigCenter::SetVideoEncodeFormat(
|
||||
VIDEO_ENCODE_FORMAT video_encode_format) {
|
||||
video_encode_format_ = video_encode_format;
|
||||
ini_.SetLongValue(section_, "video_encode_format",
|
||||
static_cast<long>(video_encode_format_));
|
||||
SI_Error rc = ini_.SaveFile(config_path_.c_str());
|
||||
if (rc < 0) {
|
||||
return -1;
|
||||
}
|
||||
int ConfigCenter::SetHardwareVideoCodec(bool enabled) {
|
||||
if (enabled && !IsHardwareVideoCodecAvailable()) return -1;
|
||||
if (StoreValues({{"hardware_video_codec", enabled ? "true" : "false"}}) != 0) return -1;
|
||||
hardware_video_codec_ = enabled;
|
||||
return 0;
|
||||
}
|
||||
|
||||
int ConfigCenter::SetHardwareVideoCodec(bool hardware_video_codec) {
|
||||
hardware_video_codec_ = hardware_video_codec;
|
||||
ini_.SetBoolValue(section_, "hardware_video_codec", hardware_video_codec_);
|
||||
SI_Error rc = ini_.SaveFile(config_path_.c_str());
|
||||
if (rc < 0) {
|
||||
return -1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
int ConfigCenter::SetTurnMode(TURN_MODE turn_mode) {
|
||||
if (!IsValidTurnModeValue(static_cast<long>(turn_mode))) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
turn_mode_ = turn_mode;
|
||||
ini_.SetLongValue(section_, "turn_mode", static_cast<long>(turn_mode_));
|
||||
ini_.SetBoolValue(section_, "enable_turn",
|
||||
turn_mode_ != TURN_MODE::DISABLED);
|
||||
SI_Error rc = ini_.SaveFile(config_path_.c_str());
|
||||
if (rc < 0) {
|
||||
return -1;
|
||||
}
|
||||
int ConfigCenter::SetTurnMode(TURN_MODE mode) {
|
||||
if (!IsValidTurnModeValue(static_cast<long>(mode))) return -1;
|
||||
if (StoreValues({{"turn_mode", std::to_string(static_cast<int>(mode))},
|
||||
{"enable_turn", mode == TURN_MODE::DISABLED ? "false" : "true"}}) != 0) return -1;
|
||||
turn_mode_ = mode;
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -308,99 +289,56 @@ int ConfigCenter::SetTurn(bool enable_turn, bool force_relay) {
|
||||
: TURN_MODE::FORCE_UDP);
|
||||
}
|
||||
|
||||
int ConfigCenter::SetServerHost(const std::string& signal_server_host) {
|
||||
signal_server_host_ = signal_server_host;
|
||||
ini_.SetValue(section_, "signal_server_host", signal_server_host_.c_str());
|
||||
SI_Error rc = ini_.SaveFile(config_path_.c_str());
|
||||
if (rc < 0) {
|
||||
return -1;
|
||||
}
|
||||
int ConfigCenter::SetServerHost(const std::string& host) {
|
||||
if (host.empty() || host.size() > 253 || host.find_first_of("/\\@?#") != std::string::npos ||
|
||||
std::any_of(host.begin(), host.end(), [](unsigned char c) { return c <= 32 || c == 127; })) return -1;
|
||||
if (StoreValues({{"signal_server_host", host}}) != 0) return -1;
|
||||
signal_server_host_ = host;
|
||||
return 0;
|
||||
}
|
||||
|
||||
int ConfigCenter::SetServerPort(int signal_server_port) {
|
||||
signal_server_port_ = signal_server_port;
|
||||
ini_.SetLongValue(section_, "signal_server_port",
|
||||
static_cast<long>(signal_server_port_));
|
||||
SI_Error rc = ini_.SaveFile(config_path_.c_str());
|
||||
if (rc < 0) {
|
||||
return -1;
|
||||
}
|
||||
int ConfigCenter::SetServerPort(int port) {
|
||||
if (port < 1 || port > 65535 || StoreValues({{"signal_server_port", std::to_string(port)}}) != 0) return -1;
|
||||
signal_server_port_ = port;
|
||||
return 0;
|
||||
}
|
||||
|
||||
int ConfigCenter::SetSelfHosted(bool enable_self_hosted) {
|
||||
enable_self_hosted_ = enable_self_hosted;
|
||||
ini_.SetBoolValue(section_, "enable_self_hosted", enable_self_hosted_);
|
||||
|
||||
// load from config if self hosted is enabled
|
||||
if (enable_self_hosted_) {
|
||||
const char* signal_server_host_value =
|
||||
ini_.GetValue(section_, "signal_server_host", nullptr);
|
||||
if (signal_server_host_value != nullptr &&
|
||||
strlen(signal_server_host_value) > 0) {
|
||||
signal_server_host_ = signal_server_host_value;
|
||||
}
|
||||
const char* signal_server_port_value =
|
||||
ini_.GetValue(section_, "signal_server_port", nullptr);
|
||||
if (signal_server_port_value != nullptr &&
|
||||
strlen(signal_server_port_value) > 0) {
|
||||
signal_server_port_ = static_cast<int>(
|
||||
ini_.GetLongValue(section_, "signal_server_port", 0));
|
||||
}
|
||||
ini_.SetValue(section_, "signal_server_host", signal_server_host_.c_str());
|
||||
ini_.SetLongValue(section_, "signal_server_port",
|
||||
static_cast<long>(signal_server_port_));
|
||||
}
|
||||
|
||||
SI_Error rc = ini_.SaveFile(config_path_.c_str());
|
||||
if (rc < 0) {
|
||||
return -1;
|
||||
}
|
||||
int ConfigCenter::SetSelfHosted(bool enabled) {
|
||||
if (enabled && (signal_server_host_.empty() || signal_server_port_ < 1 || signal_server_port_ > 65535)) return -1;
|
||||
if (StoreValues({{"enable_self_hosted", enabled ? "true" : "false"}}) != 0) return -1;
|
||||
enable_self_hosted_ = enabled;
|
||||
return 0;
|
||||
}
|
||||
|
||||
int ConfigCenter::SetAutostart(bool enable_autostart) {
|
||||
enable_autostart_ = enable_autostart;
|
||||
bool success = false;
|
||||
if (enable_autostart) {
|
||||
success = EnableAutostart("CrossDesk");
|
||||
} else {
|
||||
success = DisableAutostart("CrossDesk");
|
||||
}
|
||||
|
||||
ini_.SetBoolValue(section_, "enable_autostart", enable_autostart_);
|
||||
SI_Error rc = ini_.SaveFile(config_path_.c_str());
|
||||
if (rc < 0) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (!success) {
|
||||
int ConfigCenter::SetAutostart(bool enabled) {
|
||||
const bool previous = IsAutostartEnabled("CrossDesk");
|
||||
if (enabled != previous && !(enabled ? EnableAutostart("CrossDesk") : DisableAutostart("CrossDesk"))) {
|
||||
LOG_ERROR("SetAutostart failed");
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (StoreValues({{"enable_autostart", enabled ? "true" : "false"}}) != 0) {
|
||||
if (enabled != previous) {
|
||||
if (previous) EnableAutostart("CrossDesk");
|
||||
else DisableAutostart("CrossDesk");
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
enable_autostart_ = enabled;
|
||||
return 0;
|
||||
}
|
||||
|
||||
int ConfigCenter::SetDaemon(bool enable_daemon) {
|
||||
enable_daemon_ = enable_daemon;
|
||||
|
||||
ini_.SetBoolValue(section_, "enable_daemon", enable_daemon_);
|
||||
SI_Error rc = ini_.SaveFile(config_path_.c_str());
|
||||
if (rc < 0) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
int ConfigCenter::SetDaemon(bool enabled) {
|
||||
if (StoreValues({{"enable_daemon", enabled ? "true" : "false"}}) != 0) return -1;
|
||||
enable_daemon_ = enabled;
|
||||
return 0;
|
||||
}
|
||||
|
||||
int ConfigCenter::SetPrivacyScreen(bool enable_privacy_screen) {
|
||||
const bool previous = enable_privacy_screen_.load();
|
||||
if (previous == enable_privacy_screen) return 0;
|
||||
ini_.SetBoolValue(section_, "enable_privacy_screen", enable_privacy_screen);
|
||||
if (ini_.SaveFile(config_path_.c_str()) < 0) {
|
||||
ini_.SetBoolValue(section_, "enable_privacy_screen", previous);
|
||||
ini_->SetBoolValue(section_, "enable_privacy_screen", enable_privacy_screen);
|
||||
if (CommitIni() < 0) {
|
||||
ini_->SetBoolValue(section_, "enable_privacy_screen", previous);
|
||||
LOG_ERROR("Failed to save automatic privacy screen preference");
|
||||
return -1;
|
||||
}
|
||||
@@ -410,11 +348,23 @@ int ConfigCenter::SetPrivacyScreen(bool enable_privacy_screen) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
int ConfigCenter::SetSaveRemotePreviews(bool enabled) {
|
||||
if (StoreValues({{"save_remote_previews", enabled ? "true" : "false"}}) != 0) {
|
||||
return -1;
|
||||
}
|
||||
save_remote_previews_.store(enabled);
|
||||
return 0;
|
||||
}
|
||||
|
||||
bool ConfigCenter::IsSaveRemotePreviews() const {
|
||||
return save_remote_previews_.load();
|
||||
}
|
||||
|
||||
int ConfigCenter::SetPortableServicePromptSuppressed(bool suppressed) {
|
||||
portable_service_prompt_suppressed_ = suppressed;
|
||||
ini_.SetBoolValue(section_, "portable_service_prompt_suppressed",
|
||||
ini_->SetBoolValue(section_, "portable_service_prompt_suppressed",
|
||||
portable_service_prompt_suppressed_);
|
||||
SI_Error rc = ini_.SaveFile(config_path_.c_str());
|
||||
SI_Error rc = CommitIni();
|
||||
if (rc < 0) {
|
||||
return -1;
|
||||
}
|
||||
@@ -426,23 +376,10 @@ int ConfigCenter::SetPortableServicePromptSuppressed(bool suppressed) {
|
||||
|
||||
ConfigCenter::LANGUAGE ConfigCenter::GetLanguage() const { return language_; }
|
||||
|
||||
ConfigCenter::VIDEO_QUALITY ConfigCenter::GetVideoQuality() const {
|
||||
return video_quality_;
|
||||
}
|
||||
|
||||
ConfigCenter::VIDEO_FRAME_RATE ConfigCenter::GetVideoFrameRate() const {
|
||||
return video_frame_rate_;
|
||||
}
|
||||
|
||||
ScreenCaptureMethod ConfigCenter::GetScreenCaptureMethod() const {
|
||||
return screen_capture_method_;
|
||||
}
|
||||
|
||||
ConfigCenter::VIDEO_ADAPTATION_POLICY
|
||||
ConfigCenter::GetVideoAdaptationPolicy() const {
|
||||
return video_adaptation_policy_;
|
||||
}
|
||||
|
||||
ConfigCenter::VIDEO_ENCODE_FORMAT ConfigCenter::GetVideoEncodeFormat() const {
|
||||
return video_encode_format_;
|
||||
}
|
||||
@@ -504,17 +441,15 @@ bool ConfigCenter::IsPortableServicePromptSuppressed() const {
|
||||
}
|
||||
|
||||
int ConfigCenter::SetFileTransferSavePath(const std::string& path) {
|
||||
if (path.size() >= 512 || std::any_of(path.begin(), path.end(), [](unsigned char c) { return c < 32 || c == 127; })) return -1;
|
||||
std::lock_guard<std::mutex> lock(file_path_mutex_);
|
||||
if (StoreValues({{"file_transfer_save_path", path}}) != 0) return -1;
|
||||
file_transfer_save_path_ = path;
|
||||
ini_.SetValue(section_, "file_transfer_save_path",
|
||||
file_transfer_save_path_.c_str());
|
||||
SI_Error rc = ini_.SaveFile(config_path_.c_str());
|
||||
if (rc < 0) {
|
||||
return -1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
std::string ConfigCenter::GetFileTransferSavePath() const {
|
||||
std::lock_guard<std::mutex> lock(file_path_mutex_);
|
||||
return file_transfer_save_path_;
|
||||
}
|
||||
} // namespace crossdesk
|
||||
|
||||
@@ -8,9 +8,12 @@
|
||||
#define _CONFIG_CENTER_H_
|
||||
|
||||
#include <atomic>
|
||||
#include <initializer_list>
|
||||
#include <memory>
|
||||
#include <mutex>
|
||||
#include <utility>
|
||||
#include <string>
|
||||
|
||||
#include "SimpleIni.h"
|
||||
#include "screen_capturer/screen_capture_method.h"
|
||||
|
||||
namespace crossdesk {
|
||||
@@ -18,13 +21,6 @@ namespace crossdesk {
|
||||
class ConfigCenter {
|
||||
public:
|
||||
enum class LANGUAGE { CHINESE = 0, ENGLISH = 1, RUSSIAN = 2 };
|
||||
enum class VIDEO_QUALITY { LOW = 0, MEDIUM = 1, HIGH = 2 };
|
||||
enum class VIDEO_FRAME_RATE { FPS_30 = 0, FPS_60 = 1 };
|
||||
enum class VIDEO_ADAPTATION_POLICY {
|
||||
FRAME_RATE_PRIORITY = 0,
|
||||
QUALITY_PRIORITY = 1,
|
||||
BALANCED = 2
|
||||
};
|
||||
enum class VIDEO_ENCODE_FORMAT { H264 = 0, AV1 = 1 };
|
||||
enum class TURN_MODE {
|
||||
DISABLED = 0,
|
||||
@@ -39,10 +35,7 @@ class ConfigCenter {
|
||||
|
||||
// write config
|
||||
int SetLanguage(LANGUAGE language);
|
||||
int SetVideoQuality(VIDEO_QUALITY video_quality);
|
||||
int SetVideoFrameRate(VIDEO_FRAME_RATE video_frame_rate);
|
||||
int SetScreenCaptureMethod(ScreenCaptureMethod method);
|
||||
int SetVideoAdaptationPolicy(VIDEO_ADAPTATION_POLICY policy);
|
||||
int SetVideoEncodeFormat(VIDEO_ENCODE_FORMAT video_encode_format);
|
||||
int SetHardwareVideoCodec(bool hardware_video_codec);
|
||||
int SetTurnMode(TURN_MODE turn_mode);
|
||||
@@ -53,16 +46,14 @@ class ConfigCenter {
|
||||
int SetAutostart(bool enable_autostart);
|
||||
int SetDaemon(bool enable_daemon);
|
||||
int SetPrivacyScreen(bool enable_privacy_screen);
|
||||
int SetSaveRemotePreviews(bool enabled);
|
||||
int SetPortableServicePromptSuppressed(bool suppressed);
|
||||
int SetFileTransferSavePath(const std::string& path);
|
||||
|
||||
// read config
|
||||
|
||||
LANGUAGE GetLanguage() const;
|
||||
VIDEO_QUALITY GetVideoQuality() const;
|
||||
VIDEO_FRAME_RATE GetVideoFrameRate() const;
|
||||
ScreenCaptureMethod GetScreenCaptureMethod() const;
|
||||
VIDEO_ADAPTATION_POLICY GetVideoAdaptationPolicy() const;
|
||||
VIDEO_ENCODE_FORMAT GetVideoEncodeFormat() const;
|
||||
// Whether this build includes a hardware video codec backend.
|
||||
static bool IsHardwareVideoCodecAvailable();
|
||||
@@ -79,6 +70,7 @@ class ConfigCenter {
|
||||
bool IsEnableAutostart() const;
|
||||
bool IsEnableDaemon() const;
|
||||
bool IsEnablePrivacyScreen() const;
|
||||
bool IsSaveRemotePreviews() const;
|
||||
bool IsPortableServicePromptSuppressed() const;
|
||||
std::string GetFileTransferSavePath() const;
|
||||
|
||||
@@ -86,16 +78,16 @@ class ConfigCenter {
|
||||
int Save();
|
||||
|
||||
private:
|
||||
int CommitIni();
|
||||
int StoreValues(std::initializer_list<std::pair<const char*, std::string>> values);
|
||||
mutable std::mutex file_path_mutex_;
|
||||
std::string config_path_;
|
||||
CSimpleIniA ini_;
|
||||
struct IniStorage;
|
||||
std::unique_ptr<IniStorage> ini_;
|
||||
const char* section_ = "Settings";
|
||||
|
||||
LANGUAGE language_ = LANGUAGE::CHINESE;
|
||||
VIDEO_QUALITY video_quality_ = VIDEO_QUALITY::HIGH;
|
||||
VIDEO_FRAME_RATE video_frame_rate_ = VIDEO_FRAME_RATE::FPS_60;
|
||||
ScreenCaptureMethod screen_capture_method_ = ScreenCaptureMethod::Auto;
|
||||
VIDEO_ADAPTATION_POLICY video_adaptation_policy_ =
|
||||
VIDEO_ADAPTATION_POLICY::QUALITY_PRIORITY;
|
||||
VIDEO_ENCODE_FORMAT video_encode_format_ = VIDEO_ENCODE_FORMAT::H264;
|
||||
bool hardware_video_codec_ = false;
|
||||
TURN_MODE turn_mode_ = TURN_MODE::AUTO_UDP_TCP;
|
||||
@@ -107,6 +99,7 @@ class ConfigCenter {
|
||||
bool enable_autostart_ = false;
|
||||
bool enable_daemon_ = false;
|
||||
std::atomic<bool> enable_privacy_screen_{true};
|
||||
std::atomic<bool> save_remote_previews_{true};
|
||||
bool portable_service_prompt_suppressed_ = false;
|
||||
std::string file_transfer_save_path_ = "";
|
||||
};
|
||||
|
||||
@@ -10,7 +10,6 @@
|
||||
#include <atomic>
|
||||
#include <chrono>
|
||||
#include <cstdint>
|
||||
#include <nlohmann/json.hpp>
|
||||
#include <string>
|
||||
|
||||
namespace crossdesk::gui_detail {
|
||||
@@ -47,7 +46,6 @@ struct InteractionState {
|
||||
};
|
||||
|
||||
struct UpdateState {
|
||||
nlohmann::json latest_version_info_ = nlohmann::json{};
|
||||
bool update_available_ = false;
|
||||
std::string latest_version_;
|
||||
std::string release_name_;
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
#include <SDL3/SDL.h>
|
||||
#include <openssl/rand.h>
|
||||
#include <slint.h>
|
||||
#include <nlohmann/json.hpp>
|
||||
#include <tinyfiledialogs.h>
|
||||
|
||||
#include <algorithm>
|
||||
@@ -15,6 +16,7 @@
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include <ctime>
|
||||
#include <filesystem>
|
||||
#include <memory>
|
||||
#include <mutex>
|
||||
@@ -54,11 +56,15 @@
|
||||
|
||||
#include "platform/linux/gui/cuda_graphics.h"
|
||||
#include "platform/linux/gui/tray/linux_tray.h"
|
||||
#include "platform/linux/headless/headless_console.h"
|
||||
// Xlib defines Status as a macro, which collides with UpdateChecker::Status.
|
||||
#undef Status
|
||||
#endif
|
||||
#include "rd_log.h"
|
||||
#include "server_window_state.h"
|
||||
#include "ui/announcement_text.h"
|
||||
#include "ui/ui_localization.h"
|
||||
#include "version_checker.h"
|
||||
#include "update_checker.h"
|
||||
#include "window_geometry.h"
|
||||
|
||||
namespace crossdesk {
|
||||
@@ -653,6 +659,41 @@ struct WindowDragState {
|
||||
float global_to_physical_scale = 1;
|
||||
slint::PhysicalPosition window_start;
|
||||
slint::PhysicalPosition last_target;
|
||||
#if defined(__linux__) && !defined(__APPLE__)
|
||||
std::unique_ptr<Display, decltype(&XCloseDisplay)> x11_display{nullptr,
|
||||
XCloseDisplay};
|
||||
slint::Timer pointer_timer;
|
||||
#endif
|
||||
|
||||
bool ReadGlobalPointer(float* x, float* y) {
|
||||
#if defined(__linux__) && !defined(__APPLE__)
|
||||
const char* driver = SDL_GetCurrentVideoDriver();
|
||||
if (driver && std::strcmp(driver, "x11") == 0) {
|
||||
// Slint can dispatch motion before SDL pumps its XInput2 events, and
|
||||
// pointer warps do not emit raw motion at all. Query X11 directly so
|
||||
// dragging never depends on SDL's cached global pointer position.
|
||||
if (!x11_display) {
|
||||
x11_display.reset(XOpenDisplay(nullptr));
|
||||
}
|
||||
if (!x11_display) {
|
||||
return false;
|
||||
}
|
||||
::Window root, child;
|
||||
int root_x, root_y, local_x, local_y;
|
||||
unsigned int buttons;
|
||||
if (!XQueryPointer(x11_display.get(), DefaultRootWindow(x11_display.get()),
|
||||
&root, &child, &root_x, &root_y, &local_x, &local_y,
|
||||
&buttons)) {
|
||||
return false;
|
||||
}
|
||||
*x = static_cast<float>(root_x);
|
||||
*y = static_cast<float>(root_y);
|
||||
return true;
|
||||
}
|
||||
#endif
|
||||
SDL_GetGlobalMouseState(x, y);
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
bool CanUseGlobalPointerPosition() {
|
||||
@@ -845,9 +886,10 @@ void DragWindow(WindowHandle& component, int phase, float mouse_x,
|
||||
state.last_local_y = mouse_y;
|
||||
state.window_start = window.position();
|
||||
state.last_target = state.window_start;
|
||||
state.use_global_pointer = CanUseGlobalPointerPosition();
|
||||
state.use_global_pointer =
|
||||
CanUseGlobalPointerPosition() &&
|
||||
state.ReadGlobalPointer(&state.pointer_start_x, &state.pointer_start_y);
|
||||
if (state.use_global_pointer) {
|
||||
SDL_GetGlobalMouseState(&state.pointer_start_x, &state.pointer_start_y);
|
||||
#if defined(__APPLE__)
|
||||
// Cocoa reports global pointer positions in logical screen points,
|
||||
// while Slint/winit accepts physical window positions.
|
||||
@@ -856,9 +898,32 @@ void DragWindow(WindowHandle& component, int phase, float mouse_x,
|
||||
state.global_to_physical_scale = 1.0f;
|
||||
#endif
|
||||
}
|
||||
#if defined(__linux__) && !defined(__APPLE__)
|
||||
if (state.use_global_pointer && state.x11_display) {
|
||||
// Moving the window can leave consecutive pointer events at identical
|
||||
// local coordinates, so Slint need not emit another TouchArea::moved.
|
||||
// Follow the global pointer throughout the drag, including those moves.
|
||||
state.pointer_timer.start(
|
||||
slint::TimerMode::Repeated, std::chrono::milliseconds(16),
|
||||
[weak = slint::ComponentWeakHandle(component), &state] {
|
||||
if (auto window = weak.lock()) {
|
||||
DragWindow(*window, 2, 0, 0, state);
|
||||
} else {
|
||||
state.active = false;
|
||||
state.pointer_timer.stop();
|
||||
}
|
||||
});
|
||||
}
|
||||
#endif
|
||||
return;
|
||||
}
|
||||
if (phase == 0) {
|
||||
#if defined(__linux__) && !defined(__APPLE__)
|
||||
state.pointer_timer.stop();
|
||||
if (state.use_global_pointer && state.x11_display) {
|
||||
DragWindow(component, 2, mouse_x, mouse_y, state);
|
||||
}
|
||||
#endif
|
||||
state.active = false;
|
||||
return;
|
||||
}
|
||||
@@ -870,7 +935,9 @@ void DragWindow(WindowHandle& component, int phase, float mouse_x,
|
||||
if (state.use_global_pointer) {
|
||||
float pointer_x = 0;
|
||||
float pointer_y = 0;
|
||||
SDL_GetGlobalMouseState(&pointer_x, &pointer_y);
|
||||
if (!state.ReadGlobalPointer(&pointer_x, &pointer_y)) {
|
||||
return;
|
||||
}
|
||||
const int delta_x = static_cast<int>(std::lround(
|
||||
(pointer_x - state.pointer_start_x) * state.global_to_physical_scale));
|
||||
const int delta_y = static_cast<int>(std::lround(
|
||||
@@ -902,6 +969,8 @@ void DragWindow(WindowHandle& component, int phase, float mouse_x,
|
||||
} // namespace
|
||||
|
||||
struct GuiApplication::SlintUi {
|
||||
uint64_t announcement_version = UINT64_MAX;
|
||||
std::string announcement_selected_id, announcement_selected_revision;
|
||||
slint::ComponentHandle<ui::MainWindow> main = ui::MainWindow::create();
|
||||
std::shared_ptr<slint::VectorModel<ui::ReleaseNoteBlock>>
|
||||
release_note_blocks_model =
|
||||
@@ -977,6 +1046,8 @@ int GuiApplication::Run() {
|
||||
dll_log_path_ = exec_log_path_;
|
||||
InitializeLogger();
|
||||
cache_path_ = path_manager_->GetCachePath().string();
|
||||
announcements_.SetStorageDirectory(path_manager_->GetCachePath() /
|
||||
"announcement_reads");
|
||||
config_center_ = std::make_unique<ConfigCenter>(cache_path_ + "/config.ini");
|
||||
|
||||
LOG_INFO("CrossDesk version: {} (Slint UI)", CROSSDESK_VERSION);
|
||||
@@ -984,39 +1055,21 @@ int GuiApplication::Run() {
|
||||
strncpy(signal_server_ip_self_, config_center_->GetSignalServerHost().c_str(),
|
||||
sizeof(signal_server_ip_self_) - 1);
|
||||
const int signal_port = config_center_->GetSignalServerPort();
|
||||
if (signal_port > 0) {
|
||||
if (signal_port > 0 && signal_port <= 65535) {
|
||||
std::snprintf(signal_server_port_self_, sizeof(signal_server_port_self_),
|
||||
"%d", signal_port);
|
||||
}
|
||||
|
||||
latest_version_info_ = CheckUpdate();
|
||||
if (!latest_version_info_.empty()) {
|
||||
std::string version;
|
||||
if (latest_version_info_.contains("latest_version") &&
|
||||
latest_version_info_["latest_version"].is_string()) {
|
||||
version = latest_version_info_["latest_version"].get<std::string>();
|
||||
} else if (latest_version_info_.contains("version") &&
|
||||
latest_version_info_["version"].is_string()) {
|
||||
version = latest_version_info_["version"].get<std::string>();
|
||||
}
|
||||
latest_version_ = version.empty() ? std::string{} : "v" + version;
|
||||
if (latest_version_info_.contains("releaseNotes") &&
|
||||
latest_version_info_["releaseNotes"].is_string()) {
|
||||
release_notes_ = latest_version_info_["releaseNotes"].get<std::string>();
|
||||
}
|
||||
if (latest_version_info_.contains("releaseName") &&
|
||||
latest_version_info_["releaseName"].is_string()) {
|
||||
release_name_ = latest_version_info_["releaseName"].get<std::string>();
|
||||
}
|
||||
if (latest_version_info_.contains("releaseDate") &&
|
||||
latest_version_info_["releaseDate"].is_string()) {
|
||||
release_date_ = latest_version_info_["releaseDate"].get<std::string>();
|
||||
}
|
||||
update_available_ =
|
||||
!version.empty() && IsNewerVersion(CROSSDESK_VERSION, latest_version_);
|
||||
}
|
||||
|
||||
InitializeSettings();
|
||||
#ifdef __linux__
|
||||
HeadlessConsole::Instance().BindSettings(
|
||||
config_center_.get(), [this] {
|
||||
std::lock_guard<std::mutex> lock(password_change_mutex_);
|
||||
return HasActiveSession() || password_change_pending_ || credential_recovery_in_progress_;
|
||||
}, [this](SettingEffect effect) {
|
||||
ApplySettingsFromConfig(effect == SettingEffect::reconnect);
|
||||
});
|
||||
#endif
|
||||
if (!InitializeSDL()) {
|
||||
return -1;
|
||||
}
|
||||
@@ -1030,6 +1083,7 @@ int GuiApplication::Run() {
|
||||
LOG_INFO("Slint backend: {}; {}", backend.backend, backend.diagnostic);
|
||||
#endif
|
||||
InitializeModules();
|
||||
update_checker_ = std::make_unique<UpdateChecker>(CROSSDESK_VERSION);
|
||||
InitializeUi();
|
||||
|
||||
ui_->timer.start(slint::TimerMode::Repeated, 16ms, [this] { Tick(); });
|
||||
@@ -1040,6 +1094,8 @@ int GuiApplication::Run() {
|
||||
ui_->main->show();
|
||||
#if _WIN32
|
||||
ConfigureWindowsWindowIcons(ui_->main->window().win32_hwnd());
|
||||
#elif defined(__APPLE__)
|
||||
ConfigureMainWindowChrome(ui_->main->window().appkit_view());
|
||||
#endif
|
||||
slint::run_event_loop(slint::EventLoopMode::RunUntilQuit);
|
||||
Cleanup();
|
||||
@@ -1050,6 +1106,9 @@ void GuiApplication::InitializeLogger() { InitLogger(exec_log_path_); }
|
||||
|
||||
void GuiApplication::InitializeSettings() {
|
||||
settings_.Load();
|
||||
if (thumbnail_->SetSavePreviews(config_center_->IsSaveRemotePreviews()) != 0) {
|
||||
LOG_ERROR("Failed to clear saved remote previews");
|
||||
}
|
||||
settings_.LoadRecentConnectionAliases();
|
||||
localization_language_index_ =
|
||||
localization::detail::ClampLanguageIndex(language_button_value_);
|
||||
@@ -1133,6 +1192,9 @@ void GuiApplication::InitializeUi() {
|
||||
}
|
||||
#endif
|
||||
#if defined(__linux__) && !defined(__APPLE__)
|
||||
// Detect the desktop session before temporarily hiding Wayland variables
|
||||
// for the XWayland GUI backend below.
|
||||
const bool is_wayland_session = IsWaylandSession();
|
||||
// Winit prefers Wayland whenever WAYLAND_DISPLAY/WAYLAND_SOCKET is present.
|
||||
// Hide them only while Slint selects its process-wide window backend, then
|
||||
// restore the environment before the rest of the application starts.
|
||||
@@ -1154,15 +1216,9 @@ void GuiApplication::InitializeUi() {
|
||||
#elif defined(__linux__)
|
||||
ui_->main->set_custom_titlebar(use_x11_custom_titlebar_);
|
||||
ui_->main->set_wayland_titlebar(use_xwayland_gui_);
|
||||
// Set this only at startup so dismissing the notice lasts until exit.
|
||||
ui_->main->set_wayland_notice_open(is_wayland_session);
|
||||
#endif
|
||||
std::vector<ui::ReleaseNoteBlock> release_note_blocks;
|
||||
for (const auto& parsed : ParseReleaseNotesMarkdownForSlint(release_notes_)) {
|
||||
ui::ReleaseNoteBlock block;
|
||||
block.content = parsed.content;
|
||||
block.section_gap = parsed.section_gap;
|
||||
release_note_blocks.push_back(std::move(block));
|
||||
}
|
||||
ui_->release_note_blocks_model->set_vector(std::move(release_note_blocks));
|
||||
ui_->main->set_release_note_blocks(ui_->release_note_blocks_model);
|
||||
RegisterFontAwesome(ui_->main->window());
|
||||
ui_->main->set_recent_connections(ui_->recent_model);
|
||||
@@ -1188,6 +1244,7 @@ void GuiApplication::InitializeSystemTray() {
|
||||
#endif
|
||||
ui_->main->window().set_minimized(false);
|
||||
#if defined(__APPLE__)
|
||||
ConfigureMainWindowChrome(ui_->main->window().appkit_view());
|
||||
MacActivateWindow(ui_->main->window().appkit_view());
|
||||
// A window restored from the tray may only obtain its NSWindow after the
|
||||
// callback returns, so let the regular UI tick configure it.
|
||||
@@ -1250,10 +1307,6 @@ void GuiApplication::ResetSettingsUi() {
|
||||
return;
|
||||
}
|
||||
ui_->main->set_language_index(language_button_value_);
|
||||
ui_->main->set_video_quality_index(video_quality_button_value_);
|
||||
ui_->main->set_frame_rate_index(video_frame_rate_button_value_);
|
||||
ui_->main->set_adaptation_policy_index(
|
||||
video_adaptation_policy_button_value_);
|
||||
ui_->main->set_codec_index(video_encode_format_button_value_);
|
||||
const bool hardware_codec_available =
|
||||
ConfigCenter::IsHardwareVideoCodecAvailable();
|
||||
@@ -1265,6 +1318,7 @@ void GuiApplication::ResetSettingsUi() {
|
||||
ui_->main->set_self_hosted_enabled(enable_self_hosted_);
|
||||
ui_->main->set_autostart_enabled(enable_autostart_);
|
||||
ui_->main->set_daemon_enabled(enable_daemon_);
|
||||
ui_->main->set_save_remote_previews(config_center_->IsSaveRemotePreviews());
|
||||
ui_->main->set_privacy_on_connect_enabled(
|
||||
config_center_->IsEnablePrivacyScreen());
|
||||
#ifdef _WIN32
|
||||
@@ -1283,6 +1337,49 @@ void GuiApplication::ResetSettingsUi() {
|
||||
}
|
||||
|
||||
void GuiApplication::BindMainCallbacks() {
|
||||
ui_->main->on_delete_announcement([this](slint::SharedString id,
|
||||
slint::SharedString revision) {
|
||||
const auto parse = [](const slint::SharedString& value)
|
||||
-> std::optional<int64_t> {
|
||||
const std::string_view text(value.data());
|
||||
int64_t number = 0;
|
||||
const auto result =
|
||||
std::from_chars(text.data(), text.data() + text.size(), number);
|
||||
if (result.ec != std::errc{} ||
|
||||
result.ptr != text.data() + text.size() || number <= 0)
|
||||
return std::nullopt;
|
||||
return number;
|
||||
};
|
||||
const auto announcement_id = parse(id);
|
||||
const auto announcement_revision = parse(revision);
|
||||
if (!announcement_id || !announcement_revision) return;
|
||||
announcements_.Dismiss(*announcement_id, *announcement_revision);
|
||||
SyncAnnouncements();
|
||||
});
|
||||
ui_->main->on_refresh_announcements([this] { announcements_.Refresh(); });
|
||||
ui_->main->on_open_announcement_link([this](slint::SharedString link) {
|
||||
const std::string url(link);
|
||||
if (announcement_text::IsWebUrl(url)) OpenUrl(url);
|
||||
});
|
||||
ui_->main->on_load_more_announcements([this] {
|
||||
announcements_.LoadMore();
|
||||
SyncAnnouncements();
|
||||
});
|
||||
ui_->main->on_open_announcement([this](slint::SharedString id,
|
||||
slint::SharedString revision) {
|
||||
const auto snapshot = announcements_.Get();
|
||||
for (const auto& item : snapshot.items) {
|
||||
if (std::to_string(item["id"].get<int64_t>()) != id.data() ||
|
||||
std::to_string(item["revision"].get<int64_t>()) != revision.data())
|
||||
continue;
|
||||
ui_->announcement_selected_id = id.data();
|
||||
ui_->announcement_selected_revision = revision.data();
|
||||
ui_->announcement_version = UINT64_MAX;
|
||||
announcements_.Read(item["id"], item["revision"]);
|
||||
SyncAnnouncements();
|
||||
break;
|
||||
}
|
||||
});
|
||||
auto& main = ui_->main;
|
||||
main->window().on_close_requested([this] {
|
||||
// Keep the application and all active sessions alive. The tray helper
|
||||
@@ -1402,6 +1499,10 @@ void GuiApplication::BindMainCallbacks() {
|
||||
: slint::SharedString{});
|
||||
});
|
||||
main->on_open_download([this] { OpenUrl("https://crossdesk.cn"); });
|
||||
main->on_check_for_updates([this] {
|
||||
update_checker_->RequestCheck();
|
||||
SyncMainWindow();
|
||||
});
|
||||
main->on_connection_cancel([this] {
|
||||
const auto props = FindRemoteSession(ui_->connection_dialog_remote_id);
|
||||
if (!props) {
|
||||
@@ -1502,6 +1603,12 @@ void GuiApplication::BindStreamCallbacks() {
|
||||
return;
|
||||
}
|
||||
auto& stream = *ui_->stream;
|
||||
stream->on_dismiss_remote_update([this] {
|
||||
if (auto props = SelectedSession()) {
|
||||
std::lock_guard lock(props->remote_version_mutex_);
|
||||
props->remote_update_dismissed_ = true;
|
||||
}
|
||||
});
|
||||
auto close_stream_sessions = [this] {
|
||||
std::vector<std::string> ids;
|
||||
{
|
||||
@@ -1623,6 +1730,32 @@ void GuiApplication::BindStreamCallbacks() {
|
||||
keyboard_.ForceReleasePressedKeys();
|
||||
}
|
||||
});
|
||||
stream->on_change_video_settings([this](int quality, int frame_rate_index,
|
||||
int preference) {
|
||||
auto props = SelectedSession();
|
||||
if (!props || !props->peer_ ||
|
||||
props->connection_status_.load() != ConnectionStatus::Connected)
|
||||
return;
|
||||
RemoteAction action{};
|
||||
action.type = ControlType::video_settings;
|
||||
action.vs = {quality, frame_rate_index == 0 ? 30 : 60, preference};
|
||||
if (frame_rate_index < 0 || frame_rate_index > 1 ||
|
||||
!ValidVideoSettings(action.vs))
|
||||
return;
|
||||
{
|
||||
std::lock_guard lock(props->video_settings_mutex_);
|
||||
if (!props->video_settings_supported_) return;
|
||||
action.vs.request_id = props->video_settings_.request_id + 1;
|
||||
const auto message = action.to_json();
|
||||
if (SendReliableDataFrame(props->peer_, message.data(), message.size(),
|
||||
props->control_data_label_.c_str()) != 0)
|
||||
return;
|
||||
props->video_settings_ = action.vs;
|
||||
props->video_settings_pending_ = true;
|
||||
props->video_settings_failed_ = false;
|
||||
props->video_settings_request_tick_ = SDL_GetTicks();
|
||||
}
|
||||
});
|
||||
stream->on_toggle_audio([this] {
|
||||
auto props = SelectedSession();
|
||||
if (!props || !props->connection_established_) {
|
||||
@@ -1761,12 +1894,24 @@ void GuiApplication::Tick() {
|
||||
slint::quit_event_loop();
|
||||
return;
|
||||
}
|
||||
PollUpdateCheck();
|
||||
HandleSessionCleanup();
|
||||
HandlePasswordChangeResult();
|
||||
HandleCredentialRecovery();
|
||||
if (!peer_) {
|
||||
CreateConnectionPeer();
|
||||
}
|
||||
#ifdef __linux__
|
||||
HeadlessConsole::Instance().Poll(
|
||||
signal_connected_.load(),
|
||||
[this](const std::string& password) { RequestPasswordChange(password); },
|
||||
[this] {
|
||||
const auto password = GenerateRandomPassword(password_saved_);
|
||||
if (password) RequestPasswordChange(*password);
|
||||
else HeadlessConsole::Instance().NotifyKey("console_random_failed");
|
||||
},
|
||||
[this] { exit_ = true; });
|
||||
#endif
|
||||
|
||||
SDL_Event event;
|
||||
#if defined(__APPLE__)
|
||||
@@ -1835,6 +1980,29 @@ void GuiApplication::Tick() {
|
||||
SyncServerWindow();
|
||||
}
|
||||
|
||||
void GuiApplication::PollUpdateCheck() {
|
||||
// The first tick runs after show()/event-loop startup. Poll never waits for
|
||||
// networking and all application/model changes remain on this UI thread.
|
||||
if (!update_checker_->Poll() ||
|
||||
update_checker_->status() == UpdateChecker::Status::Failed) {
|
||||
return;
|
||||
}
|
||||
const auto& info = *update_checker_->latest();
|
||||
latest_version_ = "v" + info.version;
|
||||
release_name_ = info.release_name;
|
||||
release_notes_ = info.release_notes;
|
||||
release_date_ = info.release_date;
|
||||
update_available_ = update_checker_->update_available();
|
||||
std::vector<ui::ReleaseNoteBlock> blocks;
|
||||
for (const auto& parsed : ParseReleaseNotesMarkdownForSlint(release_notes_)) {
|
||||
ui::ReleaseNoteBlock block;
|
||||
block.content = parsed.content;
|
||||
block.section_gap = parsed.section_gap;
|
||||
blocks.push_back(std::move(block));
|
||||
}
|
||||
ui_->release_note_blocks_model->set_vector(std::move(blocks));
|
||||
}
|
||||
|
||||
void GuiApplication::ShareLocalCursorState() {
|
||||
constexpr auto kCursorEchoSuppressionInterval = 300ms;
|
||||
constexpr auto kCursorStateHeartbeatInterval = 500ms;
|
||||
@@ -1989,7 +2157,7 @@ void GuiApplication::ShareLocalCursorState() {
|
||||
if (!delivery.has_sent ||
|
||||
delivery.last_sent.render_mode != outgoing.render_mode ||
|
||||
delivery.last_sent.hidden_reason != outgoing.hidden_reason) {
|
||||
LOG_INFO("Send cursor presentation to [{}]: mode={}, reason={}",
|
||||
LOG_DEBUG("Send cursor presentation to [{}]: mode={}, reason={}",
|
||||
recipient.remote_id, static_cast<int>(outgoing.render_mode),
|
||||
static_cast<int>(outgoing.hidden_reason));
|
||||
}
|
||||
@@ -2003,7 +2171,17 @@ void GuiApplication::ShareLocalCursorState() {
|
||||
}
|
||||
|
||||
bool GuiApplication::RequestPasswordChange(const std::string &password) {
|
||||
if (password.size() != 6) {
|
||||
const auto notify = [](const std::string& message) {
|
||||
#ifdef __linux__
|
||||
HeadlessConsole::Instance().NotifyKey(message.c_str());
|
||||
#else
|
||||
(void)message;
|
||||
#endif
|
||||
};
|
||||
if (password.size() != 6 ||
|
||||
password.find_first_not_of("0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz") !=
|
||||
std::string::npos) {
|
||||
notify("console_password_invalid");
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -2011,6 +2189,7 @@ bool GuiApplication::RequestPasswordChange(const std::string &password) {
|
||||
offline_warning_text_ =
|
||||
localization::signal_disconnected[localization_language_index_];
|
||||
show_offline_warning_window_ = true;
|
||||
notify("console_password_offline");
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -2018,6 +2197,7 @@ bool GuiApplication::RequestPasswordChange(const std::string &password) {
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(password_change_mutex_);
|
||||
if (password_change_pending_) {
|
||||
notify("console_password_pending");
|
||||
return true;
|
||||
}
|
||||
request_id =
|
||||
@@ -2050,6 +2230,7 @@ bool GuiApplication::RequestPasswordChange(const std::string &password) {
|
||||
offline_warning_text_ = localization::failed[localization_language_index_];
|
||||
show_offline_warning_window_ = true;
|
||||
LOG_ERROR("Could not durably stage password change");
|
||||
notify("console_password_stage_failed");
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -2070,6 +2251,9 @@ bool GuiApplication::RequestPasswordChange(const std::string &password) {
|
||||
if (!settings_.ClearPendingPasswordChange()) {
|
||||
LOG_WARN("Could not clear unsent pending password change");
|
||||
}
|
||||
notify("console_password_send_failed");
|
||||
} else {
|
||||
notify("console_password_submitted");
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -2107,6 +2291,10 @@ void GuiApplication::HandlePasswordChangeResult() {
|
||||
}
|
||||
|
||||
if (!succeeded) {
|
||||
#ifdef __linux__
|
||||
HeadlessConsole::Instance().NotifyKey(
|
||||
uncertain ? "console_password_uncertain" : "console_password_rejected");
|
||||
#endif
|
||||
if (uncertain) {
|
||||
LOG_WARN("Password change outcome is unknown: {}; re-authenticating",
|
||||
error);
|
||||
@@ -2134,6 +2322,9 @@ void GuiApplication::HandlePasswordChangeResult() {
|
||||
}
|
||||
|
||||
LOG_INFO("Password changed successfully for [{}]", client_id_);
|
||||
#ifdef __linux__
|
||||
HeadlessConsole::Instance().NotifyKey("console_password_confirmed");
|
||||
#endif
|
||||
if (peer_) {
|
||||
CloseConnectionPeer();
|
||||
}
|
||||
@@ -2165,12 +2356,18 @@ void GuiApplication::HandleCredentialRecovery() {
|
||||
}
|
||||
|
||||
if (promote_pending) {
|
||||
#ifdef __linux__
|
||||
HeadlessConsole::Instance().NotifyKey("console_password_recovered");
|
||||
#endif
|
||||
if (!settings_.PromotePendingPasswordChange()) {
|
||||
LOG_WARN("Recovered credential is active but promotion remains pending");
|
||||
} else {
|
||||
LOG_INFO("Recovered password change with pending credential");
|
||||
}
|
||||
} else if (clear_pending) {
|
||||
#ifdef __linux__
|
||||
HeadlessConsole::Instance().NotifyKey("console_password_retained");
|
||||
#endif
|
||||
if (!settings_.ClearPendingPasswordChange()) {
|
||||
LOG_WARN("Active credential recovered but pending record could not be "
|
||||
"cleared");
|
||||
@@ -2200,16 +2397,81 @@ void GuiApplication::UpdateLocalization() {
|
||||
ui_->localized_language = language;
|
||||
}
|
||||
|
||||
void GuiApplication::SyncAnnouncements() {
|
||||
if (signal_connected_) {
|
||||
announcements_.Configure(params_.signal_server_ip,
|
||||
params_.signal_server_port, client_id_);
|
||||
}
|
||||
const auto request = announcements_.TakePendingRequest();
|
||||
if (!request.is_null()) {
|
||||
const auto payload = request.dump();
|
||||
if (!peer_ || SendSignalMessage(peer_, payload.data(), payload.size()) != 0)
|
||||
announcements_.Failed(request["request_id"]);
|
||||
}
|
||||
if (ui_->announcement_version == announcements_.Version()) return;
|
||||
const auto state = announcements_.Get();
|
||||
ui_->announcement_version = state.version;
|
||||
ui_->main->set_announcement_unread(state.unread);
|
||||
ui_->main->set_announcement_total(state.total);
|
||||
ui_->main->set_announcement_loading(state.loading);
|
||||
ui_->main->set_announcement_failed(state.failed);
|
||||
ui_->main->set_announcement_read_save_failed(state.read_save_failed);
|
||||
ui_->main->set_announcement_delete_failed(state.delete_failed);
|
||||
ui_->main->set_announcement_loaded(state.loaded);
|
||||
std::vector<ui::AnnouncementItem> rows;
|
||||
bool selected_found = false;
|
||||
for (const auto& item : state.items) {
|
||||
ui::AnnouncementItem row;
|
||||
const auto id = std::to_string(item["id"].get<int64_t>());
|
||||
const auto revision = std::to_string(item["revision"].get<int64_t>());
|
||||
row.id = UiText(id);
|
||||
row.revision = UiText(revision);
|
||||
row.title = UiText(item["title"].get<std::string>());
|
||||
const auto timestamp =
|
||||
static_cast<time_t>(item["updated_at"].get<int64_t>());
|
||||
std::tm local{};
|
||||
#ifdef _WIN32
|
||||
localtime_s(&local, ×tamp);
|
||||
#else
|
||||
localtime_r(×tamp, &local);
|
||||
#endif
|
||||
char date[32]{};
|
||||
std::strftime(date, sizeof(date), "%Y-%m-%d %H:%M", &local);
|
||||
row.date = UiText(date);
|
||||
row.read = item["read"].get<bool>();
|
||||
if (id == ui_->announcement_selected_id &&
|
||||
revision == ui_->announcement_selected_revision) {
|
||||
selected_found = true;
|
||||
ui_->main->set_announcement_selected_id(row.id);
|
||||
ui_->main->set_announcement_selected_title(row.title);
|
||||
ui_->main->set_announcement_selected_body(
|
||||
announcement_text::Format(item["body"].get<std::string>()));
|
||||
ui_->main->set_announcement_selected_date(row.date);
|
||||
ui_->main->set_announcement_selected_read(row.read);
|
||||
}
|
||||
rows.push_back(std::move(row));
|
||||
}
|
||||
if (!selected_found) {
|
||||
ui_->announcement_selected_id.clear();
|
||||
ui_->main->set_announcement_selected_id("");
|
||||
ui_->main->set_announcement_selected_body(slint::StyledText{});
|
||||
}
|
||||
ui_->main->set_announcements(
|
||||
std::make_shared<slint::VectorModel<ui::AnnouncementItem>>(
|
||||
std::move(rows)));
|
||||
}
|
||||
|
||||
void GuiApplication::SyncMainWindow() {
|
||||
if (!ui_) {
|
||||
return;
|
||||
}
|
||||
SyncAnnouncements();
|
||||
#if _WIN32
|
||||
ConfigureWindowsWindowIcons(ui_->main->window().win32_hwnd());
|
||||
#endif
|
||||
#if defined(__APPLE__)
|
||||
if (ui_->main_native_titlebar_attempts > 0) {
|
||||
if (HideDisabledMainWindowZoomButton()) {
|
||||
if (ConfigureMainWindowChrome(ui_->main->window().appkit_view())) {
|
||||
ui_->main_native_titlebar_attempts = 0;
|
||||
} else {
|
||||
--ui_->main_native_titlebar_attempts;
|
||||
@@ -2231,6 +2493,23 @@ void GuiApplication::SyncMainWindow() {
|
||||
ui_->main->set_signal_tls_error(signal_status_ ==
|
||||
SignalStatus::SignalTlsCertError);
|
||||
ui_->main->set_update_available(update_available_);
|
||||
switch (update_checker_->status()) {
|
||||
case UpdateChecker::Status::Idle:
|
||||
ui_->main->set_update_check_status(ui::UpdateCheckStatus::Idle);
|
||||
break;
|
||||
case UpdateChecker::Status::Checking:
|
||||
ui_->main->set_update_check_status(ui::UpdateCheckStatus::Checking);
|
||||
break;
|
||||
case UpdateChecker::Status::UpToDate:
|
||||
ui_->main->set_update_check_status(ui::UpdateCheckStatus::UpToDate);
|
||||
break;
|
||||
case UpdateChecker::Status::Available:
|
||||
ui_->main->set_update_check_status(ui::UpdateCheckStatus::Available);
|
||||
break;
|
||||
case UpdateChecker::Status::Failed:
|
||||
ui_->main->set_update_check_status(ui::UpdateCheckStatus::Failed);
|
||||
break;
|
||||
}
|
||||
ui_->main->set_current_version(CROSSDESK_VERSION);
|
||||
ui_->main->set_latest_version(UiText(latest_version_));
|
||||
ui_->main->set_release_name(UiText(release_name_));
|
||||
@@ -2242,9 +2521,11 @@ void GuiApplication::SyncMainWindow() {
|
||||
}
|
||||
|
||||
std::ostringstream signature_builder;
|
||||
signature_builder << config_center_->IsSaveRemotePreviews() << '\n';
|
||||
for (const auto& [key, connection] : recent_connections_) {
|
||||
signature_builder << key << '\0' << connection.remote_id << '\0'
|
||||
<< connection.remote_host_name << '\0'
|
||||
<< static_cast<int>(connection.platform) << '\0'
|
||||
<< settings_.RecentConnectionDisplayName(connection)
|
||||
<< '\0'
|
||||
<< device_presence_cache_.IsOnline(connection.remote_id)
|
||||
@@ -2259,22 +2540,27 @@ void GuiApplication::SyncMainWindow() {
|
||||
std::vector<ui::RecentConnection> model;
|
||||
model.reserve(recent_connections_.size());
|
||||
for (const auto& [_, connection] : recent_connections_) {
|
||||
ui::RecentConnection item;
|
||||
// Slint-generated scalar fields have no defaults. With no image,
|
||||
// has_thumbnail must remain false so the remote platform is rendered.
|
||||
ui::RecentConnection item{};
|
||||
item.remote_id = UiText(connection.remote_id);
|
||||
item.host_name = UiText(connection.remote_host_name);
|
||||
item.display_name =
|
||||
UiText(settings_.RecentConnectionDisplayName(connection));
|
||||
item.online = device_presence_cache_.IsOnline(connection.remote_id);
|
||||
item.platform = UiText(HostPlatformName(connection.platform));
|
||||
std::vector<unsigned char> rgba;
|
||||
int image_width = 0;
|
||||
int image_height = 0;
|
||||
if (!connection.image_path.empty() &&
|
||||
if (config_center_->IsSaveRemotePreviews() &&
|
||||
!connection.image_path.empty() &&
|
||||
thumbnail_->DecodeImage(connection.image_path, &rgba, &image_width,
|
||||
&image_height)) {
|
||||
slint::SharedPixelBuffer<slint::Rgba8Pixel> pixels(image_width,
|
||||
image_height);
|
||||
std::memcpy(pixels.begin(), rgba.data(), rgba.size());
|
||||
item.thumbnail = slint::Image(std::move(pixels));
|
||||
item.has_thumbnail = true;
|
||||
}
|
||||
model.push_back(std::move(item));
|
||||
}
|
||||
@@ -2439,6 +2725,11 @@ void GuiApplication::SyncXWaylandWindowActivation() {
|
||||
#endif
|
||||
|
||||
void GuiApplication::SetStreamKeyboardFocus(bool focused) {
|
||||
// The local upgrade dialog owns input even while the native window is active.
|
||||
if (focused && ui_ && ui_->stream &&
|
||||
(*ui_->stream)->get_remote_update_visible()) {
|
||||
focused = false;
|
||||
}
|
||||
if (focus_on_stream_window_ == focused) {
|
||||
return;
|
||||
}
|
||||
@@ -2616,6 +2907,21 @@ void GuiApplication::SyncStreamWindow() {
|
||||
return;
|
||||
}
|
||||
const ConnectionStatus status = props->connection_status_.load();
|
||||
bool remote_update_available = false;
|
||||
if (status == ConnectionStatus::Connected) {
|
||||
std::lock_guard lock(props->remote_version_mutex_);
|
||||
// Wait for host_info: a version still in transit is not a legacy peer.
|
||||
if (props->remote_host_info_received_ && !props->remote_update_dismissed_) {
|
||||
remote_update_available = props->remote_app_version_.empty() ||
|
||||
(update_checker_->latest() &&
|
||||
AvailableAppUpdate(props->remote_app_version_,
|
||||
*update_checker_->latest()).has_value());
|
||||
}
|
||||
}
|
||||
(*ui_->stream)->set_remote_update_visible(remote_update_available);
|
||||
if (remote_update_available) {
|
||||
SetStreamKeyboardFocus(false);
|
||||
}
|
||||
const bool waiting_for_frame =
|
||||
status == ConnectionStatus::Connected &&
|
||||
!(*ui_->stream)->get_has_frame();
|
||||
@@ -2672,6 +2978,33 @@ void GuiApplication::SyncStreamWindow() {
|
||||
(*ui_->stream)->set_remote_cursor_active(remote_cursor_active);
|
||||
(*ui_->stream)->set_remote_cursor_shape(remote_cursor_shape);
|
||||
(*ui_->stream)->set_audio_enabled(props->audio_capture_button_pressed_);
|
||||
{
|
||||
std::lock_guard lock(props->video_settings_mutex_);
|
||||
if (props->video_settings_pending_ &&
|
||||
SDL_GetTicks() - props->video_settings_request_tick_ > 5000) {
|
||||
props->video_settings_pending_ = false;
|
||||
props->video_settings_failed_ = true;
|
||||
const auto request_id = props->video_settings_.request_id;
|
||||
props->video_settings_ = props->applied_video_settings_;
|
||||
props->video_settings_.request_id = request_id;
|
||||
}
|
||||
(*ui_->stream)
|
||||
->set_video_settings_enabled(status == ConnectionStatus::Connected &&
|
||||
props->video_settings_supported_);
|
||||
(*ui_->stream)
|
||||
->set_video_settings_feedback(UiText(
|
||||
props->video_settings_failed_ ? localization::video_settings_failed
|
||||
[localization_language_index_]
|
||||
: props->video_settings_pending_
|
||||
? localization::video_settings_pending
|
||||
[localization_language_index_]
|
||||
: ""));
|
||||
(*ui_->stream)->set_video_quality_index(props->video_settings_.quality);
|
||||
(*ui_->stream)
|
||||
->set_frame_rate_index(props->video_settings_.frame_rate == 30 ? 0 : 1);
|
||||
(*ui_->stream)
|
||||
->set_adaptation_policy_index(props->video_settings_.preference);
|
||||
}
|
||||
#if defined(__APPLE__)
|
||||
fullscreen_button_pressed_ = IsStreamWindowFullscreen();
|
||||
#else
|
||||
@@ -2690,7 +3023,7 @@ void GuiApplication::SyncStreamWindow() {
|
||||
ui_->display_model->set_vector(std::move(displays));
|
||||
(*ui_->stream)->set_selected_display(props->selected_display_);
|
||||
|
||||
const auto& net = props->net_traffic_stats_;
|
||||
const auto net = props->net_traffic_stats_.Load();
|
||||
(*ui_->stream)->set_stats_encryption_enabled(
|
||||
status == ConnectionStatus::Connected && net.srtp_active);
|
||||
std::vector<ui::NetworkStatsRow> stats_rows;
|
||||
@@ -2779,12 +3112,14 @@ void GuiApplication::SyncStreamWindow() {
|
||||
props->file_transfer_.file_transfer_window_visible_);
|
||||
|
||||
const auto fps_now = std::chrono::steady_clock::now();
|
||||
bool refresh_stats = false;
|
||||
if (!props->net_traffic_stats_button_pressed_) {
|
||||
props->fps_ = 0;
|
||||
props->frame_count_ = 0;
|
||||
props->last_time_ = {};
|
||||
} else if (props->last_time_.time_since_epoch().count() == 0) {
|
||||
props->last_time_ = fps_now;
|
||||
refresh_stats = true;
|
||||
} else {
|
||||
const auto elapsed = std::chrono::duration_cast<std::chrono::milliseconds>(
|
||||
fps_now - props->last_time_)
|
||||
@@ -2793,9 +3128,42 @@ void GuiApplication::SyncStreamWindow() {
|
||||
props->fps_ = static_cast<int>(props->frame_count_ * 1000 / elapsed);
|
||||
props->frame_count_ = 0;
|
||||
props->last_time_ = fps_now;
|
||||
refresh_stats = true;
|
||||
}
|
||||
}
|
||||
(*ui_->stream)->set_stats_fps(UiText(std::to_string(props->fps_)));
|
||||
if (!props->net_traffic_stats_button_pressed_ ||
|
||||
status != ConnectionStatus::Connected) {
|
||||
props->video_latency_snapshot_.reset();
|
||||
} else if (refresh_stats) {
|
||||
props->video_latency_snapshot_ = props->video_latency_->Get(fps_now);
|
||||
}
|
||||
const auto& latency = props->video_latency_snapshot_;
|
||||
(*ui_->stream)
|
||||
->set_stats_video_latency(UiText(
|
||||
latency ? std::to_string(
|
||||
static_cast<int>(std::lround(latency->average_ms))) +
|
||||
" ms"
|
||||
: "—"));
|
||||
const auto display_rtt = props->net_traffic_stats_.SmoothedRttMs(fps_now);
|
||||
const bool rtt_available = props->net_traffic_stats_button_pressed_ &&
|
||||
status == ConnectionStatus::Connected &&
|
||||
display_rtt.has_value();
|
||||
std::string connection_latency = "—";
|
||||
if (rtt_available) {
|
||||
connection_latency =
|
||||
*display_rtt < 1
|
||||
? "<1 ms"
|
||||
: std::to_string(static_cast<int>(std::lround(*display_rtt))) + " ms";
|
||||
}
|
||||
(*ui_->stream)->set_stats_connection_latency(UiText(connection_latency));
|
||||
if (refresh_stats && status == ConnectionStatus::Connected) {
|
||||
LOG_DEBUG("Latency stats: remote={} video_ms={} video_samples={} "
|
||||
"rtt_raw_ms={} rtt_display_ms={}",
|
||||
props->remote_id_, latency ? latency->average_ms : -1,
|
||||
latency ? latency->samples : 0, net.rtt_ms,
|
||||
display_rtt.value_or(-1));
|
||||
}
|
||||
}
|
||||
|
||||
void GuiApplication::SyncStreamVideoFrame() {
|
||||
@@ -2815,6 +3183,7 @@ void GuiApplication::SyncStreamVideoFrame() {
|
||||
frame.width = props->video_width_;
|
||||
frame.height = props->video_height_;
|
||||
frame.sequence = props->video_frame_sequence_;
|
||||
frame.timing = props->video_frame_timing_;
|
||||
}
|
||||
|
||||
const auto result = ui_->video_presenter->Present(frame);
|
||||
@@ -3062,12 +3431,6 @@ void GuiApplication::SaveSettingsFromUi() {
|
||||
auto& main = ui_->main;
|
||||
language_button_value_ =
|
||||
localization::detail::ClampLanguageIndex(main->get_language_index());
|
||||
video_quality_button_value_ =
|
||||
std::clamp(main->get_video_quality_index(), 0, 2);
|
||||
video_frame_rate_button_value_ =
|
||||
std::clamp(main->get_frame_rate_index(), 0, 1);
|
||||
video_adaptation_policy_button_value_ =
|
||||
std::clamp(main->get_adaptation_policy_index(), 0, 2);
|
||||
video_encode_format_button_value_ = std::clamp(main->get_codec_index(), 0, 1);
|
||||
enable_hardware_video_codec_ = ConfigCenter::IsHardwareVideoCodecAvailable() &&
|
||||
main->get_hardware_codec_enabled();
|
||||
@@ -3080,24 +3443,23 @@ void GuiApplication::SaveSettingsFromUi() {
|
||||
static_cast<ConfigCenter::LANGUAGE>(language_button_value_);
|
||||
localization_language_index_ = language_button_value_;
|
||||
config_center_->SetLanguage(localization_language_);
|
||||
privacy_.SetText({
|
||||
localization::privacy_screen_unlock_hint[localization_language_index_]});
|
||||
config_center_->SetVideoQuality(
|
||||
static_cast<ConfigCenter::VIDEO_QUALITY>(video_quality_button_value_));
|
||||
config_center_->SetVideoFrameRate(static_cast<ConfigCenter::VIDEO_FRAME_RATE>(
|
||||
video_frame_rate_button_value_));
|
||||
config_center_->SetVideoAdaptationPolicy(
|
||||
static_cast<ConfigCenter::VIDEO_ADAPTATION_POLICY>(
|
||||
video_adaptation_policy_button_value_));
|
||||
privacy_.SetText(
|
||||
{localization::privacy_screen_unlock_hint[localization_language_index_]});
|
||||
config_center_->SetVideoEncodeFormat(
|
||||
static_cast<ConfigCenter::VIDEO_ENCODE_FORMAT>(
|
||||
video_encode_format_button_value_));
|
||||
config_center_->SetHardwareVideoCodec(enable_hardware_video_codec_);
|
||||
config_center_->SetTurn(enable_turn_, main->get_force_relay_enabled());
|
||||
main->set_force_relay_enabled(config_center_->IsForceRelay());
|
||||
config_center_->SetSelfHosted(enable_self_hosted_);
|
||||
config_center_->SetAutostart(enable_autostart_);
|
||||
config_center_->SetDaemon(enable_daemon_);
|
||||
if (config_center_->SetSaveRemotePreviews(main->get_save_remote_previews()) != 0) {
|
||||
LOG_ERROR("Failed to save remote preview preference");
|
||||
}
|
||||
if (thumbnail_->SetSavePreviews(config_center_->IsSaveRemotePreviews()) != 0) {
|
||||
LOG_ERROR("Failed to clear saved remote previews");
|
||||
}
|
||||
reload_recent_connections_ = true;
|
||||
#ifdef _WIN32
|
||||
if (!HasActiveSession()) {
|
||||
const auto capture_method = static_cast<ScreenCaptureMethod>(
|
||||
@@ -3132,11 +3494,29 @@ void GuiApplication::SaveSettingsFromUi() {
|
||||
config_center_->SetServerPort(*port);
|
||||
}
|
||||
|
||||
config_center_->SetSelfHosted(enable_self_hosted_);
|
||||
ApplySettingsFromConfig(!HasActiveSession());
|
||||
}
|
||||
|
||||
void GuiApplication::ApplySettingsFromConfig(bool reconnect) {
|
||||
language_button_value_ = static_cast<int>(config_center_->GetLanguage());
|
||||
localization_language_index_ = localization::detail::ClampLanguageIndex(language_button_value_);
|
||||
localization_language_ = config_center_->GetLanguage();
|
||||
video_encode_format_button_value_ = static_cast<int>(config_center_->GetVideoEncodeFormat());
|
||||
enable_hardware_video_codec_ = config_center_->IsHardwareVideoCodec();
|
||||
enable_turn_ = config_center_->IsEnableTurn();
|
||||
enable_self_hosted_ = config_center_->IsSelfHosted();
|
||||
enable_autostart_ = config_center_->IsEnableAutostart();
|
||||
enable_daemon_ = config_center_->IsEnableDaemon();
|
||||
privacy_.SetText({localization::privacy_screen_unlock_hint[localization_language_index_]});
|
||||
const auto path = config_center_->GetFileTransferSavePath();
|
||||
std::snprintf(file_transfer_save_path_buf_, sizeof(file_transfer_save_path_buf_), "%s", path.c_str());
|
||||
const auto host = config_center_->GetSignalServerHost();
|
||||
std::snprintf(signal_server_ip_self_, sizeof(signal_server_ip_self_), "%s", host.c_str());
|
||||
const int port = config_center_->GetSignalServerPort();
|
||||
const auto port_text = port > 0 ? std::to_string(port) : std::string{};
|
||||
std::snprintf(signal_server_port_self_, sizeof(signal_server_port_self_), "%s", port_text.c_str());
|
||||
language_button_value_last_ = language_button_value_;
|
||||
video_quality_button_value_last_ = video_quality_button_value_;
|
||||
video_frame_rate_button_value_last_ = video_frame_rate_button_value_;
|
||||
video_adaptation_policy_button_value_last_ =
|
||||
video_adaptation_policy_button_value_;
|
||||
video_encode_format_button_value_last_ = video_encode_format_button_value_;
|
||||
enable_hardware_video_codec_last_ = enable_hardware_video_codec_;
|
||||
enable_turn_last_ = enable_turn_;
|
||||
@@ -3144,11 +3524,17 @@ void GuiApplication::SaveSettingsFromUi() {
|
||||
enable_autostart_last_ = enable_autostart_;
|
||||
enable_daemon_last_ = enable_daemon_;
|
||||
file_transfer_save_path_last_ = path;
|
||||
ui_->localized_language = -1;
|
||||
|
||||
if (!HasActiveSession()) {
|
||||
#ifdef __linux__
|
||||
HeadlessConsole::Instance().SetLanguage(localization_language_index_);
|
||||
#endif
|
||||
if (ui_) {
|
||||
ui_->localized_language = -1;
|
||||
ResetSettingsUi();
|
||||
}
|
||||
if (reconnect && !HasActiveSession()) {
|
||||
// Tick recreates the peer after asynchronous cleanup, using the committed
|
||||
// configuration. Keep the application and its console running throughout.
|
||||
CloseAllRemoteSessions();
|
||||
CreateConnectionPeer();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3422,6 +3808,9 @@ bool GuiApplication::OpenUrl(const std::string& url) {
|
||||
}
|
||||
|
||||
void GuiApplication::Cleanup() {
|
||||
#ifdef __linux__
|
||||
HeadlessConsole::Instance().BindSettings(nullptr, {}, {});
|
||||
#endif
|
||||
if (!ui_) {
|
||||
SDL_Quit();
|
||||
return;
|
||||
@@ -3463,6 +3852,9 @@ void GuiApplication::Cleanup() {
|
||||
}
|
||||
#endif
|
||||
ui_.reset();
|
||||
if (update_checker_) {
|
||||
update_checker_->Stop();
|
||||
}
|
||||
SDL_Quit();
|
||||
}
|
||||
|
||||
|
||||
@@ -19,6 +19,8 @@
|
||||
|
||||
namespace crossdesk {
|
||||
|
||||
class UpdateChecker;
|
||||
|
||||
// Slint application shell. GuiRuntime remains the owner of transport, media,
|
||||
// device, clipboard, transfer and settings behavior.
|
||||
class GuiApplication final : private GuiRuntime {
|
||||
@@ -48,11 +50,13 @@ private:
|
||||
void BindStreamCallbacks();
|
||||
void BindServerCallbacks();
|
||||
void Tick();
|
||||
void PollUpdateCheck();
|
||||
void ShareLocalCursorState();
|
||||
bool RequestPasswordChange(const std::string &password);
|
||||
void HandlePasswordChangeResult();
|
||||
void HandleCredentialRecovery();
|
||||
void SyncMainWindow();
|
||||
void SyncAnnouncements();
|
||||
void SyncConnectionDialog();
|
||||
void SyncPlatformDialogs();
|
||||
void SyncStreamWindow();
|
||||
@@ -68,6 +72,7 @@ private:
|
||||
void UpdateLocalization();
|
||||
void ResetSettingsUi();
|
||||
void SaveSettingsFromUi();
|
||||
void ApplySettingsFromConfig(bool reconnect);
|
||||
#if _WIN32 && CROSSDESK_PORTABLE
|
||||
void CheckPortableWindowsService();
|
||||
void StartPortableWindowsServiceInstall();
|
||||
@@ -88,6 +93,7 @@ private:
|
||||
bool OpenUrl(const std::string &url);
|
||||
|
||||
std::unique_ptr<SlintUi> ui_;
|
||||
std::unique_ptr<UpdateChecker> update_checker_;
|
||||
CursorStateProvider cursor_state_provider_;
|
||||
std::unordered_map<std::string, CursorDeliveryState>
|
||||
cursor_delivery_states_;
|
||||
|
||||
@@ -20,6 +20,79 @@ struct TranslationRow {
|
||||
|
||||
// Single source of truth for all UI strings.
|
||||
#define CROSSDESK_LOCALIZATION_ALL(X) \
|
||||
X(messages, u8"消息", "Messages", u8"Сообщения") \
|
||||
X(message_unread, u8"未读", "Unread", u8"Не прочитано") \
|
||||
X(message_read, u8"已读", "Read", u8"Прочитано") \
|
||||
X(message_empty, u8"暂无公告", "No announcements", u8"Нет объявлений") \
|
||||
X(message_select, u8"选择公告查看详情", "Select an announcement to read", u8"Выберите объявление") \
|
||||
X(message_loading, u8"正在加载…", "Loading…", u8"Загрузка…") \
|
||||
X(message_read_save_error, u8"无法保存本地阅读状态,请重新打开公告重试。", "Unable to save reading status locally. Open the announcement again to retry.", u8"Не удалось сохранить статус чтения. Откройте объявление снова.") \
|
||||
X(message_close, u8"关闭", "Close", u8"Закрыть") \
|
||||
X(message_delete, u8"删除", "Delete", u8"Удалить") \
|
||||
X(message_delete_title, u8"删除公告", "Delete announcement", u8"Удалить объявление") \
|
||||
X(message_delete_confirmation, u8"删除这条公告?", "Delete this announcement?", u8"Удалить это объявление?") \
|
||||
X(message_delete_error, u8"无法删除这条公告,请重试。", "Unable to delete this announcement. Please try again.", u8"Не удалось удалить объявление. Повторите попытку.") \
|
||||
X(console_cli_help, u8"Linux 无头运行选项:\n --headless 自动选择已有桌面并启用控制台\n --headless-display DISPLAY 高级:指定已有显示会话\n --headless-size WIDTHxHEIGHT 新建虚拟屏幕;默认1920x1080,偶数尺寸320..8192\n --headless-session EXECUTABLE 启动桌面环境(startxfce4;none 仅启动裸屏幕)\n --no-headless 禁用自动回退\n正常启动会自动选择已有桌面,无需填写显示编号。控制台输入 settings 管理设置。\n", "Linux headless options:\n --headless Select an existing desktop and enable the console\n --headless-display DISPLAY Advanced: override desktop selection\n --headless-size WIDTHxHEIGHT New virtual display; default 1920x1080, even 320..8192\n --headless-session EXECUTABLE Start a desktop (startxfce4; none for a bare display)\n --no-headless Disable automatic fallback\nNormal startup selects your existing desktop automatically. Enter settings in the console to configure it.\n", u8"Параметры запуска без монитора:\n --headless Выбрать рабочий стол и включить консоль\n --headless-display DISPLAY Дополнительно: указать сеанс дисплея\n --headless-size WIDTHxHEIGHT Новый виртуальный экран; 1920x1080 по умолчанию, чётные размеры 320..8192\n --headless-session EXECUTABLE Запустить рабочий стол (startxfce4; none — только экран)\n --no-headless Отключить автоматический резервный запуск\nРабочий стол выбирается автоматически. Команда settings открывает настройки консоли.\n") \
|
||||
X(console_title, u8"CrossDesk 无头控制台", "CrossDesk headless console", u8"Консоль CrossDesk") \
|
||||
X(console_log_file, u8"日志文件", "Log file", u8"Файл журнала") \
|
||||
X(console_help, u8"命令: status 查看ID/密码/状态;settings 设置;password 修改密码;random 随机密码;help 帮助;quit 退出", "Commands: status, settings, password, random, help, quit", u8"Команды: status, settings, password, random, help, quit") \
|
||||
X(console_status, u8"状态", "Status", u8"Состояние") \
|
||||
X(console_online, u8"在线", "Online", u8"В сети") \
|
||||
X(console_offline, u8"未连接服务器", "Disconnected from server", u8"Нет подключения к серверу") \
|
||||
X(console_id, u8"设备 ID", "Device ID", u8"ID устройства") \
|
||||
X(console_password, u8"连接密码", "Connection password", u8"Пароль подключения") \
|
||||
X(console_waiting, u8"等待分配", "Waiting for assignment", u8"Ожидание назначения") \
|
||||
X(console_password_hidden, u8"(仅在交互终端显示)", "(shown only in an interactive terminal)", u8"(виден только в интерактивном терминале)") \
|
||||
X(console_cancelled, u8"已取消修改。", "Change cancelled.", u8"Изменение отменено.") \
|
||||
X(console_password_invalid, u8"密码必须是6位英文字母或数字,未提交修改。", "Password must be 6 ASCII letters or digits; no change submitted.", u8"Пароль должен содержать 6 латинских букв или цифр. Изменение не отправлено.") \
|
||||
X(console_password_offline, u8"尚未连接服务器,暂时无法修改密码。", "Connect to the server before changing the password.", u8"Для смены пароля подключитесь к серверу.") \
|
||||
X(console_password_prompt, u8"输入新密码(6位英文字母或数字;输入 cancel 取消):", "New password (6 ASCII letters or digits; cancel to abort):", u8"Новый пароль (6 латинских букв или цифр; cancel — отмена):") \
|
||||
X(console_quitting, u8"正在退出 CrossDesk……", "Exiting CrossDesk...", u8"Выход из CrossDesk...") \
|
||||
X(console_unknown_command, u8"未知命令,输入 help 查看帮助。", "Unknown command. Enter help for commands.", u8"Неизвестная команда. Введите help для справки.") \
|
||||
X(console_line_too_long, u8"命令过长,已忽略。", "Command too long; ignored.", u8"Команда слишком длинная и проигнорирована.") \
|
||||
X(console_random_failed, u8"无法生成随机密码,请重试。", "Unable to generate a random password; try again.", u8"Не удалось создать случайный пароль. Повторите попытку.") \
|
||||
X(console_password_pending, u8"密码修改正在处理中,请等待服务器确认。", "Password change pending; wait for server confirmation.", u8"Смена пароля выполняется. Дождитесь подтверждения сервера.") \
|
||||
X(console_password_stage_failed, u8"无法保存密码修改记录,未提交修改。请检查日志。", "Unable to save password recovery record; no change submitted. Check the log.", u8"Не удалось сохранить запись восстановления пароля. Изменение не отправлено. Проверьте журнал.") \
|
||||
X(console_password_send_failed, u8"发送密码修改请求失败,请等待连接恢复后重试。", "Failed to submit password change; retry after reconnection.", u8"Не удалось отправить запрос смены пароля. Повторите после подключения.") \
|
||||
X(console_password_submitted, u8"已提交密码修改,正在等待服务器确认……", "Password change submitted; waiting for server confirmation...", u8"Запрос смены пароля отправлен. Ожидание подтверждения...") \
|
||||
X(console_password_uncertain, u8"密码修改结果尚未确认,正在重新连接服务器核实。", "Password change not yet confirmed; reconnecting to verify.", u8"Смена пароля не подтверждена. Повторное подключение для проверки.") \
|
||||
X(console_password_rejected, u8"服务器未接受密码修改,原密码保持不变。详情见日志。", "Server rejected the password change; the original password remains. See the log.", u8"Сервер отклонил смену пароля. Старый пароль сохранён. См. журнал.") \
|
||||
X(console_password_confirmed, u8"服务器已确认密码修改,正在重新连接。", "Server confirmed the password change; reconnecting.", u8"Сервер подтвердил смену пароля. Повторное подключение.") \
|
||||
X(console_password_recovered, u8"重新连接确认:新密码已生效。", "Reconnection confirmed: the new password is active.", u8"Повторное подключение подтвердило новый пароль.") \
|
||||
X(console_password_retained, u8"重新连接确认:继续使用原密码。", "Reconnection confirmed: the original password remains active.", u8"Повторное подключение подтвердило прежний пароль.") \
|
||||
X(console_exited, u8"CrossDesk 已退出。", "CrossDesk exited.", u8"CrossDesk завершён.") \
|
||||
X(console_stopped, u8"CrossDesk 已停止,退出码", "CrossDesk stopped; exit code", u8"CrossDesk остановлен; код выхода") \
|
||||
X(console_settings_title, u8"设置(修改后立即保存)", "Settings (each change is saved immediately)", u8"Настройки (изменения сохраняются сразу)") \
|
||||
X(console_settings_prompt, u8"输入编号或选项名称修改;back 返回。也可使用 settings set 名称 值。", "Enter an item number or key; back to return. Shortcut: settings set KEY VALUE.", u8"Введите номер или имя параметра; back — назад. Команда: settings set KEY VALUE.") \
|
||||
X(console_settings_value, u8"输入新值(cancel 取消)", "Enter a new value (cancel to abort)", u8"Введите новое значение (cancel — отмена)") \
|
||||
X(console_settings_unavailable, u8"设置尚未初始化,请稍后重试。", "Settings are not ready; try again shortly.", u8"Настройки ещё не готовы. Повторите позже.") \
|
||||
X(console_settings_invalid, u8"无效的选项或值,未保存。请按菜单列出的格式输入。", "Invalid setting or value; not saved. Use the format shown in the menu.", u8"Неверный параметр или значение. Ничего не сохранено. Используйте указанный формат.") \
|
||||
X(console_settings_save_failed, u8"保存设置失败,原设置保持不变。请检查日志。", "Failed to save settings; the previous value is retained. Check the log.", u8"Не удалось сохранить настройки. Прежнее значение сохранено. Проверьте журнал.") \
|
||||
X(console_settings_busy, u8"有远程会话或密码修改正在进行,暂时不能更改连接设置。", "Connection settings cannot change during a remote session or password change.", u8"Нельзя менять параметры подключения во время удалённого сеанса или смены пароля.") \
|
||||
X(console_settings_saved, u8"已保存并生效。", "Saved and applied.", u8"Сохранено и применено.") \
|
||||
X(console_settings_unchanged, u8"设置未改变。", "Setting unchanged.", u8"Настройка не изменилась.") \
|
||||
X(console_settings_reconnect, u8"已保存,正在重新连接服务器。", "Saved; reconnecting to the server.", u8"Сохранено. Повторное подключение к серверу.") \
|
||||
X(console_settings_next_connection, u8"已保存,将用于后续连接。", "Saved; applies to subsequent connections.", u8"Сохранено. Будет применено к следующим подключениям.") \
|
||||
X(console_settings_next_file, u8"已保存,将用于后续接收的文件。", "Saved; applies to subsequently received files.", u8"Сохранено. Будет применено к следующим получаемым файлам.") \
|
||||
X(console_settings_autostart, u8"已保存,在下次图形桌面登录时生效。", "Saved; applies at the next graphical desktop login.", u8"Сохранено. Будет применено при следующем входе в графический сеанс.") \
|
||||
X(console_settings_daemon, u8"已保存,普通模式重启后生效;无头控制台仍保持前台运行。", "Saved for the next normal-mode start; the headless console stays in the foreground.", u8"Сохранено для следующего запуска в обычном режиме. Консоль остаётся на переднем плане.") \
|
||||
X(console_settings_hardware_unavailable, u8"当前构建不支持硬件编解码,未保存。", "Hardware codecs are unavailable in this build; not saved.", u8"Аппаратные кодеки недоступны в этой сборке. Не сохранено.") \
|
||||
X(console_settings_server_required, u8"请先设置有效的服务器地址和端口,再启用自托管。", "Set a valid server host and port before enabling self-hosting.", u8"Укажите адрес и порт сервера перед включением собственного сервера.") \
|
||||
X(console_settings_path_invalid, u8"请选择已存在且可写的绝对目录路径;default 使用默认目录。", "Use an existing writable absolute directory, or default for the default location.", u8"Укажите существующий доступный для записи абсолютный путь к каталогу или default.") \
|
||||
X(console_settings_readonly, u8"该选项由系统或控制端管理,不可在此修改。", "This option is managed by the system or controller and is read-only here.", u8"Этот параметр управляется системой или клиентом и здесь недоступен для изменения.") \
|
||||
X(console_setting_language, u8"语言", "Language", u8"Язык") \
|
||||
X(console_setting_codec, u8"视频编码", "Video codec", u8"Видеокодек") \
|
||||
X(console_setting_hardware, u8"硬件编解码", "Hardware codecs", u8"Аппаратные кодеки") \
|
||||
X(console_setting_turn, u8"中继模式", "TURN relay mode", u8"Режим ретрансляции TURN") \
|
||||
X(console_setting_self_hosted, u8"自托管", "Self-hosted server", u8"Собственный сервер") \
|
||||
X(console_setting_host, u8"服务器地址", "Server host", u8"Адрес сервера") \
|
||||
X(console_setting_port, u8"信令端口", "Signaling port", u8"Порт сигнализации") \
|
||||
X(console_setting_privacy, u8"连接时自动开启隐私屏", "Privacy screen on connection", u8"Приватный экран при подключении") \
|
||||
X(console_setting_files, u8"文件保存目录", "Received file directory", u8"Каталог получаемых файлов") \
|
||||
X(console_setting_autostart, u8"登录桌面时自启", "Start at desktop login", u8"Запуск при входе в систему") \
|
||||
X(console_setting_daemon, u8"守护进程(无头控制台中不生效)", "Daemon (inactive in headless console)", u8"Фоновый режим (не действует в консоли)") \
|
||||
X(save_remote_previews, u8"保存远程画面预览:", "Save remote screen previews:", u8"Сохранять превью экрана:") \
|
||||
X(unknown_platform, u8"未知平台", "Unknown platform", u8"Неизвестная платформа") \
|
||||
X(privacy_screen, u8"隐私屏", "Privacy screen", u8"Приватный экран") \
|
||||
X(privacy_enable, u8"开启隐私屏", "Enable privacy screen", u8"Включить приватный экран") \
|
||||
X(privacy_disable, u8"关闭隐私屏", "Disable privacy screen", u8"Выключить приватный экран") \
|
||||
@@ -131,6 +204,13 @@ struct TranslationRow {
|
||||
X(screen_capture_method, u8"采集方式:", "Capture Method:", \
|
||||
u8"Способ захвата:") \
|
||||
X(screen_capture_method_auto, u8"自动", "Auto", u8"Авто") \
|
||||
X(video_settings, u8"画面设置", "Video settings", u8"Настройки видео") \
|
||||
X(remote_update_title, u8"被控端版本过低", "Remote device needs an update", u8"Версия на удалённом устройстве устарела") \
|
||||
X(remote_update, u8"请升级到最新版本。", "Please update to the latest version.", u8"Обновите её до последней версии.") \
|
||||
X(remote_update_dismiss, u8"知道了", "Got it", u8"Понятно") \
|
||||
X(video_settings_pending, u8"正在应用…", "Applying…", u8"Применение…") \
|
||||
X(video_settings_failed, u8"调整失败,请重试。", "Could not apply settings. Try again.", u8"Не удалось применить настройки. Повторите попытку.") \
|
||||
X(video_settings_unavailable, u8"连接建立后可调整,需被控端支持。", "Available when connected to a supported remote device.", u8"Доступно при подключении к поддерживаемому устройству.") \
|
||||
X(video_quality, u8"画面质量:", "Video Quality:", u8"Качество видео:") \
|
||||
X(video_frame_rate, u8"画面采集帧率:", \
|
||||
"Video Capture Frame Rate:", u8"Частота захвата видео:") \
|
||||
@@ -193,8 +273,18 @@ struct TranslationRow {
|
||||
X(no_such_id, u8"无此ID", "No such ID", u8"ID не найден") \
|
||||
X(about, u8"关于", "About", u8"О программе") \
|
||||
X(notification, u8"通知", "Notification", u8"Уведомление") \
|
||||
X(new_version, u8"新版本", "New version", u8"Новая версия") \
|
||||
X(new_version_available, u8"新版本可用", "New Version Available", \
|
||||
u8"Доступна новая версия") \
|
||||
X(check_for_updates, u8"检查更新", "Check for updates", \
|
||||
u8"Проверить обновления") \
|
||||
X(checking_for_updates, u8"正在检查更新…", "Checking for updates…", \
|
||||
u8"Проверка обновлений…") \
|
||||
X(up_to_date, u8"已是最新版本", "You're up to date", \
|
||||
u8"Установлена последняя версия") \
|
||||
X(update_check_failed, u8"检查失败,请稍后重试", \
|
||||
"Check failed. Please try again later.", \
|
||||
u8"Не удалось проверить обновления. Повторите позже.") \
|
||||
X(release_notes, u8"更新内容", "Release Notes", u8"Содержание обновления") \
|
||||
X(version, u8"版本", "Version", u8"Версия") \
|
||||
X(release_date, u8"发布日期: ", "Release Date: ", u8"Дата релиза: ") \
|
||||
@@ -233,6 +323,8 @@ struct TranslationRow {
|
||||
"File Transfer Save Path:", u8"Путь сохранения файлов:") \
|
||||
X(default_desktop, u8"桌面", "Desktop", u8"Рабочий стол") \
|
||||
X(resolution, u8"分辨率", "Res", u8"Разрешение") \
|
||||
X(video_latency, u8"视频延时", "Video delay", u8"Задержка") \
|
||||
X(connection_latency, u8"连接延时", "Connection RTT", u8"Задержка соединения") \
|
||||
X(connection_mode, u8"连接模式", "Mode", u8"Режим") \
|
||||
X(connection_mode_direct, u8"直连", "Direct", u8"Прямой") \
|
||||
X(connection_mode_relay, u8"中继", "Relay", u8"Релейный") \
|
||||
@@ -251,6 +343,34 @@ struct TranslationRow {
|
||||
X(permission_required_message, u8"该应用需要授权以下权限:", \
|
||||
"The application requires the following permissions:", \
|
||||
u8"Для работы приложения требуются следующие разрешения:") \
|
||||
X(wayland_notice_title, u8"Wayland 会话提示", "Wayland Session Notice", \
|
||||
u8"Уведомление о сеансе Wayland") \
|
||||
X(wayland_notice_subtitle, u8"检测到当前桌面会话使用 Wayland。", \
|
||||
"The current desktop session uses Wayland.", \
|
||||
u8"Текущий сеанс рабочего стола использует Wayland.") \
|
||||
X(wayland_permission_title, u8"每次共享均需本机授权", \
|
||||
"Local approval required each time", \
|
||||
u8"Разрешение требуется каждый раз") \
|
||||
X(wayland_notice_message, \
|
||||
u8"受 Wayland 安全机制限制,每次启动远程桌面共享时," \
|
||||
u8"均需在本机手动授权屏幕共享与远程控制,授权后方可正常使用。", \
|
||||
"Wayland security restrictions require you to authorize screen sharing " \
|
||||
"and remote control locally each time you start sharing this desktop. " \
|
||||
"Remote access is available only after authorization.", \
|
||||
u8"Из-за ограничений безопасности Wayland при каждом запуске общего " \
|
||||
u8"доступа к рабочему столу необходимо вручную разрешать на этом " \
|
||||
u8"компьютере показ экрана и удалённое управление. Удалённый доступ " \
|
||||
u8"возможен только после разрешения.") \
|
||||
X(wayland_recommendation_title, u8"建议使用 X11(Xorg)", \
|
||||
"X11 (Xorg) is recommended", u8"Рекомендуем X11 (Xorg)") \
|
||||
X(wayland_recommendation_message, \
|
||||
u8"为便于持续使用远程桌面,建议注销当前桌面会话," \
|
||||
u8"在登录界面选择 X11(Xorg)会话后重新登录。", \
|
||||
"For ongoing remote desktop access, we recommend logging out, selecting " \
|
||||
"an X11 (Xorg) session on the login screen, and logging in again.", \
|
||||
u8"Для постоянного удалённого доступа рекомендуем завершить сеанс, " \
|
||||
u8"выбрать X11 (Xorg) на экране входа и войти снова.") \
|
||||
X(wayland_notice_acknowledge, u8"我知道了", "Got it", u8"Понятно") \
|
||||
X(show_main_window, u8"显示主界面", "Show Main Window", \
|
||||
u8"Показать главное окно") \
|
||||
X(privacy_screen_unlock_hint, u8"按下快捷键,解除隐私屏", \
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
#include "features/clipboard/clipboard_controller.h"
|
||||
|
||||
#include <SDL3/SDL_clipboard.h>
|
||||
#include <SDL3/SDL_error.h>
|
||||
#include <SDL3/SDL_events.h>
|
||||
|
||||
#include <cstring>
|
||||
#include <shared_mutex>
|
||||
#include <utility>
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
#ifndef CROSSDESK_GUI_CLIPBOARD_CONTROLLER_H_
|
||||
#define CROSSDESK_GUI_CLIPBOARD_CONTROLLER_H_
|
||||
/*
|
||||
* @Author: DI JUNKUN
|
||||
* @Date: 2026-09-25
|
||||
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
|
||||
*/
|
||||
|
||||
#include <SDL3/SDL.h>
|
||||
#ifndef _CLIPBOARD_CONTROLLER_H_
|
||||
#define _CLIPBOARD_CONTROLLER_H_
|
||||
|
||||
#include <atomic>
|
||||
#include <cstddef>
|
||||
@@ -42,4 +46,4 @@ private:
|
||||
|
||||
} // namespace crossdesk
|
||||
|
||||
#endif // CROSSDESK_GUI_CLIPBOARD_CONTROLLER_H_
|
||||
#endif // _CLIPBOARD_CONTROLLER_H_
|
||||
@@ -1,5 +1,9 @@
|
||||
#include "features/devices/session_device_manager.h"
|
||||
|
||||
#include <SDL3/SDL_audio.h>
|
||||
#include <SDL3/SDL_error.h>
|
||||
#include <SDL3/SDL_init.h>
|
||||
|
||||
#include <display_stream_id.h>
|
||||
#include <remote_action.h>
|
||||
|
||||
@@ -8,6 +12,8 @@
|
||||
#include <cstdlib>
|
||||
#include <utility>
|
||||
|
||||
#include "device_controller_factory.h"
|
||||
#include "screen_capturer_factory.h"
|
||||
#include "platform.h"
|
||||
#include "rd_log.h"
|
||||
#include "runtime/gui_runtime.h"
|
||||
@@ -76,7 +82,7 @@ void SessionDeviceManager::RecordCaptureCadence(
|
||||
: capture_secure_callbacks_ == capture_callbacks_
|
||||
? "secure_desktop"
|
||||
: "mixed";
|
||||
LOG_INFO(
|
||||
LOG_DEBUG(
|
||||
"Capture delivery: source={} normal_callbacks={} secure_callbacks={} "
|
||||
"target_fps={} input_fps={:.2f} "
|
||||
"forwarded_fps={:.2f} callbacks={} rate_dropped={} "
|
||||
@@ -142,10 +148,10 @@ int SessionDeviceManager::InitializeScreenCapturer() {
|
||||
capture_secure_callbacks_ = 0;
|
||||
capture_send_errors_ = 0;
|
||||
capture_max_gap_us_ = capture_send_us_ = capture_max_send_us_ = 0;
|
||||
const int fps = owner_.config_center_->GetVideoFrameRate() ==
|
||||
ConfigCenter::VIDEO_FRAME_RATE::FPS_30
|
||||
? 30
|
||||
: 60;
|
||||
// Keep the shared source ready for either live rate. MiniRTC admits 30/60
|
||||
// frames independently per viewer, so switching never restarts native
|
||||
// capture.
|
||||
const int fps = 60;
|
||||
LOG_INFO("Init screen capturer with {} fps", fps);
|
||||
SetDisplayInfoList({});
|
||||
registered_display_stream_count_ = 0;
|
||||
@@ -219,7 +225,7 @@ int SessionDeviceManager::InitializeScreenCapturer() {
|
||||
last_video_frame_stream_id_ != stream_id;
|
||||
if (resumed_after_gap || stream_changed) {
|
||||
if (RequestVideoKeyFrame(owner_.peer_, stream_id.c_str()) == 0) {
|
||||
LOG_INFO("Request video key frame before sending captured frame, "
|
||||
LOG_DEBUG("Request video key frame before sending captured frame, "
|
||||
"stream='{}', gap_ms={}, stream_changed={}",
|
||||
stream_id, duration_ms, stream_changed);
|
||||
}
|
||||
|
||||
@@ -8,8 +8,6 @@
|
||||
#define _SESSION_DEVICE_MANAGER_H_
|
||||
|
||||
|
||||
#include <SDL3/SDL.h>
|
||||
|
||||
#include <atomic>
|
||||
#include <chrono>
|
||||
#include <cstddef>
|
||||
@@ -22,14 +20,17 @@
|
||||
#include <remote_action.h>
|
||||
|
||||
#include "device_controller.h"
|
||||
#include "device_controller_factory.h"
|
||||
#include "display_info.h"
|
||||
#include "screen_capturer_factory.h"
|
||||
#include "speaker_capture_controller.h"
|
||||
|
||||
struct SDL_AudioStream;
|
||||
|
||||
namespace crossdesk {
|
||||
|
||||
class GuiRuntime;
|
||||
class DeviceControllerFactory;
|
||||
class ScreenCapturerFactory;
|
||||
class ScreenCapturer;
|
||||
|
||||
// Owns media capture/playback and remote input devices for the active GUI
|
||||
// session. GuiRuntime supplies application intent; this class handles device
|
||||
|
||||
@@ -298,7 +298,7 @@ void FileTransferManager::HandleAck(const char *data, size_t size) {
|
||||
uint32_t rate_bps = 0;
|
||||
if (props) {
|
||||
const uint32_t bitrate =
|
||||
props->net_traffic_stats_.data_outbound_stats.bitrate;
|
||||
props->net_traffic_stats_.DataOutboundBitrate();
|
||||
if (bitrate > 0 && state->file_sending_.load()) {
|
||||
rate_bps = static_cast<uint32_t>(bitrate * 0.99f);
|
||||
const uint32_t current_rate = state->file_send_rate_bps_.load();
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
#include <string>
|
||||
#include <unordered_map>
|
||||
|
||||
#include "runtime/gui_state.h"
|
||||
#include "runtime/remote_session.h"
|
||||
|
||||
namespace crossdesk {
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
#include <fstream>
|
||||
#include <functional>
|
||||
#include <memory>
|
||||
#include <nlohmann/json.hpp>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
|
||||
@@ -297,12 +298,6 @@ int SettingsManager::Load() {
|
||||
|
||||
owner_.language_button_value_ = localization::detail::ClampLanguageIndex(
|
||||
static_cast<int>(owner_.config_center_->GetLanguage()));
|
||||
owner_.video_quality_button_value_ =
|
||||
static_cast<int>(owner_.config_center_->GetVideoQuality());
|
||||
owner_.video_frame_rate_button_value_ =
|
||||
static_cast<int>(owner_.config_center_->GetVideoFrameRate());
|
||||
owner_.video_adaptation_policy_button_value_ =
|
||||
static_cast<int>(owner_.config_center_->GetVideoAdaptationPolicy());
|
||||
owner_.video_encode_format_button_value_ =
|
||||
static_cast<int>(owner_.config_center_->GetVideoEncodeFormat());
|
||||
owner_.enable_hardware_video_codec_ =
|
||||
@@ -324,11 +319,6 @@ int SettingsManager::Load() {
|
||||
owner_.file_transfer_save_path_last_ = saved_path;
|
||||
|
||||
owner_.language_button_value_last_ = owner_.language_button_value_;
|
||||
owner_.video_quality_button_value_last_ = owner_.video_quality_button_value_;
|
||||
owner_.video_frame_rate_button_value_last_ =
|
||||
owner_.video_frame_rate_button_value_;
|
||||
owner_.video_adaptation_policy_button_value_last_ =
|
||||
owner_.video_adaptation_policy_button_value_;
|
||||
owner_.video_encode_format_button_value_last_ =
|
||||
owner_.video_encode_format_button_value_;
|
||||
owner_.enable_hardware_video_codec_last_ =
|
||||
|
||||
@@ -58,8 +58,9 @@ private:
|
||||
struct CacheV1 {
|
||||
char client_id_with_password[17];
|
||||
int language;
|
||||
int video_quality;
|
||||
int video_frame_rate;
|
||||
// Reserved legacy slots: retain the on-disk cache layout.
|
||||
int legacy_video_quality;
|
||||
int legacy_video_frame_rate;
|
||||
int video_encode_format;
|
||||
bool enable_hardware_video_codec;
|
||||
bool enable_turn;
|
||||
@@ -71,8 +72,9 @@ private:
|
||||
struct CacheV2 {
|
||||
char client_id_with_password[17];
|
||||
int language;
|
||||
int video_quality;
|
||||
int video_frame_rate;
|
||||
// Reserved legacy slots: retain the on-disk cache layout.
|
||||
int legacy_video_quality;
|
||||
int legacy_video_frame_rate;
|
||||
int video_encode_format;
|
||||
bool enable_hardware_video_codec;
|
||||
bool enable_turn;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
#include "rendering/slint_video_presenter.h"
|
||||
#include "crossdesk_ui.h"
|
||||
|
||||
#include <libyuv.h>
|
||||
#include <slint.h>
|
||||
@@ -86,6 +87,7 @@ struct SlintVideoPresenter::Impl {
|
||||
VideoRenderSize gl_image_size;
|
||||
VideoRenderSize gl_pending_size;
|
||||
bool gl_pending_dirty = false;
|
||||
VideoLatencyFrame gl_pending_timing;
|
||||
#endif
|
||||
|
||||
SurfaceState CurrentState() const {
|
||||
@@ -191,6 +193,7 @@ struct SlintVideoPresenter::Impl {
|
||||
glPixelStorei(GL_UNPACK_ALIGNMENT, previous_unpack_alignment);
|
||||
glBindTexture(GL_TEXTURE_2D, static_cast<GLuint>(previous_texture));
|
||||
gl_pending_dirty = false;
|
||||
gl_pending_timing.MarkSubmitted();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -321,7 +324,7 @@ SlintVideoPresenter::PresentResult SlintVideoPresenter::Present(
|
||||
impl_->seeded_frame_sequence[frame.remote_id] != frame.sequence) {
|
||||
const auto submit_result = impl_->renderer.SubmitCachedNv12(
|
||||
frame.remote_id, frame.nv12->data(), frame.nv12->size(), frame.width,
|
||||
frame.height);
|
||||
frame.height, frame.timing);
|
||||
if (submit_result == VideoRenderer::SubmitResult::submitted ||
|
||||
submit_result == VideoRenderer::SubmitResult::dropped) {
|
||||
impl_->seeded_frame_sequence[frame.remote_id] = frame.sequence;
|
||||
@@ -439,6 +442,7 @@ SlintVideoPresenter::PresentResult SlintVideoPresenter::Present(
|
||||
impl_->gl_pending_frame.swap(impl_->gl_conversion_frame);
|
||||
impl_->gl_pending_size = {output_width, output_height};
|
||||
impl_->gl_pending_dirty = true;
|
||||
impl_->gl_pending_timing = frame.timing;
|
||||
}
|
||||
const VideoRenderSize output_size{output_width, output_height};
|
||||
if (impl_->gl_image_size != output_size) {
|
||||
@@ -468,6 +472,7 @@ SlintVideoPresenter::PresentResult SlintVideoPresenter::Present(
|
||||
}
|
||||
|
||||
(*impl_->stream)->set_frame(slint::Image(std::move(pixels)));
|
||||
frame.timing.MarkSubmitted();
|
||||
(*impl_->stream)->set_has_frame(true);
|
||||
(*impl_->stream)->set_receiving_text("");
|
||||
result.width = frame.width;
|
||||
|
||||
@@ -14,7 +14,15 @@
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "crossdesk_ui.h"
|
||||
#include "rendering/video_latency.h"
|
||||
|
||||
namespace slint {
|
||||
template <typename T> class ComponentHandle;
|
||||
}
|
||||
|
||||
namespace crossdesk::ui {
|
||||
class StreamWindow;
|
||||
}
|
||||
|
||||
namespace crossdesk {
|
||||
|
||||
@@ -36,6 +44,7 @@ public:
|
||||
int width = 0;
|
||||
int height = 0;
|
||||
uint64_t sequence = 0;
|
||||
VideoLatencyFrame timing;
|
||||
};
|
||||
|
||||
struct PresentResult {
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
/*
|
||||
* @Author: DI JUNKUN
|
||||
* @Date: 2026-09-26
|
||||
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
|
||||
*/
|
||||
|
||||
#ifndef _VIDEO_LATENCY_H_
|
||||
#define _VIDEO_LATENCY_H_
|
||||
|
||||
#include <chrono>
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <deque>
|
||||
#include <memory>
|
||||
#include <mutex>
|
||||
#include <optional>
|
||||
|
||||
namespace crossdesk {
|
||||
|
||||
class VideoLatencyStats;
|
||||
|
||||
// Travels with the exact queued pixels, including native frames. Its clock is
|
||||
// translated once at the receive callback; rendering needs no live PeerPtr.
|
||||
struct VideoLatencyFrame {
|
||||
using Clock = std::chrono::steady_clock;
|
||||
std::shared_ptr<VideoLatencyStats> stats;
|
||||
uint64_t id = 0;
|
||||
Clock::time_point capture_time{};
|
||||
void MarkPresented(Clock::time_point now = Clock::now()) const;
|
||||
// Fallback for renderers which expose submission but no presentation time.
|
||||
void MarkSubmitted(Clock::time_point now = Clock::now()) const;
|
||||
};
|
||||
|
||||
class VideoLatencyStats
|
||||
: public std::enable_shared_from_this<VideoLatencyStats> {
|
||||
public:
|
||||
using Clock = VideoLatencyFrame::Clock;
|
||||
struct Snapshot {
|
||||
double average_ms;
|
||||
size_t samples;
|
||||
};
|
||||
|
||||
VideoLatencyFrame Frame(uint64_t captured_us, int64_t local_now_us,
|
||||
Clock::time_point now = Clock::now()) {
|
||||
if (captured_us == 0 || local_now_us <= 0 ||
|
||||
captured_us > uint64_t(local_now_us) ||
|
||||
uint64_t(local_now_us) - captured_us > 5'000'000)
|
||||
return {};
|
||||
std::lock_guard lock(mutex_);
|
||||
return {shared_from_this(), ++issued_id_,
|
||||
now - std::chrono::microseconds(local_now_us - captured_us)};
|
||||
}
|
||||
|
||||
void Record(const VideoLatencyFrame& frame, Clock::time_point now) {
|
||||
std::lock_guard lock(mutex_);
|
||||
// Redraws, cached snapshots and late completions must not count twice.
|
||||
if (frame.id <= last_id_) return;
|
||||
last_id_ = frame.id;
|
||||
const double ms =
|
||||
std::chrono::duration<double, std::milli>(now - frame.capture_time)
|
||||
.count();
|
||||
if (ms < 0 || ms > 5000) return;
|
||||
samples_.push_back({now, ms});
|
||||
Prune(now);
|
||||
}
|
||||
|
||||
std::optional<Snapshot> Get(Clock::time_point now = Clock::now()) {
|
||||
std::lock_guard lock(mutex_);
|
||||
Prune(now);
|
||||
if (samples_.empty()) return std::nullopt;
|
||||
double sum = 0;
|
||||
for (const auto& sample : samples_) {
|
||||
sum += sample.ms;
|
||||
}
|
||||
return Snapshot{sum / samples_.size(), samples_.size()};
|
||||
}
|
||||
|
||||
void Reset() {
|
||||
std::lock_guard lock(mutex_);
|
||||
samples_.clear();
|
||||
last_id_ = issued_id_; // Invalidate frames still held by a renderer.
|
||||
}
|
||||
|
||||
private:
|
||||
void Prune(Clock::time_point now) {
|
||||
while (!samples_.empty() &&
|
||||
(now - samples_.front().time > std::chrono::seconds(1) ||
|
||||
samples_.size() > 240))
|
||||
samples_.pop_front();
|
||||
}
|
||||
struct Sample {
|
||||
Clock::time_point time;
|
||||
double ms;
|
||||
};
|
||||
std::mutex mutex_;
|
||||
uint64_t issued_id_ = 0;
|
||||
uint64_t last_id_ = 0;
|
||||
std::deque<Sample> samples_;
|
||||
};
|
||||
|
||||
inline void VideoLatencyFrame::MarkSubmitted(Clock::time_point now) const {
|
||||
MarkPresented(now);
|
||||
}
|
||||
|
||||
inline void VideoLatencyFrame::MarkPresented(Clock::time_point now) const {
|
||||
if (stats) stats->Record(*this, now);
|
||||
}
|
||||
} // namespace crossdesk
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,483 @@
|
||||
#include "runtime/announcement_inbox.h"
|
||||
|
||||
#include <openssl/sha.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <charconv>
|
||||
#include <fstream>
|
||||
#include <set>
|
||||
#include <system_error>
|
||||
|
||||
#ifdef _WIN32
|
||||
#include <windows.h>
|
||||
#endif
|
||||
|
||||
namespace crossdesk {
|
||||
namespace {
|
||||
using nlohmann::json;
|
||||
bool Integer(const json& object, const char* key, int64_t low, int64_t high) {
|
||||
return object.contains(key) && object[key].is_number_integer() &&
|
||||
object[key] >= low && object[key] <= high;
|
||||
}
|
||||
std::string ScopeFilename(const std::string& scope) {
|
||||
unsigned char digest[SHA256_DIGEST_LENGTH];
|
||||
SHA256(reinterpret_cast<const unsigned char*>(scope.data()), scope.size(),
|
||||
digest);
|
||||
std::string name;
|
||||
constexpr char hex[] = "0123456789abcdef";
|
||||
for (auto byte : digest) {
|
||||
name += hex[byte >> 4];
|
||||
name += hex[byte & 15];
|
||||
}
|
||||
return name + ".json";
|
||||
}
|
||||
} // namespace
|
||||
|
||||
AnnouncementInbox::Snapshot AnnouncementInbox::Get() const {
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
return state_;
|
||||
}
|
||||
uint64_t AnnouncementInbox::Version() const {
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
return state_.version;
|
||||
}
|
||||
void AnnouncementInbox::SetStorageDirectory(
|
||||
const std::filesystem::path& directory) {
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
directory_ = directory;
|
||||
}
|
||||
void AnnouncementInbox::Configure(std::string host, int port,
|
||||
const std::string& device_id) {
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
if (directory_.empty() || host.empty() || port <= 0 || device_id.empty())
|
||||
return;
|
||||
for (auto& ch : host)
|
||||
if (ch >= 'A' && ch <= 'Z') ch += 'a' - 'A';
|
||||
if (host.back() == '.') host.pop_back();
|
||||
const auto scope = json::array({host, port, device_id}).dump();
|
||||
if (scope_ == scope) return;
|
||||
scope_ = scope;
|
||||
read_file_ = directory_ / ScopeFilename(scope);
|
||||
const auto version = state_.version + 1;
|
||||
state_ = {};
|
||||
state_.version = version;
|
||||
catalog_.clear();
|
||||
catalog_revision_ = -1;
|
||||
page_ids_.clear();
|
||||
page_bodies_.clear();
|
||||
visible_limit_ = 20;
|
||||
LoadLocalState();
|
||||
BeginCatalog();
|
||||
}
|
||||
void AnnouncementInbox::Reset() {
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
const auto version = state_.version + 1;
|
||||
state_ = {};
|
||||
state_.version = version;
|
||||
scope_.clear();
|
||||
read_file_.clear();
|
||||
reads_.clear();
|
||||
dismissed_.clear();
|
||||
page_ids_.clear();
|
||||
page_bodies_.clear();
|
||||
visible_limit_ = 20;
|
||||
catalog_.clear();
|
||||
catalog_revision_ = -1;
|
||||
connected_ = false;
|
||||
BeginCatalog();
|
||||
}
|
||||
void AnnouncementInbox::BeginCatalog() {
|
||||
pending_catalog_.clear();
|
||||
pending_revision_ = -1;
|
||||
pending_total_ = 0;
|
||||
phase_ = Phase::Catalog;
|
||||
request_id_.clear();
|
||||
state_.loading = false;
|
||||
refresh_ = connected_;
|
||||
++state_.version;
|
||||
}
|
||||
void AnnouncementInbox::SetConnected(bool connected) {
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
connected_ = connected;
|
||||
BeginCatalog();
|
||||
}
|
||||
void AnnouncementInbox::Refresh() {
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
BeginCatalog();
|
||||
}
|
||||
void AnnouncementInbox::LoadMore() {
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
if (!connected_ || state_.loading || refresh_ || state_.failed ||
|
||||
catalog_revision_ < 0 || visible_limit_ >= state_.total)
|
||||
return;
|
||||
visible_limit_ += (std::min)(20, state_.total - visible_limit_);
|
||||
RebuildVisibleItems();
|
||||
++state_.version;
|
||||
}
|
||||
void AnnouncementInbox::UpdateReadingState() {
|
||||
state_.total = 0;
|
||||
state_.unread = 0;
|
||||
for (const auto& [id, revision] : catalog_) {
|
||||
if (IsDismissed(id, revision)) continue;
|
||||
++state_.total;
|
||||
const auto read = reads_.find(id);
|
||||
if (read == reads_.end() || read->second != revision) ++state_.unread;
|
||||
}
|
||||
for (auto& item : state_.items) {
|
||||
const auto read = reads_.find(item["id"]);
|
||||
item["read"] = read != reads_.end() && read->second == item["revision"];
|
||||
}
|
||||
}
|
||||
bool AnnouncementInbox::Read(int64_t id, int64_t revision) {
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
for (const auto& item : state_.items) {
|
||||
if (item["id"] != id || item["revision"] != revision) continue;
|
||||
if (item["read"].get<bool>()) return true;
|
||||
auto updated = reads_;
|
||||
updated[id] = revision;
|
||||
if (!SaveLocalState(updated, dismissed_)) {
|
||||
state_.read_save_failed = true;
|
||||
++state_.version;
|
||||
return false;
|
||||
}
|
||||
reads_ = std::move(updated);
|
||||
state_.read_save_failed = false;
|
||||
state_.delete_failed = false;
|
||||
UpdateReadingState();
|
||||
++state_.version;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
bool AnnouncementInbox::IsDismissed(int64_t id, int64_t revision) const {
|
||||
const auto found = dismissed_.find(id);
|
||||
return found != dismissed_.end() && found->second == revision;
|
||||
}
|
||||
|
||||
bool AnnouncementInbox::Dismiss(int64_t id, int64_t revision) {
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
for (const auto& item : state_.items) {
|
||||
if (item["id"] != id || item["revision"] != revision) continue;
|
||||
auto updated = dismissed_;
|
||||
updated[id] = revision;
|
||||
if (!SaveLocalState(reads_, updated)) {
|
||||
state_.delete_failed = true;
|
||||
++state_.version;
|
||||
return false;
|
||||
}
|
||||
dismissed_ = std::move(updated);
|
||||
state_.delete_failed = false;
|
||||
state_.read_save_failed = false;
|
||||
// An index refresh already in flight must finish before reusing its order.
|
||||
if (phase_ == Phase::Catalog && (state_.loading || refresh_)) {
|
||||
auto visible = json::array();
|
||||
for (const auto& current : state_.items)
|
||||
if (!IsDismissed(current["id"], current["revision"]))
|
||||
visible.push_back(current);
|
||||
state_.items = std::move(visible);
|
||||
UpdateReadingState();
|
||||
} else {
|
||||
request_id_.clear();
|
||||
state_.loading = false;
|
||||
RebuildVisibleItems();
|
||||
}
|
||||
++state_.version;
|
||||
return true;
|
||||
}
|
||||
// The confirmation may refer to an announcement updated while it was open.
|
||||
state_.delete_failed = true;
|
||||
++state_.version;
|
||||
return false;
|
||||
}
|
||||
|
||||
void AnnouncementInbox::RebuildVisibleItems() {
|
||||
UpdateReadingState();
|
||||
const int wanted = (std::min)(visible_limit_, state_.total);
|
||||
auto visible = json::array();
|
||||
std::set<int> retained_pages;
|
||||
int position = 0;
|
||||
phase_ = Phase::Page;
|
||||
refresh_ = false;
|
||||
for (int physical = 0;
|
||||
wanted > 0 && physical < static_cast<int>(catalog_.size());
|
||||
physical += 20) {
|
||||
const auto ids = page_ids_.find(physical);
|
||||
if (ids == page_ids_.end()) {
|
||||
pending_page_offset_ = physical;
|
||||
refresh_ = connected_;
|
||||
break;
|
||||
}
|
||||
std::set<int64_t> page_selection;
|
||||
for (auto id : ids->second) {
|
||||
const auto entry = catalog_.find(id);
|
||||
if (entry == catalog_.end() || IsDismissed(id, entry->second)) continue;
|
||||
if (position < wanted) page_selection.insert(id);
|
||||
++position;
|
||||
}
|
||||
if (page_selection.empty()) {
|
||||
page_bodies_.erase(physical);
|
||||
continue;
|
||||
}
|
||||
retained_pages.insert(physical);
|
||||
const auto bodies = page_bodies_.find(physical);
|
||||
if (bodies == page_bodies_.end()) {
|
||||
pending_page_offset_ = physical;
|
||||
refresh_ = connected_;
|
||||
break;
|
||||
}
|
||||
for (const auto& item : bodies->second)
|
||||
if (page_selection.count(item["id"])) visible.push_back(item);
|
||||
if (visible.size() == static_cast<size_t>(wanted)) break;
|
||||
}
|
||||
if (visible.size() == static_cast<size_t>(wanted)) {
|
||||
for (auto it = page_bodies_.begin(); it != page_bodies_.end();)
|
||||
if (!retained_pages.count(it->first))
|
||||
it = page_bodies_.erase(it);
|
||||
else
|
||||
++it;
|
||||
}
|
||||
// A failed body fetch must not erase announcements already received. Keep
|
||||
// still-published, unchanged versions until the replacement batch arrives.
|
||||
if (visible.size() < static_cast<size_t>(wanted)) {
|
||||
std::set<int64_t> retained_ids;
|
||||
for (const auto& item : visible) retained_ids.insert(item["id"].get<int64_t>());
|
||||
for (const auto& item : state_.items) {
|
||||
const int64_t id = item["id"];
|
||||
const auto current = catalog_.find(id);
|
||||
if (current == catalog_.end() || current->second != item["revision"] ||
|
||||
IsDismissed(id, current->second) || !retained_ids.insert(id).second)
|
||||
continue;
|
||||
visible.push_back(item);
|
||||
if (visible.size() == static_cast<size_t>(wanted)) break;
|
||||
}
|
||||
}
|
||||
state_.items = std::move(visible);
|
||||
state_.loaded = true;
|
||||
UpdateReadingState();
|
||||
}
|
||||
|
||||
void AnnouncementInbox::Fail() {
|
||||
request_id_.clear();
|
||||
refresh_ = false;
|
||||
state_.loading = false;
|
||||
state_.failed = true;
|
||||
++state_.version;
|
||||
}
|
||||
nlohmann::json AnnouncementInbox::TakePendingRequest(
|
||||
std::chrono::steady_clock::time_point now) {
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
if (!connected_ || scope_.empty()) return nullptr;
|
||||
if (state_.loading && now >= deadline_) Fail();
|
||||
if (!refresh_) return nullptr;
|
||||
refresh_ = false;
|
||||
request_id_ = std::to_string(++serial_);
|
||||
state_.loading = true;
|
||||
state_.failed = false;
|
||||
++state_.version;
|
||||
deadline_ = now + std::chrono::seconds(12);
|
||||
return {{"type", "announcements_list"},
|
||||
{"request_id", request_id_},
|
||||
{"summary_only", phase_ == Phase::Catalog},
|
||||
{"offset", phase_ == Phase::Catalog
|
||||
? static_cast<int>(pending_catalog_.size())
|
||||
: pending_page_offset_}};
|
||||
}
|
||||
void AnnouncementInbox::Failed(const std::string& request_id) {
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
if (request_id == request_id_) Fail();
|
||||
}
|
||||
void AnnouncementInbox::Receive(const nlohmann::json& message) {
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
if (!message.is_object() || !message.contains("request_id") ||
|
||||
message["request_id"] != request_id_ || request_id_.empty())
|
||||
return;
|
||||
if (message.contains("error")) {
|
||||
Fail();
|
||||
return;
|
||||
}
|
||||
const bool summary = phase_ == Phase::Catalog;
|
||||
const int limit = summary ? 200 : 20;
|
||||
if (!Integer(message, "offset", 0, INT32_MAX) ||
|
||||
!Integer(message, "total", 0, INT32_MAX) ||
|
||||
!Integer(message, "catalog_revision", 0, INT64_MAX) ||
|
||||
!message.contains("summary_only") || message["summary_only"] != summary ||
|
||||
!message.contains("items") || !message["items"].is_array() ||
|
||||
message["items"].size() > static_cast<size_t>(limit)) {
|
||||
Fail();
|
||||
return;
|
||||
}
|
||||
const int offset = message["offset"], total = message["total"];
|
||||
const int64_t revision = message["catalog_revision"];
|
||||
if (offset > total ||
|
||||
message["items"].size() !=
|
||||
static_cast<size_t>((std::min)(limit, total - offset))) {
|
||||
Fail();
|
||||
return;
|
||||
}
|
||||
const auto expected_revision =
|
||||
summary ? pending_revision_ : catalog_revision_;
|
||||
if (expected_revision >= 0 && expected_revision != revision) {
|
||||
BeginCatalog();
|
||||
return;
|
||||
}
|
||||
for (const auto& item : message["items"]) {
|
||||
if (!item.is_object() || !Integer(item, "id", 1, INT64_MAX) ||
|
||||
!Integer(item, "revision", 1, INT64_MAX)) {
|
||||
Fail();
|
||||
return;
|
||||
}
|
||||
if (!summary) {
|
||||
if (!Integer(item, "updated_at", 1, 32503680000LL)) {
|
||||
Fail();
|
||||
return;
|
||||
}
|
||||
for (const auto* key : {"title", "body"}) {
|
||||
if (!item.contains(key) || !item[key].is_string() ||
|
||||
item[key].get_ref<const std::string&>().size() >
|
||||
(std::string(key) == "title" ? 240u : 8000u)) {
|
||||
Fail();
|
||||
return;
|
||||
}
|
||||
}
|
||||
const auto found = catalog_.find(item["id"]);
|
||||
if (found == catalog_.end() || found->second != item["revision"]) {
|
||||
Fail();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
request_id_.clear();
|
||||
state_.loading = false;
|
||||
if (summary) {
|
||||
if (pending_revision_ < 0) {
|
||||
pending_revision_ = revision;
|
||||
pending_total_ = total;
|
||||
}
|
||||
if (pending_total_ != total ||
|
||||
offset != static_cast<int>(pending_catalog_.size())) {
|
||||
Fail();
|
||||
return;
|
||||
}
|
||||
for (const auto& item : message["items"]) {
|
||||
if (!pending_catalog_.emplace(item["id"], item["revision"]).second) {
|
||||
Fail();
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (static_cast<int>(pending_catalog_.size()) < total) {
|
||||
refresh_ = true;
|
||||
++state_.version;
|
||||
return;
|
||||
}
|
||||
if (catalog_revision_ != revision) {
|
||||
page_ids_.clear();
|
||||
page_bodies_.clear();
|
||||
}
|
||||
catalog_ = std::move(pending_catalog_);
|
||||
catalog_revision_ = revision;
|
||||
// Prune expired records in memory; the next local action persists them.
|
||||
for (auto* records : {&reads_, &dismissed_}) {
|
||||
for (auto it = records->begin(); it != records->end();)
|
||||
if (!catalog_.count(it->first))
|
||||
it = records->erase(it);
|
||||
else
|
||||
++it;
|
||||
}
|
||||
} else {
|
||||
if (total != static_cast<int>(catalog_.size())) {
|
||||
Fail();
|
||||
return;
|
||||
}
|
||||
if (offset != pending_page_offset_) {
|
||||
Fail();
|
||||
return;
|
||||
}
|
||||
std::set<int64_t> unique;
|
||||
std::vector<int64_t> ids;
|
||||
for (const auto& item : message["items"]) {
|
||||
const int64_t id = item["id"];
|
||||
if (!unique.insert(id).second) {
|
||||
Fail();
|
||||
return;
|
||||
}
|
||||
ids.push_back(id);
|
||||
}
|
||||
page_ids_[offset] = std::move(ids);
|
||||
page_bodies_[offset] = message["items"];
|
||||
}
|
||||
RebuildVisibleItems();
|
||||
state_.failed = false;
|
||||
++state_.version;
|
||||
}
|
||||
|
||||
void AnnouncementInbox::LoadLocalState() {
|
||||
reads_.clear();
|
||||
dismissed_.clear();
|
||||
std::error_code ec;
|
||||
const auto size = std::filesystem::file_size(read_file_, ec);
|
||||
if (ec || size > 16 * 1024 * 1024) return;
|
||||
std::ifstream input(read_file_, std::ios::binary);
|
||||
const auto data = json::parse(input, nullptr, false);
|
||||
if (!data.is_object() || !data.contains("scope") || data["scope"] != scope_ ||
|
||||
!data.contains("version") ||
|
||||
(data["version"] != 1 && data["version"] != 2) ||
|
||||
!data.contains("reads") || !data["reads"].is_object())
|
||||
return;
|
||||
const auto load = [](const json& entries, Revisions& destination) {
|
||||
if (!entries.is_object()) return;
|
||||
for (const auto& entry : entries.items()) {
|
||||
int64_t id = 0;
|
||||
const auto parsed = std::from_chars(
|
||||
entry.key().data(), entry.key().data() + entry.key().size(), id);
|
||||
if (parsed.ec == std::errc{} &&
|
||||
parsed.ptr == entry.key().data() + entry.key().size() && id > 0 &&
|
||||
entry.value().is_number_integer() && entry.value() > 0 &&
|
||||
entry.value() <= INT64_MAX)
|
||||
destination[id] = entry.value();
|
||||
}
|
||||
};
|
||||
load(data["reads"], reads_);
|
||||
if (data.contains("dismissed")) load(data["dismissed"], dismissed_);
|
||||
}
|
||||
bool AnnouncementInbox::SaveLocalState(const Revisions& reads,
|
||||
const Revisions& dismissed) const {
|
||||
if (read_file_.empty()) return false;
|
||||
std::error_code ec;
|
||||
std::filesystem::create_directories(directory_, ec);
|
||||
if (ec) return false;
|
||||
const auto encode = [](const Revisions& records) {
|
||||
auto entries = json::object();
|
||||
for (const auto& [id, revision] : records)
|
||||
entries[std::to_string(id)] = revision;
|
||||
return entries;
|
||||
};
|
||||
const auto payload = json({{"version", 2},
|
||||
{"scope", scope_},
|
||||
{"reads", encode(reads)},
|
||||
{"dismissed", encode(dismissed)}})
|
||||
.dump();
|
||||
auto temporary = read_file_;
|
||||
temporary += ".tmp-" +
|
||||
std::to_string(
|
||||
std::chrono::steady_clock::now().time_since_epoch().count());
|
||||
{
|
||||
std::ofstream output(temporary, std::ios::binary | std::ios::trunc);
|
||||
output.write(payload.data(), static_cast<std::streamsize>(payload.size()));
|
||||
output.close();
|
||||
if (!output) {
|
||||
std::filesystem::remove(temporary, ec);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
#ifdef _WIN32
|
||||
const bool saved =
|
||||
MoveFileExW(temporary.c_str(), read_file_.c_str(),
|
||||
MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH) != 0;
|
||||
#else
|
||||
std::filesystem::rename(temporary, read_file_, ec);
|
||||
const bool saved = !ec;
|
||||
#endif
|
||||
if (!saved) std::filesystem::remove(temporary, ec);
|
||||
return saved;
|
||||
}
|
||||
} // namespace crossdesk
|
||||
@@ -0,0 +1,78 @@
|
||||
/*
|
||||
* @Author: DI JUNKUN
|
||||
* @Date: 2026-09-29
|
||||
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
|
||||
*/
|
||||
|
||||
#ifndef _ANNOUNCEMENT_INBOX_H_
|
||||
#define _ANNOUNCEMENT_INBOX_H_
|
||||
|
||||
#include <chrono>
|
||||
#include <cstdint>
|
||||
#include <filesystem>
|
||||
#include <map>
|
||||
#include <mutex>
|
||||
#include <nlohmann/json.hpp>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace crossdesk {
|
||||
// Announcement data is shared; read and dismissed versions stay on this device.
|
||||
class AnnouncementInbox {
|
||||
public:
|
||||
struct Snapshot {
|
||||
nlohmann::json items = nlohmann::json::array();
|
||||
int total = 0, unread = 0;
|
||||
bool loaded = false, loading = false, failed = false;
|
||||
bool read_save_failed = false;
|
||||
bool delete_failed = false;
|
||||
uint64_t version = 0;
|
||||
};
|
||||
Snapshot Get() const;
|
||||
uint64_t Version() const;
|
||||
void SetStorageDirectory(const std::filesystem::path& directory);
|
||||
// Called on the UI thread after login supplies the actual device ID.
|
||||
void Configure(std::string host, int port, const std::string& device_id);
|
||||
void Reset();
|
||||
void SetConnected(bool connected);
|
||||
void Refresh();
|
||||
void LoadMore();
|
||||
bool Read(int64_t id, int64_t revision);
|
||||
bool Dismiss(int64_t id, int64_t revision);
|
||||
nlohmann::json TakePendingRequest(std::chrono::steady_clock::time_point now =
|
||||
std::chrono::steady_clock::now());
|
||||
void Failed(const std::string& request_id);
|
||||
void Receive(const nlohmann::json& message);
|
||||
|
||||
private:
|
||||
using Revisions = std::map<int64_t, int64_t>;
|
||||
enum class Phase { Catalog, Page };
|
||||
void BeginCatalog();
|
||||
void Fail();
|
||||
void UpdateReadingState();
|
||||
bool IsDismissed(int64_t id, int64_t revision) const;
|
||||
void RebuildVisibleItems();
|
||||
void LoadLocalState();
|
||||
bool SaveLocalState(const Revisions& reads, const Revisions& dismissed) const;
|
||||
|
||||
mutable std::mutex mutex_;
|
||||
Snapshot state_;
|
||||
std::filesystem::path directory_, read_file_;
|
||||
std::string scope_, request_id_;
|
||||
Revisions reads_, dismissed_, catalog_, pending_catalog_;
|
||||
// Keep bodies for the loaded list prefix. Lightweight page IDs let us skip
|
||||
// locally deleted entries without sending deletion state upstream.
|
||||
std::map<int, std::vector<int64_t>> page_ids_;
|
||||
std::map<int, nlohmann::json> page_bodies_;
|
||||
int pending_page_offset_ = 0;
|
||||
int visible_limit_ = 20;
|
||||
int64_t catalog_revision_ = -1, pending_revision_ = -1;
|
||||
int pending_total_ = 0;
|
||||
Phase phase_ = Phase::Catalog;
|
||||
bool connected_ = false, refresh_ = false;
|
||||
uint64_t serial_ = 0;
|
||||
std::chrono::steady_clock::time_point deadline_{};
|
||||
};
|
||||
} // namespace crossdesk
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,59 @@
|
||||
/*
|
||||
* @Author: DI JUNKUN
|
||||
* @Date: 2026-09-26
|
||||
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
|
||||
*/
|
||||
|
||||
#ifndef _CONNECTION_LATENCY_H_
|
||||
#define _CONNECTION_LATENCY_H_
|
||||
|
||||
#include <chrono>
|
||||
#include <cmath>
|
||||
#include <mutex>
|
||||
#include <optional>
|
||||
|
||||
namespace crossdesk::gui_detail {
|
||||
|
||||
// Presentation-only smoothing. The transport's raw RTT remains available for
|
||||
// recovery, congestion control and diagnostics. A missing one-second report
|
||||
// does not flicker, but a disconnected/stalled peer cannot leave a stale value.
|
||||
class ConnectionLatencyStats {
|
||||
public:
|
||||
using Clock = std::chrono::steady_clock;
|
||||
|
||||
void Update(double rtt_ms, Clock::time_point now = Clock::now()) {
|
||||
if (!std::isfinite(rtt_ms) || rtt_ms < 0 || rtt_ms > 2000) return;
|
||||
std::lock_guard lock(mutex_);
|
||||
if (average_ms_ && now < updated_) return;
|
||||
if (!Fresh(now)) {
|
||||
average_ms_ = rtt_ms;
|
||||
} else {
|
||||
*average_ms_ += 0.25 * (rtt_ms - *average_ms_);
|
||||
}
|
||||
updated_ = now;
|
||||
}
|
||||
|
||||
std::optional<double> Get(Clock::time_point now = Clock::now()) const {
|
||||
std::lock_guard lock(mutex_);
|
||||
return Fresh(now) ? average_ms_ : std::nullopt;
|
||||
}
|
||||
|
||||
void Reset() {
|
||||
std::lock_guard lock(mutex_);
|
||||
average_ms_.reset();
|
||||
}
|
||||
|
||||
private:
|
||||
bool Fresh(Clock::time_point now) const {
|
||||
// The UI may snapshot `now` just before the report thread updates us.
|
||||
// A newer valid report must not cause a one-frame unavailable flicker.
|
||||
return average_ms_ && now - updated_ < std::chrono::seconds(3);
|
||||
}
|
||||
mutable std::mutex mutex_;
|
||||
std::optional<double> average_ms_;
|
||||
Clock::time_point updated_{};
|
||||
};
|
||||
|
||||
} // namespace crossdesk::gui_detail
|
||||
|
||||
#endif
|
||||
@@ -236,7 +236,8 @@ void GuiRuntime::CloseRemoteSession(std::shared_ptr<RemoteSession> props) {
|
||||
std::shared_ptr<std::vector<unsigned char>> frame_snapshot;
|
||||
int video_width = 0;
|
||||
int video_height = 0;
|
||||
{
|
||||
const bool save_preview = config_center_->IsSaveRemotePreviews();
|
||||
if (save_preview) {
|
||||
std::lock_guard<std::mutex> lock(props->video_frame_mutex_);
|
||||
frame_snapshot = props->front_frame_;
|
||||
video_width = props->video_width_;
|
||||
@@ -249,7 +250,8 @@ void GuiRuntime::CloseRemoteSession(std::shared_ptr<RemoteSession> props) {
|
||||
}
|
||||
}
|
||||
auto* native_renderer = video_renderer_.get();
|
||||
if ((!frame_snapshot || frame_snapshot->empty()) && native_renderer) {
|
||||
if (save_preview && (!frame_snapshot || frame_snapshot->empty()) &&
|
||||
native_renderer) {
|
||||
auto native_snapshot = std::make_shared<std::vector<unsigned char>>();
|
||||
if (native_renderer->CopyLatestNv12(props->remote_id_,
|
||||
native_snapshot.get(), &video_width,
|
||||
@@ -269,9 +271,14 @@ void GuiRuntime::CloseRemoteSession(std::shared_ptr<RemoteSession> props) {
|
||||
std::unique_lock lock(remote_sessions_mutex_);
|
||||
peer = std::exchange(props->peer_, nullptr);
|
||||
}
|
||||
HostPlatform platform;
|
||||
{
|
||||
std::lock_guard lock(props->remote_version_mutex_);
|
||||
platform = props->recent_platform_;
|
||||
}
|
||||
const auto queued_at = std::chrono::steady_clock::now();
|
||||
session_cleanup_tasks_[props->remote_id_] = session_cleanup_queue_.PostTask(
|
||||
[props, peer, frame_snapshot, video_width, video_height,
|
||||
[props, peer, frame_snapshot, video_width, video_height, platform,
|
||||
thumbnail = thumbnail_, queued_at]() mutable {
|
||||
if (peer) {
|
||||
LOG_INFO("[{}] Background leave connection [{}]", props->local_id_,
|
||||
@@ -279,12 +286,16 @@ void GuiRuntime::CloseRemoteSession(std::shared_ptr<RemoteSession> props) {
|
||||
LeaveConnection(peer, props->remote_id_.c_str());
|
||||
DestroyPeer(&peer);
|
||||
}
|
||||
if (thumbnail && frame_snapshot && !frame_snapshot->empty() &&
|
||||
video_width > 0 && video_height > 0) {
|
||||
thumbnail->SaveToThumbnail(
|
||||
reinterpret_cast<char*>(frame_snapshot->data()), video_width,
|
||||
video_height, props->remote_id_, props->remote_host_name_,
|
||||
props->remember_password_ ? props->remote_password_ : "");
|
||||
if (thumbnail && props->was_connected_) {
|
||||
const char* nv12 = frame_snapshot && !frame_snapshot->empty()
|
||||
? reinterpret_cast<const char*>(frame_snapshot->data()) : nullptr;
|
||||
if (thumbnail->SaveToThumbnail(
|
||||
nv12, video_width, video_height, props->remote_id_,
|
||||
props->remote_host_name_,
|
||||
props->remember_password_ ? props->remote_password_ : "",
|
||||
platform) != 0) {
|
||||
LOG_ERROR("[{}] Failed to save recent connection", props->remote_id_);
|
||||
}
|
||||
}
|
||||
const auto elapsed = std::chrono::duration_cast<std::chrono::milliseconds>(
|
||||
std::chrono::steady_clock::now() - queued_at).count();
|
||||
@@ -301,8 +312,12 @@ void GuiRuntime::CloseServerController(const std::string& remote_id) {
|
||||
}
|
||||
|
||||
void GuiRuntime::CloseConnectionPeer() {
|
||||
if (!peer_) return;
|
||||
if (!peer_) {
|
||||
announcements_.Reset();
|
||||
return;
|
||||
}
|
||||
peer_events_->Deactivate();
|
||||
announcements_.Reset();
|
||||
std::vector<std::string> controllers;
|
||||
{
|
||||
std::shared_lock lock(connection_status_mutex_);
|
||||
@@ -417,8 +432,11 @@ void GuiRuntime::WaitForSessionCleanup() {
|
||||
|
||||
void GuiRuntime::ResetRemoteSessionResources(
|
||||
std::shared_ptr<RemoteSession> props) {
|
||||
props->net_traffic_stats_.Reset();
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(props->video_frame_mutex_);
|
||||
props->video_latency_->Reset();
|
||||
props->video_frame_timing_ = {};
|
||||
props->front_frame_.reset();
|
||||
props->back_frame_.reset();
|
||||
props->thumbnail_frame_.reset();
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
#include "runtime/gui_runtime.h"
|
||||
|
||||
#include <SDL3/SDL_timer.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <chrono>
|
||||
#include <cstring>
|
||||
@@ -14,6 +16,9 @@
|
||||
#include "localization.h"
|
||||
#include "platform/video_renderer.h"
|
||||
#include "rd_log.h"
|
||||
#ifdef __linux__
|
||||
#include "platform/linux/headless/headless_console.h"
|
||||
#endif
|
||||
|
||||
namespace crossdesk {
|
||||
|
||||
@@ -92,6 +97,11 @@ int GuiRuntime::CreateConnectionPeer() {
|
||||
|
||||
const char *active_identity =
|
||||
self_hosted ? self_hosted_user_id_ : client_id_with_password_;
|
||||
#ifdef __linux__
|
||||
// Publish the committed identity; an unconfirmed recovery credential must
|
||||
// not be advertised as the current connection password.
|
||||
HeadlessConsole::Instance().SetIdentity(active_identity);
|
||||
#endif
|
||||
const std::string login_identity =
|
||||
try_pending_identity ? pending_identity : std::string(active_identity);
|
||||
std::memset(connection_login_identity_, 0,
|
||||
@@ -136,28 +146,12 @@ int GuiRuntime::CreateConnectionPeer() {
|
||||
params_.turn_mode = static_cast<TurnMode>(config_center_->GetTurnMode());
|
||||
params_.enable_srtp = config_center_->IsEnableSrtp();
|
||||
params_.video_content_type = VideoContentType::ScreenContent;
|
||||
params_.video_quality =
|
||||
static_cast<VideoQuality>(config_center_->GetVideoQuality());
|
||||
params_.video_frame_rate =
|
||||
config_center_->GetVideoFrameRate() ==
|
||||
ConfigCenter::VIDEO_FRAME_RATE::FPS_30
|
||||
? 30
|
||||
: 60;
|
||||
switch (config_center_->GetVideoAdaptationPolicy()) {
|
||||
case ConfigCenter::VIDEO_ADAPTATION_POLICY::FRAME_RATE_PRIORITY:
|
||||
params_.video_degradation_preference =
|
||||
VideoDegradationPreference::MaintainFrameRate;
|
||||
break;
|
||||
case ConfigCenter::VIDEO_ADAPTATION_POLICY::BALANCED:
|
||||
params_.video_degradation_preference =
|
||||
VideoDegradationPreference::Balanced;
|
||||
break;
|
||||
case ConfigCenter::VIDEO_ADAPTATION_POLICY::QUALITY_PRIORITY:
|
||||
default:
|
||||
params_.video_degradation_preference =
|
||||
VideoDegradationPreference::MaintainResolution;
|
||||
break;
|
||||
}
|
||||
// Bootstrap outgoing media with fixed defaults. Live overrides come only
|
||||
// from the authorized controller and apply to its connection on this host.
|
||||
params_.video_quality = VideoQuality::QualityHigh;
|
||||
params_.video_frame_rate = 60;
|
||||
params_.video_degradation_preference =
|
||||
VideoDegradationPreference::MaintainResolution;
|
||||
params_.on_receive_video_buffer = nullptr;
|
||||
params_.on_receive_audio_buffer = PeerEventHandler::OnReceiveAudioBuffer;
|
||||
params_.on_receive_data_buffer = PeerEventHandler::OnReceiveDataBuffer;
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
#define _GUI_RUNTIME_H_
|
||||
|
||||
#include <atomic>
|
||||
#include <cstdint>
|
||||
#include <deque>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
@@ -19,6 +20,7 @@
|
||||
#include "features/settings/settings_manager.h"
|
||||
#include "privacy_controller.h"
|
||||
#include "runtime/gui_state.h"
|
||||
#include "runtime/announcement_inbox.h"
|
||||
#include "runtime/peer_event_handler.h"
|
||||
#include "runtime/privacy_session_policy.h"
|
||||
|
||||
@@ -44,8 +46,8 @@ class GuiRuntime : protected gui_detail::GuiState {
|
||||
GuiRuntime();
|
||||
~GuiRuntime();
|
||||
|
||||
static void SdlCaptureAudioIn(void* userdata, Uint8* stream, int len);
|
||||
static void SdlCaptureAudioOut(void* userdata, Uint8* stream, int len);
|
||||
static void SdlCaptureAudioIn(void* userdata, uint8_t* stream, int len);
|
||||
static void SdlCaptureAudioOut(void* userdata, uint8_t* stream, int len);
|
||||
|
||||
int CreateConnectionPeer();
|
||||
int ConnectTo(const std::string& remote_id, const char* password,
|
||||
@@ -98,6 +100,7 @@ class GuiRuntime : protected gui_detail::GuiState {
|
||||
#endif
|
||||
|
||||
PrivacyController privacy_;
|
||||
AnnouncementInbox announcements_;
|
||||
PrivacySessionPolicy privacy_sessions_;
|
||||
std::mutex privacy_commands_mutex_;
|
||||
std::deque<std::pair<std::string, PrivacyCommand>> privacy_commands_;
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
/*
|
||||
* @Author: DI JUNKUN
|
||||
* @Date: 2026-09-26
|
||||
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
|
||||
*/
|
||||
|
||||
#ifndef _NET_TRAFFIC_STATS_CACHE_H_
|
||||
#define _NET_TRAFFIC_STATS_CACHE_H_
|
||||
|
||||
#include <atomic>
|
||||
|
||||
#include "minirtc.h"
|
||||
#include "runtime/connection_latency.h"
|
||||
|
||||
namespace crossdesk::gui_detail {
|
||||
|
||||
// Fields are independent observations and may come from different reports.
|
||||
// Relaxed accesses keep each field safe without publishing any other state.
|
||||
class NetTrafficStatsCache {
|
||||
static_assert(std::atomic<uint32_t>::is_always_lock_free &&
|
||||
std::atomic<float>::is_always_lock_free &&
|
||||
std::atomic<double>::is_always_lock_free &&
|
||||
std::atomic<bool>::is_always_lock_free,
|
||||
"Network statistics require lock-free scalar atomics");
|
||||
|
||||
public:
|
||||
void Store(const MiniRtcNetTrafficStats& stats) {
|
||||
video_inbound_stats_.Store(stats.video_inbound_stats);
|
||||
video_outbound_stats_.Store(stats.video_outbound_stats);
|
||||
audio_inbound_stats_.Store(stats.audio_inbound_stats);
|
||||
audio_outbound_stats_.Store(stats.audio_outbound_stats);
|
||||
data_inbound_stats_.Store(stats.data_inbound_stats);
|
||||
data_outbound_stats_.Store(stats.data_outbound_stats);
|
||||
total_inbound_stats_.Store(stats.total_inbound_stats);
|
||||
total_outbound_stats_.Store(stats.total_outbound_stats);
|
||||
srtp_active_.store(stats.srtp_active, std::memory_order_relaxed);
|
||||
rtt_ms_.store(stats.rtt_ms, std::memory_order_relaxed);
|
||||
connection_latency_.Update(stats.rtt_ms);
|
||||
}
|
||||
|
||||
MiniRtcNetTrafficStats Load() const {
|
||||
MiniRtcNetTrafficStats stats{};
|
||||
stats.video_inbound_stats = video_inbound_stats_.Load();
|
||||
stats.video_outbound_stats = video_outbound_stats_.Load();
|
||||
stats.audio_inbound_stats = audio_inbound_stats_.Load();
|
||||
stats.audio_outbound_stats = audio_outbound_stats_.Load();
|
||||
stats.data_inbound_stats = data_inbound_stats_.Load();
|
||||
stats.data_outbound_stats = data_outbound_stats_.Load();
|
||||
stats.total_inbound_stats = total_inbound_stats_.Load();
|
||||
stats.total_outbound_stats = total_outbound_stats_.Load();
|
||||
stats.srtp_active = srtp_active_.load(std::memory_order_relaxed);
|
||||
stats.rtt_ms = rtt_ms_.load(std::memory_order_relaxed);
|
||||
return stats;
|
||||
}
|
||||
|
||||
uint32_t DataOutboundBitrate() const {
|
||||
return data_outbound_stats_.bitrate.load(std::memory_order_relaxed);
|
||||
}
|
||||
|
||||
std::optional<double> SmoothedRttMs(
|
||||
ConnectionLatencyStats::Clock::time_point now =
|
||||
ConnectionLatencyStats::Clock::now()) const {
|
||||
return connection_latency_.Get(now);
|
||||
}
|
||||
|
||||
void ResetLatency() { connection_latency_.Reset(); }
|
||||
|
||||
void Reset() {
|
||||
Store(MiniRtcNetTrafficStats{});
|
||||
ResetLatency();
|
||||
}
|
||||
|
||||
private:
|
||||
struct InboundStats {
|
||||
void Store(const MiniRtcInboundStats& stats) {
|
||||
bitrate.store(stats.bitrate, std::memory_order_relaxed);
|
||||
rtp_packet_count.store(stats.rtp_packet_count, std::memory_order_relaxed);
|
||||
loss_rate.store(stats.loss_rate, std::memory_order_relaxed);
|
||||
}
|
||||
|
||||
MiniRtcInboundStats Load() const {
|
||||
return {bitrate.load(std::memory_order_relaxed),
|
||||
rtp_packet_count.load(std::memory_order_relaxed),
|
||||
loss_rate.load(std::memory_order_relaxed)};
|
||||
}
|
||||
|
||||
std::atomic<uint32_t> bitrate{0};
|
||||
std::atomic<uint32_t> rtp_packet_count{0};
|
||||
std::atomic<float> loss_rate{0};
|
||||
};
|
||||
|
||||
struct OutboundStats {
|
||||
void Store(const MiniRtcOutboundStats& stats) {
|
||||
bitrate.store(stats.bitrate, std::memory_order_relaxed);
|
||||
rtp_packet_count.store(stats.rtp_packet_count, std::memory_order_relaxed);
|
||||
}
|
||||
|
||||
MiniRtcOutboundStats Load() const {
|
||||
return {bitrate.load(std::memory_order_relaxed),
|
||||
rtp_packet_count.load(std::memory_order_relaxed)};
|
||||
}
|
||||
|
||||
std::atomic<uint32_t> bitrate{0};
|
||||
std::atomic<uint32_t> rtp_packet_count{0};
|
||||
};
|
||||
|
||||
InboundStats video_inbound_stats_;
|
||||
OutboundStats video_outbound_stats_;
|
||||
InboundStats audio_inbound_stats_;
|
||||
OutboundStats audio_outbound_stats_;
|
||||
InboundStats data_inbound_stats_;
|
||||
OutboundStats data_outbound_stats_;
|
||||
InboundStats total_inbound_stats_;
|
||||
OutboundStats total_outbound_stats_;
|
||||
std::atomic<bool> srtp_active_{false};
|
||||
std::atomic<double> rtt_ms_{-1};
|
||||
ConnectionLatencyStats connection_latency_;
|
||||
};
|
||||
|
||||
} // namespace crossdesk::gui_detail
|
||||
|
||||
#endif
|
||||
@@ -1,6 +1,10 @@
|
||||
#include <SDL3/SDL_timer.h>
|
||||
|
||||
#include "runtime/peer_event_handler.h"
|
||||
|
||||
#include <app_version.h>
|
||||
#include <remote_action.h>
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
#include <algorithm>
|
||||
#include <chrono>
|
||||
@@ -146,6 +150,47 @@ void PeerEventHandler::OnReceiveDataBuffer(
|
||||
}
|
||||
|
||||
std::string remote_id(user_id, user_id_size);
|
||||
if (remote_action.type == ControlType::video_settings_status) {
|
||||
if (auto props = runtime->FindRemoteSession(remote_id)) {
|
||||
if (source_id != props->control_data_label_) return;
|
||||
std::lock_guard lock(props->video_settings_mutex_);
|
||||
if (!props->video_settings_supported_) return;
|
||||
if (remote_action.vs.accepted &&
|
||||
static_cast<int32_t>(remote_action.vs.request_id -
|
||||
props->applied_video_settings_.request_id) >= 0) {
|
||||
props->applied_video_settings_ = remote_action.vs;
|
||||
}
|
||||
if (remote_action.vs.request_id != props->video_settings_.request_id) return;
|
||||
props->video_settings_pending_ = false;
|
||||
props->video_settings_failed_ = !remote_action.vs.accepted;
|
||||
if (remote_action.vs.accepted) {
|
||||
props->video_settings_ = remote_action.vs;
|
||||
props->applied_video_settings_ = remote_action.vs;
|
||||
} else {
|
||||
const auto request_id = props->video_settings_.request_id;
|
||||
props->video_settings_ = props->applied_video_settings_;
|
||||
props->video_settings_.request_id = request_id;
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (remote_action.type == ControlType::video_settings) {
|
||||
if (source_id != runtime->control_data_label_ ||
|
||||
!runtime->IsAuthorizedController(remote_id))
|
||||
return;
|
||||
const auto& settings = remote_action.vs;
|
||||
remote_action.vs.accepted =
|
||||
UpdateVideoSettings(
|
||||
runtime->peer_, remote_id.data(), remote_id.size(),
|
||||
static_cast<VideoQuality>(settings.quality), settings.frame_rate,
|
||||
static_cast<VideoDegradationPreference>(settings.preference)) == 0;
|
||||
remote_action.type = ControlType::video_settings_status;
|
||||
const auto message = remote_action.to_json();
|
||||
SendReliableDataFrameToPeer(runtime->peer_, message.data(), message.size(),
|
||||
runtime->control_data_label_.c_str(),
|
||||
remote_id.data(), remote_id.size());
|
||||
return;
|
||||
}
|
||||
if (remote_action.type == ControlType::privacy_status) {
|
||||
if (auto props = runtime->FindRemoteSession(remote_id)) {
|
||||
if (source_id != props->control_data_label_) return;
|
||||
@@ -250,7 +295,7 @@ void PeerEventHandler::OnReceiveDataBuffer(
|
||||
props->remote_cursor_state_received_ = true;
|
||||
props->cursor_presentation_.Observe(merged, SDL_GetTicks());
|
||||
if (changed) {
|
||||
LOG_INFO("Received cursor state: seq={}, visible={}, shape={}, "
|
||||
LOG_DEBUG("Received cursor state: seq={}, visible={}, shape={}, "
|
||||
"mode={}, reason={}",
|
||||
remote_action.cs.seq, remote_action.cs.visible,
|
||||
static_cast<int>(remote_action.cs.shape),
|
||||
@@ -274,6 +319,33 @@ void PeerEventHandler::OnReceiveDataBuffer(
|
||||
}
|
||||
|
||||
if (is_client_mode) {
|
||||
if (props && source_id == props->control_data_label_) {
|
||||
std::lock_guard lock(props->remote_version_mutex_);
|
||||
props->remote_host_info_received_ = true;
|
||||
props->remote_platform_ = remote_action.i.platform;
|
||||
props->recent_platform_ = remote_action.i.platform;
|
||||
props->remote_app_version_ = IsValidAppVersion(remote_action.i.app_version)
|
||||
? remote_action.i.app_version : "";
|
||||
}
|
||||
// Negotiate live settings only on the reliable control stream. Old
|
||||
// hosts omit this capability and retain their existing video behavior.
|
||||
if (props && source_id == props->control_data_label_) {
|
||||
std::lock_guard lock(props->video_settings_mutex_);
|
||||
const bool first_advertisement = !props->video_settings_supported_;
|
||||
props->video_settings_supported_ =
|
||||
remote_action.i.supports_video_settings;
|
||||
if (first_advertisement && props->video_settings_supported_) {
|
||||
RemoteAction settings{};
|
||||
settings.type = ControlType::video_settings;
|
||||
++props->video_settings_.request_id;
|
||||
props->video_settings_pending_ = true;
|
||||
props->video_settings_request_tick_ = SDL_GetTicks();
|
||||
settings.vs = props->video_settings_;
|
||||
const auto message = settings.to_json();
|
||||
SendReliableDataFrame(props->peer_, message.data(), message.size(),
|
||||
props->control_data_label_.c_str());
|
||||
}
|
||||
}
|
||||
// client mode
|
||||
if (props && props->remote_host_name_.empty()) {
|
||||
props->remote_host_name_ = std::string(remote_action.i.host_name,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#include "runtime/peer_event_handler.h"
|
||||
|
||||
#include <remote_action.h>
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
#include <algorithm>
|
||||
#include <chrono>
|
||||
@@ -21,6 +22,9 @@
|
||||
#include "platform/video_renderer.h"
|
||||
#include "rd_log.h"
|
||||
#include "runtime/gui_runtime.h"
|
||||
#ifdef __linux__
|
||||
#include "platform/linux/headless/headless_console.h"
|
||||
#endif
|
||||
|
||||
#if _WIN32
|
||||
#include "interactive_state.h"
|
||||
@@ -28,6 +32,19 @@
|
||||
#endif
|
||||
|
||||
namespace crossdesk {
|
||||
namespace {
|
||||
|
||||
#if defined(_WIN32)
|
||||
constexpr HostPlatform kClientPlatform = HostPlatform::Windows;
|
||||
#elif defined(__APPLE__)
|
||||
constexpr HostPlatform kClientPlatform = HostPlatform::MacOS;
|
||||
#elif defined(__linux__)
|
||||
constexpr HostPlatform kClientPlatform = HostPlatform::Linux;
|
||||
#else
|
||||
constexpr HostPlatform kClientPlatform = HostPlatform::Unknown;
|
||||
#endif
|
||||
|
||||
} // namespace
|
||||
|
||||
void PeerEventHandler::SendClientInfo(PeerPtr* peer,
|
||||
const std::string& client_id) {
|
||||
@@ -35,19 +52,9 @@ void PeerEventHandler::SendClientInfo(PeerPtr* peer,
|
||||
return;
|
||||
}
|
||||
|
||||
#if defined(_WIN32)
|
||||
constexpr const char* kClientPlatform = "windows";
|
||||
#elif defined(__APPLE__)
|
||||
constexpr const char* kClientPlatform = "macos";
|
||||
#elif defined(__linux__)
|
||||
constexpr const char* kClientPlatform = "linux";
|
||||
#else
|
||||
constexpr const char* kClientPlatform = "unknown";
|
||||
#endif
|
||||
|
||||
const nlohmann::json message = {{"type", "client_info"},
|
||||
{"version", CROSSDESK_VERSION},
|
||||
{"platform", kClientPlatform}};
|
||||
{"platform", HostPlatformName(kClientPlatform)}};
|
||||
const std::string payload = message.dump();
|
||||
if (SendSignalMessage(peer, payload.data(), payload.size()) != 0) {
|
||||
LOG_WARN("[{}] failed to report client information", client_id);
|
||||
@@ -72,7 +79,11 @@ void PeerEventHandler::OnSignalMessage(const char* message, size_t size,
|
||||
return;
|
||||
}
|
||||
std::string type = j["type"].get<std::string>();
|
||||
if (type == "presence") {
|
||||
if (type == "announcements_changed") {
|
||||
runtime->announcements_.Refresh();
|
||||
} else if (type == "announcements") {
|
||||
runtime->announcements_.Receive(j);
|
||||
} else if (type == "presence") {
|
||||
if (j.contains("devices") && j["devices"].is_array()) {
|
||||
for (auto& dev : j["devices"]) {
|
||||
if (!dev.is_object()) {
|
||||
@@ -165,6 +176,7 @@ void PeerEventHandler::OnSignalStatus(SignalStatus status, const char* user_id,
|
||||
|
||||
std::string client_id(user_id, user_id_size);
|
||||
if (client_id == runtime->client_id_) {
|
||||
runtime->announcements_.SetConnected(status == SignalStatus::SignalConnected);
|
||||
runtime->signal_status_ = status;
|
||||
runtime->device_presence_cache_.SetSignalConnected(
|
||||
status == SignalStatus::SignalConnected);
|
||||
@@ -266,7 +278,9 @@ void PeerEventHandler::OnConnectionStatus(ConnectionStatus status,
|
||||
{
|
||||
RemoteAction remote_action = MakeHostInformation(
|
||||
GetHostName(), runtime->devices_.host_display_list(),
|
||||
/*supports_privacy_screen=*/true);
|
||||
/*supports_privacy_screen=*/true, CROSSDESK_VERSION,
|
||||
kClientPlatform);
|
||||
remote_action.i.supports_video_settings = true;
|
||||
for (std::size_t i = 0; i < remote_action.i.display_num; i++) {
|
||||
LOG_INFO("Local display [{}:{}]", i + 1,
|
||||
remote_action.i.display_list[i]);
|
||||
@@ -286,6 +300,7 @@ void PeerEventHandler::OnConnectionStatus(ConnectionStatus status,
|
||||
runtime->need_to_create_stream_window_ = true;
|
||||
}
|
||||
props->connection_established_ = true;
|
||||
props->was_connected_ = true;
|
||||
runtime->start_keyboard_capturer_ = true;
|
||||
break;
|
||||
}
|
||||
@@ -295,8 +310,22 @@ void PeerEventHandler::OnConnectionStatus(ConnectionStatus status,
|
||||
runtime->keyboard_.ReleaseRemotePressedKeys(remote_id,
|
||||
"connection_closed");
|
||||
props->connection_established_ = false;
|
||||
{
|
||||
std::lock_guard lock(props->remote_version_mutex_);
|
||||
props->remote_host_info_received_ = false;
|
||||
props->remote_app_version_.clear();
|
||||
props->remote_platform_ = HostPlatform::Unknown;
|
||||
props->remote_update_dismissed_ = false;
|
||||
}
|
||||
props->net_traffic_stats_.Reset();
|
||||
props->enable_mouse_control_ = false;
|
||||
runtime->ResetRemoteServiceStatus(*props);
|
||||
{
|
||||
std::lock_guard lock(props->video_settings_mutex_);
|
||||
props->video_settings_supported_ = false;
|
||||
props->video_settings_pending_ = false;
|
||||
props->video_settings_failed_ = false;
|
||||
}
|
||||
{
|
||||
std::lock_guard lock(props->remote_cursor_state_mutex_);
|
||||
props->remote_cursor_state_ = {};
|
||||
@@ -313,7 +342,8 @@ void PeerEventHandler::OnConnectionStatus(ConnectionStatus status,
|
||||
needs_native_snapshot =
|
||||
!props->thumbnail_frame_ || props->thumbnail_frame_->empty();
|
||||
}
|
||||
if (needs_native_snapshot) {
|
||||
if (needs_native_snapshot &&
|
||||
runtime->config_center_->IsSaveRemotePreviews()) {
|
||||
auto* native_renderer = runtime->video_renderer_.get();
|
||||
auto snapshot = std::make_shared<std::vector<unsigned char>>();
|
||||
if (native_renderer &&
|
||||
@@ -325,6 +355,8 @@ void PeerEventHandler::OnConnectionStatus(ConnectionStatus status,
|
||||
}
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(props->video_frame_mutex_);
|
||||
props->video_latency_->Reset();
|
||||
props->video_frame_timing_ = {};
|
||||
props->front_frame_.reset();
|
||||
props->back_frame_.reset();
|
||||
if (native_snapshot &&
|
||||
@@ -381,7 +413,9 @@ void PeerEventHandler::OnConnectionStatus(ConnectionStatus status,
|
||||
{
|
||||
RemoteAction remote_action = MakeHostInformation(
|
||||
GetHostName(), runtime->devices_.host_display_list(),
|
||||
/*supports_privacy_screen=*/true);
|
||||
/*supports_privacy_screen=*/true, CROSSDESK_VERSION,
|
||||
kClientPlatform);
|
||||
remote_action.i.supports_video_settings = true;
|
||||
for (std::size_t i = 0; i < remote_action.i.display_num; i++) {
|
||||
LOG_INFO("Local display [{}:{}]", i + 1,
|
||||
remote_action.i.display_list[i]);
|
||||
@@ -487,12 +521,16 @@ void PeerEventHandler::OnNetStatusReport(
|
||||
LOG_INFO("Use client id [{}] and save id into cache file", id);
|
||||
runtime->settings_.Save();
|
||||
}
|
||||
#ifdef __linux__
|
||||
HeadlessConsole::Instance().SetIdentity(id + "@" + password);
|
||||
#endif
|
||||
}
|
||||
|
||||
std::string remote_id(user_id, user_id_size);
|
||||
auto props = runtime->FindRemoteSession(remote_id);
|
||||
if (!props) return;
|
||||
if (props->traversal_mode_ != mode) {
|
||||
props->net_traffic_stats_.ResetLatency();
|
||||
props->traversal_mode_ = mode;
|
||||
LOG_INFO("Net mode: [{}]", int(props->traversal_mode_));
|
||||
}
|
||||
@@ -503,7 +541,7 @@ void PeerEventHandler::OnNetStatusReport(
|
||||
|
||||
// only display client side net status if connected to itself
|
||||
if (!(runtime->peer_reserved_ && !strstr(client_id, "C-"))) {
|
||||
props->net_traffic_stats_ = *net_traffic_stats;
|
||||
props->net_traffic_stats_.Store(*net_traffic_stats);
|
||||
}
|
||||
}
|
||||
} // namespace crossdesk
|
||||
|
||||
@@ -36,7 +36,7 @@ void PeerEventHandler::OnReceiveVideoBuffer(
|
||||
auto callback = handler->EnterCallback();
|
||||
if (!callback) return;
|
||||
GuiRuntime* runtime = handler ? &handler->owner_ : nullptr;
|
||||
if (!runtime) {
|
||||
if (!runtime || !video_frame) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -45,13 +45,18 @@ void PeerEventHandler::OnReceiveVideoBuffer(
|
||||
if (!props) return;
|
||||
|
||||
if (props->connection_established_) {
|
||||
const auto timing_now = VideoLatencyFrame::Clock::now();
|
||||
const auto timing = props->video_latency_->Frame(
|
||||
video_frame->captured_timestamp, GetSystemTimeMicros(props->peer_),
|
||||
timing_now);
|
||||
bool background_snapshot_only = false;
|
||||
auto* native_renderer = runtime->video_renderer_.get();
|
||||
if (native_renderer && native_renderer->IsActive()) {
|
||||
VideoRenderer::SubmitResult submit_result =
|
||||
VideoRenderer::SubmitResult::failed;
|
||||
if (const auto* native = GetNativeVideoFrame(video_frame)) {
|
||||
submit_result = native_renderer->SubmitNativeFrame(remote_id, *native);
|
||||
submit_result =
|
||||
native_renderer->SubmitNativeFrame(remote_id, *native, timing);
|
||||
// A CPU NV12 native frame is valid MiniRTC input, but the macOS Metal
|
||||
// fast path only accepts CVPixelBuffer. Materialize it when that path
|
||||
// rejects a software decoder's descriptor. This also supports peers
|
||||
@@ -69,14 +74,14 @@ void PeerEventHandler::OnReceiveVideoBuffer(
|
||||
submit_result = native_renderer->SubmitNv12(
|
||||
remote_id, fallback.data(), fallback.size(),
|
||||
static_cast<int>(native->width),
|
||||
static_cast<int>(native->height));
|
||||
static_cast<int>(native->height), timing);
|
||||
}
|
||||
}
|
||||
} else
|
||||
{
|
||||
submit_result = native_renderer->SubmitNv12(
|
||||
remote_id, reinterpret_cast<const uint8_t*>(video_frame->data),
|
||||
video_frame->size, video_frame->width, video_frame->height);
|
||||
video_frame->size, video_frame->width, video_frame->height, timing);
|
||||
}
|
||||
if (submit_result == VideoRenderer::SubmitResult::submitted) {
|
||||
std::lock_guard<std::mutex> lock(props->video_frame_mutex_);
|
||||
@@ -170,6 +175,7 @@ void PeerEventHandler::OnReceiveVideoBuffer(
|
||||
props->video_size_ = frame_size;
|
||||
|
||||
props->front_frame_.swap(props->back_frame_);
|
||||
props->video_frame_timing_ = timing;
|
||||
props->thumbnail_frame_ = props->front_frame_;
|
||||
props->thumbnail_width_ = video_frame->width;
|
||||
props->thumbnail_height_ = video_frame->height;
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
#include <SDL3/SDL_timer.h>
|
||||
|
||||
/*
|
||||
* @Author: DI JUNKUN
|
||||
* @Date: 2026-09-11
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
#include <filesystem>
|
||||
#include <memory>
|
||||
#include <mutex>
|
||||
#include <optional>
|
||||
#include <queue>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
@@ -23,6 +24,8 @@
|
||||
#include "display_info.h"
|
||||
#include "cursor_presentation.h"
|
||||
#include "minirtc.h"
|
||||
#include "rendering/video_latency.h"
|
||||
#include "runtime/net_traffic_stats_cache.h"
|
||||
|
||||
namespace crossdesk { class PeerEventHandler; }
|
||||
|
||||
@@ -84,6 +87,7 @@ struct RemoteSession {
|
||||
bool signal_connected_ = false;
|
||||
SignalStatus signal_status_ = SignalStatus::SignalClosed;
|
||||
bool connection_established_ = false;
|
||||
std::atomic<bool> was_connected_{false};
|
||||
bool rejoin_ = false;
|
||||
bool net_traffic_stats_button_pressed_ = false;
|
||||
bool enable_mouse_control_ = true;
|
||||
@@ -115,10 +119,30 @@ struct RemoteSession {
|
||||
int selected_display_ = 0;
|
||||
size_t video_size_ = 0;
|
||||
uint64_t video_frame_sequence_ = 0;
|
||||
VideoLatencyFrame video_frame_timing_;
|
||||
std::shared_ptr<VideoLatencyStats> video_latency_ =
|
||||
std::make_shared<VideoLatencyStats>();
|
||||
bool tab_selected_ = false;
|
||||
bool tab_opened_ = true;
|
||||
std::string remote_host_name_;
|
||||
// Host metadata and dismissal state belong to this connection and are
|
||||
// protected by remote_version_mutex_.
|
||||
std::mutex remote_version_mutex_;
|
||||
bool remote_host_info_received_ = false;
|
||||
std::string remote_app_version_;
|
||||
HostPlatform remote_platform_ = HostPlatform::Unknown;
|
||||
// Retain history metadata after disconnect resets the live capabilities.
|
||||
HostPlatform recent_platform_ = HostPlatform::Unknown;
|
||||
bool remote_update_dismissed_ = false;
|
||||
bool remote_service_status_received_ = false;
|
||||
std::mutex video_settings_mutex_;
|
||||
// Requested and confirmed settings for this remote session only.
|
||||
VideoSettings video_settings_{2, 60, 1};
|
||||
bool video_settings_supported_ = false;
|
||||
VideoSettings applied_video_settings_{2, 60, 1, 0, false};
|
||||
bool video_settings_pending_ = false;
|
||||
bool video_settings_failed_ = false;
|
||||
uint64_t video_settings_request_tick_ = 0;
|
||||
std::mutex privacy_status_mutex_;
|
||||
PrivacyStatus privacy_status_{};
|
||||
bool privacy_status_received_ = false;
|
||||
@@ -142,7 +166,9 @@ struct RemoteSession {
|
||||
int fps_ = 0;
|
||||
int frame_count_ = 0;
|
||||
std::chrono::steady_clock::time_point last_time_;
|
||||
MiniRtcNetTrafficStats net_traffic_stats_{};
|
||||
// UI-thread snapshot refreshed with FPS once per second.
|
||||
std::optional<VideoLatencyStats::Snapshot> video_latency_snapshot_;
|
||||
NetTrafficStatsCache net_traffic_stats_;
|
||||
|
||||
using QueuedFile = FileTransferState::QueuedFile;
|
||||
using FileTransferStatus = FileTransferState::FileTransferStatus;
|
||||
|
||||
@@ -128,9 +128,6 @@ struct UserSettingsState {
|
||||
char self_hosted_user_id_[17] = "";
|
||||
char connection_login_identity_[17] = "";
|
||||
int language_button_value_ = 0;
|
||||
int video_quality_button_value_ = 2;
|
||||
int video_frame_rate_button_value_ = 1;
|
||||
int video_adaptation_policy_button_value_ = 1;
|
||||
int video_encode_format_button_value_ = 0;
|
||||
bool enable_hardware_video_codec_ = true;
|
||||
bool enable_turn_ = true;
|
||||
@@ -138,9 +135,6 @@ struct UserSettingsState {
|
||||
char signal_server_port_[6] = "9099";
|
||||
bool enable_self_hosted_ = false;
|
||||
int language_button_value_last_ = 0;
|
||||
int video_quality_button_value_last_ = 0;
|
||||
int video_frame_rate_button_value_last_ = 0;
|
||||
int video_adaptation_policy_button_value_last_ = 1;
|
||||
int video_encode_format_button_value_last_ = 0;
|
||||
bool enable_hardware_video_codec_last_ = false;
|
||||
bool enable_turn_last_ = true;
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
/*
|
||||
* @Author: DI JUNKUN
|
||||
* @Date: 2026-09-29
|
||||
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
|
||||
*/
|
||||
|
||||
#ifndef _ANNOUNCEMENT_TEXT_H_
|
||||
#define _ANNOUNCEMENT_TEXT_H_
|
||||
|
||||
#include <slint.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <cctype>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
|
||||
namespace crossdesk::announcement_text {
|
||||
|
||||
inline size_t WebSchemeLength(std::string_view url) {
|
||||
for (const std::string_view prefix : {"https://", "http://"}) {
|
||||
if (url.size() >= prefix.size() &&
|
||||
std::equal(prefix.begin(), prefix.end(), url.begin(),
|
||||
[](char expected, unsigned char actual) {
|
||||
return expected == std::tolower(actual);
|
||||
})) {
|
||||
return prefix.size();
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
inline bool IsWebUrl(std::string_view url) {
|
||||
const size_t scheme = WebSchemeLength(url);
|
||||
if (!scheme || url.size() == scheme ||
|
||||
std::any_of(url.begin(), url.end(), [](unsigned char ch) {
|
||||
return ch <= 0x20 || ch == 0x7f || ch == '\\' || ch == '<' ||
|
||||
ch == '>' || ch == '"';
|
||||
})) {
|
||||
return false;
|
||||
}
|
||||
const auto authority =
|
||||
url.substr(scheme, url.find_first_of("/?#", scheme) - scheme);
|
||||
return !authority.empty() && authority.back() != '@';
|
||||
}
|
||||
|
||||
namespace detail {
|
||||
|
||||
inline size_t UrlEnd(std::string_view text, size_t start) {
|
||||
// Do not include surrounding prose punctuation in a bare URL. Balanced
|
||||
// parentheses and IPv6 brackets are part of the address when present.
|
||||
const std::string_view punctuation =
|
||||
reinterpret_cast<const char*>(u8",。;:!?、()【】《》「」『』“”‘’");
|
||||
int parentheses = 0, brackets = 0;
|
||||
size_t end = start;
|
||||
for (; end < text.size(); ++end) {
|
||||
const unsigned char ch = text[end];
|
||||
if (ch <= 0x20 || ch == 0x7f ||
|
||||
std::string_view("<>\"'`\\{}").find(ch) != std::string_view::npos ||
|
||||
(ch >= 0xe0 && end + 3 <= text.size() &&
|
||||
punctuation.find(text.substr(end, 3)) != std::string_view::npos)) {
|
||||
break;
|
||||
}
|
||||
if (ch == '(') ++parentheses;
|
||||
if (ch == ')' && parentheses-- == 0) break;
|
||||
if (ch == '[') ++brackets;
|
||||
if (ch == ']' && brackets-- == 0) break;
|
||||
}
|
||||
return end;
|
||||
}
|
||||
|
||||
inline void AppendLiteral(std::string& output, std::string_view text) {
|
||||
for (const unsigned char ch : text) {
|
||||
// Entity-encode spaces so leading indentation is not parsed as code.
|
||||
if (ch == ' ') {
|
||||
output += " ";
|
||||
} else if (ch == '\t') {
|
||||
output += "	";
|
||||
} else {
|
||||
if (ch < 0x80 && std::ispunct(ch)) output += '\\';
|
||||
output += static_cast<char>(ch);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
inline void AppendLink(std::string& output, std::string_view label,
|
||||
std::string_view url) {
|
||||
output += '[';
|
||||
AppendLiteral(output, label);
|
||||
output += "](<";
|
||||
for (const char ch : url) {
|
||||
// Markdown decodes entities in link destinations; keep query strings exact.
|
||||
if (ch == '&')
|
||||
output += "&";
|
||||
else
|
||||
output += ch;
|
||||
}
|
||||
output += ">)";
|
||||
}
|
||||
|
||||
} // namespace detail
|
||||
|
||||
inline slint::StyledText Format(std::string_view body) {
|
||||
std::string markdown;
|
||||
markdown.reserve(body.size());
|
||||
bool has_link = false;
|
||||
for (size_t pos = 0; pos < body.size();) {
|
||||
if (body[pos] == '[') {
|
||||
const size_t label_end = body.find("](", pos + 1);
|
||||
if (label_end != std::string_view::npos && label_end > pos + 1 &&
|
||||
body.substr(pos + 1, label_end - pos - 1).find_first_of("[]\r\n") ==
|
||||
std::string_view::npos &&
|
||||
WebSchemeLength(body.substr(label_end + 2))) {
|
||||
const size_t end = detail::UrlEnd(body, label_end + 2);
|
||||
const auto url = body.substr(label_end + 2, end - label_end - 2);
|
||||
if (end < body.size() && body[end] == ')' && IsWebUrl(url)) {
|
||||
detail::AppendLink(markdown,
|
||||
body.substr(pos + 1, label_end - pos - 1), url);
|
||||
pos = end + 1;
|
||||
has_link = true;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (WebSchemeLength(body.substr(pos))) {
|
||||
size_t end = detail::UrlEnd(body, pos);
|
||||
while (end > pos && std::string_view(".,;:!?").find(body[end - 1]) !=
|
||||
std::string_view::npos) {
|
||||
--end;
|
||||
}
|
||||
const auto url = body.substr(pos, end - pos);
|
||||
if (IsWebUrl(url)) {
|
||||
detail::AppendLink(markdown, url, url);
|
||||
pos = end;
|
||||
has_link = true;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if (body[pos] == '\r' && pos + 1 < body.size() && body[pos + 1] == '\n') {
|
||||
++pos;
|
||||
continue;
|
||||
}
|
||||
if (body[pos] == '\n') {
|
||||
// Preserve empty lines instead of letting Markdown collapse them.
|
||||
if (markdown.empty() || markdown.back() == '\n') markdown += " ";
|
||||
markdown += '\n';
|
||||
} else {
|
||||
detail::AppendLiteral(markdown, body.substr(pos, 1));
|
||||
}
|
||||
++pos;
|
||||
}
|
||||
if (has_link) {
|
||||
if (markdown.back() == '\n') markdown += " ";
|
||||
if (const auto styled = slint::StyledText::from_markdown(markdown))
|
||||
return *styled;
|
||||
}
|
||||
return slint::StyledText::from_plain_text(body);
|
||||
}
|
||||
|
||||
} // namespace crossdesk::announcement_text
|
||||
|
||||
#endif
|
||||
@@ -339,6 +339,7 @@ export component MiniToggleSwitch inherits Rectangle {
|
||||
|
||||
export component CompactButton inherits Rectangle {
|
||||
in property <string> text;
|
||||
in property <length> text-size: ImGuiFontStyle.body;
|
||||
in property <bool> primary: false;
|
||||
in property <bool> enabled: true;
|
||||
callback clicked;
|
||||
@@ -355,7 +356,7 @@ export component CompactButton inherits Rectangle {
|
||||
Text {
|
||||
text: root.text;
|
||||
color: root.enabled ? #24272d : #8e9298;
|
||||
font-size: ImGuiFontStyle.body;
|
||||
font-size: root.text-size;
|
||||
overflow: elide;
|
||||
horizontal-alignment: center;
|
||||
vertical-alignment: center;
|
||||
@@ -365,6 +366,7 @@ export component CompactButton inherits Rectangle {
|
||||
|
||||
export component PrimaryButton inherits Rectangle {
|
||||
in property <string> text;
|
||||
in property <length> text-size: ImGuiFontStyle.body;
|
||||
in property <bool> enabled: true;
|
||||
callback clicked;
|
||||
|
||||
@@ -379,7 +381,7 @@ export component PrimaryButton inherits Rectangle {
|
||||
Text {
|
||||
text: root.text;
|
||||
color: white;
|
||||
font-size: ImGuiFontStyle.body;
|
||||
font-size: root.text-size;
|
||||
horizontal-alignment: center;
|
||||
vertical-alignment: center;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// Single AOT entry point. Generating one header for the entire UI avoids
|
||||
// duplicate imported-component definitions when C++ uses all three windows.
|
||||
export { MainWindow, RecentConnection, UiStrings } from "main_window.slint";
|
||||
export { AnnouncementItem, MessageStrings } from "message_panel.slint";
|
||||
export { MainWindow, RecentConnection, UpdateCheckStatus, UiStrings } from "main_window.slint";
|
||||
export { StreamWindow, StreamTab, FileTransferEntry, NetworkStatsRow, StreamStrings } from "stream_window.slint";
|
||||
export { ServerWindow, ControllerEntry } from "server_window.slint";
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { LineEdit, ScrollView } from "std-widgets.slint";
|
||||
import { AnnouncementItem, MessagePanel, MessageStrings } from "message_panel.slint";
|
||||
import { FontAwesomeIcons, IconButton, CompactComboBox, ImGuiCheckBox, MiniToggleSwitch, CompactButton, PrimaryButton, SecondaryButton, ToggleSwitch, SectionTitle, DialogSurface, ImGuiLineStyle, ImGuiFontStyle } from "common.slint";
|
||||
|
||||
export struct RecentConnection {
|
||||
@@ -7,6 +8,8 @@ export struct RecentConnection {
|
||||
host-name: string,
|
||||
online: bool,
|
||||
thumbnail: image,
|
||||
has-thumbnail: bool,
|
||||
platform: string,
|
||||
}
|
||||
|
||||
export struct ReleaseNoteBlock {
|
||||
@@ -14,6 +17,8 @@ export struct ReleaseNoteBlock {
|
||||
section-gap: bool,
|
||||
}
|
||||
|
||||
export enum UpdateCheckStatus { idle, checking, up-to-date, available, failed }
|
||||
|
||||
export global UiStrings {
|
||||
in-out property <string> local-desktop: "Local Desktop";
|
||||
in-out property <string> remote-desktop: "Remote Desktop";
|
||||
@@ -38,14 +43,8 @@ export global UiStrings {
|
||||
in-out property <string> delete-connection: "Delete connection";
|
||||
in-out property <string> confirm-delete: "Delete this recent connection?";
|
||||
in-out property <string> language: "Language";
|
||||
in-out property <string> video-quality: "Video quality";
|
||||
in-out property <string> frame-rate: "Frame rate";
|
||||
in-out property <string> capture-method: "Capture method";
|
||||
in-out property <string> capture-method-auto: "Auto";
|
||||
in-out property <string> adaptation-policy: "Video preference";
|
||||
in-out property <string> priority-frame-rate: "Frame rate";
|
||||
in-out property <string> priority-quality: "Quality";
|
||||
in-out property <string> priority-balanced: "Balanced";
|
||||
in-out property <string> codec: "Video codec";
|
||||
in-out property <string> hardware-codec: "Hardware video codec";
|
||||
in-out property <string> turn-relay: "TURN relay";
|
||||
@@ -53,6 +52,8 @@ export global UiStrings {
|
||||
in-out property <string> self-hosted: "Self-hosted server";
|
||||
in-out property <string> autostart: "Start with system";
|
||||
in-out property <string> daemon: "Run in background";
|
||||
in-out property <string> save-remote-previews: "Save remote screen previews:";
|
||||
in-out property <string> unknown-platform: "Unknown platform";
|
||||
in-out property <string> privacy-on-connect: "Privacy screen on connect";
|
||||
in-out property <string> file-save-path: "File save path";
|
||||
in-out property <string> default-desktop: "Desktop";
|
||||
@@ -65,6 +66,11 @@ export global UiStrings {
|
||||
in-out property <string> signal-disconnected: "Signal server disconnected";
|
||||
in-out property <string> tls-error: "Signal server TLS certificate error";
|
||||
in-out property <string> update-available: "A new version is available";
|
||||
in-out property <string> new-version: "New version";
|
||||
in-out property <string> check-for-updates: "Check for updates";
|
||||
in-out property <string> checking-for-updates: "Checking for updates…";
|
||||
in-out property <string> up-to-date: "You’re up to date";
|
||||
in-out property <string> update-check-failed: "Check failed. Please try again later.";
|
||||
in-out property <string> release-notes: "Release notes";
|
||||
in-out property <string> later: "Later";
|
||||
in-out property <string> download: "Download";
|
||||
@@ -76,6 +82,13 @@ export global UiStrings {
|
||||
in-out property <string> permission-required: "The application requires the following permissions:";
|
||||
in-out property <string> screen-recording-permission: "Screen recording permission";
|
||||
in-out property <string> accessibility-permission: "Accessibility permission";
|
||||
in-out property <string> wayland-notice-title: "Wayland Session Notice";
|
||||
in-out property <string> wayland-notice-subtitle;
|
||||
in-out property <string> wayland-permission-title;
|
||||
in-out property <string> wayland-notice-message;
|
||||
in-out property <string> wayland-recommendation-title;
|
||||
in-out property <string> wayland-recommendation-message;
|
||||
in-out property <string> wayland-notice-acknowledge: "Got it";
|
||||
in-out property <string> service-setup-title: "Install CrossDesk Service";
|
||||
in-out property <string> service-setup-message: "Administrator permission is required to control this device while it is locked.";
|
||||
in-out property <string> service-settings-label: "Lock Screen Service:";
|
||||
@@ -84,9 +97,6 @@ export global UiStrings {
|
||||
in-out property <string> do-not-remind: "Do not remind again";
|
||||
in-out property <string> notification: "Notification";
|
||||
in-out property <string> service-suppressed-message: "You will not be reminded again. You can install the service later in Settings.";
|
||||
in-out property <string> quality-low: "Low";
|
||||
in-out property <string> quality-medium: "Medium";
|
||||
in-out property <string> quality-high: "High";
|
||||
in-out property <string> codec-h264: "H.264";
|
||||
in-out property <string> codec-av1: "AV1";
|
||||
in-out property <string> self-hosted-settings: "Self-hosted server settings";
|
||||
@@ -138,19 +148,50 @@ component DialogPromptText inherits Rectangle {
|
||||
}
|
||||
}
|
||||
|
||||
// Shared online-state artwork. Both the recent-connection card and the
|
||||
// server status bar use the same pure green center and proportional glow.
|
||||
// Explicit device labels make state readable without interpreting a dot.
|
||||
// Opaque surfaces keep the badge legible over both dark and light previews.
|
||||
component ConnectionStatusBadge inherits Rectangle {
|
||||
in property <string> label;
|
||||
in property <bool> online: false;
|
||||
|
||||
preferred-width: badge-label.preferred-width + 16px;
|
||||
width: self.preferred-width;
|
||||
height: 20px;
|
||||
border-radius: self.height / 2;
|
||||
background: root.online ? #eaf8f0 : #f2f4f7;
|
||||
border-width: 1px;
|
||||
border-color: root.online ? #bce3cd : #d5dbe3;
|
||||
|
||||
badge-label := Text {
|
||||
x: 8px;
|
||||
width: max(0px, parent.width - 16px);
|
||||
height: parent.height;
|
||||
text: root.label;
|
||||
color: root.online ? #16a34a : #596575;
|
||||
font-size: ImGuiFontStyle.small;
|
||||
font-weight: 600;
|
||||
horizontal-alignment: center;
|
||||
vertical-alignment: center;
|
||||
overflow: elide;
|
||||
}
|
||||
}
|
||||
|
||||
// Keep the green center and fade the surrounding glow into the status bar.
|
||||
component OnlineStatusIndicator inherits Rectangle {
|
||||
in property <length> dot-diameter: 8px;
|
||||
|
||||
width: root.dot-diameter * 2.2;
|
||||
height: self.width;
|
||||
border-radius: self.width / 2;
|
||||
// Slint's default radial radius is half the bounding-box diagonal.
|
||||
// Fade out at the circle's edge (1 / sqrt(2)), before rounded clipping.
|
||||
background: @radial-gradient(
|
||||
circle,
|
||||
#00ff0030 0%,
|
||||
#00ff0018 48%,
|
||||
transparent 100%
|
||||
#00ff0040 0%,
|
||||
#00ff0038 32%,
|
||||
#00ff0018 50%,
|
||||
#00ff0007 62%,
|
||||
#00ff0000 70.7%
|
||||
);
|
||||
|
||||
Rectangle {
|
||||
@@ -265,7 +306,33 @@ export component MainWindow inherits Window {
|
||||
in property <bool> signal-connected: false;
|
||||
in property <bool> signal-tls-error: false;
|
||||
in property <[RecentConnection]> recent-connections;
|
||||
in property <[AnnouncementItem]> announcements;
|
||||
in property <int> announcement-unread;
|
||||
private property <string> announcement-unread-label:
|
||||
root.announcement-unread > 99 ? "99+" : "" + root.announcement-unread;
|
||||
in property <int> announcement-total;
|
||||
in property <bool> announcement-loading;
|
||||
in property <bool> announcement-failed;
|
||||
in property <bool> announcement-read-save-failed;
|
||||
in property <bool> announcement-delete-failed;
|
||||
in property <bool> announcement-loaded;
|
||||
in-out property <bool> messages-open: false;
|
||||
in property <string> announcement-selected-id;
|
||||
in property <string> announcement-selected-title;
|
||||
in property <styled-text> announcement-selected-body;
|
||||
in property <string> announcement-selected-date;
|
||||
in property <bool> announcement-selected-read;
|
||||
callback open-announcement(string, string);
|
||||
callback open-announcement-link(string);
|
||||
callback delete-announcement(string, string);
|
||||
callback refresh-announcements;
|
||||
callback load-more-announcements;
|
||||
in property <bool> update-available: false;
|
||||
in property <UpdateCheckStatus> update-check-status: idle;
|
||||
private property <string> update-status-text:
|
||||
root.update-check-status == UpdateCheckStatus.checking ? UiStrings.checking-for-updates :
|
||||
root.update-check-status == UpdateCheckStatus.up-to-date ? UiStrings.up-to-date :
|
||||
root.update-check-status == UpdateCheckStatus.failed ? UiStrings.update-check-failed : "";
|
||||
in property <string> current-version: "";
|
||||
in property <string> latest-version: "";
|
||||
in property <string> release-name: "";
|
||||
@@ -282,6 +349,7 @@ export component MainWindow inherits Window {
|
||||
in property <bool> permission-dialog-open: false;
|
||||
in property <bool> screen-recording-granted: true;
|
||||
in property <bool> accessibility-granted: true;
|
||||
in-out property <bool> wayland-notice-open: false;
|
||||
in property <bool> portable-service-settings-visible: false;
|
||||
in property <bool> portable-service-dialog-open: false;
|
||||
in property <bool> portable-service-suppressed-notice-open: false;
|
||||
@@ -292,11 +360,8 @@ export component MainWindow inherits Window {
|
||||
in-out property <bool> portable-service-do-not-remind: false;
|
||||
|
||||
in-out property <int> language-index: 0;
|
||||
in-out property <int> video-quality-index: 2;
|
||||
in-out property <int> frame-rate-index: 1;
|
||||
in property <bool> capture-method-visible: false;
|
||||
in-out property <int> capture-method-index: 0;
|
||||
in-out property <int> adaptation-policy-index: 1;
|
||||
in-out property <int> codec-index: 0;
|
||||
in-out property <bool> hardware-codec-enabled: true;
|
||||
in-out property <bool> turn-enabled: true;
|
||||
@@ -307,6 +372,7 @@ export component MainWindow inherits Window {
|
||||
in-out property <bool> self-hosted-enabled: false;
|
||||
in-out property <bool> autostart-enabled: false;
|
||||
in-out property <bool> daemon-enabled: false;
|
||||
in-out property <bool> save-remote-previews: true;
|
||||
// Show the preference on every platform; only adapted hosts can change it.
|
||||
in property <bool> privacy-setting-available: false;
|
||||
in-out property <bool> privacy-on-connect-enabled: true;
|
||||
@@ -333,6 +399,7 @@ export component MainWindow inherits Window {
|
||||
callback cancel-self-hosted-settings;
|
||||
callback browse-save-path;
|
||||
callback open-download;
|
||||
callback check-for-updates;
|
||||
callback connection-cancel;
|
||||
callback connection-acknowledge;
|
||||
callback connection-submit-password(string, bool);
|
||||
@@ -352,7 +419,7 @@ export component MainWindow inherits Window {
|
||||
private property <length> settings-scroll-y: 0px;
|
||||
in-out property <bool> self-host-settings-open: false;
|
||||
in-out property <bool> about-open: false;
|
||||
in-out property <bool> update-open: root.update-available;
|
||||
in-out property <bool> update-open: false;
|
||||
in-out property <bool> reset-password-open: false;
|
||||
in-out property <bool> reset-password-invalid: false;
|
||||
in-out property <string> new-password-input: "";
|
||||
@@ -363,8 +430,6 @@ export component MainWindow inherits Window {
|
||||
private property <string> delete-remote-id: "";
|
||||
private property <bool> copy-toast: false;
|
||||
private property <bool> compact-language: root.language-index == 0;
|
||||
private property <bool> update-icon-blink-on:
|
||||
root.update-available && mod(animation-tick(), 2s) < 1s;
|
||||
private property <bool> recent-tooltip-visible: false;
|
||||
private property <string> recent-tooltip-name: "";
|
||||
private property <string> recent-tooltip-host: "";
|
||||
@@ -393,6 +458,14 @@ export component MainWindow inherits Window {
|
||||
}
|
||||
}
|
||||
|
||||
changed wayland-notice-open => {
|
||||
if root.wayland-notice-open { focus-sink.focus(); }
|
||||
}
|
||||
|
||||
changed messages-open => {
|
||||
if root.messages-open { focus-sink.focus(); }
|
||||
}
|
||||
|
||||
reset-remote-id => {
|
||||
root.remote-id-input = "";
|
||||
remote-id-editor.focus();
|
||||
@@ -597,7 +670,22 @@ export component MainWindow inherits Window {
|
||||
border-width: 0px;
|
||||
border-radius: 0px;
|
||||
|
||||
focus-sink := FocusScope { x: 0px; y: 0px; width: 0px; height: 0px; }
|
||||
focus-sink := FocusScope {
|
||||
x: 0px; y: 0px; width: 0px; height: 0px;
|
||||
key-pressed(event) => {
|
||||
if root.wayland-notice-open {
|
||||
if event.text == Key.Return || event.text == Key.Escape {
|
||||
root.wayland-notice-open = false;
|
||||
}
|
||||
return accept;
|
||||
}
|
||||
if root.messages-open {
|
||||
if event.text == Key.Escape { root.messages-open = false; }
|
||||
return accept;
|
||||
}
|
||||
return reject;
|
||||
}
|
||||
}
|
||||
TouchArea { clicked => { focus-sink.focus(); } }
|
||||
|
||||
// Slint owns the client area below the native macOS title bar. Draw
|
||||
@@ -623,22 +711,80 @@ export component MainWindow inherits Window {
|
||||
icon-size: 14px;
|
||||
hover-background: #e1e5eb;
|
||||
pressed-background: #cbd1d9;
|
||||
tooltip: root.announcement-unread > 0
|
||||
? MessageStrings.unread + " " + root.announcement-unread + (root.update-available ? " · " + UiStrings.update-available : "")
|
||||
: root.update-available ? UiStrings.update-available : "";
|
||||
z: 8;
|
||||
clicked => { root.menu-open = !root.menu-open; }
|
||||
}
|
||||
if root.update-available && !root.menu-open && root.update-icon-blink-on: Text {
|
||||
x: menu-button.x + 16px;
|
||||
y: menu-button.y + 1px;
|
||||
// Unread messages take precedence so the menu has only one indicator.
|
||||
if root.update-available && root.announcement-unread <= 0: Rectangle {
|
||||
x: menu-button.x + menu-button.width - self.width + 4px;
|
||||
y: menu-button.y - 1px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
text: FontAwesomeIcons.circle-arrow-up;
|
||||
color: #24272d;
|
||||
font-family: "Font Awesome 6 Free";
|
||||
font-weight: 900;
|
||||
font-size: 13px;
|
||||
horizontal-alignment: center;
|
||||
vertical-alignment: center;
|
||||
border-radius: 8px;
|
||||
border-width: 1px;
|
||||
border-color: white;
|
||||
background: #2563eb;
|
||||
opacity: root.menu-open || mod(animation-tick(), 2s) < 1s ? 1 : 0;
|
||||
z: 9;
|
||||
accessible-role: button;
|
||||
accessible-label: UiStrings.update-available;
|
||||
accessible-action-default => { root.menu-open = !root.menu-open; }
|
||||
// Center the visible mark independently of font baseline metrics.
|
||||
Rectangle {
|
||||
x: (parent.width - self.width) / 2;
|
||||
y: (parent.height - self.height) / 2;
|
||||
width: 2px;
|
||||
height: 10px;
|
||||
accessible-role: none;
|
||||
Rectangle {
|
||||
y: 0px;
|
||||
width: parent.width;
|
||||
height: 6px;
|
||||
border-radius: 1px;
|
||||
background: white;
|
||||
}
|
||||
Rectangle {
|
||||
y: 8px;
|
||||
width: parent.width;
|
||||
height: 2px;
|
||||
border-radius: 1px;
|
||||
background: white;
|
||||
}
|
||||
}
|
||||
TouchArea {
|
||||
mouse-cursor: pointer;
|
||||
clicked => { root.menu-open = !root.menu-open; }
|
||||
}
|
||||
}
|
||||
if root.announcement-unread > 0: Rectangle {
|
||||
x: menu-button.x + menu-button.width - self.width + 4px;
|
||||
y: menu-button.y - 1px;
|
||||
width: max(16px, unread-badge-label.preferred-width + 8px);
|
||||
height: 16px;
|
||||
border-radius: 8px;
|
||||
border-width: 1px;
|
||||
border-color: white;
|
||||
background: #d92d20;
|
||||
z: 9;
|
||||
accessible-role: button;
|
||||
accessible-label: MessageStrings.messages + " · " + MessageStrings.unread + " " + root.announcement-unread;
|
||||
accessible-action-default => { root.menu-open = !root.menu-open; }
|
||||
unread-badge-label := Text {
|
||||
text: root.announcement-unread-label;
|
||||
color: white;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
horizontal-alignment: center;
|
||||
vertical-alignment: center;
|
||||
accessible-role: none;
|
||||
}
|
||||
TouchArea {
|
||||
mouse-cursor: pointer;
|
||||
clicked => { root.menu-open = !root.menu-open; }
|
||||
}
|
||||
}
|
||||
|
||||
// Local and remote desktop panels.
|
||||
@@ -864,51 +1010,88 @@ export component MainWindow inherits Window {
|
||||
private property <length> toolbar-width: 79px;
|
||||
private property <length> toolbar-right-padding: 3px;
|
||||
|
||||
Image {
|
||||
Rectangle {
|
||||
x: 7px;
|
||||
y: 7px;
|
||||
width: connection-card.content-width;
|
||||
height: 84px;
|
||||
source: connection.thumbnail;
|
||||
image-fit: cover;
|
||||
}
|
||||
Rectangle {
|
||||
x: 7px;
|
||||
y: 91px;
|
||||
width: connection-card.content-width;
|
||||
height: 25px;
|
||||
background: #00000018;
|
||||
border-radius: 3px;
|
||||
Text {
|
||||
x: 4px;
|
||||
y: 1px;
|
||||
height: parent.height - self.y;
|
||||
width: max(0px, parent.width - self.x - 4px
|
||||
- (card-touch.has-hover
|
||||
? connection-card.toolbar-width + connection-card.toolbar-right-padding
|
||||
: 0px));
|
||||
text: connection.display-name;
|
||||
color: #30343b;
|
||||
// The ImGui card footer uses its own 0.52 font
|
||||
// scale and renders slightly larger than the
|
||||
// compact body text used in dialogs.
|
||||
font-size: ImGuiFontStyle.base;
|
||||
overflow: elide;
|
||||
vertical-alignment: center;
|
||||
height: 109px;
|
||||
border-radius: 4px;
|
||||
clip: true;
|
||||
|
||||
Rectangle {
|
||||
x: 0px;
|
||||
y: 0px;
|
||||
width: parent.width;
|
||||
height: 84px;
|
||||
background: connection.platform == "windows" ? @linear-gradient(135deg, #1f61b3, #337ac4) :
|
||||
connection.platform == "macos" ? @linear-gradient(135deg, #6e4aad, #8c63c4) :
|
||||
connection.platform == "linux" ? @linear-gradient(135deg, #a14f14, #ba631f) :
|
||||
connection.platform == "ios" ? @linear-gradient(135deg, #146e66, #1f8278) :
|
||||
@linear-gradient(135deg, #596675, #737d8c);
|
||||
if connection.has-thumbnail: Image {
|
||||
width: parent.width;
|
||||
height: parent.height;
|
||||
source: connection.thumbnail;
|
||||
image-fit: cover;
|
||||
}
|
||||
if !connection.has-thumbnail: Text {
|
||||
x: 8px;
|
||||
width: parent.width - 16px;
|
||||
height: parent.height;
|
||||
text: connection.platform == "windows" ? "Windows" :
|
||||
connection.platform == "macos" ? "macOS" :
|
||||
connection.platform == "linux" ? "Linux" :
|
||||
connection.platform == "ios" ? "iOS" : UiStrings.unknown-platform;
|
||||
color: white;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
horizontal-alignment: center;
|
||||
vertical-alignment: center;
|
||||
overflow: elide;
|
||||
}
|
||||
}
|
||||
Rectangle {
|
||||
x: 0px;
|
||||
y: 84px;
|
||||
width: parent.width;
|
||||
height: 25px;
|
||||
background: #00000018;
|
||||
Text {
|
||||
x: 4px;
|
||||
y: 1px;
|
||||
height: parent.height - self.y;
|
||||
width: max(0px, parent.width - self.x - 4px
|
||||
- (card-touch.has-hover
|
||||
? connection-card.toolbar-width + connection-card.toolbar-right-padding
|
||||
: 0px));
|
||||
text: connection.display-name;
|
||||
color: #30343b;
|
||||
// The ImGui card footer uses its own 0.52 font
|
||||
// scale and renders slightly larger than the
|
||||
// compact body text used in dialogs.
|
||||
font-size: ImGuiFontStyle.base;
|
||||
overflow: elide;
|
||||
vertical-alignment: center;
|
||||
}
|
||||
}
|
||||
ConnectionStatusBadge {
|
||||
x: 6px;
|
||||
y: 6px;
|
||||
width: min(self.preferred-width, parent.width - 12px);
|
||||
label: connection.online ? UiStrings.online : UiStrings.offline;
|
||||
online: connection.online;
|
||||
}
|
||||
// The software renderer only clips rectangular bounds.
|
||||
// This outer border masks the four corners there too.
|
||||
Rectangle {
|
||||
x: -parent.border-radius;
|
||||
y: -parent.border-radius;
|
||||
width: parent.width + 2 * parent.border-radius;
|
||||
height: parent.height + 2 * parent.border-radius;
|
||||
border-width: parent.border-radius;
|
||||
border-radius: 2 * parent.border-radius;
|
||||
border-color: recent-list.background;
|
||||
}
|
||||
}
|
||||
if connection.online: OnlineStatusIndicator {
|
||||
x: 16px - self.width / 2;
|
||||
y: 16px - self.height / 2;
|
||||
dot-diameter: 9px;
|
||||
}
|
||||
if !connection.online: Rectangle {
|
||||
x: 11.5px;
|
||||
y: 11.5px;
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
border-radius: 4.5px;
|
||||
background: #9ca3af;
|
||||
}
|
||||
card-touch := TouchArea {
|
||||
x: 7px;
|
||||
@@ -1094,9 +1277,10 @@ export component MainWindow inherits Window {
|
||||
if root.menu-open: Rectangle {
|
||||
x: menu-button.x + menu-button.width - self.width;
|
||||
y: menu-button.y + menu-button.height + 2px;
|
||||
width: min(118px, max(menu-settings-label.preferred-width,
|
||||
menu-about-label.preferred-width + (root.update-available ? 25px : 0px)) + 28px);
|
||||
height: 64px;
|
||||
width: max(80px, max(menu-settings-label.preferred-width,
|
||||
max(menu-messages-label.preferred-width + (root.announcement-unread > 0 ? menu-message-badge.width + 8px : 0px),
|
||||
menu-about-label.preferred-width + (root.update-available ? menu-update-badge.width + 8px : 0px))) + 24px);
|
||||
height: 93px;
|
||||
background: white;
|
||||
border-width: 1px;
|
||||
border-color: ImGuiLineStyle.border;
|
||||
@@ -1117,7 +1301,7 @@ export component MainWindow inherits Window {
|
||||
accessible-label: UiStrings.settings;
|
||||
accessible-action-default => { settings-touch.clicked(); }
|
||||
menu-settings-label := Text {
|
||||
x: 9px;
|
||||
x: 7px;
|
||||
height: parent.height;
|
||||
text: UiStrings.settings;
|
||||
color: #2d3138;
|
||||
@@ -1129,6 +1313,47 @@ export component MainWindow inherits Window {
|
||||
clicked => { root.menu-open = false; root.settings-open = true; }
|
||||
}
|
||||
}
|
||||
menu-messages := Rectangle {
|
||||
height: 27px;
|
||||
background: messages-touch.pressed ? #d8dadd
|
||||
: messages-touch.has-hover ? #f0f1f2 : white;
|
||||
border-radius: 3px;
|
||||
accessible-role: button;
|
||||
accessible-label: MessageStrings.messages + (root.announcement-unread > 0 ? " · " + MessageStrings.unread + " " + root.announcement-unread : "");
|
||||
accessible-action-default => { messages-touch.clicked(); }
|
||||
menu-messages-label := Text {
|
||||
x: 7px;
|
||||
height: parent.height;
|
||||
text: MessageStrings.messages;
|
||||
color: #2d3138;
|
||||
vertical-alignment: center;
|
||||
accessible-role: none;
|
||||
}
|
||||
menu-message-badge := Rectangle {
|
||||
visible: root.announcement-unread > 0;
|
||||
x: parent.width - self.width - 7px;
|
||||
y: (parent.height - self.height) / 2;
|
||||
width: max(18px, menu-message-count.preferred-width + 10px);
|
||||
height: 18px;
|
||||
border-radius: 9px;
|
||||
background: #fee4e2;
|
||||
accessible-role: none;
|
||||
menu-message-count := Text {
|
||||
text: root.announcement-unread-label;
|
||||
font-size: 10px; font-weight: 600; color: #b42318;
|
||||
horizontal-alignment: center; vertical-alignment: center;
|
||||
accessible-role: none;
|
||||
}
|
||||
}
|
||||
messages-touch := TouchArea {
|
||||
mouse-cursor: pointer;
|
||||
clicked => {
|
||||
root.menu-open = false;
|
||||
root.messages-open = true;
|
||||
root.refresh-announcements();
|
||||
}
|
||||
}
|
||||
}
|
||||
menu-about := Rectangle {
|
||||
height: 27px;
|
||||
background: about-touch.pressed ? #d8dadd
|
||||
@@ -1137,28 +1362,31 @@ export component MainWindow inherits Window {
|
||||
border-width: 0px;
|
||||
border-radius: 3px;
|
||||
accessible-role: button;
|
||||
accessible-label: UiStrings.about;
|
||||
accessible-label: UiStrings.about + (root.update-available ? " · " + UiStrings.update-available : "");
|
||||
accessible-action-default => { about-touch.clicked(); }
|
||||
menu-about-label := Text {
|
||||
x: 9px;
|
||||
x: 7px;
|
||||
height: parent.height;
|
||||
text: UiStrings.about;
|
||||
color: #2d3138;
|
||||
vertical-alignment: center;
|
||||
accessible-role: none;
|
||||
}
|
||||
if root.update-available && root.update-icon-blink-on: Text {
|
||||
x: menu-about-label.x + menu-about-label.preferred-width + 7px;
|
||||
width: 18px;
|
||||
height: parent.height;
|
||||
text: FontAwesomeIcons.circle-arrow-up;
|
||||
color: #2d3138;
|
||||
font-family: "Font Awesome 6 Free";
|
||||
font-weight: 900;
|
||||
font-size: 16px;
|
||||
horizontal-alignment: center;
|
||||
vertical-alignment: center;
|
||||
menu-update-badge := Rectangle {
|
||||
visible: root.update-available;
|
||||
x: parent.width - self.width - 7px;
|
||||
y: (parent.height - self.height) / 2;
|
||||
width: menu-update-label.preferred-width + 12px;
|
||||
height: 18px;
|
||||
border-radius: 9px;
|
||||
background: #dbeafe;
|
||||
accessible-role: none;
|
||||
menu-update-label := Text {
|
||||
text: UiStrings.new-version;
|
||||
font-size: 11px; font-weight: 400; color: #1d4ed8;
|
||||
horizontal-alignment: center; vertical-alignment: center;
|
||||
accessible-role: none;
|
||||
}
|
||||
}
|
||||
about-touch := TouchArea {
|
||||
mouse-cursor: pointer;
|
||||
@@ -1174,6 +1402,40 @@ export component MainWindow inherits Window {
|
||||
background: ImGuiLineStyle.separator;
|
||||
z: 1;
|
||||
}
|
||||
Rectangle {
|
||||
x: 4px;
|
||||
y: 60.5px;
|
||||
width: parent.width - 8px;
|
||||
height: 1px;
|
||||
background: ImGuiLineStyle.separator;
|
||||
z: 1;
|
||||
}
|
||||
}
|
||||
|
||||
if root.messages-open: Rectangle {
|
||||
background: #00000042;
|
||||
z: 40;
|
||||
TouchArea { clicked => { focus-sink.focus(); } }
|
||||
MessagePanel {
|
||||
x: (parent.width - self.width) / 2;
|
||||
y: (parent.height - self.height) / 2;
|
||||
width: 520px;
|
||||
height: 330px;
|
||||
items: root.announcements;
|
||||
unread-count: root.announcement-unread; total: root.announcement-total;
|
||||
loading: root.announcement-loading; failed: root.announcement-failed; loaded: root.announcement-loaded;
|
||||
read-save-failed: root.announcement-read-save-failed;
|
||||
delete-failed: root.announcement-delete-failed;
|
||||
connected: root.signal-connected;
|
||||
selected-id: root.announcement-selected-id; selected-title: root.announcement-selected-title;
|
||||
selected-body: root.announcement-selected-body; selected-date: root.announcement-selected-date;
|
||||
selected-read: root.announcement-selected-read;
|
||||
open-message(id, revision) => { root.open-announcement(id, revision); }
|
||||
open-link(url) => { root.open-announcement-link(url); }
|
||||
delete-message(id, revision) => { root.delete-announcement(id, revision); }
|
||||
load-more => { root.load-more-announcements(); }
|
||||
close => { root.messages-open = false; }
|
||||
}
|
||||
}
|
||||
|
||||
copy-timer := Timer {
|
||||
@@ -1493,21 +1755,6 @@ export component MainWindow inherits Window {
|
||||
Text { x: 8px; y: 3px; width: root.compact-language ? 94px : 162px; height: 24px; text: UiStrings.capture-method; font-size: ImGuiFontStyle.body; overflow: elide; vertical-alignment: center; }
|
||||
CompactComboBox { x: root.compact-language ? 106px : 174px; y: 2px; enabled: !root.settings-session-active; model: [UiStrings.capture-method-auto, "DXGI", "WGC", "GDI"]; current-index <=> root.capture-method-index; }
|
||||
}
|
||||
Rectangle {
|
||||
height: 30px;
|
||||
Text { x: 8px; y: 3px; width: root.compact-language ? 94px : 162px; height: 24px; text: UiStrings.video-quality; font-size: ImGuiFontStyle.body; overflow: elide; vertical-alignment: center; }
|
||||
CompactComboBox { x: root.compact-language ? 106px : 174px; y: 2px; enabled: !root.settings-session-active; model: [UiStrings.quality-low, UiStrings.quality-medium, UiStrings.quality-high]; current-index <=> root.video-quality-index; }
|
||||
}
|
||||
Rectangle {
|
||||
height: 30px;
|
||||
Text { x: 8px; y: 3px; width: root.compact-language ? 94px : 162px; height: 24px; text: UiStrings.frame-rate; font-size: ImGuiFontStyle.body; overflow: elide; vertical-alignment: center; }
|
||||
CompactComboBox { x: root.compact-language ? 106px : 174px; y: 2px; enabled: !root.settings-session-active; model: ["30 fps", "60 fps"]; current-index <=> root.frame-rate-index; }
|
||||
}
|
||||
Rectangle {
|
||||
height: 30px;
|
||||
Text { x: 8px; y: 3px; width: root.compact-language ? 94px : 162px; height: 24px; text: UiStrings.adaptation-policy; font-size: ImGuiFontStyle.body; overflow: elide; vertical-alignment: center; }
|
||||
CompactComboBox { x: root.compact-language ? 91px : 160px; y: 2px; width: root.compact-language ? 88px : 87px; enabled: !root.settings-session-active; model: [UiStrings.priority-frame-rate, UiStrings.priority-quality, UiStrings.priority-balanced]; current-index <=> root.adaptation-policy-index; }
|
||||
}
|
||||
Rectangle {
|
||||
height: 30px;
|
||||
Text { x: 8px; y: 3px; width: root.compact-language ? 94px : 162px; height: 24px; text: UiStrings.codec; font-size: ImGuiFontStyle.body; overflow: elide; vertical-alignment: center; }
|
||||
@@ -1528,6 +1775,11 @@ export component MainWindow inherits Window {
|
||||
Text { x: 8px; y: 3px; width: root.compact-language ? 145px : 212px; height: 24px; text: UiStrings.force-relay; color: root.turn-enabled ? #202124 : #8b8f95; font-size: ImGuiFontStyle.body; overflow: elide; vertical-alignment: center; }
|
||||
ImGuiCheckBox { x: root.compact-language ? 157px : 224px; y: 3px; checked <=> root.force-relay-enabled; enabled: root.turn-enabled && !root.settings-session-active; }
|
||||
}
|
||||
Rectangle {
|
||||
height: 30px;
|
||||
Text { x: 8px; y: 3px; width: root.compact-language ? 145px : 212px; height: 24px; text: UiStrings.save-remote-previews; font-size: ImGuiFontStyle.body; overflow: elide; vertical-alignment: center; }
|
||||
ImGuiCheckBox { x: root.compact-language ? 157px : 224px; y: 3px; checked <=> root.save-remote-previews; }
|
||||
}
|
||||
Rectangle {
|
||||
height: 30px;
|
||||
Text { x: 8px; y: 3px; width: root.compact-language ? 145px : 212px; height: 24px; text: UiStrings.privacy-on-connect; color: root.privacy-setting-available ? #202124 : #8b8f95; font-size: ImGuiFontStyle.body; overflow: elide; vertical-alignment: center; }
|
||||
@@ -1621,10 +1873,10 @@ export component MainWindow inherits Window {
|
||||
z: 46;
|
||||
TouchArea { clicked => { focus-sink.focus(); } }
|
||||
DialogSurface {
|
||||
x: 170px;
|
||||
y: 127px;
|
||||
width: 300px;
|
||||
height: 196px;
|
||||
x: (root.width - self.width) / 2;
|
||||
y: (root.height - self.height) / 2;
|
||||
width: 320px;
|
||||
height: 216px;
|
||||
border-radius: 7px;
|
||||
|
||||
Rectangle {
|
||||
@@ -1638,19 +1890,81 @@ export component MainWindow inherits Window {
|
||||
Rectangle { y: parent.height - 7px; height: 7px; background: #d3d3d3; }
|
||||
Text { x: 6px; text: UiStrings.about; font-size: ImGuiFontStyle.base; font-weight: 600; color: #202124; vertical-alignment: center; }
|
||||
}
|
||||
Text { x: 30px; y: 40px; width: 240px; height: 20px; text: UiStrings.version + ": CrossDesk " + root.current-version; color: #202124; font-size: ImGuiFontStyle.body; vertical-alignment: center; }
|
||||
if root.update-available: Text { x: 30px; y: 60px; width: 240px; height: 20px; text: UiStrings.update-available + ":"; color: #202124; font-size: ImGuiFontStyle.body; vertical-alignment: center; }
|
||||
if root.update-available: Rectangle {
|
||||
x: 30px;
|
||||
y: 60px;
|
||||
width: 240px;
|
||||
height: 20px;
|
||||
version-link := Text { text: root.latest-version; color: version-touch.has-hover ? #174ea6 : #334fd1; font-size: ImGuiFontStyle.body; horizontal-alignment: center; vertical-alignment: center; }
|
||||
version-touch := TouchArea { mouse-cursor: pointer; clicked => { root.open-download(); } }
|
||||
// Keep the dialog/footer stable while visible text rows determine
|
||||
// each group's size and the layout redistributes the space between.
|
||||
about-content := VerticalLayout {
|
||||
padding: 16px;
|
||||
padding-top: 35px;
|
||||
spacing: 12px;
|
||||
alignment: space-between;
|
||||
|
||||
VerticalLayout {
|
||||
vertical-stretch: 0;
|
||||
spacing: 4px;
|
||||
Text {
|
||||
text: "CrossDesk " + root.current-version;
|
||||
color: #202124;
|
||||
font-size: ImGuiFontStyle.base;
|
||||
font-weight: 600;
|
||||
min-height: 18px;
|
||||
overflow: elide;
|
||||
}
|
||||
if root.update-available: Rectangle {
|
||||
height: max(18px, version-link.preferred-height);
|
||||
accessible-role: button;
|
||||
accessible-label: UiStrings.release-notes + " " + root.latest-version;
|
||||
accessible-action-default => { version-touch.clicked(); }
|
||||
version-link := Text {
|
||||
x: 0px;
|
||||
y: 0px;
|
||||
width: parent.width;
|
||||
height: parent.height;
|
||||
text: UiStrings.update-available + ": " + root.latest-version;
|
||||
color: version-touch.has-hover ? #174ea6 : #334fd1;
|
||||
font-size: ImGuiFontStyle.body;
|
||||
vertical-alignment: center;
|
||||
horizontal-alignment: left;
|
||||
wrap: word-wrap;
|
||||
}
|
||||
version-touch := TouchArea {
|
||||
mouse-cursor: pointer;
|
||||
clicked => { root.about-open = false; root.update-open = true; }
|
||||
}
|
||||
}
|
||||
if root.update-status-text != "": Text {
|
||||
text: root.update-status-text;
|
||||
color: root.update-check-status == UpdateCheckStatus.failed ? #b3261e : #61656c;
|
||||
font-size: ImGuiFontStyle.body;
|
||||
min-height: 18px;
|
||||
wrap: word-wrap;
|
||||
}
|
||||
}
|
||||
|
||||
VerticalLayout {
|
||||
vertical-stretch: 0;
|
||||
spacing: 3px;
|
||||
Rectangle { height: 1px; background: #e6e8eb; }
|
||||
Text { height: 16px; text: UiStrings.copyright; color: #61656c; font-size: ImGuiFontStyle.body; vertical-alignment: center; }
|
||||
Text { height: 16px; text: UiStrings.license; color: #61656c; font-size: ImGuiFontStyle.body; vertical-alignment: center; }
|
||||
}
|
||||
|
||||
HorizontalLayout {
|
||||
vertical-stretch: 0;
|
||||
alignment: end;
|
||||
spacing: 8px;
|
||||
CompactButton {
|
||||
width: max(76px, check-button-metrics.preferred-width + 20px);
|
||||
text: UiStrings.check-for-updates;
|
||||
enabled: root.update-check-status != UpdateCheckStatus.checking;
|
||||
accessible-role: button;
|
||||
accessible-label: self.text;
|
||||
accessible-action-default => { if self.enabled { root.check-for-updates(); } }
|
||||
clicked => { root.check-for-updates(); }
|
||||
check-button-metrics := Text { visible: false; text: UiStrings.check-for-updates; font-size: ImGuiFontStyle.body; }
|
||||
}
|
||||
CompactButton { width: 48px; primary: true; text: UiStrings.ok; clicked => { root.about-open = false; } }
|
||||
}
|
||||
}
|
||||
Text { x: 30px; y: 85px; width: 240px; height: 20px; text: UiStrings.copyright; color: #202124; font-size: ImGuiFontStyle.body; vertical-alignment: center; }
|
||||
Text { x: 30px; y: 105px; width: 240px; height: 20px; text: UiStrings.license; color: #202124; font-size: ImGuiFontStyle.body; vertical-alignment: center; }
|
||||
CompactButton { x: 133px; y: 145px; primary: true; text: UiStrings.ok; clicked => { root.about-open = false; } }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1729,6 +2043,105 @@ export component MainWindow inherits Window {
|
||||
}
|
||||
}
|
||||
|
||||
// Linux Wayland sessions show this advisory once per application launch.
|
||||
if root.wayland-notice-open: Rectangle {
|
||||
background: #14233b66;
|
||||
z: 50;
|
||||
TouchArea { clicked => { focus-sink.focus(); } }
|
||||
DialogSurface {
|
||||
x: (parent.width - self.width) / 2;
|
||||
y: (parent.height - self.height) / 2;
|
||||
width: 512px;
|
||||
height: wayland-notice-content.preferred-height;
|
||||
border-radius: 14px;
|
||||
border-color: #dce3ee;
|
||||
drop-shadow-color: #07142b30;
|
||||
drop-shadow-blur: 28px;
|
||||
drop-shadow-offset-y: 10px;
|
||||
|
||||
wayland-notice-content := VerticalLayout {
|
||||
padding: 24px; spacing: 18px;
|
||||
VerticalLayout {
|
||||
spacing: 7px;
|
||||
Text {
|
||||
text: UiStrings.wayland-notice-title;
|
||||
color: #1c2b43;
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
wrap: word-wrap;
|
||||
}
|
||||
Text {
|
||||
text: UiStrings.wayland-notice-subtitle;
|
||||
color: #68758a;
|
||||
font-size: ImGuiFontStyle.base;
|
||||
wrap: word-wrap;
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
background: #fff8eb;
|
||||
border-color: #f0dfba;
|
||||
border-width: 1px;
|
||||
border-radius: 9px;
|
||||
VerticalLayout {
|
||||
padding: 14px; spacing: 8px;
|
||||
Text {
|
||||
text: UiStrings.wayland-permission-title;
|
||||
color: #805719;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
wrap: word-wrap;
|
||||
}
|
||||
Text {
|
||||
text: UiStrings.wayland-notice-message;
|
||||
color: #6c5a3d;
|
||||
font-size: ImGuiFontStyle.base;
|
||||
wrap: word-wrap;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
background: #f3f7fd;
|
||||
border-color: #dfe8f5;
|
||||
border-width: 1px;
|
||||
border-radius: 9px;
|
||||
VerticalLayout {
|
||||
padding: 14px; spacing: 8px;
|
||||
Text {
|
||||
text: UiStrings.wayland-recommendation-title;
|
||||
color: #244c87;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
wrap: word-wrap;
|
||||
}
|
||||
Text {
|
||||
text: UiStrings.wayland-recommendation-message;
|
||||
color: #59677c;
|
||||
font-size: ImGuiFontStyle.base;
|
||||
wrap: word-wrap;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle { height: 1px; background: #e9edf3; }
|
||||
HorizontalLayout {
|
||||
height: 34px; alignment: end;
|
||||
PrimaryButton {
|
||||
width: 104px; height: 34px;
|
||||
border-radius: 7px;
|
||||
text-size: ImGuiFontStyle.base;
|
||||
text: UiStrings.wayland-notice-acknowledge;
|
||||
accessible-role: button;
|
||||
accessible-label: UiStrings.wayland-notice-acknowledge;
|
||||
accessible-action-default => { root.wayland-notice-open = false; }
|
||||
clicked => { root.wayland-notice-open = false; }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Portable Windows builds prompt for the optional lock-screen service.
|
||||
if root.portable-service-dialog-open: Rectangle {
|
||||
background: #00000052;
|
||||
|
||||
@@ -0,0 +1,249 @@
|
||||
import { ScrollView } from "std-widgets.slint";
|
||||
import { CompactButton, DialogSurface, IconButton, FontAwesomeIcons, ImGuiFontStyle } from "common.slint";
|
||||
|
||||
export struct AnnouncementItem {
|
||||
id: string,
|
||||
revision: string,
|
||||
title: string,
|
||||
date: string,
|
||||
read: bool,
|
||||
}
|
||||
|
||||
export global MessageStrings {
|
||||
in-out property <string> messages: "Messages";
|
||||
in-out property <string> unread: "Unread";
|
||||
in-out property <string> read: "Read";
|
||||
in-out property <string> empty: "No announcements";
|
||||
in-out property <string> select-message: "Select an announcement to read";
|
||||
in-out property <string> loading: "Loading…";
|
||||
in-out property <string> read-save-error: "Unable to save reading status locally. Open the announcement again to retry.";
|
||||
in-out property <string> close: "Close";
|
||||
in-out property <string> delete-message: "Delete";
|
||||
in-out property <string> delete-title: "Delete announcement";
|
||||
in-out property <string> delete-confirmation: "Delete this announcement?";
|
||||
in-out property <string> delete-error: "Unable to delete this announcement. Please try again.";
|
||||
in-out property <string> cancel: "Cancel";
|
||||
}
|
||||
|
||||
export component MessagePanel inherits DialogSurface {
|
||||
in property <[AnnouncementItem]> items;
|
||||
in property <int> unread-count;
|
||||
in property <int> total;
|
||||
in property <bool> loading;
|
||||
in property <bool> failed;
|
||||
in property <bool> read-save-failed;
|
||||
in property <bool> delete-failed;
|
||||
in property <bool> loaded;
|
||||
in property <bool> connected;
|
||||
in property <string> selected-id;
|
||||
in property <string> selected-title;
|
||||
in property <styled-text> selected-body;
|
||||
in property <string> selected-date;
|
||||
in property <bool> selected-read;
|
||||
callback open-message(string, string);
|
||||
callback open-link(string);
|
||||
callback delete-message(string, string);
|
||||
callback load-more;
|
||||
callback close;
|
||||
private property <string> notice-text: root.delete-failed ? MessageStrings.delete-error
|
||||
: root.read-save-failed ? MessageStrings.read-save-error : "";
|
||||
private property <length> content-top: title-bar.height + (root.notice-text == "" ? 14px : 50px);
|
||||
private property <color> separator-color: #e6e8eb;
|
||||
private property <string> pending-delete-id;
|
||||
private property <string> pending-delete-revision;
|
||||
function request-delete(id: string, revision: string) {
|
||||
root.pending-delete-id = id;
|
||||
root.pending-delete-revision = revision;
|
||||
confirmation-keys.focus();
|
||||
}
|
||||
confirmation-keys := FocusScope {
|
||||
width: 0px; height: 0px;
|
||||
key-pressed(event) => {
|
||||
if event.text == Key.Escape {
|
||||
if root.pending-delete-id != "" { root.pending-delete-id = ""; }
|
||||
else { root.close(); }
|
||||
return accept;
|
||||
}
|
||||
return reject;
|
||||
}
|
||||
}
|
||||
background: white;
|
||||
border-radius: 7px;
|
||||
TouchArea { }
|
||||
title-bar := Rectangle {
|
||||
x: 0px; y: 0px; width: parent.width; height: 21px;
|
||||
background: #d3d3d3; border-radius: 7px;
|
||||
Rectangle { y: parent.height - 7px; height: 7px; background: #d3d3d3; }
|
||||
message-heading := Text {
|
||||
x: 6px; width: parent.width - 100px; height: parent.height;
|
||||
text: MessageStrings.messages;
|
||||
font-size: ImGuiFontStyle.base; font-weight: 600; color: #202124;
|
||||
vertical-alignment: center; overflow: elide;
|
||||
}
|
||||
if root.unread-count > 0: Rectangle {
|
||||
x: message-heading.x + message-heading.preferred-width + 8px;
|
||||
y: (parent.height - self.height) / 2; height: 16px;
|
||||
width: max(16px, unread-count-label.preferred-width + 8px);
|
||||
border-radius: 8px; background: #eaf2ff;
|
||||
accessible-role: text;
|
||||
accessible-label: MessageStrings.unread + " " + root.unread-count;
|
||||
unread-count-label := Text {
|
||||
text: "" + root.unread-count; color: #2563eb;
|
||||
font-size: ImGuiFontStyle.small; font-weight: 600;
|
||||
horizontal-alignment: center; vertical-alignment: center;
|
||||
accessible-role: none;
|
||||
}
|
||||
}
|
||||
if root.loading: Text {
|
||||
x: parent.width - 150px; width: 110px; height: parent.height;
|
||||
text: MessageStrings.loading; color: #61656c; font-size: ImGuiFontStyle.small;
|
||||
horizontal-alignment: right; vertical-alignment: center;
|
||||
}
|
||||
IconButton {
|
||||
x: parent.width - 25px; y: 2px; width: 19px; height: parent.height - 4px;
|
||||
border-radius: 3px;
|
||||
icon: FontAwesomeIcons.xmark; icon-size: ImGuiFontStyle.base;
|
||||
icon-color: #202124;
|
||||
hover-background: #bfc2c7; pressed-background: #b2b2b2;
|
||||
accessible-role: button;
|
||||
accessible-label: MessageStrings.close;
|
||||
accessible-action-default => { root.close(); }
|
||||
clicked => { root.close(); }
|
||||
}
|
||||
}
|
||||
if root.notice-text != "": Rectangle {
|
||||
x: 16px; y: title-bar.height + 10px; width: parent.width - 32px; height: 28px;
|
||||
border-radius: 4px;
|
||||
background: #fef2f2;
|
||||
Text {
|
||||
x: 8px; width: parent.width - 16px; height: parent.height;
|
||||
text: root.notice-text; font-size: ImGuiFontStyle.body; overflow: elide;
|
||||
color: #b3261e;
|
||||
vertical-alignment: center;
|
||||
}
|
||||
}
|
||||
announcement-list := Rectangle {
|
||||
x: 16px; y: root.content-top; width: 168px; height: parent.height - self.y - 16px;
|
||||
announcements-scroll := ScrollView {
|
||||
width: parent.width; height: parent.height;
|
||||
VerticalLayout {
|
||||
width: announcements-scroll.width; spacing: 0px; padding: 0px;
|
||||
padding-right: 8px;
|
||||
alignment: start;
|
||||
for item[index] in root.items: Rectangle {
|
||||
height: 54px;
|
||||
background: root.selected-id == item.id ? #eaf2ff : entry-touch.has-hover ? #f0f1f2 : transparent;
|
||||
border-radius: 4px;
|
||||
Text { x: 8px; y: 6px; width: parent.width - 32px; height: 18px; text: item.title; overflow: elide; font-size: ImGuiFontStyle.base; font-weight: item.read ? 400 : 600; color: #202124; vertical-alignment: center; }
|
||||
if !item.read: Rectangle {
|
||||
x: parent.width - 17px; y: 13px; width: 5px; height: 5px;
|
||||
border-radius: 2.5px; background: #2563eb;
|
||||
accessible-role: none;
|
||||
}
|
||||
Text { x: 8px; y: 28px; width: parent.width - 36px; height: 16px; text: item.date; color: #61656c; font-size: ImGuiFontStyle.small; overflow: elide; vertical-alignment: center; }
|
||||
accessible-role: button;
|
||||
accessible-label: item.title + " " + (item.read ? MessageStrings.read : MessageStrings.unread);
|
||||
accessible-action-default => { root.open-message(item.id, item.revision); }
|
||||
entry-touch := TouchArea { mouse-cursor: pointer; clicked => { root.open-message(item.id, item.revision); } }
|
||||
IconButton {
|
||||
x: parent.width - 24px; y: 26px; width: 20px; height: 20px;
|
||||
icon: FontAwesomeIcons.xmark; icon-size: ImGuiFontStyle.small;
|
||||
icon-color: #61656c; danger: true;
|
||||
accessible-role: button;
|
||||
accessible-label: MessageStrings.delete-message + " " + item.title;
|
||||
accessible-action-default => { root.request-delete(item.id, item.revision); }
|
||||
clicked => { root.request-delete(item.id, item.revision); }
|
||||
}
|
||||
if index < root.items.length - 1: Rectangle {
|
||||
x: 8px; y: parent.height - 1px;
|
||||
width: parent.width - 16px; height: 1px;
|
||||
background: root.separator-color;
|
||||
accessible-role: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if root.items.length == 0 && root.total == 0 && root.loaded && root.connected && !root.loading && !root.failed: Text {
|
||||
x: 16px; y: root.content-top; width: parent.width - 32px; height: announcement-list.height;
|
||||
text: MessageStrings.empty; wrap: word-wrap;
|
||||
color: #61656c; font-size: ImGuiFontStyle.body;
|
||||
horizontal-alignment: center; vertical-alignment: center;
|
||||
}
|
||||
content-divider := Rectangle {
|
||||
x: announcement-list.x + announcement-list.width + 12px;
|
||||
y: root.content-top; width: 1px; height: announcement-list.height;
|
||||
background: root.separator-color;
|
||||
visible: root.items.length > 0 || root.selected-id != "";
|
||||
accessible-role: none;
|
||||
}
|
||||
announcement-details := Rectangle {
|
||||
x: content-divider.x + content-divider.width + 12px;
|
||||
y: root.content-top; width: parent.width - self.x - 16px; height: announcement-list.height;
|
||||
if root.selected-id == "" && root.items.length > 0: Text { x: 8px; width: parent.width - 16px; text: MessageStrings.select-message; color: #61656c; font-size: ImGuiFontStyle.body; wrap: word-wrap; horizontal-alignment: center; vertical-alignment: center; }
|
||||
if root.selected-id != "": details-scroll := ScrollView {
|
||||
width: parent.width; height: parent.height;
|
||||
VerticalLayout {
|
||||
width: details-scroll.width; padding: 8px; padding-top: 6px; padding-right: 16px;
|
||||
spacing: 12px; alignment: start;
|
||||
VerticalLayout {
|
||||
vertical-stretch: 0; spacing: 4px;
|
||||
Text { vertical-stretch: 0; min-height: 18px; text: root.selected-title; wrap: word-wrap; font-size: ImGuiFontStyle.base; font-weight: 600; color: #202124; vertical-alignment: center; }
|
||||
Text { vertical-stretch: 0; min-height: 16px; text: root.selected-date; accessible-label: root.selected-date + " · " + (root.selected-read ? MessageStrings.read : MessageStrings.unread); color: #61656c; font-size: ImGuiFontStyle.small; wrap: word-wrap; vertical-alignment: center; }
|
||||
}
|
||||
Rectangle { height: 1px; background: root.separator-color; }
|
||||
StyledText {
|
||||
vertical-stretch: 0;
|
||||
text: root.selected-body;
|
||||
default-color: #202124; default-font-size: ImGuiFontStyle.body;
|
||||
link-color: #2563eb;
|
||||
link-clicked(url) => { root.open-link(url); }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Timer {
|
||||
interval: 100ms;
|
||||
running: root.connected && !root.loading && !root.failed
|
||||
&& root.pending-delete-id == "" && root.items.length > 0
|
||||
&& root.items.length < root.total && announcements-scroll.viewport-height > 0px
|
||||
&& announcements-scroll.viewport-height + announcements-scroll.viewport-y <= announcements-scroll.height + 80px;
|
||||
triggered => { root.load-more(); }
|
||||
}
|
||||
if root.pending-delete-id != "": Rectangle {
|
||||
background: #00000042; border-radius: root.border-radius; z: 30;
|
||||
TouchArea { clicked => { confirmation-keys.focus(); } }
|
||||
DialogSurface {
|
||||
x: (parent.width - self.width) / 2;
|
||||
y: (parent.height - self.height) / 2;
|
||||
width: 230px; height: confirmation-content.preferred-height;
|
||||
border-radius: root.border-radius;
|
||||
TouchArea { clicked => { confirmation-keys.focus(); } }
|
||||
confirmation-content := VerticalLayout {
|
||||
padding: 16px;
|
||||
padding-top: 26px;
|
||||
padding-bottom: 26px;
|
||||
spacing: 20px;
|
||||
Text { text: MessageStrings.delete-confirmation; font-size: ImGuiFontStyle.base; color: #202124; wrap: word-wrap; horizontal-alignment: center; }
|
||||
HorizontalLayout {
|
||||
alignment: center; spacing: 8px;
|
||||
cancel-delete-button := CompactButton {
|
||||
width: 72px; text: MessageStrings.cancel;
|
||||
accessible-role: button; accessible-label: MessageStrings.cancel;
|
||||
accessible-action-default => { cancel-delete-button.clicked(); }
|
||||
clicked => { root.pending-delete-id = ""; }
|
||||
}
|
||||
confirm-delete-button := CompactButton {
|
||||
width: 72px; text: MessageStrings.delete-message; primary: true;
|
||||
accessible-role: button; accessible-label: MessageStrings.delete-title;
|
||||
accessible-action-default => { confirm-delete-button.clicked(); }
|
||||
clicked => {
|
||||
root.delete-message(root.pending-delete-id, root.pending-delete-revision);
|
||||
root.pending-delete-id = "";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ComboBox, ScrollView } from "std-widgets.slint";
|
||||
import { FontAwesomeIcons, IconButton, PrimaryButton, ImGuiLineStyle, ImGuiFontStyle } from "common.slint";
|
||||
import { FontAwesomeIcons, IconButton, PrimaryButton, DialogSurface, ImGuiLineStyle, ImGuiFontStyle } from "common.slint";
|
||||
|
||||
export struct StreamTab {
|
||||
remote-id: string,
|
||||
@@ -23,6 +23,21 @@ export struct NetworkStatsRow {
|
||||
}
|
||||
|
||||
export global StreamStrings {
|
||||
in-out property <string> remote-update-title: "Remote device needs an update";
|
||||
in-out property <string> remote-update: "Please update to the latest version.";
|
||||
in-out property <string> remote-update-dismiss: "Got it";
|
||||
in-out property <string> video-settings: "Video settings";
|
||||
in-out property <string> video-settings-unavailable: "The remote device does not support live video settings";
|
||||
in-out property <string> video-quality: "Video quality";
|
||||
in-out property <string> frame-rate: "Capture frame rate";
|
||||
in-out property <string> adaptation-policy: "Video preference";
|
||||
in-out property <string> quality-low: "Low";
|
||||
in-out property <string> quality-medium: "Medium";
|
||||
in-out property <string> quality-high: "High";
|
||||
in-out property <string> priority-frame-rate: "Frame rate";
|
||||
in-out property <string> priority-quality: "Quality";
|
||||
in-out property <string> priority-balanced: "Balanced";
|
||||
|
||||
in-out property <string> select-display: "Select Display";
|
||||
in-out property <string> send-shortcut: "Send Shortcut";
|
||||
in-out property <string> privacy-enable: "Enable privacy screen";
|
||||
@@ -44,6 +59,8 @@ export global StreamStrings {
|
||||
in-out property <string> stats-out: "Out";
|
||||
in-out property <string> stats-loss-rate: "Loss Rate";
|
||||
in-out property <string> stats-resolution: "Res";
|
||||
in-out property <string> stats-video-latency: "Video delay";
|
||||
in-out property <string> stats-connection-latency: "Connection RTT";
|
||||
in-out property <string> stats-connection-mode: "Mode";
|
||||
in-out property <string> stats-encryption: "Encryption";
|
||||
in-out property <string> stats-encryption-enabled: "Enabled";
|
||||
@@ -103,23 +120,21 @@ component ControlBarButton inherits Rectangle {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
// Match the two offset diagonal strokes used by the ImGui mouse/audio
|
||||
// disabled state instead of covering the glyph with a full-width slash.
|
||||
if root.slashed: Rectangle {
|
||||
x: 11px;
|
||||
y: 0px;
|
||||
width: 2px;
|
||||
height: 24px;
|
||||
background: black;
|
||||
transform-rotation: -45deg;
|
||||
// Draw the two offset disabled-state strokes as paths. Rotated rectangles
|
||||
// stay vertical in the software renderer used for fallback and UI capture.
|
||||
if root.slashed: Path {
|
||||
width: 24px; height: 24px;
|
||||
viewbox-width: 24; viewbox-height: 24;
|
||||
commands: "M 3.5 3.5 L 20.5 20.5";
|
||||
stroke: black;
|
||||
stroke-width: 2px;
|
||||
}
|
||||
if root.slashed: Rectangle {
|
||||
x: 9.5px;
|
||||
y: 1.5px;
|
||||
width: 2px;
|
||||
height: 24px;
|
||||
background: touch.has-hover ? #4296fa : #b3d5fd;
|
||||
transform-rotation: -45deg;
|
||||
if root.slashed: Path {
|
||||
width: 24px; height: 24px;
|
||||
viewbox-width: 24; viewbox-height: 24;
|
||||
commands: "M 2 5 L 19 22";
|
||||
stroke: touch.has-hover ? #4296fa : #b3d5fd;
|
||||
stroke-width: 2px;
|
||||
}
|
||||
|
||||
touch := TouchArea {
|
||||
@@ -128,6 +143,35 @@ component ControlBarButton inherits Rectangle {
|
||||
}
|
||||
}
|
||||
|
||||
component VideoSettingRow inherits VerticalLayout {
|
||||
in property <string> label;
|
||||
in property <[string]> options;
|
||||
in property <int> selected;
|
||||
in property <bool> enabled;
|
||||
callback choose(int);
|
||||
callback pressed;
|
||||
spacing: 4px;
|
||||
Text { text: root.label; color: #5c626c; font-size: 11px; height: 18px; }
|
||||
HorizontalLayout {
|
||||
spacing: 4px;
|
||||
for option[index] in root.options: Rectangle {
|
||||
height: 27px;
|
||||
background: !root.enabled ? #f1f1f1 : root.selected == index ? #d7eaff
|
||||
: option-touch.has-hover ? #eaf3ff : #f5f6f8;
|
||||
border-radius: 4px;
|
||||
border-width: 1px;
|
||||
border-color: root.enabled && root.selected == index ? #4296fa : #dce0e6;
|
||||
Text { text: option; color: root.enabled ? #30343b : #8b8f95;
|
||||
font-size: 11px; horizontal-alignment: center; vertical-alignment: center; }
|
||||
option-touch := TouchArea {
|
||||
pointer-event(event) => { if event.kind == PointerEventKind.down { root.pressed(); } }
|
||||
clicked => { if root.enabled { root.choose(index); } }
|
||||
scroll-event(event) => { accept }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export component StreamWindow inherits Window {
|
||||
title: "CrossDesk";
|
||||
icon: @image-url("../../../resources/linux/crossdesk_256x256.png");
|
||||
@@ -155,6 +199,8 @@ export component StreamWindow inherits Window {
|
||||
in property <bool> has-frame: false;
|
||||
in property <string> status-text: "";
|
||||
in property <string> receiving-text: "";
|
||||
in property <bool> remote-update-visible: false;
|
||||
callback dismiss-remote-update;
|
||||
in property <[string]> displays;
|
||||
in-out property <int> selected-display: 0;
|
||||
in property <bool> mouse-control-enabled: true;
|
||||
@@ -168,11 +214,19 @@ export component StreamWindow inherits Window {
|
||||
in property <bool> remote-cursor-active: false;
|
||||
in property <int> remote-cursor-shape: 0;
|
||||
in property <bool> audio-enabled: true;
|
||||
in property <bool> video-settings-enabled: false;
|
||||
in property <string> video-settings-feedback: "";
|
||||
in-out property <int> video-quality-index: 2;
|
||||
in-out property <int> frame-rate-index: 1;
|
||||
in-out property <int> adaptation-policy-index: 1;
|
||||
callback change-video-settings(int, int, int);
|
||||
in property <bool> fullscreen-enabled: false;
|
||||
in property <bool> stats-visible: false;
|
||||
in property <[NetworkStatsRow]> stats-rows;
|
||||
in property <string> stats-fps: "0";
|
||||
in property <string> stats-resolution: "";
|
||||
in property <string> stats-video-latency: "—";
|
||||
in property <string> stats-connection-latency: "—";
|
||||
in property <string> stats-connection-mode: "";
|
||||
in property <bool> stats-encryption-enabled: false;
|
||||
in property <bool> file-transfer-visible: false;
|
||||
@@ -208,21 +262,40 @@ export component StreamWindow inherits Window {
|
||||
private property <length> stats-row-height: 18px;
|
||||
private property <length> stats-header-height: 18px;
|
||||
private property <length> stats-content-height:
|
||||
root.stats-header-height + root.stats-rows.length * root.stats-row-height + 44px;
|
||||
root.stats-header-height + root.stats-rows.length * root.stats-row-height + 65px;
|
||||
private property <bool> display-menu-open: false;
|
||||
private property <bool> display-menu-animation-enabled: false;
|
||||
private property <float> display-menu-progress: 0.0;
|
||||
private property <bool> shortcut-menu-open: false;
|
||||
in-out property <bool> video-menu-open: false;
|
||||
private property <length> video-menu-width: min(320px, video.width - 8px);
|
||||
private property <length> video-menu-height: root.video-settings-enabled && root.video-settings-feedback == "" ? 182px : 220px;
|
||||
private property <length> video-menu-x: min(max(4px, control.x + video-button.x), video.width - root.video-menu-width - 4px);
|
||||
private property <length> video-menu-y: control.y + root.control-toolbar-height + root.video-menu-height <= video.height
|
||||
? control.y + root.control-toolbar-height - 1px : max(0px, control.y - root.video-menu-height);
|
||||
changed selected-tab => { root.video-menu-open = false; }
|
||||
changed control-expanded => { if !root.control-expanded { root.video-menu-open = false; } }
|
||||
changed window-active => { if !root.window-active { root.video-menu-open = false; } }
|
||||
changed remote-update-visible => {
|
||||
if root.remote-update-visible {
|
||||
root.video-menu-open = false;
|
||||
root.display-menu-open = false;
|
||||
root.shortcut-menu-open = false;
|
||||
}
|
||||
input-focus.focus();
|
||||
root.keyboard-focus-changed(!root.remote-update-visible);
|
||||
}
|
||||
private property <bool> control-tooltip-visible: false;
|
||||
private property <ControlBarTooltip> hovered-control: {
|
||||
if root.window-active && !root.control-dragging
|
||||
&& !root.display-menu-open && !root.shortcut-menu-open {
|
||||
&& !root.display-menu-open && !root.shortcut-menu-open && !root.video-menu-open {
|
||||
if collapse-button.has-hover { return collapse-button.tooltip-info; }
|
||||
if root.control-expanded {
|
||||
if display-button.has-hover { return display-button.tooltip-info; }
|
||||
if shortcut-button.has-hover { return shortcut-button.tooltip-info; }
|
||||
if mouse-button.has-hover { return mouse-button.tooltip-info; }
|
||||
if privacy-button.has-hover { return privacy-button.tooltip-info; }
|
||||
if video-button.has-hover { return video-button.tooltip-info; }
|
||||
if audio-button.has-hover { return audio-button.tooltip-info; }
|
||||
if file-button.has-hover { return file-button.tooltip-info; }
|
||||
if stats-button.has-hover { return stats-button.tooltip-info; }
|
||||
@@ -286,6 +359,7 @@ export component StreamWindow inherits Window {
|
||||
}
|
||||
|
||||
is-local-control-area(pointer-x, pointer-y) => {
|
||||
if root.remote-update-visible { return true; }
|
||||
let over-control = pointer-x >= control.x
|
||||
&& pointer-x <= control.x + control.width
|
||||
&& pointer-y >= control.y
|
||||
@@ -305,7 +379,10 @@ export component StreamWindow inherits Window {
|
||||
&& pointer-x <= shortcut-menu-x + 180px
|
||||
&& pointer-y >= shortcut-menu-y
|
||||
&& pointer-y <= shortcut-menu-y + 68px;
|
||||
return over-control || over-display-menu || over-shortcut-menu;
|
||||
let over-video-menu = root.video-menu-open
|
||||
&& pointer-x >= root.video-menu-x && pointer-x <= root.video-menu-x + root.video-menu-width
|
||||
&& pointer-y >= root.video-menu-y && pointer-y <= root.video-menu-y + root.video-menu-height;
|
||||
return over-control || over-display-menu || over-shortcut-menu || over-video-menu;
|
||||
}
|
||||
|
||||
begin-control-drag(press-x, press-y) => {
|
||||
@@ -324,6 +401,7 @@ export component StreamWindow inherits Window {
|
||||
root.display-menu-progress = 0.0;
|
||||
root.display-menu-open = false;
|
||||
root.shortcut-menu-open = false;
|
||||
root.video-menu-open = false;
|
||||
root.control-dragging = true;
|
||||
}
|
||||
move-control-drag(pointer-x, pointer-y) => {
|
||||
@@ -592,9 +670,11 @@ export component StreamWindow inherits Window {
|
||||
|
||||
drop-zone := DropArea {
|
||||
can-drop(event) => {
|
||||
return root.can-drop-file(event.data) ? DragAction.copy : DragAction.none;
|
||||
return !root.remote-update-visible && root.can-drop-file(event.data)
|
||||
? DragAction.copy : DragAction.none;
|
||||
}
|
||||
dropped(event) => {
|
||||
if root.remote-update-visible { return DragAction.none; }
|
||||
root.file-dropped(event.data);
|
||||
return DragAction.copy;
|
||||
}
|
||||
@@ -605,11 +685,20 @@ export component StreamWindow inherits Window {
|
||||
focus-gained => { root.keyboard-focus-changed(true); }
|
||||
focus-lost => { root.keyboard-focus-changed(false); }
|
||||
key-pressed(event) => {
|
||||
root.key-input(event.text, true, event.modifiers.control, event.modifiers.alt, event.modifiers.shift, event.modifiers.meta);
|
||||
if !root.remote-update-visible {
|
||||
root.key-input(event.text, true, event.modifiers.control, event.modifiers.alt, event.modifiers.shift, event.modifiers.meta);
|
||||
}
|
||||
accept
|
||||
}
|
||||
key-released(event) => {
|
||||
root.key-input(event.text, false, event.modifiers.control, event.modifiers.alt, event.modifiers.shift, event.modifiers.meta);
|
||||
if root.remote-update-visible {
|
||||
// Dismiss on release so the same key cannot reach the peer.
|
||||
if event.text == Key.Escape || event.text == Key.Return {
|
||||
root.dismiss-remote-update();
|
||||
}
|
||||
} else {
|
||||
root.key-input(event.text, false, event.modifiers.control, event.modifiers.alt, event.modifiers.shift, event.modifiers.meta);
|
||||
}
|
||||
accept
|
||||
}
|
||||
pointer := TouchArea {
|
||||
@@ -660,7 +749,12 @@ export component StreamWindow inherits Window {
|
||||
root.suppress-next-remote-pointer-release = false;
|
||||
input-focus.focus();
|
||||
}
|
||||
if event.kind == PointerEventKind.down
|
||||
// A gesture started before the modal appeared keeps
|
||||
// pointer capture. Let its release reach the peer.
|
||||
if root.remote-update-visible && !root.control-dragging
|
||||
&& event.kind == PointerEventKind.up {
|
||||
root.pointer-input(event.button, event.kind, self.mouse-x, self.mouse-y);
|
||||
} else if event.kind == PointerEventKind.down
|
||||
&& event.button == PointerEventButton.left
|
||||
&& over-control {
|
||||
root.begin-control-drag(self.mouse-x, self.mouse-y);
|
||||
@@ -697,7 +791,7 @@ export component StreamWindow inherits Window {
|
||||
control := Rectangle {
|
||||
x: root.control-dragging ? root.control-drag-x : root.control-docked-left ? 0px : parent.width - self.width;
|
||||
y: min(max(0px, root.control-pos-y), parent.height - self.height);
|
||||
width: root.control-expanded ? min(76px + 8 * root.control-button-step, parent.width) : 20px;
|
||||
width: root.control-expanded ? min(76px + 9 * root.control-button-step, parent.width) : 20px;
|
||||
height: root.stats-visible && root.control-expanded
|
||||
? min(root.control-toolbar-height + root.stats-content-height + 8px, parent.height)
|
||||
: root.control-toolbar-height;
|
||||
@@ -729,6 +823,7 @@ export component StreamWindow inherits Window {
|
||||
root.display-menu-progress = 0.0;
|
||||
root.display-menu-open = !root.display-menu-open;
|
||||
root.shortcut-menu-open = false;
|
||||
root.video-menu-open = false;
|
||||
if root.display-menu-open {
|
||||
display-menu-open-timer.running = true;
|
||||
}
|
||||
@@ -744,6 +839,7 @@ export component StreamWindow inherits Window {
|
||||
root.display-menu-progress = 0.0;
|
||||
root.shortcut-menu-open = !root.shortcut-menu-open;
|
||||
root.display-menu-open = false;
|
||||
root.video-menu-open = false;
|
||||
}
|
||||
}
|
||||
mouse-button := ControlBarButton {
|
||||
@@ -766,23 +862,34 @@ export component StreamWindow inherits Window {
|
||||
icon: FontAwesomeIcons.folder-open; tooltip: StreamStrings.select-file;
|
||||
clicked => { root.select-file(); }
|
||||
}
|
||||
stats-button := ControlBarButton {
|
||||
video-button := ControlBarButton {
|
||||
visible: root.control-expanded;
|
||||
x: (root.control-docked-left ? 9px : 42px) + 6 * root.control-button-step; y: 7px;
|
||||
icon: "\u{f1de}"; selected: root.video-menu-open;
|
||||
tooltip: StreamStrings.video-settings;
|
||||
clicked => {
|
||||
root.video-menu-open = !root.video-menu-open;
|
||||
root.display-menu-open = false;
|
||||
root.shortcut-menu-open = false;
|
||||
}
|
||||
}
|
||||
stats-button := ControlBarButton {
|
||||
visible: root.control-expanded;
|
||||
x: (root.control-docked-left ? 9px : 42px) + 7 * root.control-button-step; y: 7px;
|
||||
icon: FontAwesomeIcons.signal; selected: root.stats-visible;
|
||||
tooltip: root.stats-visible ? StreamStrings.hide-stats : StreamStrings.show-stats;
|
||||
clicked => { root.toggle-stats(); }
|
||||
}
|
||||
fullscreen-button := ControlBarButton {
|
||||
visible: root.control-expanded;
|
||||
x: (root.control-docked-left ? 9px : 42px) + 7 * root.control-button-step; y: 7px;
|
||||
x: (root.control-docked-left ? 9px : 42px) + 8 * root.control-button-step; y: 7px;
|
||||
icon: root.fullscreen-enabled ? FontAwesomeIcons.compress : FontAwesomeIcons.expand;
|
||||
tooltip: root.fullscreen-enabled ? StreamStrings.exit-fullscreen : StreamStrings.fullscreen;
|
||||
clicked => { root.toggle-fullscreen(); }
|
||||
}
|
||||
close-button := ControlBarButton {
|
||||
visible: root.control-expanded;
|
||||
x: (root.control-docked-left ? 9px : 42px) + 8 * root.control-button-step; y: 7px;
|
||||
x: (root.control-docked-left ? 9px : 42px) + 9 * root.control-button-step; y: 7px;
|
||||
icon: FontAwesomeIcons.xmark; tooltip: StreamStrings.disconnect;
|
||||
clicked => { root.disconnect(); }
|
||||
}
|
||||
@@ -831,18 +938,22 @@ export component StreamWindow inherits Window {
|
||||
private property <length> details-right-width:
|
||||
right-label-width-probe.preferred-width + 6px
|
||||
+ max(resolution-width-probe.preferred-width,
|
||||
connection-latency-width-probe.preferred-width,
|
||||
encryption-width-probe.preferred-width
|
||||
+ (root.stats-encryption-enabled ? 14px : 0px));
|
||||
private property <length> details-right-x: self.width / 2;
|
||||
private property <length> details-right-x:
|
||||
min(self.width / 2, self.width - self.details-right-width - 6px);
|
||||
|
||||
left-label-width-probe := Text {
|
||||
visible: false;
|
||||
text: "FPS\n" + StreamStrings.stats-connection-mode;
|
||||
text: "FPS\n" + StreamStrings.stats-video-latency
|
||||
+ "\n" + StreamStrings.stats-connection-mode;
|
||||
font-size: ImGuiFontStyle.small;
|
||||
}
|
||||
right-label-width-probe := Text {
|
||||
visible: false;
|
||||
text: StreamStrings.stats-resolution + "\n" + StreamStrings.stats-encryption;
|
||||
text: StreamStrings.stats-resolution + "\n" + StreamStrings.stats-connection-latency
|
||||
+ "\n" + StreamStrings.stats-encryption;
|
||||
font-size: ImGuiFontStyle.small;
|
||||
}
|
||||
resolution-width-probe := Text {
|
||||
@@ -851,6 +962,12 @@ export component StreamWindow inherits Window {
|
||||
font-size: ImGuiFontStyle.body;
|
||||
font-weight: 600;
|
||||
}
|
||||
connection-latency-width-probe := Text {
|
||||
visible: false;
|
||||
text: root.stats-connection-latency;
|
||||
font-size: ImGuiFontStyle.body;
|
||||
font-weight: 600;
|
||||
}
|
||||
encryption-width-probe := Text {
|
||||
visible: false;
|
||||
text: root.stats-encryption-enabled
|
||||
@@ -957,12 +1074,14 @@ export component StreamWindow inherits Window {
|
||||
for metric[index] in [
|
||||
{ label: "FPS", value: root.stats-fps },
|
||||
{ label: StreamStrings.stats-resolution, value: root.stats-resolution },
|
||||
{ label: StreamStrings.stats-video-latency, value: root.stats-video-latency },
|
||||
{ label: StreamStrings.stats-connection-latency, value: root.stats-connection-latency },
|
||||
{ label: StreamStrings.stats-connection-mode, value: root.stats-connection-mode },
|
||||
{ label: StreamStrings.stats-encryption, value: root.stats-encryption-enabled
|
||||
? StreamStrings.stats-encryption-enabled : StreamStrings.stats-encryption-disabled },
|
||||
]: Rectangle {
|
||||
private property <length> encryption-icon-width:
|
||||
index == 3 && root.stats-encryption-enabled ? 14px : 0px;
|
||||
index == 5 && root.stats-encryption-enabled ? 14px : 0px;
|
||||
x: mod(index, 2) == 1 ? stats-table.details-right-x : 6px;
|
||||
y: root.stats-header-height + root.stats-rows.length * stats-table.row-height
|
||||
+ 2px + floor(index / 2) * 21px;
|
||||
@@ -986,14 +1105,14 @@ export component StreamWindow inherits Window {
|
||||
height: parent.height;
|
||||
width: parent.width - self.x - parent.encryption-icon-width;
|
||||
text: metric.value;
|
||||
color: index == 3 && root.stats-encryption-enabled ? #18794e : #253247;
|
||||
color: index == 5 && root.stats-encryption-enabled ? #18794e : #253247;
|
||||
font-size: ImGuiFontStyle.body;
|
||||
font-weight: 600;
|
||||
horizontal-alignment: left;
|
||||
vertical-alignment: center;
|
||||
overflow: elide;
|
||||
}
|
||||
if index == 3 && root.stats-encryption-enabled: Text {
|
||||
if index == 5 && root.stats-encryption-enabled: Text {
|
||||
x: metric-value.x + min(metric-value.preferred-width, metric-value.width) + 4px;
|
||||
y: metric-value.y + (metric-value.height - self.height) / 2;
|
||||
width: 10px;
|
||||
@@ -1010,6 +1129,59 @@ export component StreamWindow inherits Window {
|
||||
}
|
||||
}
|
||||
|
||||
// While the menu is open, outside gestures dismiss it locally.
|
||||
// Keep the backdrop through pointer-up; otherwise Slint's implicit
|
||||
// pointer move or release can reach the remote input surface.
|
||||
if root.video-menu-open: Rectangle {
|
||||
width: parent.width; height: parent.height; z: 9;
|
||||
TouchArea {
|
||||
pointer-event(event) => {
|
||||
if event.kind == PointerEventKind.down {
|
||||
root.suppress-next-remote-pointer-release = true;
|
||||
}
|
||||
}
|
||||
clicked => { root.video-menu-open = false; }
|
||||
scroll-event(event) => { accept }
|
||||
}
|
||||
}
|
||||
if root.video-menu-open: Rectangle {
|
||||
x: root.video-menu-x; y: root.video-menu-y;
|
||||
width: root.video-menu-width; height: root.video-menu-height;
|
||||
background: white; border-width: 1px; border-color: ImGuiLineStyle.border;
|
||||
border-radius: 5px; z: 11;
|
||||
TouchArea {
|
||||
pointer-event(event) => { if event.kind == PointerEventKind.down { root.suppress-next-remote-pointer-release = true; } }
|
||||
scroll-event(event) => { accept }
|
||||
}
|
||||
VerticalLayout {
|
||||
padding: 10px; spacing: 8px;
|
||||
VideoSettingRow {
|
||||
label: StreamStrings.video-quality;
|
||||
options: [StreamStrings.quality-low, StreamStrings.quality-medium, StreamStrings.quality-high];
|
||||
selected: root.video-quality-index; enabled: root.video-settings-enabled;
|
||||
pressed => { root.suppress-next-remote-pointer-release = true; }
|
||||
choose(index) => { root.change-video-settings(index, root.frame-rate-index, root.adaptation-policy-index); }
|
||||
}
|
||||
VideoSettingRow {
|
||||
label: StreamStrings.frame-rate; options: ["30 fps", "60 fps"];
|
||||
selected: root.frame-rate-index; enabled: root.video-settings-enabled;
|
||||
pressed => { root.suppress-next-remote-pointer-release = true; }
|
||||
choose(index) => { root.change-video-settings(root.video-quality-index, index, root.adaptation-policy-index); }
|
||||
}
|
||||
VideoSettingRow {
|
||||
label: StreamStrings.adaptation-policy;
|
||||
options: [StreamStrings.priority-frame-rate, StreamStrings.priority-quality, StreamStrings.priority-balanced];
|
||||
selected: root.adaptation-policy-index; enabled: root.video-settings-enabled;
|
||||
pressed => { root.suppress-next-remote-pointer-release = true; }
|
||||
choose(index) => { root.change-video-settings(root.video-quality-index, root.frame-rate-index, index); }
|
||||
}
|
||||
if !root.video-settings-enabled || root.video-settings-feedback != "": Text {
|
||||
text: root.video-settings-enabled ? root.video-settings-feedback : StreamStrings.video-settings-unavailable; wrap: word-wrap;
|
||||
color: #8b8f95; font-size: 11px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if root.display-menu-open: display-menu := Rectangle {
|
||||
private property <length> expanded-height: min(180px, root.displays.length * 30px + 8px);
|
||||
private property <int> hovered-index:
|
||||
@@ -1131,6 +1303,80 @@ export component StreamWindow inherits Window {
|
||||
}
|
||||
}
|
||||
|
||||
if root.remote-update-visible: Rectangle {
|
||||
width: parent.width; height: parent.height;
|
||||
background: #0c162c52;
|
||||
z: 13;
|
||||
TouchArea {
|
||||
mouse-cursor: default;
|
||||
scroll-event(event) => { accept }
|
||||
}
|
||||
}
|
||||
remote-update-banner := DialogSurface {
|
||||
visible: root.remote-update-visible;
|
||||
x: (parent.width - self.width) / 2;
|
||||
y: min(max(64px, parent.height * 0.18), max(16px, parent.height - self.height - 16px));
|
||||
width: min(520px, parent.width - 48px);
|
||||
height: max(208px, remote-update-message.y + remote-update-message.height + 92px);
|
||||
border-color: #e5e9f0;
|
||||
border-radius: 14px;
|
||||
drop-shadow-color: #07142b30;
|
||||
drop-shadow-blur: 32px;
|
||||
drop-shadow-offset-y: 12px;
|
||||
z: 14;
|
||||
TouchArea {
|
||||
mouse-cursor: default;
|
||||
pointer-event(event) => {
|
||||
if event.kind == PointerEventKind.down {
|
||||
root.suppress-next-remote-pointer-release = true;
|
||||
}
|
||||
}
|
||||
scroll-event(event) => { accept }
|
||||
}
|
||||
Rectangle {
|
||||
x: 32px; y: 32px; width: 48px; height: 48px;
|
||||
background: #edf4ff; border-radius: 12px;
|
||||
Path {
|
||||
x: 12px; y: 12px; width: 24px; height: 24px;
|
||||
viewbox-width: 24; viewbox-height: 24;
|
||||
commands: "M 12 16 L 12 3 M 6.5 8.5 L 12 3 L 17.5 8.5 M 4 15 L 4 20 Q 4 21 5 21 L 19 21 Q 20 21 20 20 L 20 15";
|
||||
fill: transparent;
|
||||
stroke: #2463c7; stroke-width: 1.8px;
|
||||
}
|
||||
}
|
||||
remote-update-title := Text {
|
||||
x: 100px; y: 34px; width: parent.width - 144px;
|
||||
height: max(28px, self.preferred-height);
|
||||
text: StreamStrings.remote-update-title;
|
||||
color: #1b2433; font-size: 20px; font-weight: 600;
|
||||
wrap: word-wrap;
|
||||
}
|
||||
remote-update-message := Text {
|
||||
x: remote-update-title.x;
|
||||
y: remote-update-title.y + remote-update-title.height + 8px;
|
||||
width: remote-update-title.width;
|
||||
height: max(22px, self.preferred-height);
|
||||
text: StreamStrings.remote-update;
|
||||
color: #667085; font-size: 15px;
|
||||
wrap: word-wrap;
|
||||
}
|
||||
IconButton {
|
||||
x: parent.width - 44px; y: 12px; width: 32px; height: 32px;
|
||||
icon: FontAwesomeIcons.xmark; icon-size: 14px;
|
||||
icon-color: #8a93a3;
|
||||
hover-background: #f2f4f7;
|
||||
clicked => { root.dismiss-remote-update(); }
|
||||
}
|
||||
PrimaryButton {
|
||||
x: parent.width - 136px; y: parent.height - 68px;
|
||||
width: 104px; height: 36px;
|
||||
border-radius: 7px;
|
||||
text: StreamStrings.remote-update-dismiss;
|
||||
text-size: 14px;
|
||||
clicked => { root.dismiss-remote-update(); }
|
||||
}
|
||||
}
|
||||
|
||||
// Transfer progress overlay at bottom-left.
|
||||
if root.file-transfer-visible: Rectangle {
|
||||
x: 18px; y: parent.height - min(210px, parent.height * 0.35); width: min(430px, parent.width * 0.6); height: min(190px, parent.height * 0.32);
|
||||
|
||||
@@ -26,6 +26,21 @@ inline int ApplyMainWindowStrings(
|
||||
const int language =
|
||||
localization::detail::ClampLanguageIndex(language_index);
|
||||
auto& strings = window->global<ui::UiStrings>();
|
||||
auto& messages = window->global<ui::MessageStrings>();
|
||||
messages.set_messages(Text(localization::messages[language]));
|
||||
messages.set_unread(Text(localization::message_unread[language]));
|
||||
messages.set_read(Text(localization::message_read[language]));
|
||||
messages.set_empty(Text(localization::message_empty[language]));
|
||||
messages.set_select_message(Text(localization::message_select[language]));
|
||||
messages.set_loading(Text(localization::message_loading[language]));
|
||||
messages.set_read_save_error(Text(localization::message_read_save_error[language]));
|
||||
messages.set_close(Text(localization::message_close[language]));
|
||||
messages.set_delete_message(Text(localization::message_delete[language]));
|
||||
messages.set_delete_title(Text(localization::message_delete_title[language]));
|
||||
messages.set_delete_confirmation(Text(localization::message_delete_confirmation[language]));
|
||||
messages.set_delete_error(Text(localization::message_delete_error[language]));
|
||||
messages.set_cancel(Text(localization::cancel[language]));
|
||||
|
||||
strings.set_local_desktop(Text(localization::local_desktop[language]));
|
||||
strings.set_remote_desktop(Text(localization::remote_desktop[language]));
|
||||
strings.set_recent_connections(
|
||||
@@ -53,19 +68,9 @@ inline int ApplyMainWindowStrings(
|
||||
strings.set_confirm_delete(
|
||||
Text(localization::confirm_delete_connection[language]));
|
||||
strings.set_language(Text(localization::language[language]));
|
||||
strings.set_video_quality(Text(localization::video_quality[language]));
|
||||
strings.set_frame_rate(Text(localization::video_frame_rate[language]));
|
||||
strings.set_capture_method(Text(localization::screen_capture_method[language]));
|
||||
strings.set_capture_method_auto(
|
||||
Text(localization::screen_capture_method_auto[language]));
|
||||
strings.set_adaptation_policy(
|
||||
Text(localization::video_adaptation_policy[language]));
|
||||
strings.set_priority_frame_rate(
|
||||
Text(localization::video_priority_frame_rate[language]));
|
||||
strings.set_priority_quality(
|
||||
Text(localization::video_priority_quality[language]));
|
||||
strings.set_priority_balanced(
|
||||
Text(localization::video_priority_balanced[language]));
|
||||
strings.set_codec(Text(localization::video_encode_format[language]));
|
||||
strings.set_hardware_codec(
|
||||
Text(localization::enable_hardware_video_codec[language]));
|
||||
@@ -75,6 +80,8 @@ inline int ApplyMainWindowStrings(
|
||||
Text(localization::self_hosted_server_config[language]));
|
||||
strings.set_autostart(Text(localization::enable_autostart[language]));
|
||||
strings.set_daemon(Text(localization::enable_daemon[language]));
|
||||
strings.set_save_remote_previews(Text(localization::save_remote_previews[language]));
|
||||
strings.set_unknown_platform(Text(localization::unknown_platform[language]));
|
||||
strings.set_privacy_on_connect(Text(localization::privacy_on_connect[language]));
|
||||
strings.set_file_save_path(
|
||||
Text(localization::file_transfer_save_path[language]));
|
||||
@@ -90,6 +97,13 @@ inline int ApplyMainWindowStrings(
|
||||
strings.set_tls_error(Text(localization::signal_tls_cert_error[language]));
|
||||
strings.set_update_available(
|
||||
Text(localization::new_version_available[language]));
|
||||
strings.set_new_version(Text(localization::new_version[language]));
|
||||
strings.set_check_for_updates(Text(localization::check_for_updates[language]));
|
||||
strings.set_checking_for_updates(
|
||||
Text(localization::checking_for_updates[language]));
|
||||
strings.set_up_to_date(Text(localization::up_to_date[language]));
|
||||
strings.set_update_check_failed(
|
||||
Text(localization::update_check_failed[language]));
|
||||
strings.set_release_notes(Text(localization::release_notes[language]));
|
||||
strings.set_download(Text(localization::update[language]));
|
||||
strings.set_input_password(Text(localization::input_password[language]));
|
||||
@@ -106,6 +120,20 @@ inline int ApplyMainWindowStrings(
|
||||
Text(localization::screen_recording_permission[language]));
|
||||
strings.set_accessibility_permission(
|
||||
Text(localization::accessibility_permission[language]));
|
||||
strings.set_wayland_notice_title(
|
||||
Text(localization::wayland_notice_title[language]));
|
||||
strings.set_wayland_notice_subtitle(
|
||||
Text(localization::wayland_notice_subtitle[language]));
|
||||
strings.set_wayland_permission_title(
|
||||
Text(localization::wayland_permission_title[language]));
|
||||
strings.set_wayland_notice_message(
|
||||
Text(localization::wayland_notice_message[language]));
|
||||
strings.set_wayland_recommendation_title(
|
||||
Text(localization::wayland_recommendation_title[language]));
|
||||
strings.set_wayland_recommendation_message(
|
||||
Text(localization::wayland_recommendation_message[language]));
|
||||
strings.set_wayland_notice_acknowledge(
|
||||
Text(localization::wayland_notice_acknowledge[language]));
|
||||
strings.set_service_setup_title(
|
||||
Text(localization::windows_service_setup_title[language]));
|
||||
strings.set_service_setup_message(
|
||||
@@ -121,10 +149,6 @@ inline int ApplyMainWindowStrings(
|
||||
strings.set_notification(Text(localization::notification[language]));
|
||||
strings.set_service_suppressed_message(
|
||||
Text(localization::windows_service_prompt_suppressed_message[language]));
|
||||
strings.set_quality_low(Text(localization::video_quality_low[language]));
|
||||
strings.set_quality_medium(
|
||||
Text(localization::video_quality_medium[language]));
|
||||
strings.set_quality_high(Text(localization::video_quality_high[language]));
|
||||
strings.set_codec_h264(Text(localization::h264[language]));
|
||||
strings.set_codec_av1(Text(localization::av1[language]));
|
||||
strings.set_self_hosted_settings(
|
||||
@@ -141,12 +165,32 @@ inline int ApplyStreamWindowStrings(
|
||||
const int language =
|
||||
localization::detail::ClampLanguageIndex(language_index);
|
||||
auto& strings = window->template global<ui::StreamStrings>();
|
||||
strings.set_remote_update_title(Text(localization::remote_update_title[language]));
|
||||
strings.set_remote_update(Text(localization::remote_update[language]));
|
||||
strings.set_remote_update_dismiss(Text(localization::remote_update_dismiss[language]));
|
||||
strings.set_select_display(Text(localization::select_display[language]));
|
||||
strings.set_send_shortcut(Text(localization::send_shortcut[language]));
|
||||
strings.set_privacy_enable(Text(localization::privacy_enable[language]));
|
||||
strings.set_privacy_disable(Text(localization::privacy_disable[language]));
|
||||
strings.set_control_mouse(Text(localization::control_mouse[language]));
|
||||
strings.set_release_mouse(Text(localization::release_mouse[language]));
|
||||
strings.set_video_settings(Text(localization::video_settings[language]));
|
||||
strings.set_video_settings_unavailable(
|
||||
Text(localization::video_settings_unavailable[language]));
|
||||
strings.set_video_quality(Text(localization::video_quality[language]));
|
||||
strings.set_frame_rate(Text(localization::video_frame_rate[language]));
|
||||
strings.set_adaptation_policy(
|
||||
Text(localization::video_adaptation_policy[language]));
|
||||
strings.set_quality_low(Text(localization::video_quality_low[language]));
|
||||
strings.set_quality_medium(
|
||||
Text(localization::video_quality_medium[language]));
|
||||
strings.set_quality_high(Text(localization::video_quality_high[language]));
|
||||
strings.set_priority_frame_rate(
|
||||
Text(localization::video_priority_frame_rate[language]));
|
||||
strings.set_priority_quality(
|
||||
Text(localization::video_priority_quality[language]));
|
||||
strings.set_priority_balanced(
|
||||
Text(localization::video_priority_balanced[language]));
|
||||
strings.set_audio(Text(localization::audio_capture[language]));
|
||||
strings.set_mute(Text(localization::mute[language]));
|
||||
strings.set_select_file(Text(localization::select_file[language]));
|
||||
@@ -166,6 +210,8 @@ inline int ApplyStreamWindowStrings(
|
||||
strings.set_stats_out(Text(localization::out[language]));
|
||||
strings.set_stats_loss_rate(Text(localization::loss_rate[language]));
|
||||
strings.set_stats_resolution(Text(localization::resolution[language]));
|
||||
strings.set_stats_video_latency(Text(localization::video_latency[language]));
|
||||
strings.set_stats_connection_latency(Text(localization::connection_latency[language]));
|
||||
strings.set_stats_connection_mode(
|
||||
Text(localization::connection_mode[language]));
|
||||
strings.set_stats_encryption(
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
#include "rd_log.h"
|
||||
|
||||
#include <filesystem>
|
||||
#include <mutex>
|
||||
#include <vector>
|
||||
|
||||
#include "spdlog/sinks/rotating_file_sink.h"
|
||||
#include "spdlog/sinks/stdout_color_sinks.h"
|
||||
|
||||
namespace crossdesk {
|
||||
|
||||
namespace {
|
||||
|
||||
std::string g_log_dir = "logs";
|
||||
std::string g_logger_name = LOGGER_NAME;
|
||||
std::once_flag g_logger_once_flag;
|
||||
std::mutex g_logger_mutex;
|
||||
std::shared_ptr<spdlog::logger> g_logger;
|
||||
@@ -20,20 +26,20 @@ std::string NormalizeLogDirectory(const std::string& log_dir) {
|
||||
|
||||
} // namespace
|
||||
|
||||
void InitLogger(const std::string& log_dir) {
|
||||
void InitLogger(const std::string& log_dir, const std::string& logger_name) {
|
||||
const auto directory = NormalizeLogDirectory(log_dir);
|
||||
std::lock_guard<std::mutex> lock(g_logger_mutex);
|
||||
if (g_logger) {
|
||||
if (directory != g_log_dir) {
|
||||
if (directory != g_log_dir || logger_name != g_logger_name) {
|
||||
g_logger->warn(
|
||||
"InitLogger called after logger initialized. Ignoring log_dir: {}, "
|
||||
"using previous log_dir: {}",
|
||||
directory, g_log_dir);
|
||||
"InitLogger called after logger initialized. Keeping logger {} in {}",
|
||||
g_logger_name, g_log_dir);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
g_log_dir = directory;
|
||||
g_logger_name = logger_name;
|
||||
}
|
||||
|
||||
std::shared_ptr<spdlog::logger> get_logger() {
|
||||
@@ -44,32 +50,23 @@ std::shared_ptr<spdlog::logger> get_logger() {
|
||||
std::error_code ec;
|
||||
std::filesystem::create_directories(g_log_dir, ec);
|
||||
|
||||
auto now = std::chrono::system_clock::now() + std::chrono::hours(8);
|
||||
auto now_time = std::chrono::system_clock::to_time_t(now);
|
||||
|
||||
std::tm tm_info;
|
||||
#ifdef _WIN32
|
||||
gmtime_s(&tm_info, &now_time);
|
||||
#else
|
||||
gmtime_r(&now_time, &tm_info);
|
||||
#endif
|
||||
|
||||
std::stringstream ss;
|
||||
ss << LOGGER_NAME;
|
||||
ss << std::put_time(&tm_info, "-%Y%m%d-%H%M%S.log");
|
||||
|
||||
std::string filename = g_log_dir + "/" + ss.str();
|
||||
// Reuse the same rotation set across restarts. Timestamped base names
|
||||
// give each launch its own backups and let the directory grow forever.
|
||||
auto file_sink = std::make_shared<spdlog::sinks::rotating_file_sink_mt>(
|
||||
g_log_dir + "/" + g_logger_name + ".log", 5 * 1024 * 1024, 3);
|
||||
|
||||
std::vector<spdlog::sink_ptr> sinks;
|
||||
sinks.push_back(std::make_shared<spdlog::sinks::stdout_color_sink_mt>());
|
||||
sinks.push_back(std::make_shared<spdlog::sinks::rotating_file_sink_mt>(
|
||||
filename, 5 * 1024 * 1024, 3));
|
||||
sinks.push_back(file_sink);
|
||||
|
||||
auto logger = std::make_shared<spdlog::logger>(LOGGER_NAME, sinks.begin(),
|
||||
auto logger = std::make_shared<spdlog::logger>(g_logger_name, sinks.begin(),
|
||||
sinks.end());
|
||||
logger->set_level(SPDLOG_ACTIVE_LEVEL <= SPDLOG_LEVEL_DEBUG
|
||||
? spdlog::level::debug
|
||||
: spdlog::level::info);
|
||||
logger->flush_on(spdlog::level::info);
|
||||
spdlog::register_logger(logger);
|
||||
logger->info("Logger initialized: path={}", filename);
|
||||
logger->info("Logger initialized: path={}", file_sink->filename());
|
||||
g_logger = std::move(logger);
|
||||
});
|
||||
|
||||
|
||||
@@ -7,35 +7,29 @@
|
||||
#ifndef _RD_LOG_H_
|
||||
#define _RD_LOG_H_
|
||||
|
||||
#include <chrono>
|
||||
#include <iomanip>
|
||||
#include <iostream>
|
||||
#include <memory>
|
||||
#include <mutex>
|
||||
#include <sstream>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "spdlog/common.h"
|
||||
#include "spdlog/logger.h"
|
||||
#include "spdlog/sinks/base_sink.h"
|
||||
#include "spdlog/sinks/rotating_file_sink.h"
|
||||
#include "spdlog/sinks/stdout_color_sinks.h"
|
||||
#include "spdlog/spdlog.h"
|
||||
|
||||
#ifndef SPDLOG_ACTIVE_LEVEL
|
||||
#define SPDLOG_ACTIVE_LEVEL SPDLOG_LEVEL_INFO
|
||||
#endif
|
||||
|
||||
#include "spdlog/spdlog.h"
|
||||
|
||||
namespace crossdesk {
|
||||
|
||||
constexpr auto LOGGER_NAME = "crossdesk";
|
||||
|
||||
void InitLogger(const std::string& log_dir);
|
||||
void InitLogger(const std::string& log_dir,
|
||||
const std::string& logger_name = LOGGER_NAME);
|
||||
|
||||
std::shared_ptr<spdlog::logger> get_logger();
|
||||
|
||||
#define LOG_TRACE(...) SPDLOG_LOGGER_TRACE(get_logger(), __VA_ARGS__)
|
||||
#define LOG_DEBUG(...) SPDLOG_LOGGER_DEBUG(get_logger(), __VA_ARGS__)
|
||||
#define LOG_INFO(...) SPDLOG_LOGGER_INFO(get_logger(), __VA_ARGS__)
|
||||
#define LOG_WARN(...) SPDLOG_LOGGER_WARN(get_logger(), __VA_ARGS__)
|
||||
#define LOG_ERROR(...) SPDLOG_LOGGER_ERROR(get_logger(), __VA_ARGS__)
|
||||
#define LOG_FATAL(...) SPDLOG_LOGGER_CRITICAL(get_logger(), __VA_ARGS__)
|
||||
} // namespace crossdesk
|
||||
#endif
|
||||
#endif
|
||||
|
||||
@@ -59,6 +59,7 @@ struct FrameSlot {
|
||||
int height = 0;
|
||||
std::string remote_id;
|
||||
uint64_t sequence = 0;
|
||||
VideoLatencyFrame timing;
|
||||
SlotUse use = SlotUse::available;
|
||||
bool valid = false;
|
||||
};
|
||||
@@ -320,6 +321,7 @@ struct OpenGlVideoRenderer::Impl {
|
||||
bool unpack_subimage_available = false;
|
||||
std::string uploaded_stream;
|
||||
uint64_t uploaded_sequence = 0;
|
||||
VideoLatencyFrame uploaded_timing;
|
||||
|
||||
#if CROSSDESK_OPENGL_CUDA_INTEROP
|
||||
enum class CudaUploadResult {
|
||||
@@ -1023,25 +1025,22 @@ void OpenGlVideoRenderer::DiscardStream(std::string_view remote_id) {
|
||||
}
|
||||
}
|
||||
|
||||
OpenGlVideoRenderer::SubmitResult
|
||||
OpenGlVideoRenderer::SubmitNv12(std::string_view remote_id,
|
||||
const uint8_t *data, size_t size,
|
||||
int width, int height) {
|
||||
return SubmitNv12Internal(remote_id, data, size, width, height, true);
|
||||
OpenGlVideoRenderer::SubmitResult OpenGlVideoRenderer::SubmitNv12(
|
||||
std::string_view remote_id, const uint8_t *data, size_t size, int width,
|
||||
int height, VideoLatencyFrame timing) {
|
||||
return SubmitNv12Internal(remote_id, data, size, width, height, true, timing);
|
||||
}
|
||||
|
||||
OpenGlVideoRenderer::SubmitResult
|
||||
OpenGlVideoRenderer::SubmitCachedNv12(std::string_view remote_id,
|
||||
const uint8_t *data, size_t size,
|
||||
int width, int height) {
|
||||
return SubmitNv12Internal(remote_id, data, size, width, height, false);
|
||||
OpenGlVideoRenderer::SubmitResult OpenGlVideoRenderer::SubmitCachedNv12(
|
||||
std::string_view remote_id, const uint8_t *data, size_t size, int width,
|
||||
int height, VideoLatencyFrame timing) {
|
||||
return SubmitNv12Internal(remote_id, data, size, width, height, false,
|
||||
timing);
|
||||
}
|
||||
|
||||
OpenGlVideoRenderer::SubmitResult
|
||||
OpenGlVideoRenderer::SubmitNv12Internal(std::string_view remote_id,
|
||||
const uint8_t *data, size_t size,
|
||||
int width, int height,
|
||||
bool replace_pending) {
|
||||
OpenGlVideoRenderer::SubmitResult OpenGlVideoRenderer::SubmitNv12Internal(
|
||||
std::string_view remote_id, const uint8_t *data, size_t size, int width,
|
||||
int height, bool replace_pending, VideoLatencyFrame timing) {
|
||||
if (!IsReady()) {
|
||||
return SubmitResult::failed;
|
||||
}
|
||||
@@ -1104,14 +1103,15 @@ OpenGlVideoRenderer::SubmitNv12Internal(std::string_view remote_id,
|
||||
target->height = height;
|
||||
target->remote_id.assign(remote_id);
|
||||
target->sequence = frames.next_sequence++;
|
||||
target->timing = timing;
|
||||
target->use = SlotUse::pending;
|
||||
target->valid = true;
|
||||
return SubmitResult::submitted;
|
||||
}
|
||||
|
||||
OpenGlVideoRenderer::SubmitResult
|
||||
OpenGlVideoRenderer::SubmitNativeFrame(std::string_view remote_id,
|
||||
const MiniRtcNativeVideoFrame &frame) {
|
||||
OpenGlVideoRenderer::SubmitResult OpenGlVideoRenderer::SubmitNativeFrame(
|
||||
std::string_view remote_id, const MiniRtcNativeVideoFrame &frame,
|
||||
VideoLatencyFrame timing) {
|
||||
if (!IsReady()) {
|
||||
return SubmitResult::failed;
|
||||
}
|
||||
@@ -1183,6 +1183,7 @@ OpenGlVideoRenderer::SubmitNativeFrame(std::string_view remote_id,
|
||||
target->height = static_cast<int>(native->height);
|
||||
target->remote_id.assign(remote_id);
|
||||
target->sequence = frames.next_sequence++;
|
||||
target->timing = timing;
|
||||
target->use = SlotUse::pending;
|
||||
target->valid = true;
|
||||
return SubmitResult::submitted;
|
||||
@@ -1204,6 +1205,7 @@ OpenGlVideoRenderer::RenderLatest(std::string_view remote_id,
|
||||
const unsigned char *y_data = nullptr;
|
||||
const unsigned char *uv_data = nullptr;
|
||||
NativeVideoFrameRef active_native_frame;
|
||||
VideoLatencyFrame timing;
|
||||
{
|
||||
std::lock_guard lock(impl_->frames->mutex);
|
||||
for (size_t index = 0; index < impl_->frames->slots.size(); ++index) {
|
||||
@@ -1221,6 +1223,7 @@ OpenGlVideoRenderer::RenderLatest(std::string_view remote_id,
|
||||
source_width = selected.width;
|
||||
source_height = selected.height;
|
||||
active_native_frame = selected.native_frame;
|
||||
timing = selected.timing;
|
||||
if (!selected.bytes.empty()) {
|
||||
const size_t y_size = static_cast<size_t>(source_width) * source_height;
|
||||
y_data = selected.bytes.data();
|
||||
@@ -1421,6 +1424,7 @@ OpenGlVideoRenderer::RenderLatest(std::string_view remote_id,
|
||||
impl_->texture_height = source_height;
|
||||
impl_->uploaded_stream.assign(remote_id);
|
||||
impl_->uploaded_sequence = sequence;
|
||||
impl_->uploaded_timing = timing;
|
||||
if (!use_pixel_unpack_buffer) {
|
||||
impl_->cpu_texture_width = source_width;
|
||||
impl_->cpu_texture_height = source_height;
|
||||
@@ -1549,6 +1553,8 @@ OpenGlVideoRenderer::RenderLatest(std::string_view remote_id,
|
||||
}
|
||||
#endif
|
||||
const GLenum draw_error = glGetError();
|
||||
if (has_video && draw_error == GL_NO_ERROR)
|
||||
impl_->uploaded_timing.MarkSubmitted();
|
||||
RenderOutcome outcome{
|
||||
!upload_succeeded || draw_error != GL_NO_ERROR
|
||||
? RenderResult::failed
|
||||
|
||||
@@ -39,11 +39,14 @@ public:
|
||||
|
||||
// Thread-safe; called from MiniRTC's decode callback thread.
|
||||
SubmitResult SubmitNv12(std::string_view remote_id, const uint8_t *data,
|
||||
size_t size, int width, int height) override;
|
||||
size_t size, int width, int height,
|
||||
VideoLatencyFrame timing = {}) override;
|
||||
SubmitResult SubmitCachedNv12(std::string_view remote_id, const uint8_t *data,
|
||||
size_t size, int width, int height) override;
|
||||
size_t size, int width, int height,
|
||||
VideoLatencyFrame timing = {}) override;
|
||||
SubmitResult SubmitNativeFrame(std::string_view remote_id,
|
||||
const MiniRtcNativeVideoFrame &frame) override;
|
||||
const MiniRtcNativeVideoFrame &frame,
|
||||
VideoLatencyFrame timing = {}) override;
|
||||
|
||||
// Draws below Slint while its OpenGL context is current. All dimensions are
|
||||
// physical pixels in the window client area. corner_radius_pixels clips the
|
||||
@@ -61,7 +64,8 @@ public:
|
||||
private:
|
||||
SubmitResult SubmitNv12Internal(std::string_view remote_id,
|
||||
const uint8_t *data, size_t size, int width,
|
||||
int height, bool replace_pending);
|
||||
int height, bool replace_pending,
|
||||
VideoLatencyFrame timing);
|
||||
|
||||
struct Impl;
|
||||
std::unique_ptr<Impl> impl_;
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
#include "platform/single_instance.h"
|
||||
|
||||
#include <cerrno>
|
||||
#include <cstring>
|
||||
#include <fcntl.h>
|
||||
#include <sys/file.h>
|
||||
#include <sys/stat.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#include <utility>
|
||||
|
||||
namespace crossdesk::platform {
|
||||
|
||||
SingleInstanceGuard::SingleInstanceGuard(std::string application)
|
||||
: application_(std::move(application)) {}
|
||||
|
||||
SingleInstanceGuard::~SingleInstanceGuard() { Release(); }
|
||||
|
||||
InstanceResult SingleInstanceGuard::TryAcquire(InstanceRole role,
|
||||
std::string& error) {
|
||||
error.clear();
|
||||
if (handle_ != -1) return InstanceResult::kAcquired;
|
||||
if (!ValidInstanceApplication(application_)) {
|
||||
error = "Invalid single-instance application name";
|
||||
return InstanceResult::kError;
|
||||
}
|
||||
// A fixed local path also coordinates launches with different TMPDIR/XDG
|
||||
// settings. O_CLOEXEC prevents unrelated desktop/helper processes retaining it.
|
||||
const std::string path = "/tmp/" + application_ + "-" +
|
||||
std::to_string(geteuid()) + "-" +
|
||||
InstanceRoleName(role) + ".lock";
|
||||
const int fd = open(path.c_str(), O_CREAT | O_RDWR | O_CLOEXEC | O_NOFOLLOW, 0600);
|
||||
if (fd < 0) {
|
||||
error = "Cannot open single-instance lock: " + std::string(std::strerror(errno));
|
||||
return InstanceResult::kError;
|
||||
}
|
||||
struct stat info {};
|
||||
if (fstat(fd, &info) != 0 || !S_ISREG(info.st_mode) || info.st_uid != geteuid()) {
|
||||
close(fd);
|
||||
error = "Single-instance lock is not a regular file owned by this user";
|
||||
return InstanceResult::kError;
|
||||
}
|
||||
int result;
|
||||
do {
|
||||
result = flock(fd, LOCK_EX | LOCK_NB);
|
||||
} while (result != 0 && errno == EINTR);
|
||||
if (result == 0) {
|
||||
handle_ = fd;
|
||||
return InstanceResult::kAcquired;
|
||||
}
|
||||
const int lock_error = errno;
|
||||
close(fd);
|
||||
if (lock_error == EWOULDBLOCK || lock_error == EAGAIN)
|
||||
return InstanceResult::kAlreadyRunning;
|
||||
error = "Cannot acquire single-instance lock: " +
|
||||
std::string(std::strerror(lock_error));
|
||||
return InstanceResult::kError;
|
||||
}
|
||||
|
||||
void SingleInstanceGuard::Release() {
|
||||
if (handle_ == -1) return;
|
||||
// Close rather than LOCK_UN: a forked headless application may still own the
|
||||
// same open file description. Never unlink a lock that another launch opened.
|
||||
close(static_cast<int>(handle_));
|
||||
handle_ = -1;
|
||||
}
|
||||
|
||||
} // namespace crossdesk::platform
|
||||
@@ -11,6 +11,8 @@ namespace crossdesk::platform {
|
||||
namespace {
|
||||
|
||||
std::filesystem::path AutostartPath(const std::string& app_name) {
|
||||
const char* config = std::getenv("XDG_CONFIG_HOME");
|
||||
if (config && *config) return std::filesystem::path(config) / "autostart" / (app_name + ".desktop");
|
||||
const char* home = std::getenv("HOME");
|
||||
if (!home || !home[0]) return {};
|
||||
return std::filesystem::path(home) / ".config" / "autostart" /
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
/*
|
||||
* @Author: DI JUNKUN
|
||||
* @Date: 2026-09-25
|
||||
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
|
||||
*/
|
||||
|
||||
#ifndef _DESKTOP_SESSION_SELECTION_H_
|
||||
#define _DESKTOP_SESSION_SELECTION_H_
|
||||
|
||||
#include <algorithm>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace crossdesk {
|
||||
|
||||
// Metadata for an online X11 user session, already filtered to our uid.
|
||||
struct DesktopLoginSession {
|
||||
std::string display;
|
||||
bool current = false;
|
||||
bool primary = false;
|
||||
bool active = false;
|
||||
bool remote = false;
|
||||
};
|
||||
|
||||
inline std::string NormalizeDesktopDisplay(std::string display) {
|
||||
if (display.compare(0, 5, "unix:") == 0) display.erase(0, 4);
|
||||
if (display.empty() || display[0] != ':') return display;
|
||||
if (display.size() > 2 && display.compare(display.size() - 2, 2, ".0") == 0) {
|
||||
display.resize(display.size() - 2);
|
||||
}
|
||||
return display;
|
||||
}
|
||||
|
||||
inline int DesktopSessionPriority(
|
||||
const std::string& display,
|
||||
const std::vector<DesktopLoginSession>& sessions) {
|
||||
int priority = 0;
|
||||
for (const auto& session : sessions) {
|
||||
if (NormalizeDesktopDisplay(session.display) !=
|
||||
NormalizeDesktopDisplay(display)) continue;
|
||||
const int rank = session.current ? 6
|
||||
: session.primary && session.active ? 5
|
||||
: session.active && !session.remote ? 4
|
||||
: session.active ? 3
|
||||
: session.primary ? 2 : 1;
|
||||
priority = std::max(priority, rank);
|
||||
}
|
||||
return priority;
|
||||
}
|
||||
|
||||
inline bool DesktopDisplayLess(const std::string& left,
|
||||
const std::string& right) {
|
||||
const auto a = NormalizeDesktopDisplay(left);
|
||||
const auto b = NormalizeDesktopDisplay(right);
|
||||
const auto is_number = [](const std::string& value) {
|
||||
return value.size() > 1 && value[0] == ':' &&
|
||||
value.find_first_not_of("0123456789", 1) == std::string::npos;
|
||||
};
|
||||
// Stable numeric order when logind is unavailable or sessions have equal
|
||||
// priority; do not depend on filesystem enumeration order (or sort :10 < :2).
|
||||
if (is_number(a) && is_number(b) && a.size() != b.size()) {
|
||||
return a.size() < b.size();
|
||||
}
|
||||
return a < b;
|
||||
}
|
||||
|
||||
inline std::optional<size_t> SelectDesktopSession(
|
||||
const std::vector<std::string>& available,
|
||||
const std::vector<DesktopLoginSession>& sessions) {
|
||||
if (available.empty()) return std::nullopt;
|
||||
size_t selected = 0;
|
||||
for (size_t i = 1; i < available.size(); ++i) {
|
||||
const int candidate = DesktopSessionPriority(available[i], sessions);
|
||||
const int current = DesktopSessionPriority(available[selected], sessions);
|
||||
if (candidate > current ||
|
||||
(candidate == current &&
|
||||
DesktopDisplayLess(available[i], available[selected]))) selected = i;
|
||||
}
|
||||
return selected;
|
||||
}
|
||||
|
||||
} // namespace crossdesk
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,358 @@
|
||||
#include "headless_console.h"
|
||||
|
||||
#include <fcntl.h>
|
||||
#include <poll.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <charconv>
|
||||
#include <cerrno>
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <iostream>
|
||||
|
||||
#include "rd_log.h"
|
||||
#include "config_center.h"
|
||||
#include "localization.h"
|
||||
#include "spdlog/sinks/rotating_file_sink.h"
|
||||
|
||||
namespace crossdesk {
|
||||
namespace {
|
||||
bool ValidPassword(const std::string& password) {
|
||||
if (password.size() != 6) return false;
|
||||
for (unsigned char c : password) {
|
||||
if (!((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') ||
|
||||
(c >= '0' && c <= '9'))) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
} // namespace
|
||||
|
||||
HeadlessConsole& HeadlessConsole::Instance() {
|
||||
static HeadlessConsole console;
|
||||
return console;
|
||||
}
|
||||
|
||||
HeadlessConsole::~HeadlessConsole() {
|
||||
if (!active()) return;
|
||||
std::cout.flush();
|
||||
std::cerr.flush();
|
||||
fflush(nullptr);
|
||||
dup2(output_, STDOUT_FILENO);
|
||||
dup2(error_, STDERR_FILENO);
|
||||
close(output_);
|
||||
close(error_);
|
||||
}
|
||||
|
||||
bool HeadlessConsole::Enable(const std::filesystem::path& log_directory,
|
||||
const std::filesystem::path& config_file) {
|
||||
if (active()) return true;
|
||||
std::error_code error;
|
||||
std::filesystem::create_directories(log_directory, error);
|
||||
if (error) return false;
|
||||
// Existing loggers keep their own rotating files and filenames. Suppress
|
||||
// only stdout/stderr copies; do not create a second diagnostic log stream.
|
||||
const int quiet = open("/dev/null", O_WRONLY | O_CLOEXEC);
|
||||
if (quiet < 0) return false;
|
||||
const int out = fcntl(STDOUT_FILENO, F_DUPFD_CLOEXEC, 3);
|
||||
const int err = fcntl(STDERR_FILENO, F_DUPFD_CLOEXEC, 3);
|
||||
if (out < 0 || err < 0) {
|
||||
if (out >= 0) close(out);
|
||||
if (err >= 0) close(err);
|
||||
close(quiet);
|
||||
return false;
|
||||
}
|
||||
std::cout.flush();
|
||||
std::cerr.flush();
|
||||
fflush(nullptr);
|
||||
if (dup2(quiet, STDOUT_FILENO) < 0 || dup2(quiet, STDERR_FILENO) < 0) {
|
||||
dup2(out, STDOUT_FILENO);
|
||||
dup2(err, STDERR_FILENO);
|
||||
close(out);
|
||||
close(err);
|
||||
close(quiet);
|
||||
return false;
|
||||
}
|
||||
close(quiet);
|
||||
output_ = out;
|
||||
error_ = err;
|
||||
interactive_ = isatty(STDIN_FILENO) && isatty(output_);
|
||||
// Initialize after redirecting output so even the logger's first message
|
||||
// stays in crossdesk.log.
|
||||
try {
|
||||
InitLogger(log_directory.string());
|
||||
for (const auto& sink : get_logger()->sinks()) {
|
||||
if (const auto file =
|
||||
std::dynamic_pointer_cast<spdlog::sinks::rotating_file_sink_mt>(sink)) {
|
||||
diagnostic_path_ = file->filename();
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (const std::exception&) {
|
||||
dup2(output_, STDOUT_FILENO);
|
||||
dup2(error_, STDERR_FILENO);
|
||||
close(output_);
|
||||
close(error_);
|
||||
output_ = error_ = -1;
|
||||
return false;
|
||||
}
|
||||
if (!config_file.empty()) {
|
||||
ConfigCenter config(config_file.string());
|
||||
SetLanguage(static_cast<int>(config.GetLanguage()));
|
||||
}
|
||||
Write(Text("console_title") + "\n" + Text("console_log_file") + ": " + diagnostic_path_ + "\n");
|
||||
Help();
|
||||
return true;
|
||||
}
|
||||
|
||||
void HeadlessConsole::Write(const std::string& text) {
|
||||
if (!active()) return;
|
||||
size_t offset = 0;
|
||||
while (offset < text.size()) {
|
||||
const ssize_t count = write(output_, text.data() + offset, text.size() - offset);
|
||||
if (count < 0 && errno == EINTR) continue;
|
||||
if (count <= 0) break;
|
||||
offset += static_cast<size_t>(count);
|
||||
}
|
||||
}
|
||||
|
||||
void HeadlessConsole::Notify(const std::string& message) {
|
||||
if (active()) Write(message + "\n");
|
||||
}
|
||||
|
||||
void HeadlessConsole::SetIdentity(const std::string& identity) {
|
||||
if (!active()) return;
|
||||
const auto separator = identity.find('@');
|
||||
const std::string id = identity.substr(0, separator);
|
||||
const std::string password = separator == std::string::npos
|
||||
? "" : identity.substr(separator + 1);
|
||||
// Credentials are rendered to a terminal, never interpreted as terminal
|
||||
// escape sequences and never forwarded to the diagnostic log.
|
||||
if (id.find_first_not_of("0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz-_") !=
|
||||
std::string::npos || (!password.empty() && !ValidPassword(password))) return;
|
||||
std::lock_guard<std::mutex> lock(identity_mutex_);
|
||||
id_ = id;
|
||||
password_ = password;
|
||||
}
|
||||
|
||||
const std::string& HeadlessConsole::Text(const char* key) const {
|
||||
return localization::detail::GetTranslatedText(key, language_);
|
||||
}
|
||||
|
||||
void HeadlessConsole::NotifyKey(const char* key) { Notify(Text(key)); }
|
||||
|
||||
void HeadlessConsole::SetLanguage(int language) {
|
||||
language = localization::detail::ClampLanguageIndex(language);
|
||||
if (language_ != language) {
|
||||
language_ = language;
|
||||
displayed_ = false;
|
||||
}
|
||||
}
|
||||
|
||||
void HeadlessConsole::BindSettings(ConfigCenter* config,
|
||||
std::function<bool()> connection_busy,
|
||||
std::function<void(SettingEffect)> applied) {
|
||||
config_ = config;
|
||||
connection_busy_ = std::move(connection_busy);
|
||||
settings_applied_ = std::move(applied);
|
||||
if (config_) SetLanguage(static_cast<int>(config_->GetLanguage()));
|
||||
}
|
||||
|
||||
void HeadlessConsole::Help() { NotifyKey("console_help"); }
|
||||
|
||||
void HeadlessConsole::ShowSettings() {
|
||||
if (!config_) { NotifyKey("console_settings_unavailable"); return; }
|
||||
Write("\n" + Text("console_settings_title") + "\n");
|
||||
const auto settings = GetConsoleSettings(*config_);
|
||||
for (size_t i = 0; i < settings.size(); ++i) {
|
||||
const auto& item = settings[i];
|
||||
std::string value = item.value;
|
||||
for (auto& c : value) if (static_cast<unsigned char>(c) < 32 || c == 127) c = '?';
|
||||
Write(std::to_string(i + 1) + ". " + Text(item.label) + " [" + item.key +
|
||||
"] = " + value + " (" + item.choices + ")\n");
|
||||
}
|
||||
NotifyKey("console_settings_prompt");
|
||||
}
|
||||
|
||||
void HeadlessConsole::ChangeSetting(const std::string& key, const std::string& value) {
|
||||
if (!config_) { NotifyKey("console_settings_unavailable"); return; }
|
||||
const auto result = ApplyConsoleSetting(*config_, key, value,
|
||||
connection_busy_ && connection_busy_());
|
||||
SetLanguage(static_cast<int>(config_->GetLanguage()));
|
||||
if (result.saved && result.effect != SettingEffect::none && settings_applied_) {
|
||||
settings_applied_(result.effect);
|
||||
}
|
||||
NotifyKey(result.message);
|
||||
}
|
||||
|
||||
bool HeadlessConsole::SettingsCommand(const std::string& line) {
|
||||
if (line == "settings" || line == "settings show") {
|
||||
waiting_password_ = false;
|
||||
selected_setting_.clear();
|
||||
settings_menu_ = line == "settings";
|
||||
ShowSettings();
|
||||
return true;
|
||||
}
|
||||
if (line.compare(0, 13, "settings set ") == 0) {
|
||||
waiting_password_ = false;
|
||||
selected_setting_.clear();
|
||||
const auto separator = line.find(' ', 13);
|
||||
if (separator == std::string::npos) NotifyKey("console_settings_invalid");
|
||||
else ChangeSetting(line.substr(13, separator - 13), line.substr(separator + 1));
|
||||
return true;
|
||||
}
|
||||
if (!settings_menu_) return false;
|
||||
if (line == "back" || (line == "cancel" && selected_setting_.empty())) {
|
||||
settings_menu_ = false;
|
||||
selected_setting_.clear();
|
||||
Help();
|
||||
return true;
|
||||
}
|
||||
if (line == "cancel") {
|
||||
selected_setting_.clear();
|
||||
NotifyKey("console_cancelled");
|
||||
ShowSettings();
|
||||
return true;
|
||||
}
|
||||
// Status and quit remain available while navigating the menu.
|
||||
if (line == "status" || line == "help" || line == "?" || line == "quit" || line == "exit") return false;
|
||||
if (!selected_setting_.empty()) {
|
||||
ChangeSetting(selected_setting_, line);
|
||||
selected_setting_.clear();
|
||||
ShowSettings();
|
||||
return true;
|
||||
}
|
||||
if (!config_) { NotifyKey("console_settings_unavailable"); return true; }
|
||||
const auto settings = GetConsoleSettings(*config_);
|
||||
size_t index = 0;
|
||||
const auto parsed = std::from_chars(line.data(), line.data() + line.size(), index);
|
||||
if (parsed.ec == std::errc{} && parsed.ptr == line.data() + line.size() &&
|
||||
index > 0 && index <= settings.size()) {
|
||||
selected_setting_ = settings[index - 1].key;
|
||||
} else {
|
||||
for (const auto& setting : settings) if (setting.key == line) selected_setting_ = setting.key;
|
||||
}
|
||||
if (selected_setting_.empty()) {
|
||||
NotifyKey("console_settings_invalid");
|
||||
} else {
|
||||
const auto item = std::find_if(settings.begin(), settings.end(),
|
||||
[&](const auto& setting) { return setting.key == selected_setting_; });
|
||||
Write(Text(item->label) + " [" + item->choices + "]\n" + Text("console_settings_value") + ":\n");
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
void HeadlessConsole::ShowStatus(bool connected) {
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(identity_mutex_);
|
||||
displayed_id_ = id_;
|
||||
displayed_password_ = password_;
|
||||
}
|
||||
displayed_connected_ = connected;
|
||||
displayed_ = true;
|
||||
Write("\n" + Text("console_status") + ": " + Text(connected ? "console_online" : "console_offline") +
|
||||
"\n" + Text("console_id") + ": " + (displayed_id_.empty() ? Text("console_waiting") : displayed_id_) +
|
||||
"\n" + Text("console_password") + ": " +
|
||||
(!interactive_ ? Text("console_password_hidden")
|
||||
: displayed_password_.empty() ? Text("console_waiting") : displayed_password_) + "\n");
|
||||
}
|
||||
|
||||
void HeadlessConsole::Command(
|
||||
const std::string& line, bool connected,
|
||||
const std::function<void(const std::string&)>& password,
|
||||
const std::function<void()>& random_password,
|
||||
const std::function<void()>& quit) {
|
||||
if (line == "quit" || line == "exit") {
|
||||
input_closed_ = true;
|
||||
NotifyKey("console_quitting");
|
||||
quit();
|
||||
return;
|
||||
}
|
||||
if (SettingsCommand(line)) return;
|
||||
if (waiting_password_) {
|
||||
waiting_password_ = false;
|
||||
if (line.empty() || line == "cancel") {
|
||||
NotifyKey("console_cancelled");
|
||||
} else if (!ValidPassword(line)) {
|
||||
NotifyKey("console_password_invalid");
|
||||
} else {
|
||||
password(line);
|
||||
}
|
||||
} else if (line == "status") {
|
||||
ShowStatus(connected);
|
||||
} else if (line == "help" || line == "?") {
|
||||
Help();
|
||||
} else if (line == "password" || line == "passwd") {
|
||||
if (!connected) {
|
||||
NotifyKey("console_password_offline");
|
||||
} else {
|
||||
waiting_password_ = true;
|
||||
NotifyKey("console_password_prompt");
|
||||
}
|
||||
} else if (line.compare(0, 9, "password ") == 0 ||
|
||||
line.compare(0, 7, "passwd ") == 0) {
|
||||
const std::string value = line.substr(line.find(' ') + 1);
|
||||
if (!ValidPassword(value)) {
|
||||
NotifyKey("console_password_invalid");
|
||||
} else if (!connected) {
|
||||
NotifyKey("console_password_offline");
|
||||
} else {
|
||||
password(value);
|
||||
}
|
||||
} else if (line == "random") {
|
||||
if (connected) random_password();
|
||||
else NotifyKey("console_password_offline");
|
||||
} else if (!line.empty()) {
|
||||
NotifyKey("console_unknown_command");
|
||||
}
|
||||
}
|
||||
|
||||
void HeadlessConsole::Poll(
|
||||
bool connected, const std::function<void(const std::string&)>& password,
|
||||
const std::function<void()>& random_password,
|
||||
const std::function<void()>& quit) {
|
||||
if (!active()) return;
|
||||
if (config_) SetLanguage(static_cast<int>(config_->GetLanguage()));
|
||||
bool changed = !displayed_ || displayed_connected_ != connected;
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(identity_mutex_);
|
||||
changed |= displayed_id_ != id_ || displayed_password_ != password_;
|
||||
}
|
||||
if (changed) ShowStatus(connected);
|
||||
if (input_closed_) return;
|
||||
// A job launched in the background must not steal input or receive SIGTTIN.
|
||||
if (isatty(STDIN_FILENO) && tcgetpgrp(STDIN_FILENO) != getpgrp()) return;
|
||||
struct pollfd input { STDIN_FILENO, POLLIN, 0 };
|
||||
if (poll(&input, 1, 0) <= 0) return;
|
||||
if (!(input.revents & (POLLIN | POLLHUP))) {
|
||||
if (input.revents & (POLLERR | POLLNVAL)) input_closed_ = true;
|
||||
return;
|
||||
}
|
||||
char buffer[256];
|
||||
const ssize_t count = read(STDIN_FILENO, buffer, sizeof(buffer));
|
||||
if (count <= 0) {
|
||||
if (count == 0 || (errno != EINTR && errno != EAGAIN)) input_closed_ = true;
|
||||
return;
|
||||
}
|
||||
for (ssize_t i = 0; i < count && !input_closed_; ++i) {
|
||||
const char c = buffer[i];
|
||||
if (c == '\n') {
|
||||
if (!discarding_line_) {
|
||||
if (!input_.empty() && input_.back() == '\r') input_.pop_back();
|
||||
Command(input_, connected, password, random_password, quit);
|
||||
}
|
||||
input_.clear();
|
||||
discarding_line_ = false;
|
||||
} else if (!discarding_line_) {
|
||||
if (input_.size() == 1024) {
|
||||
input_.clear();
|
||||
discarding_line_ = true;
|
||||
waiting_password_ = false;
|
||||
NotifyKey("console_line_too_long");
|
||||
} else {
|
||||
input_ += c;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} // namespace crossdesk
|
||||
@@ -0,0 +1,78 @@
|
||||
/*
|
||||
* @Author: DI JUNKUN
|
||||
* @Date: 2026-09-25
|
||||
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
|
||||
*/
|
||||
|
||||
#ifndef _HEADLESS_CONSOLE_H_
|
||||
#define _HEADLESS_CONSOLE_H_
|
||||
|
||||
#include <filesystem>
|
||||
#include <functional>
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
|
||||
#include "headless_settings.h"
|
||||
|
||||
namespace crossdesk {
|
||||
|
||||
// Enabled before display discovery and fork, with no input thread. The GUI
|
||||
// thread polls commands; transport callbacks only publish credential snapshots.
|
||||
class HeadlessConsole {
|
||||
public:
|
||||
static HeadlessConsole& Instance();
|
||||
~HeadlessConsole();
|
||||
bool Enable(const std::filesystem::path& log_directory,
|
||||
const std::filesystem::path& config_file = {});
|
||||
bool active() const { return output_ >= 0; }
|
||||
const std::string& diagnostic_path() const { return diagnostic_path_; }
|
||||
void SetIdentity(const std::string& identity);
|
||||
void Notify(const std::string& message);
|
||||
const std::string& Text(const char* key) const;
|
||||
void NotifyKey(const char* key);
|
||||
void SetLanguage(int language);
|
||||
void BindSettings(ConfigCenter* config, std::function<bool()> connection_busy,
|
||||
std::function<void(SettingEffect)> applied);
|
||||
void Poll(bool connected, const std::function<void(const std::string&)>& password,
|
||||
const std::function<void()>& random_password,
|
||||
const std::function<void()>& quit);
|
||||
|
||||
private:
|
||||
HeadlessConsole() = default;
|
||||
void Write(const std::string& text);
|
||||
void ShowStatus(bool connected);
|
||||
void Help();
|
||||
void ShowSettings();
|
||||
void ChangeSetting(const std::string& key, const std::string& value);
|
||||
bool SettingsCommand(const std::string& line);
|
||||
void Command(const std::string& line, bool connected,
|
||||
const std::function<void(const std::string&)>& password,
|
||||
const std::function<void()>& random_password,
|
||||
const std::function<void()>& quit);
|
||||
|
||||
int output_ = -1;
|
||||
int error_ = -1;
|
||||
bool interactive_ = false;
|
||||
bool input_closed_ = false;
|
||||
bool waiting_password_ = false;
|
||||
bool discarding_line_ = false;
|
||||
std::string input_;
|
||||
std::string diagnostic_path_;
|
||||
std::mutex identity_mutex_;
|
||||
std::string id_;
|
||||
std::string password_;
|
||||
std::string displayed_id_;
|
||||
std::string displayed_password_;
|
||||
bool displayed_connected_ = false;
|
||||
bool displayed_ = false;
|
||||
int language_ = 0;
|
||||
ConfigCenter* config_ = nullptr;
|
||||
std::function<bool()> connection_busy_;
|
||||
std::function<void(SettingEffect)> settings_applied_;
|
||||
bool settings_menu_ = false;
|
||||
std::string selected_setting_;
|
||||
};
|
||||
|
||||
} // namespace crossdesk
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,657 @@
|
||||
#include "headless_session.h"
|
||||
#include "desktop_session_selection.h"
|
||||
#include "rd_log.h"
|
||||
|
||||
#include <X11/Xatom.h>
|
||||
#include <X11/Xlib.h>
|
||||
#include <dlfcn.h>
|
||||
#include <fcntl.h>
|
||||
#include <poll.h>
|
||||
#include <signal.h>
|
||||
#include <sys/prctl.h>
|
||||
#include <sys/random.h>
|
||||
#include <sys/stat.h>
|
||||
#include <sys/wait.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <cerrno>
|
||||
#include <charconv>
|
||||
#include <chrono>
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include <filesystem>
|
||||
#include <iostream>
|
||||
#include <optional>
|
||||
#include <sstream>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
#include <vector>
|
||||
|
||||
namespace crossdesk {
|
||||
namespace {
|
||||
using Clock = std::chrono::steady_clock;
|
||||
using namespace std::chrono_literals;
|
||||
volatile sig_atomic_t stop_signal = 0;
|
||||
void RequestStop(int signal) { stop_signal = signal; }
|
||||
|
||||
// Bootstrap runs before GUI initialization, and may fork. Reuse the existing
|
||||
// application logger when the headless console has silenced standard streams.
|
||||
// Callers without a prepared console retain their normal stderr diagnostics.
|
||||
bool log_diagnostics_to_file = false;
|
||||
class Diagnostic {
|
||||
public:
|
||||
explicit Diagnostic(spdlog::level::level_enum level = spdlog::level::err)
|
||||
: level_(level) {}
|
||||
template <typename T> Diagnostic& operator<<(const T& value) {
|
||||
text_ << value;
|
||||
return *this;
|
||||
}
|
||||
~Diagnostic() {
|
||||
std::string message = text_.str();
|
||||
if (!log_diagnostics_to_file) {
|
||||
std::cerr << message;
|
||||
return;
|
||||
}
|
||||
if (!message.empty() && message.back() == '\n') message.pop_back();
|
||||
get_logger()->log(level_, "{}", message);
|
||||
}
|
||||
private:
|
||||
spdlog::level::level_enum level_;
|
||||
std::ostringstream text_;
|
||||
};
|
||||
|
||||
bool HasEnv(const char* name) {
|
||||
const char* value = std::getenv(name);
|
||||
return value && *value;
|
||||
}
|
||||
|
||||
bool ParseDimension(const std::string& value, int& result) {
|
||||
const auto parsed =
|
||||
std::from_chars(value.data(), value.data() + value.size(), result);
|
||||
return parsed.ec == std::errc{} && parsed.ptr == value.data() + value.size() &&
|
||||
result >= 320 && result <= 8192 && result % 2 == 0;
|
||||
}
|
||||
|
||||
struct Options {
|
||||
bool force = false;
|
||||
bool disabled = false;
|
||||
bool help = false;
|
||||
bool new_session = false;
|
||||
std::string size = "1920x1080";
|
||||
std::string session;
|
||||
std::string display;
|
||||
};
|
||||
|
||||
bool ParseOptions(int argc, char* argv[], Options& options) {
|
||||
for (int i = 1; i < argc; ++i) {
|
||||
const std::string arg = argv[i];
|
||||
if (arg == "--headless") {
|
||||
options.force = true;
|
||||
} else if (arg == "--no-headless") {
|
||||
options.disabled = true;
|
||||
} else if (arg == "--headless-help") {
|
||||
options.help = true;
|
||||
} else if (arg == "--headless-size" || arg == "--headless-session" ||
|
||||
arg == "--headless-display") {
|
||||
if (++i == argc || argv[i][0] == '\0') {
|
||||
Diagnostic() << arg << " requires a value\n";
|
||||
return false;
|
||||
}
|
||||
if (arg == "--headless-size") {
|
||||
options.size = argv[i];
|
||||
options.new_session = true;
|
||||
} else if (arg == "--headless-session") {
|
||||
options.session = argv[i];
|
||||
options.new_session = true;
|
||||
} else {
|
||||
options.display = argv[i];
|
||||
}
|
||||
options.force = true;
|
||||
} else if (arg.rfind("--headless", 0) == 0) {
|
||||
Diagnostic() << "Unknown option: " << arg << '\n';
|
||||
return false;
|
||||
}
|
||||
}
|
||||
int width = 0, height = 0;
|
||||
const auto separator = options.size.find('x');
|
||||
if (separator == std::string::npos ||
|
||||
!ParseDimension(options.size.substr(0, separator), width) ||
|
||||
!ParseDimension(options.size.substr(separator + 1), height)) {
|
||||
Diagnostic() << "--headless-size requires WIDTHxHEIGHT with even dimensions "
|
||||
"between 320 and 8192\n";
|
||||
return false;
|
||||
}
|
||||
if (options.force && options.disabled) {
|
||||
Diagnostic() << "--no-headless conflicts with --headless options\n";
|
||||
return false;
|
||||
}
|
||||
if (!options.display.empty() && options.new_session) {
|
||||
Diagnostic() << "--headless-display reuses an existing desktop and cannot be "
|
||||
"combined with --headless-size or --headless-session\n";
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
struct ExistingDesktop {
|
||||
std::string display;
|
||||
std::optional<std::string> authority;
|
||||
};
|
||||
|
||||
std::vector<DesktopLoginSession> GetDesktopLoginSessions() {
|
||||
// Optional runtime integration: non-systemd distributions and containers
|
||||
// still discover X11 desktops without a build/runtime dependency on logind.
|
||||
struct Library {
|
||||
void* handle = dlopen("libsystemd.so.0", RTLD_LAZY | RTLD_LOCAL);
|
||||
~Library() { if (handle) dlclose(handle); }
|
||||
} library;
|
||||
if (!library.handle) return {};
|
||||
using UserSessions = int (*)(uid_t, int, char***);
|
||||
using SessionString = int (*)(const char*, char**);
|
||||
using SessionFlag = int (*)(const char*);
|
||||
using UserDisplay = int (*)(uid_t, char**);
|
||||
using PidSession = int (*)(pid_t, char**);
|
||||
const auto list = reinterpret_cast<UserSessions>(
|
||||
dlsym(library.handle, "sd_uid_get_sessions"));
|
||||
const auto type = reinterpret_cast<SessionString>(
|
||||
dlsym(library.handle, "sd_session_get_type"));
|
||||
const auto session_class = reinterpret_cast<SessionString>(
|
||||
dlsym(library.handle, "sd_session_get_class"));
|
||||
const auto state = reinterpret_cast<SessionString>(
|
||||
dlsym(library.handle, "sd_session_get_state"));
|
||||
const auto display = reinterpret_cast<SessionString>(
|
||||
dlsym(library.handle, "sd_session_get_display"));
|
||||
const auto active = reinterpret_cast<SessionFlag>(
|
||||
dlsym(library.handle, "sd_session_is_active"));
|
||||
const auto remote = reinterpret_cast<SessionFlag>(
|
||||
dlsym(library.handle, "sd_session_is_remote"));
|
||||
const auto primary = reinterpret_cast<UserDisplay>(
|
||||
dlsym(library.handle, "sd_uid_get_display"));
|
||||
const auto current = reinterpret_cast<PidSession>(
|
||||
dlsym(library.handle, "sd_pid_get_session"));
|
||||
if (!list || !type || !session_class || !state || !display) return {};
|
||||
auto read = [](SessionString function, const char* id) {
|
||||
char* value = nullptr;
|
||||
const int status = function(id, &value);
|
||||
std::string result = status >= 0 && value ? value : "";
|
||||
free(value);
|
||||
return result;
|
||||
};
|
||||
char* value = nullptr;
|
||||
std::string primary_id, current_id;
|
||||
if (primary && primary(getuid(), &value) >= 0 && value) primary_id = value;
|
||||
free(value);
|
||||
value = nullptr;
|
||||
if (current && current(0, &value) >= 0 && value) current_id = value;
|
||||
free(value);
|
||||
if (current_id.empty() && HasEnv("XDG_SESSION_ID")) {
|
||||
current_id = std::getenv("XDG_SESSION_ID");
|
||||
}
|
||||
char** ids = nullptr;
|
||||
const int count = list(getuid(), 0, &ids);
|
||||
if (count < 0 || !ids) return {};
|
||||
std::vector<DesktopLoginSession> sessions;
|
||||
for (int i = 0; i < count; ++i) {
|
||||
const std::string kind = read(session_class, ids[i]);
|
||||
const std::string status = read(state, ids[i]);
|
||||
if (read(type, ids[i]) == "x11" &&
|
||||
(kind == "user" || kind == "user-early") &&
|
||||
(status == "online" || status == "active")) {
|
||||
const auto address = NormalizeDesktopDisplay(read(display, ids[i]));
|
||||
// Only local displays, never greeters, SSH forwarding or other users.
|
||||
if (address.size() > 1 && address[0] == ':' &&
|
||||
address.find_first_not_of("0123456789.", 1) == std::string::npos) {
|
||||
sessions.push_back({address, current_id == ids[i], primary_id == ids[i],
|
||||
active && active(ids[i]) > 0,
|
||||
remote && remote(ids[i]) > 0});
|
||||
}
|
||||
}
|
||||
free(ids[i]);
|
||||
}
|
||||
free(ids);
|
||||
return sessions;
|
||||
}
|
||||
|
||||
void SetAuthority(const std::optional<std::string>& authority) {
|
||||
if (authority) {
|
||||
setenv("XAUTHORITY", authority->c_str(), 1);
|
||||
} else {
|
||||
unsetenv("XAUTHORITY");
|
||||
}
|
||||
}
|
||||
|
||||
Window ReadWindowProperty(Display* display, Window window, Atom property) {
|
||||
Atom type = None;
|
||||
int format = 0;
|
||||
unsigned long count = 0, remaining = 0;
|
||||
unsigned char* data = nullptr;
|
||||
Window result = None;
|
||||
if (XGetWindowProperty(display, window, property, 0, 1, False, XA_WINDOW,
|
||||
&type, &format, &count, &remaining, &data) == Success &&
|
||||
type == XA_WINDOW && format == 32 && count == 1 && data) {
|
||||
result = *reinterpret_cast<Window*>(data);
|
||||
}
|
||||
if (data) XFree(data);
|
||||
return result;
|
||||
}
|
||||
|
||||
bool HasDesktop(const std::string& address) {
|
||||
Display* display = XOpenDisplay(address.c_str());
|
||||
if (!display) return false;
|
||||
// A root framebuffer alone may be the empty Xvfb that caused the black
|
||||
// background. Require a live EWMH window manager, including its self-check.
|
||||
// Discovery runs before any threads; a WM disappearing mid-probe is benign.
|
||||
auto previous = XSetErrorHandler([](Display*, XErrorEvent*) { return 0; });
|
||||
const Window root = DefaultRootWindow(display);
|
||||
XWindowAttributes geometry{};
|
||||
const Atom property = XInternAtom(display, "_NET_SUPPORTING_WM_CHECK", True);
|
||||
const Window manager = property == None ? None
|
||||
: ReadWindowProperty(display, root, property);
|
||||
const bool valid = manager != None &&
|
||||
ReadWindowProperty(display, manager, property) == manager &&
|
||||
XGetWindowAttributes(display, root, &geometry) &&
|
||||
geometry.width > 1 && geometry.height > 1;
|
||||
XCloseDisplay(display);
|
||||
XSetErrorHandler(previous);
|
||||
return valid;
|
||||
}
|
||||
|
||||
std::vector<ExistingDesktop> FindExistingDesktops(
|
||||
const std::string& requested,
|
||||
const std::vector<DesktopLoginSession>& sessions) {
|
||||
const char* value = std::getenv("XAUTHORITY");
|
||||
const std::optional<std::string> original =
|
||||
value ? std::optional<std::string>(value) : std::nullopt;
|
||||
std::vector<std::optional<std::string>> authorities{original};
|
||||
// An explicit authority must be respected, including for automation and
|
||||
// isolated tests. Otherwise also try GDM's per-user authority file.
|
||||
if (!original) {
|
||||
const std::string gdm = "/run/user/" + std::to_string(getuid()) +
|
||||
"/gdm/Xauthority";
|
||||
struct stat info{};
|
||||
if (stat(gdm.c_str(), &info) == 0 && info.st_uid == getuid() &&
|
||||
S_ISREG(info.st_mode) && access(gdm.c_str(), R_OK) == 0) {
|
||||
authorities.emplace_back(gdm);
|
||||
}
|
||||
}
|
||||
auto probe = [&](const std::string& address) -> std::optional<ExistingDesktop> {
|
||||
for (const auto& authority : authorities) {
|
||||
SetAuthority(authority);
|
||||
if (HasDesktop(address)) return ExistingDesktop{address, authority};
|
||||
}
|
||||
return std::nullopt;
|
||||
};
|
||||
std::vector<ExistingDesktop> matches;
|
||||
if (!requested.empty()) {
|
||||
if (auto match = probe(requested)) matches.push_back(*match);
|
||||
} else if (HasEnv("DISPLAY")) {
|
||||
// An explicit, usable desktop is unambiguous even with several logins.
|
||||
if (auto match = probe(std::getenv("DISPLAY"))) matches.push_back(*match);
|
||||
}
|
||||
if (requested.empty() && matches.empty()) {
|
||||
std::vector<std::string> candidates;
|
||||
// logind may identify a desktop whose X socket is owned by root. Its
|
||||
// session still belongs to our uid and must pass the same X11/WM probes.
|
||||
for (const auto& session : sessions) candidates.push_back(session.display);
|
||||
std::error_code error;
|
||||
std::filesystem::directory_iterator it("/tmp/.X11-unix", error), end;
|
||||
for (; !error && it != end; it.increment(error)) {
|
||||
const std::string name = it->path().filename().string();
|
||||
struct stat info{};
|
||||
if (name.size() > 1 && name[0] == 'X' &&
|
||||
name.find_first_not_of("0123456789", 1) == std::string::npos &&
|
||||
lstat(it->path().c_str(), &info) == 0 && S_ISSOCK(info.st_mode) &&
|
||||
info.st_uid == getuid()) {
|
||||
candidates.push_back(":" + name.substr(1));
|
||||
}
|
||||
}
|
||||
std::sort(candidates.begin(), candidates.end(), DesktopDisplayLess);
|
||||
candidates.erase(std::unique(candidates.begin(), candidates.end()),
|
||||
candidates.end());
|
||||
for (const auto& address : candidates) {
|
||||
if (auto match = probe(address)) matches.push_back(*match);
|
||||
}
|
||||
}
|
||||
SetAuthority(original);
|
||||
return matches;
|
||||
}
|
||||
|
||||
void ConfigureHeadlessX11(const std::string& display) {
|
||||
// Capture, input and all GUI components must use the same X11 desktop,
|
||||
// including when inheriting Wayland/DRM or NVIDIA PRIME hints from a shell.
|
||||
setenv("DISPLAY", display.c_str(), 1);
|
||||
setenv("XDG_SESSION_TYPE", "x11", 1);
|
||||
unsetenv("WAYLAND_DISPLAY");
|
||||
unsetenv("WAYLAND_SOCKET");
|
||||
setenv("CROSSDESK_SCREEN_BACKEND", "x11", 1);
|
||||
setenv("SDL_VIDEODRIVER", "x11", 1);
|
||||
setenv("SLINT_BACKEND", "winit-software", 1);
|
||||
setenv("LIBGL_ALWAYS_SOFTWARE", "1", 1);
|
||||
unsetenv("__NV_PRIME_RENDER_OFFLOAD");
|
||||
unsetenv("__NV_PRIME_RENDER_OFFLOAD_PROVIDER");
|
||||
unsetenv("__GLX_VENDOR_LIBRARY_NAME");
|
||||
}
|
||||
|
||||
std::string FindExecutable(const std::string& name) {
|
||||
if (name.find('/') != std::string::npos) {
|
||||
return access(name.c_str(), X_OK) == 0 ? name : std::string{};
|
||||
}
|
||||
const char* path = std::getenv("PATH");
|
||||
std::string paths = path ? path : "/usr/bin:/bin";
|
||||
size_t start = 0;
|
||||
do {
|
||||
const auto end = paths.find(':', start);
|
||||
const std::string directory = paths.substr(start, end - start);
|
||||
const std::string candidate =
|
||||
(directory.empty() ? "." : directory) + "/" + name;
|
||||
if (access(candidate.c_str(), X_OK) == 0) return candidate;
|
||||
if (end == std::string::npos) break;
|
||||
start = end + 1;
|
||||
} while (start <= paths.size());
|
||||
return {};
|
||||
}
|
||||
|
||||
// Xauthority's binary format consists of a family and four counted strings,
|
||||
// all lengths in network byte order. FamilyWild and an empty display number
|
||||
// allow the cookie to be prepared before Xvfb allocates its display number.
|
||||
class PrivateAuthority {
|
||||
public:
|
||||
bool Create() {
|
||||
char directory[] = "/tmp/crossdesk-headless-XXXXXX";
|
||||
if (!mkdtemp(directory)) return false; // mode 0700
|
||||
directory_ = directory;
|
||||
path_ = directory_ + "/Xauthority";
|
||||
unsigned char cookie[16];
|
||||
size_t offset = 0;
|
||||
while (offset < sizeof(cookie)) {
|
||||
const ssize_t count = getrandom(cookie + offset, sizeof(cookie) - offset, 0);
|
||||
if (count < 0 && errno == EINTR) continue;
|
||||
if (count <= 0) return false;
|
||||
offset += count;
|
||||
}
|
||||
const int fd = open(path_.c_str(), O_WRONLY | O_CREAT | O_EXCL | O_CLOEXEC,
|
||||
S_IRUSR | S_IWUSR);
|
||||
if (fd < 0) return false;
|
||||
FILE* file = fdopen(fd, "wb");
|
||||
if (!file) {
|
||||
close(fd);
|
||||
return false;
|
||||
}
|
||||
auto word = [file](unsigned int value) {
|
||||
return fputc((value >> 8) & 255, file) != EOF &&
|
||||
fputc(value & 255, file) != EOF;
|
||||
};
|
||||
constexpr char protocol[] = "MIT-MAGIC-COOKIE-1";
|
||||
const bool written = word(65535) && word(0) && word(0) &&
|
||||
word(sizeof(protocol) - 1) &&
|
||||
fwrite(protocol, 1, sizeof(protocol) - 1, file) ==
|
||||
sizeof(protocol) - 1 &&
|
||||
word(sizeof(cookie)) &&
|
||||
fwrite(cookie, 1, sizeof(cookie), file) == sizeof(cookie);
|
||||
const bool closed = fclose(file) == 0;
|
||||
return written && closed;
|
||||
}
|
||||
~PrivateAuthority() {
|
||||
if (!path_.empty()) unlink(path_.c_str());
|
||||
if (!directory_.empty()) rmdir(directory_.c_str());
|
||||
}
|
||||
const std::string& path() const { return path_; }
|
||||
|
||||
private:
|
||||
std::string directory_;
|
||||
std::string path_;
|
||||
};
|
||||
|
||||
class SignalGuard {
|
||||
public:
|
||||
SignalGuard() {
|
||||
stop_signal = 0;
|
||||
struct sigaction action {};
|
||||
action.sa_handler = RequestStop;
|
||||
sigemptyset(&action.sa_mask);
|
||||
sigaction(SIGINT, &action, &old_int_);
|
||||
sigaction(SIGTERM, &action, &old_term_);
|
||||
sigaction(SIGHUP, &action, &old_hup_);
|
||||
}
|
||||
~SignalGuard() {
|
||||
sigaction(SIGINT, &old_int_, nullptr);
|
||||
sigaction(SIGTERM, &old_term_, nullptr);
|
||||
sigaction(SIGHUP, &old_hup_, nullptr);
|
||||
}
|
||||
|
||||
private:
|
||||
struct sigaction old_int_ {}, old_term_ {}, old_hup_ {};
|
||||
};
|
||||
|
||||
class Child {
|
||||
public:
|
||||
Child() = default;
|
||||
Child(const Child&) = delete;
|
||||
Child& operator=(const Child&) = delete;
|
||||
~Child() { Stop(); }
|
||||
|
||||
bool Start(const std::function<int()>& run, bool own_group = true) {
|
||||
own_group_ = own_group;
|
||||
const pid_t parent = getpid();
|
||||
pid_ = fork();
|
||||
if (pid_ == 0) {
|
||||
if (own_group_) setpgid(0, 0);
|
||||
signal(SIGINT, SIG_DFL);
|
||||
signal(SIGTERM, SIG_DFL);
|
||||
signal(SIGHUP, SIG_DFL);
|
||||
// Also stop direct children if the supervisor itself is killed.
|
||||
if (prctl(PR_SET_PDEATHSIG, SIGTERM) != 0 || getppid() != parent) _exit(1);
|
||||
_exit(run());
|
||||
}
|
||||
if (pid_ < 0) return false;
|
||||
if (own_group_) setpgid(pid_, pid_);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool Exited(int* code = nullptr) const {
|
||||
if (pid_ <= 0) return false;
|
||||
siginfo_t info{};
|
||||
// Leave the child waitable until Stop(), preventing pid/group reuse.
|
||||
if (waitid(P_PID, pid_, &info, WEXITED | WNOHANG | WNOWAIT) != 0 ||
|
||||
info.si_pid == 0) return false;
|
||||
if (code) {
|
||||
*code = info.si_code == CLD_EXITED ? info.si_status : 128 + info.si_status;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
void Stop() {
|
||||
if (pid_ <= 0) return;
|
||||
const pid_t target = own_group_ ? -pid_ : pid_;
|
||||
kill(target, SIGTERM);
|
||||
const auto deadline = Clock::now() + 2s;
|
||||
while (!Exited() && Clock::now() < deadline) std::this_thread::sleep_for(25ms);
|
||||
kill(target, SIGKILL); // Include descendants of desktop session scripts.
|
||||
while (waitpid(pid_, nullptr, 0) < 0 && errno == EINTR) {}
|
||||
pid_ = -1;
|
||||
}
|
||||
|
||||
private:
|
||||
pid_t pid_ = -1;
|
||||
bool own_group_ = true;
|
||||
};
|
||||
|
||||
int Exec(const std::vector<std::string>& arguments) {
|
||||
std::vector<char*> argv;
|
||||
for (const auto& arg : arguments) argv.push_back(const_cast<char*>(arg.c_str()));
|
||||
argv.push_back(nullptr);
|
||||
execv(argv[0], argv.data());
|
||||
Diagnostic() << "Cannot start " << arguments[0] << ": " << std::strerror(errno)
|
||||
<< '\n';
|
||||
return 127;
|
||||
}
|
||||
|
||||
bool StartServer(Child& server, const std::string& executable,
|
||||
const Options& options, const PrivateAuthority& authority,
|
||||
std::string& display) {
|
||||
int pipe_fds[2];
|
||||
if (pipe2(pipe_fds, O_CLOEXEC | O_NONBLOCK) != 0) return false;
|
||||
const bool started = server.Start([&] {
|
||||
close(pipe_fds[0]);
|
||||
fcntl(pipe_fds[1], F_SETFD, 0);
|
||||
return Exec({executable, "-displayfd", std::to_string(pipe_fds[1]),
|
||||
"-screen", "0", options.size + "x24", "-nolisten", "tcp",
|
||||
"-auth", authority.path(), "-noreset"});
|
||||
});
|
||||
close(pipe_fds[1]);
|
||||
std::string number;
|
||||
const auto deadline = Clock::now() + 10s;
|
||||
while (started && !stop_signal && !server.Exited() && Clock::now() < deadline) {
|
||||
struct pollfd descriptor { pipe_fds[0], POLLIN, 0 };
|
||||
if (poll(&descriptor, 1, 50) <= 0) continue;
|
||||
char buffer[32];
|
||||
const ssize_t size = read(pipe_fds[0], buffer, sizeof(buffer));
|
||||
if (size <= 0) break;
|
||||
number.append(buffer, size);
|
||||
if (number.find('\n') != std::string::npos || number.size() > 16) break;
|
||||
}
|
||||
close(pipe_fds[0]);
|
||||
if (number.size() < 2 || number.back() != '\n') return false;
|
||||
number.pop_back();
|
||||
if (number.find_first_not_of("0123456789") != std::string::npos) return false;
|
||||
display = ":" + number;
|
||||
return true;
|
||||
}
|
||||
|
||||
int RunHeadless(const Options& options, const std::function<int()>& run) {
|
||||
const std::string xvfb = FindExecutable("Xvfb");
|
||||
if (xvfb.empty()) {
|
||||
Diagnostic() << "No usable display. Headless mode requires Xvfb; on Debian/Ubuntu "
|
||||
"install it with: sudo apt install xvfb\n";
|
||||
return 1;
|
||||
}
|
||||
std::string session;
|
||||
std::string dbus;
|
||||
if (!options.session.empty() && options.session != "none") {
|
||||
session = FindExecutable(options.session);
|
||||
dbus = FindExecutable("dbus-run-session");
|
||||
if (session.empty() || dbus.empty()) {
|
||||
Diagnostic() << "Headless desktop requires an executable --headless-session "
|
||||
"and dbus-run-session (Debian/Ubuntu: dbus-daemon)\n";
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
SignalGuard signals;
|
||||
PrivateAuthority authority;
|
||||
if (!authority.Create()) {
|
||||
Diagnostic() << "Cannot create private headless Xauthority: "
|
||||
<< std::strerror(errno) << '\n';
|
||||
return 1;
|
||||
}
|
||||
Child server;
|
||||
std::string display;
|
||||
if (!StartServer(server, xvfb, options, authority, display)) {
|
||||
Diagnostic() << "Xvfb failed to become ready within 10 seconds\n";
|
||||
return stop_signal ? 128 + stop_signal : 1;
|
||||
}
|
||||
ConfigureHeadlessX11(display);
|
||||
setenv("XAUTHORITY", authority.path().c_str(), 1);
|
||||
setenv("CROSSDESK_HEADLESS_ACTIVE", "1", 1);
|
||||
|
||||
Display* probe = XOpenDisplay(nullptr);
|
||||
if (!probe) {
|
||||
Diagnostic() << "Cannot connect to the private Xvfb display\n";
|
||||
return 1;
|
||||
}
|
||||
XSetScreenSaver(probe, 0, 0, DefaultBlanking, DefaultExposures);
|
||||
XCloseDisplay(probe);
|
||||
Diagnostic(spdlog::level::info) << "CrossDesk headless display " << display << " (" << options.size
|
||||
<< "), XAUTHORITY=" << authority.path() << '\n';
|
||||
Child desktop;
|
||||
if (!session.empty() && !desktop.Start([&] {
|
||||
unsetenv("DBUS_SESSION_BUS_ADDRESS");
|
||||
unsetenv("SESSION_MANAGER");
|
||||
return Exec({dbus, "--", session});
|
||||
})) return 1;
|
||||
Child application;
|
||||
// Keep an interactive application in the foreground terminal group so its
|
||||
// console can read commands. Xvfb and the optional desktop remain isolated.
|
||||
if (!application.Start(run, !isatty(STDIN_FILENO))) return 1;
|
||||
int result = 1;
|
||||
while (!stop_signal) {
|
||||
if (application.Exited(&result)) return result;
|
||||
if (server.Exited() || desktop.Exited()) {
|
||||
Diagnostic() << "Headless display or desktop session exited; stopping CrossDesk\n";
|
||||
return 1;
|
||||
}
|
||||
std::this_thread::sleep_for(50ms);
|
||||
}
|
||||
return 128 + stop_signal;
|
||||
}
|
||||
} // namespace
|
||||
|
||||
int RunWithLinuxDisplay(int argc, char* argv[],
|
||||
const std::function<int()>& run_application,
|
||||
const std::function<bool()>& prepare_headless) {
|
||||
log_diagnostics_to_file = false;
|
||||
Options options;
|
||||
if (!ParseOptions(argc, argv, options)) return 2;
|
||||
if (options.help) {
|
||||
std::cout << "Linux headless options:\n"
|
||||
" --headless Reuse an existing desktop when available\n"
|
||||
" --headless-display DISPLAY Advanced: override automatic desktop selection\n"
|
||||
" --headless-size WIDTHxHEIGHT New Xvfb; default 1920x1080; even, 320..8192\n"
|
||||
" --headless-session EXECUTABLE New desktop (startxfce4, or none for bare Xvfb)\n"
|
||||
" --no-headless Disable automatic Xvfb fallback\n"
|
||||
"Normal startup automatically discovers and selects your existing "
|
||||
"desktop. No display number is required. A new Xvfb does not "
|
||||
"contain your existing apps.\n";
|
||||
return 0;
|
||||
}
|
||||
XInitThreads();
|
||||
if (!options.force) {
|
||||
if (options.disabled || HasEnv("WAYLAND_DISPLAY") || HasEnv("WAYLAND_SOCKET")) {
|
||||
return run_application();
|
||||
}
|
||||
// Preserve normal graphical launches before SSH/headless discovery.
|
||||
if (HasEnv("DISPLAY")) {
|
||||
if (Display* display = XOpenDisplay(nullptr)) {
|
||||
XCloseDisplay(display);
|
||||
return run_application();
|
||||
}
|
||||
}
|
||||
}
|
||||
if (prepare_headless) {
|
||||
if (!prepare_headless()) return 1;
|
||||
log_diagnostics_to_file = true;
|
||||
}
|
||||
if (!options.new_session) {
|
||||
const auto sessions = GetDesktopLoginSessions();
|
||||
const auto desktops = FindExistingDesktops(options.display, sessions);
|
||||
std::vector<std::string> available;
|
||||
for (const auto& desktop : desktops) available.push_back(desktop.display);
|
||||
if (auto selected = SelectDesktopSession(available, sessions)) {
|
||||
const auto& desktop = desktops[*selected];
|
||||
SetAuthority(desktop.authority);
|
||||
ConfigureHeadlessX11(desktop.display);
|
||||
Diagnostic(spdlog::level::info) << "CrossDesk reusing existing desktop " << desktop.display
|
||||
<< "; Dock, panels and running applications are preserved\n";
|
||||
if (desktops.size() > 1) {
|
||||
Diagnostic(spdlog::level::info) << "Automatically selected from " << desktops.size()
|
||||
<< " available desktops using login-session priority\n";
|
||||
}
|
||||
// The desktop belongs to the user, not this process. Never supervise or
|
||||
// terminate its X server, window manager or applications on exit.
|
||||
return run_application();
|
||||
}
|
||||
if (!options.display.empty()) {
|
||||
Diagnostic() << "No accessible desktop/window manager on " << options.display
|
||||
<< ". Check DISPLAY and XAUTHORITY; no new desktop was created.\n";
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
if (options.session.empty() || options.session == "none") {
|
||||
Diagnostic(spdlog::level::warn) << "Starting a separate bare Xvfb display: it has no Dock, panels "
|
||||
"or existing applications. To include a desktop environment, "
|
||||
"use --headless-session startxfce4 after installing Xfce.\n";
|
||||
}
|
||||
return RunHeadless(options, run_application);
|
||||
}
|
||||
} // namespace crossdesk
|
||||
@@ -0,0 +1,23 @@
|
||||
/*
|
||||
* @Author: DI JUNKUN
|
||||
* @Date: 2026-09-25
|
||||
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
|
||||
*/
|
||||
|
||||
#ifndef _HEADLESS_SESSION_H_
|
||||
#define _HEADLESS_SESSION_H_
|
||||
|
||||
#include <functional>
|
||||
|
||||
namespace crossdesk {
|
||||
|
||||
// Must run before logging, SDL, Slint or any worker threads are initialized.
|
||||
// Automatically selects the user's existing desktop, or supervises a private Xvfb
|
||||
// server and the application as children for the duration of a headless session.
|
||||
int RunWithLinuxDisplay(int argc, char* argv[],
|
||||
const std::function<int()>& run_application,
|
||||
const std::function<bool()>& prepare_headless = {});
|
||||
|
||||
} // namespace crossdesk
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,153 @@
|
||||
#include "headless_settings.h"
|
||||
|
||||
#include <unistd.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <charconv>
|
||||
#include <cstdlib>
|
||||
#include <filesystem>
|
||||
#include <functional>
|
||||
#include <optional>
|
||||
|
||||
#include "config_center.h"
|
||||
|
||||
namespace crossdesk {
|
||||
namespace {
|
||||
std::string Lower(std::string value) {
|
||||
for (auto& c : value) if (c >= 'A' && c <= 'Z') c += 'a' - 'A';
|
||||
return value;
|
||||
}
|
||||
std::optional<bool> Boolean(const std::string& value) {
|
||||
if (value == "on" || value == "true" || value == "1") return true;
|
||||
if (value == "off" || value == "false" || value == "0") return false;
|
||||
return std::nullopt;
|
||||
}
|
||||
const char* OnOff(bool value) { return value ? "on" : "off"; }
|
||||
const char* languages[] = {"zh-CN", "en-US", "ru-RU"};
|
||||
const char* turn_modes[] = {"off", "auto", "udp", "tcp"};
|
||||
} // namespace
|
||||
|
||||
std::vector<ConsoleSetting> GetConsoleSettings(const ConfigCenter& config) {
|
||||
const auto language = std::clamp(static_cast<int>(config.GetLanguage()), 0, 2);
|
||||
const auto turn = std::clamp(static_cast<int>(config.GetTurnMode()), 0, 3);
|
||||
return {
|
||||
{"language", "console_setting_language", languages[language], "zh-CN / en-US / ru-RU"},
|
||||
{"codec", "console_setting_codec", config.GetVideoEncodeFormat() == ConfigCenter::VIDEO_ENCODE_FORMAT::AV1 ? "av1" : "h264", "h264 / av1"},
|
||||
{"hardware", "console_setting_hardware", OnOff(config.IsHardwareVideoCodec()), "on / off"},
|
||||
{"turn", "console_setting_turn", turn_modes[turn], "off / auto / udp / tcp"},
|
||||
{"self_hosted", "console_setting_self_hosted", OnOff(config.IsSelfHosted()), "on / off"},
|
||||
{"server_host", "console_setting_host", config.GetSignalServerHost(), "example.com"},
|
||||
{"server_port", "console_setting_port", std::to_string(config.GetSignalServerPort()), "1..65535"},
|
||||
{"privacy", "console_setting_privacy", OnOff(config.IsEnablePrivacyScreen()), "on / off"},
|
||||
{"file_path", "console_setting_files", config.GetFileTransferSavePath().empty() ? "default" : config.GetFileTransferSavePath(), "/path/to/directory / default"},
|
||||
{"autostart", "console_setting_autostart", OnOff(config.IsEnableAutostart()), "on / off"},
|
||||
{"daemon", "console_setting_daemon", OnOff(config.IsEnableDaemon()), "on / off"},
|
||||
};
|
||||
}
|
||||
|
||||
SettingResult ApplyConsoleSetting(ConfigCenter& config, const std::string& key,
|
||||
const std::string& input, bool connection_busy) {
|
||||
SettingResult result;
|
||||
if (key == "capture_method" || key == "fps" || key == "quality") {
|
||||
result.message = "console_settings_readonly";
|
||||
return result;
|
||||
}
|
||||
const bool network = key == "codec" || key == "hardware" || key == "turn" ||
|
||||
key == "self_hosted" || key == "server_host" || key == "server_port";
|
||||
if (network && connection_busy) {
|
||||
result.message = "console_settings_busy";
|
||||
return result;
|
||||
}
|
||||
const std::string value = Lower(input);
|
||||
int status = -1;
|
||||
SettingEffect effect = SettingEffect::immediate;
|
||||
const char* message = "console_settings_saved";
|
||||
std::string canonical = input;
|
||||
std::function<int()> save;
|
||||
if (key == "language") {
|
||||
int language = value == "zh-cn" || value == "zh" || value == "0" ? 0
|
||||
: value == "en-us" || value == "en" || value == "1" ? 1
|
||||
: value == "ru-ru" || value == "ru" || value == "2" ? 2 : -1;
|
||||
if (language < 0) return result;
|
||||
canonical = languages[language];
|
||||
save = [&config, language] { return config.SetLanguage(static_cast<ConfigCenter::LANGUAGE>(language)); };
|
||||
} else if (key == "codec") {
|
||||
if (value != "h264" && value != "av1") return result;
|
||||
canonical = value;
|
||||
save = [&] { return config.SetVideoEncodeFormat(value == "h264" ? ConfigCenter::VIDEO_ENCODE_FORMAT::H264 : ConfigCenter::VIDEO_ENCODE_FORMAT::AV1); };
|
||||
} else if (key == "turn") {
|
||||
const auto found = std::find(std::begin(turn_modes), std::end(turn_modes), value);
|
||||
if (found == std::end(turn_modes)) return result;
|
||||
const int mode = static_cast<int>(found - std::begin(turn_modes));
|
||||
canonical = value;
|
||||
save = [&config, mode] { return config.SetTurnMode(static_cast<ConfigCenter::TURN_MODE>(mode)); };
|
||||
} else if (key == "server_host") {
|
||||
if (input.empty() || input.size() > 253 ||
|
||||
input.find_first_of("/\\@?#") != std::string::npos ||
|
||||
std::any_of(input.begin(), input.end(), [](unsigned char c) { return c <= 32 || c == 127; })) return result;
|
||||
save = [&] { return config.SetServerHost(input); };
|
||||
} else if (key == "server_port") {
|
||||
int port = 0;
|
||||
const auto parsed = std::from_chars(input.data(), input.data() + input.size(), port);
|
||||
if (parsed.ec != std::errc{} || parsed.ptr != input.data() + input.size() || port < 1 || port > 65535) return result;
|
||||
canonical = std::to_string(port);
|
||||
save = [&config, port] { return config.SetServerPort(port); };
|
||||
} else if (key == "file_path") {
|
||||
std::string path = input == "default" ? "" : input;
|
||||
if (path.compare(0, 2, "~/") == 0 && std::getenv("HOME")) path.replace(0, 1, std::getenv("HOME"));
|
||||
std::error_code error;
|
||||
if (!path.empty() && (path.size() >= 512 || !std::filesystem::path(path).is_absolute() ||
|
||||
!std::filesystem::is_directory(path, error) || access(path.c_str(), W_OK | X_OK) != 0 ||
|
||||
std::any_of(path.begin(), path.end(), [](unsigned char c) { return c < 32 || c == 127; }))) {
|
||||
result.message = "console_settings_path_invalid";
|
||||
return result;
|
||||
}
|
||||
canonical = path.empty() ? "default" : path;
|
||||
save = [&config, path] { return config.SetFileTransferSavePath(path); };
|
||||
effect = SettingEffect::next_file;
|
||||
message = "console_settings_next_file";
|
||||
} else {
|
||||
const auto boolean = Boolean(value);
|
||||
if (!boolean) return result;
|
||||
const bool enabled = *boolean;
|
||||
canonical = OnOff(enabled);
|
||||
if (key == "hardware") {
|
||||
if (enabled && !ConfigCenter::IsHardwareVideoCodecAvailable()) {
|
||||
result.message = "console_settings_hardware_unavailable";
|
||||
return result;
|
||||
}
|
||||
save = [&config, enabled] { return config.SetHardwareVideoCodec(enabled); };
|
||||
} else if (key == "self_hosted") {
|
||||
if (enabled && (config.GetSignalServerHost().empty() || config.GetSignalServerPort() <= 0 || config.GetSignalServerPort() > 65535)) {
|
||||
result.message = "console_settings_server_required";
|
||||
return result;
|
||||
}
|
||||
save = [&config, enabled] { return config.SetSelfHosted(enabled); };
|
||||
} else if (key == "privacy") {
|
||||
save = [&config, enabled] { return config.SetPrivacyScreen(enabled); };
|
||||
effect = SettingEffect::next_connection;
|
||||
message = "console_settings_next_connection";
|
||||
} else if (key == "autostart") {
|
||||
save = [&config, enabled] { return config.SetAutostart(enabled); };
|
||||
effect = SettingEffect::next_login;
|
||||
message = "console_settings_autostart";
|
||||
} else if (key == "daemon") {
|
||||
save = [&config, enabled] { return config.SetDaemon(enabled); };
|
||||
effect = SettingEffect::normal_restart;
|
||||
message = "console_settings_daemon";
|
||||
} else return result;
|
||||
}
|
||||
for (const auto& setting : GetConsoleSettings(config)) {
|
||||
if (key != "autostart" && key != "hardware" && setting.key == key && setting.value == canonical) {
|
||||
return {true, SettingEffect::none, "console_settings_unchanged"};
|
||||
}
|
||||
}
|
||||
status = save();
|
||||
if (status != 0) return {false, SettingEffect::none, "console_settings_save_failed"};
|
||||
if (network && ((key != "server_host" && key != "server_port") || config.IsSelfHosted())) {
|
||||
effect = SettingEffect::reconnect;
|
||||
message = "console_settings_reconnect";
|
||||
}
|
||||
return {true, effect, message};
|
||||
}
|
||||
} // namespace crossdesk
|
||||
@@ -0,0 +1,36 @@
|
||||
/*
|
||||
* @Author: DI JUNKUN
|
||||
* @Date: 2026-09-25
|
||||
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
|
||||
*/
|
||||
|
||||
#ifndef _HEADLESS_SETTINGS_H_
|
||||
#define _HEADLESS_SETTINGS_H_
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace crossdesk {
|
||||
class ConfigCenter;
|
||||
|
||||
enum class SettingEffect { none, immediate, reconnect, next_connection, next_file,
|
||||
next_login, normal_restart };
|
||||
struct ConsoleSetting {
|
||||
std::string key;
|
||||
const char* label;
|
||||
std::string value;
|
||||
const char* choices;
|
||||
};
|
||||
|
||||
struct SettingResult {
|
||||
bool saved = false;
|
||||
SettingEffect effect = SettingEffect::none;
|
||||
const char* message = "console_settings_invalid";
|
||||
};
|
||||
|
||||
std::vector<ConsoleSetting> GetConsoleSettings(const ConfigCenter& config);
|
||||
SettingResult ApplyConsoleSetting(ConfigCenter& config, const std::string& key,
|
||||
const std::string& value, bool connection_busy);
|
||||
} // namespace crossdesk
|
||||
|
||||
#endif
|
||||
@@ -35,8 +35,6 @@ int PlatformMouseController::Init(std::vector<DisplayInfo> display_info_list) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
root_ = DefaultRootWindow(display_);
|
||||
|
||||
int event_base, error_base, major_version, minor_version;
|
||||
if (!XTestQueryExtension(display_, &event_base, &error_base, &major_version,
|
||||
&minor_version)) {
|
||||
@@ -166,7 +164,10 @@ void PlatformMouseController::SetMousePosition(int x, int y) {
|
||||
if (!display_) {
|
||||
return;
|
||||
}
|
||||
XWarpPointer(display_, None, root_, 0, 0, 0, 0, x, y);
|
||||
// Inject motion through XTest so XInput2 raw-motion listeners (including
|
||||
// SDL's global pointer cache) see the movement.
|
||||
// XWarpPointer moves the cursor without producing those raw events.
|
||||
XTestFakeMotionEvent(display_, DefaultScreen(display_), x, y, CurrentTime);
|
||||
XFlush(display_);
|
||||
}
|
||||
|
||||
|
||||
@@ -54,7 +54,6 @@ class PlatformMouseController final : public MouseController {
|
||||
enum class WaylandAbsoluteMode { kUnknown, kPixels, kNormalized, kDisabled };
|
||||
|
||||
_XDisplay* display_ = nullptr;
|
||||
unsigned long root_ = 0;
|
||||
std::vector<DisplayInfo> display_info_list_;
|
||||
int screen_width_ = 0;
|
||||
int screen_height_ = 0;
|
||||
|
||||
@@ -67,6 +67,7 @@ ScreenCapturerX11::~ScreenCapturerX11() { Destroy(); }
|
||||
|
||||
int ScreenCapturerX11::Init(const int fps, cb_desktop_data cb) {
|
||||
Destroy();
|
||||
if (!cb) return -1;
|
||||
|
||||
display_ = XOpenDisplay(nullptr);
|
||||
if (!display_) {
|
||||
@@ -75,59 +76,57 @@ int ScreenCapturerX11::Init(const int fps, cb_desktop_data cb) {
|
||||
}
|
||||
|
||||
root_ = DefaultRootWindow(display_);
|
||||
screen_res_ = XRRGetScreenResources(display_, root_);
|
||||
if (!screen_res_) {
|
||||
LOG_ERROR("Failed to get screen resources");
|
||||
XCloseDisplay(display_);
|
||||
display_ = nullptr;
|
||||
return 1;
|
||||
XWindowAttributes attr{};
|
||||
if (!XGetWindowAttributes(display_, root_, &attr)) {
|
||||
LOG_ERROR("Failed to get X11 root window geometry");
|
||||
Destroy();
|
||||
return -1;
|
||||
}
|
||||
|
||||
for (int i = 0; i < screen_res_->noutput; ++i) {
|
||||
RROutput output = screen_res_->outputs[i];
|
||||
XRROutputInfo* output_info =
|
||||
XRRGetOutputInfo(display_, screen_res_, output);
|
||||
|
||||
if (output_info->connection == RR_Connected && output_info->crtc != 0) {
|
||||
XRRCrtcInfo* crtc_info =
|
||||
XRRGetCrtcInfo(display_, screen_res_, output_info->crtc);
|
||||
|
||||
std::string name(output_info->name);
|
||||
|
||||
if (name.empty()) {
|
||||
name = "Display" + std::to_string(i + 1);
|
||||
}
|
||||
|
||||
display_info_list_.push_back(DisplayInfo(
|
||||
(void*)display_, name, true, crtc_info->x, crtc_info->y,
|
||||
crtc_info->x + crtc_info->width, crtc_info->y + crtc_info->height));
|
||||
|
||||
XRRFreeCrtcInfo(crtc_info);
|
||||
}
|
||||
|
||||
if (output_info) {
|
||||
XRRFreeOutputInfo(output_info);
|
||||
}
|
||||
}
|
||||
|
||||
XWindowAttributes attr;
|
||||
XGetWindowAttributes(display_, root_, &attr);
|
||||
|
||||
width_ = attr.width;
|
||||
height_ = attr.height;
|
||||
|
||||
if ((width_ & 1) != 0 || (height_ & 1) != 0) {
|
||||
LOG_WARN("X11 root size {}x{} is not even, aligning down to {}x{} for NV12",
|
||||
width_, height_, width_ & ~1, height_ & ~1);
|
||||
width_ &= ~1;
|
||||
height_ &= ~1;
|
||||
}
|
||||
|
||||
width_ = attr.width & ~1;
|
||||
height_ = attr.height & ~1;
|
||||
if (width_ <= 1 || height_ <= 1) {
|
||||
LOG_ERROR("Invalid capture size after alignment: {}x{}", width_, height_);
|
||||
Destroy();
|
||||
return -2;
|
||||
}
|
||||
|
||||
int event_base = 0, error_base = 0;
|
||||
if (XRRQueryExtension(display_, &event_base, &error_base)) {
|
||||
screen_res_ = XRRGetScreenResources(display_, root_);
|
||||
}
|
||||
if (screen_res_) {
|
||||
for (int i = 0; i < screen_res_->noutput; ++i) {
|
||||
XRROutputInfo* output =
|
||||
XRRGetOutputInfo(display_, screen_res_, screen_res_->outputs[i]);
|
||||
if (!output) continue;
|
||||
if (output->connection == RR_Connected && output->crtc != 0) {
|
||||
XRRCrtcInfo* crtc = XRRGetCrtcInfo(display_, screen_res_, output->crtc);
|
||||
if (crtc) {
|
||||
std::string name(output->name, output->nameLen);
|
||||
if (name.empty()) name = "Display" + std::to_string(i + 1);
|
||||
// Reject stale/offscreen CRTCs after hot-unplug. The root drawable
|
||||
// remains a usable virtual screen even when no output is connected.
|
||||
if (crtc->width > 1 && crtc->height > 1 && crtc->x >= 0 &&
|
||||
crtc->y >= 0 &&
|
||||
crtc->x + crtc->width <= static_cast<unsigned>(attr.width) &&
|
||||
crtc->y + crtc->height <= static_cast<unsigned>(attr.height)) {
|
||||
display_info_list_.emplace_back(
|
||||
display_, name, true, crtc->x, crtc->y,
|
||||
crtc->x + crtc->width, crtc->y + crtc->height);
|
||||
}
|
||||
XRRFreeCrtcInfo(crtc);
|
||||
}
|
||||
}
|
||||
XRRFreeOutputInfo(output);
|
||||
}
|
||||
}
|
||||
if (display_info_list_.empty()) {
|
||||
display_info_list_.emplace_back(display_, "Virtual Display", true,
|
||||
0, 0, width_, height_);
|
||||
LOG_INFO("No active X11 outputs; capturing virtual root window {}x{}",
|
||||
width_, height_);
|
||||
}
|
||||
|
||||
fps_ = fps;
|
||||
callback_ = cb;
|
||||
|
||||
@@ -136,6 +135,7 @@ int ScreenCapturerX11::Init(const int fps, cb_desktop_data cb) {
|
||||
|
||||
if (!ProbeCapture()) {
|
||||
LOG_ERROR("X11 backend probe failed, XGetImage is not usable");
|
||||
Destroy();
|
||||
return -3;
|
||||
}
|
||||
|
||||
@@ -147,6 +147,10 @@ int ScreenCapturerX11::Destroy() {
|
||||
|
||||
y_plane_.clear();
|
||||
uv_plane_.clear();
|
||||
display_info_list_.clear();
|
||||
monitor_index_ = initial_monitor_index_ = 0;
|
||||
root_ = 0;
|
||||
callback_ = nullptr;
|
||||
|
||||
if (screen_res_) {
|
||||
XRRFreeScreenResources(screen_res_);
|
||||
@@ -162,6 +166,7 @@ int ScreenCapturerX11::Destroy() {
|
||||
|
||||
int ScreenCapturerX11::Start(bool show_cursor) {
|
||||
if (running_) return 0;
|
||||
if (!display_ || display_info_list_.empty() || !callback_) return -1;
|
||||
show_cursor_ = show_cursor;
|
||||
running_ = true;
|
||||
paused_ = false;
|
||||
@@ -206,6 +211,8 @@ int ScreenCapturerX11::Resume(int monitor_index) {
|
||||
}
|
||||
|
||||
int ScreenCapturerX11::SwitchTo(int monitor_index) {
|
||||
if (monitor_index < 0 ||
|
||||
monitor_index >= static_cast<int>(display_info_list_.size())) return -1;
|
||||
monitor_index_ = monitor_index;
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -35,18 +35,20 @@ class MacMetalVideoRenderer final : public VideoRenderer {
|
||||
|
||||
// Copies one tightly packed NV12 frame into a free shared Metal slot.
|
||||
SubmitResult SubmitNv12(std::string_view remote_id, const uint8_t* data,
|
||||
size_t size, int width, int height) override;
|
||||
size_t size, int width, int height,
|
||||
VideoLatencyFrame timing = {}) override;
|
||||
|
||||
// Retains an IOSurface-backed VideoToolbox CVPixelBuffer until the Metal
|
||||
// command buffer using it has completed.
|
||||
SubmitResult SubmitNativeFrame(std::string_view remote_id,
|
||||
const MiniRtcNativeVideoFrame& frame) override;
|
||||
const MiniRtcNativeVideoFrame& frame,
|
||||
VideoLatencyFrame timing = {}) override;
|
||||
|
||||
// Seeds a newly selected stream from its retained CPU snapshot without
|
||||
// replacing a newer decoded frame that is already queued or rendering.
|
||||
SubmitResult SubmitCachedNv12(std::string_view remote_id,
|
||||
const uint8_t* data, size_t size, int width,
|
||||
int height) override;
|
||||
SubmitResult SubmitCachedNv12(std::string_view remote_id, const uint8_t* data,
|
||||
size_t size, int width, int height,
|
||||
VideoLatencyFrame timing = {}) override;
|
||||
|
||||
// Attaches a native CAMetalLayer-backed sibling below Slint's NSView and
|
||||
// restores the containing AppKit window's ordinary opaque titlebar.
|
||||
@@ -76,7 +78,8 @@ class MacMetalVideoRenderer final : public VideoRenderer {
|
||||
private:
|
||||
SubmitResult SubmitNv12Internal(std::string_view remote_id,
|
||||
const uint8_t* data, size_t size, int width,
|
||||
int height, bool replace_pending);
|
||||
int height, bool replace_pending,
|
||||
VideoLatencyFrame timing);
|
||||
|
||||
struct Impl;
|
||||
std::unique_ptr<Impl> impl_;
|
||||
|
||||
@@ -3,11 +3,13 @@
|
||||
#import <AppKit/AppKit.h>
|
||||
#import <CoreVideo/CVMetalTextureCache.h>
|
||||
#import <Metal/Metal.h>
|
||||
#import <QuartzCore/CABase.h>
|
||||
#import <QuartzCore/CAMetalLayer.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <atomic>
|
||||
#include <cmath>
|
||||
#include <cstring>
|
||||
#include <limits>
|
||||
#include <mutex>
|
||||
@@ -340,6 +342,7 @@ struct FrameSlot {
|
||||
int height = 0;
|
||||
std::string remote_id;
|
||||
uint64_t sequence = 0;
|
||||
VideoLatencyFrame timing;
|
||||
SlotUse use = SlotUse::available;
|
||||
bool valid = false;
|
||||
|
||||
@@ -744,14 +747,16 @@ void MacMetalVideoRenderer::DiscardStream(std::string_view remote_id) {
|
||||
}
|
||||
}
|
||||
|
||||
MacMetalVideoRenderer::SubmitResult MacMetalVideoRenderer::SubmitNv12(
|
||||
std::string_view remote_id, const uint8_t* data, size_t size, int width,
|
||||
int height) {
|
||||
return SubmitNv12Internal(remote_id, data, size, width, height, true);
|
||||
MacMetalVideoRenderer::SubmitResult MacMetalVideoRenderer::SubmitNv12(std::string_view remote_id,
|
||||
const uint8_t* data,
|
||||
size_t size, int width,
|
||||
int height,
|
||||
VideoLatencyFrame timing) {
|
||||
return SubmitNv12Internal(remote_id, data, size, width, height, true, timing);
|
||||
}
|
||||
|
||||
MacMetalVideoRenderer::SubmitResult MacMetalVideoRenderer::SubmitNativeFrame(
|
||||
std::string_view remote_id, const MiniRtcNativeVideoFrame& frame) {
|
||||
std::string_view remote_id, const MiniRtcNativeVideoFrame& frame, VideoLatencyFrame timing) {
|
||||
if (!IsReady()) {
|
||||
return SubmitResult::failed;
|
||||
}
|
||||
@@ -781,8 +786,7 @@ MacMetalVideoRenderer::SubmitResult MacMetalVideoRenderer::SubmitNativeFrame(
|
||||
packed_nv12.size()) != 0) {
|
||||
return SubmitResult::failed;
|
||||
}
|
||||
return SubmitNv12(remote_id, packed_nv12.data(), packed_nv12.size(), width,
|
||||
height);
|
||||
return SubmitNv12(remote_id, packed_nv12.data(), packed_nv12.size(), width, height, timing);
|
||||
};
|
||||
|
||||
CVPixelBufferRef pixel_buffer =
|
||||
@@ -878,6 +882,7 @@ MacMetalVideoRenderer::SubmitResult MacMetalVideoRenderer::SubmitNativeFrame(
|
||||
target->height = height;
|
||||
target->remote_id.assign(remote_id);
|
||||
target->sequence = frames.next_sequence++;
|
||||
target->timing = timing;
|
||||
target->use = SlotUse::pending;
|
||||
target->valid = true;
|
||||
if (!impl_->native_path_logged.exchange(true, std::memory_order_acq_rel)) {
|
||||
@@ -887,14 +892,14 @@ MacMetalVideoRenderer::SubmitResult MacMetalVideoRenderer::SubmitNativeFrame(
|
||||
}
|
||||
|
||||
MacMetalVideoRenderer::SubmitResult MacMetalVideoRenderer::SubmitCachedNv12(
|
||||
std::string_view remote_id, const uint8_t* data, size_t size, int width,
|
||||
int height) {
|
||||
return SubmitNv12Internal(remote_id, data, size, width, height, false);
|
||||
std::string_view remote_id, const uint8_t* data, size_t size, int width, int height,
|
||||
VideoLatencyFrame timing) {
|
||||
return SubmitNv12Internal(remote_id, data, size, width, height, false, timing);
|
||||
}
|
||||
|
||||
MacMetalVideoRenderer::SubmitResult MacMetalVideoRenderer::SubmitNv12Internal(
|
||||
std::string_view remote_id, const uint8_t* data, size_t size, int width,
|
||||
int height, bool replace_pending) {
|
||||
std::string_view remote_id, const uint8_t* data, size_t size, int width, int height,
|
||||
bool replace_pending, VideoLatencyFrame timing) {
|
||||
if (!IsReady()) {
|
||||
return SubmitResult::failed;
|
||||
}
|
||||
@@ -968,6 +973,7 @@ MacMetalVideoRenderer::SubmitResult MacMetalVideoRenderer::SubmitNv12Internal(
|
||||
|
||||
target->remote_id.assign(remote_id);
|
||||
target->sequence = frames.next_sequence++;
|
||||
target->timing = timing;
|
||||
target->use = SlotUse::pending;
|
||||
target->valid = true;
|
||||
return SubmitResult::submitted;
|
||||
@@ -1189,6 +1195,7 @@ MacMetalVideoRenderer::RenderOutcome MacMetalVideoRenderer::RenderLatest(
|
||||
|
||||
id<MTLTexture> y_texture = nil;
|
||||
id<MTLTexture> uv_texture = nil;
|
||||
VideoLatencyFrame timing;
|
||||
{
|
||||
std::lock_guard lock(impl_->frames->mutex);
|
||||
auto& slot = impl_->frames->slots[slot_index];
|
||||
@@ -1202,6 +1209,7 @@ MacMetalVideoRenderer::RenderOutcome MacMetalVideoRenderer::RenderLatest(
|
||||
slot.use = SlotUse::in_flight;
|
||||
y_texture = slot.y_texture;
|
||||
uv_texture = slot.uv_texture;
|
||||
timing = slot.timing;
|
||||
}
|
||||
|
||||
MTLRenderPassDescriptor* pass = [MTLRenderPassDescriptor renderPassDescriptor];
|
||||
@@ -1259,6 +1267,19 @@ MacMetalVideoRenderer::RenderOutcome MacMetalVideoRenderer::RenderLatest(
|
||||
slot.use = SlotUse::available;
|
||||
}
|
||||
}];
|
||||
[drawable addPresentedHandler:^(id<MTLDrawable> presented) {
|
||||
// presentedTime uses the Core Animation host clock. Translate its age
|
||||
// into our steady clock, excluding callback scheduling delay. Capturing
|
||||
// timing by value keeps this safe after a session/renderer is destroyed.
|
||||
const double presented_time = presented.presentedTime;
|
||||
const auto now = VideoLatencyFrame::Clock::now();
|
||||
const double age = CACurrentMediaTime() - presented_time;
|
||||
if (presented_time > 0 && std::isfinite(age) && age >= 0 && age <= 5) {
|
||||
timing.MarkPresented(
|
||||
now - std::chrono::duration_cast<VideoLatencyFrame::Clock::duration>(
|
||||
std::chrono::duration<double>(age)));
|
||||
}
|
||||
}];
|
||||
[command_buffer presentDrawable:drawable];
|
||||
[command_buffer commit];
|
||||
impl_->rendered_stream.assign(remote_id);
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
#include "runtime/gui_runtime.h"
|
||||
|
||||
#include <SDL3/SDL_timer.h>
|
||||
|
||||
#include "rd_log.h"
|
||||
|
||||
#include <ApplicationServices/ApplicationServices.h>
|
||||
|
||||
@@ -80,25 +80,36 @@ bool StartNativeWindowDrag() {
|
||||
}
|
||||
}
|
||||
|
||||
bool HideDisabledMainWindowZoomButton() {
|
||||
bool ConfigureMainWindowChrome(void *opaque_slint_view) {
|
||||
@autoreleasepool {
|
||||
for (NSWindow *window in [NSApp windows]) {
|
||||
if (![window.title isEqualToString:@"CrossDesk"]) {
|
||||
continue;
|
||||
}
|
||||
|
||||
NSButton *zoom_button =
|
||||
[window standardWindowButton:NSWindowZoomButton];
|
||||
// The main window has a fixed size, so AppKit disables its zoom button.
|
||||
// Stream windows remain resizable and must keep their native button.
|
||||
if (zoom_button == nil || zoom_button.enabled) {
|
||||
continue;
|
||||
}
|
||||
|
||||
zoom_button.hidden = YES;
|
||||
return true;
|
||||
NSView *slint_view = (__bridge NSView *)opaque_slint_view;
|
||||
NSWindow *window = slint_view.window;
|
||||
if (window == nil ||
|
||||
(window.styleMask & NSWindowStyleMaskTitled) == 0) {
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
|
||||
NSButton *zoom_button =
|
||||
[window standardWindowButton:NSWindowZoomButton];
|
||||
if (zoom_button == nil || zoom_button.enabled) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Winit creates a transparent AppKit backing even when Slint paints an
|
||||
// opaque client area. macOS 27 can expose that backing in the titlebar.
|
||||
// Use a fixed white backing instead of AppKit's theme-dependent titlebar
|
||||
// material. Aqua keeps the native title and controls readable on white.
|
||||
window.styleMask &= ~NSWindowStyleMaskFullSizeContentView;
|
||||
window.appearance = [NSAppearance appearanceNamed:NSAppearanceNameAqua];
|
||||
window.titlebarAppearsTransparent = YES;
|
||||
window.opaque = YES;
|
||||
window.backgroundColor = NSColor.whiteColor;
|
||||
zoom_button.hidden = YES;
|
||||
|
||||
NSView *frame_view = window.contentView.superview;
|
||||
frame_view.needsLayout = YES;
|
||||
[frame_view layoutSubtreeIfNeeded];
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -903,12 +903,15 @@ void ScreenCapturerSckImpl::StartOrReconfigureCapturer() {
|
||||
// method is still running on another thread.
|
||||
std::mutex _capturer_lock;
|
||||
ScreenCapturerSckImpl *_capturer;
|
||||
dispatch_queue_t _completion_queue;
|
||||
}
|
||||
|
||||
- (instancetype)initWithCapturer:(ScreenCapturerSckImpl *)capturer {
|
||||
self = [super init];
|
||||
if (self) {
|
||||
_capturer = capturer;
|
||||
_completion_queue = dispatch_queue_create("CrossDesk.ScreenCaptureKit.Completions",
|
||||
DISPATCH_QUEUE_SERIAL);
|
||||
[NSNotificationCenter.defaultCenter addObserver:self selector:@selector(privacyCoversChanged:)
|
||||
name:@"CrossDeskPrivacyCoversChanged" object:nil];
|
||||
}
|
||||
@@ -922,18 +925,25 @@ void ScreenCapturerSckImpl::StartOrReconfigureCapturer() {
|
||||
- (void)configurationFinished:(SCStream*)stream generation:(uint64_t)generation
|
||||
revision:(uint64_t)revision complete:(BOOL)complete
|
||||
configuration:(SCStreamConfiguration*)configuration error:(NSError*)error {
|
||||
std::lock_guard<std::mutex> lock(_capturer_lock);
|
||||
if (_capturer)
|
||||
_capturer->ConfigurationFinished(stream, generation, revision, complete, configuration, error);
|
||||
// SCK may call a completion inline (notably updateContentFilter when nothing
|
||||
// changed). The caller can still hold both _capturer_lock and the capturer's
|
||||
// state lock. Always defer before acquiring either lock, including errors.
|
||||
// The block retains this helper; releaseCapturer revokes queued callbacks
|
||||
// before the C++ capturer is destroyed.
|
||||
dispatch_async(_completion_queue, ^{
|
||||
std::lock_guard<std::mutex> lock(self->_capturer_lock);
|
||||
if (self->_capturer)
|
||||
self->_capturer->ConfigurationFinished(stream, generation, revision, complete,
|
||||
configuration, error);
|
||||
});
|
||||
}
|
||||
- (void)onShareableContentCreated:(SCShareableContent *)content generation:(uint64_t)generation
|
||||
revision:(uint64_t)revision {
|
||||
std::lock_guard<std::mutex> lock(_capturer_lock);
|
||||
if (_capturer) {
|
||||
_capturer->OnShareableContentCreated(content, generation, revision);
|
||||
} else {
|
||||
LOG_ERROR("Invalid capturer");
|
||||
}
|
||||
dispatch_async(_completion_queue, ^{
|
||||
std::lock_guard<std::mutex> lock(self->_capturer_lock);
|
||||
if (self->_capturer)
|
||||
self->_capturer->OnShareableContentCreated(content, generation, revision);
|
||||
});
|
||||
}
|
||||
|
||||
- (void)stream:(SCStream *)stream
|
||||
@@ -971,8 +981,10 @@ void ScreenCapturerSckImpl::StartOrReconfigureCapturer() {
|
||||
}
|
||||
|
||||
- (void)stream:(SCStream*)stream didStopWithError:(NSError*)error {
|
||||
std::lock_guard<std::mutex> lock(_capturer_lock);
|
||||
if (_capturer) _capturer->CaptureStopped(stream, error);
|
||||
dispatch_async(_completion_queue, ^{
|
||||
std::lock_guard<std::mutex> lock(self->_capturer_lock);
|
||||
if (self->_capturer) self->_capturer->CaptureStopped(stream, error);
|
||||
});
|
||||
}
|
||||
|
||||
@end
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
/*
|
||||
* @Author: DI JUNKUN
|
||||
* @Date: 2026-09-26
|
||||
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
|
||||
*/
|
||||
|
||||
#ifndef _SINGLE_INSTANCE_H_
|
||||
#define _SINGLE_INSTANCE_H_
|
||||
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
|
||||
namespace crossdesk::platform {
|
||||
|
||||
enum class InstanceRole { kLauncher, kClient, kServiceCli };
|
||||
enum class InstanceResult { kAcquired, kAlreadyRunning, kError };
|
||||
|
||||
// Per-user ownership, independent of the install/portable directory. The OS
|
||||
// releases ownership on process exit, including crashes; no PID files to age out.
|
||||
class SingleInstanceGuard {
|
||||
public:
|
||||
explicit SingleInstanceGuard(std::string application = "CrossDesk");
|
||||
~SingleInstanceGuard();
|
||||
SingleInstanceGuard(const SingleInstanceGuard&) = delete;
|
||||
SingleInstanceGuard& operator=(const SingleInstanceGuard&) = delete;
|
||||
|
||||
InstanceResult TryAcquire(InstanceRole role, std::string& error);
|
||||
void Release();
|
||||
|
||||
private:
|
||||
std::string application_;
|
||||
std::intptr_t handle_ = -1;
|
||||
};
|
||||
|
||||
inline const char* InstanceRoleName(InstanceRole role) {
|
||||
switch (role) {
|
||||
case InstanceRole::kLauncher: return "launcher";
|
||||
case InstanceRole::kClient: return "client";
|
||||
case InstanceRole::kServiceCli: return "service-cli";
|
||||
}
|
||||
return "client";
|
||||
}
|
||||
|
||||
inline bool ValidInstanceApplication(const std::string& application) {
|
||||
return !application.empty() && application.size() <= 100 &&
|
||||
application.find_first_not_of(
|
||||
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_") ==
|
||||
std::string::npos;
|
||||
}
|
||||
|
||||
} // namespace crossdesk::platform
|
||||
|
||||
#endif
|
||||
@@ -9,6 +9,7 @@
|
||||
#include <vector>
|
||||
|
||||
#include "minirtc.h"
|
||||
#include "rendering/video_latency.h"
|
||||
|
||||
namespace crossdesk {
|
||||
|
||||
@@ -54,14 +55,17 @@ public:
|
||||
|
||||
virtual SubmitResult SubmitNv12(std::string_view remote_id,
|
||||
const uint8_t* data, size_t size, int width,
|
||||
int height) = 0;
|
||||
int height,
|
||||
VideoLatencyFrame timing = {}) = 0;
|
||||
virtual SubmitResult SubmitCachedNv12(std::string_view remote_id,
|
||||
const uint8_t* data, size_t size,
|
||||
int width, int height) = 0;
|
||||
int width, int height,
|
||||
VideoLatencyFrame timing = {}) = 0;
|
||||
// Retains a platform-native decoded frame when the backend supports its
|
||||
// storage and handle types. Unsupported backends keep the default fallback.
|
||||
virtual SubmitResult SubmitNativeFrame(std::string_view,
|
||||
const MiniRtcNativeVideoFrame&) {
|
||||
const MiniRtcNativeVideoFrame&,
|
||||
VideoLatencyFrame = {}) {
|
||||
return SubmitResult::failed;
|
||||
}
|
||||
|
||||
|
||||
@@ -8,9 +8,10 @@ namespace crossdesk {
|
||||
// left-mouse-down event. Returns false when no suitable native event exists.
|
||||
bool StartNativeWindowDrag();
|
||||
|
||||
// Hides the disabled native zoom button on CrossDesk's fixed-size main
|
||||
// window. Returns true once the matching AppKit window has been configured.
|
||||
bool HideDisabledMainWindowZoomButton();
|
||||
// Sets a fixed white native titlebar and hides the disabled zoom button on
|
||||
// the fixed-size main window. |slint_view| is its AppKit NSView*. Returns false
|
||||
// while the native window or its fixed-size constraints are not ready.
|
||||
bool ConfigureMainWindowChrome(void* slint_view);
|
||||
|
||||
// Configures live resize and replaces the stream window's native Space
|
||||
// fullscreen action with an immediate, single-window fullscreen transition.
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
#include "runtime/gui_runtime.h"
|
||||
|
||||
#include <SDL3/SDL_timer.h>
|
||||
|
||||
#include <remote_action.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstring>
|
||||
#include <nlohmann/json.hpp>
|
||||
#include <shared_mutex>
|
||||
#include <string>
|
||||
|
||||
|
||||
@@ -547,7 +547,7 @@ void ScreenCapturerDxgi::CaptureLoop() {
|
||||
if (last_logged_cursor_embedding_ < 0 ||
|
||||
(software_cursor_output_ &&
|
||||
last_logged_cursor_embedding_ != static_cast<int>(cursor_embedded))) {
|
||||
LOG_INFO("DXGI cursor: monitor={} usbmmidd={} mouse_update={} "
|
||||
LOG_DEBUG("DXGI cursor: monitor={} usbmmidd={} mouse_update={} "
|
||||
"separate_pointer_visible={} pointer_shape_bytes={} "
|
||||
"compose_cursor={} embedded={}",
|
||||
frame_monitor, software_cursor_output_,
|
||||
|
||||
@@ -1638,7 +1638,7 @@ void ScreenCapturerWin::SecureDesktopCaptureLoop() {
|
||||
const ULONGLONG elapsed = GetTickCount64() - stats_started;
|
||||
if (elapsed == 0 || (!force && elapsed < 5000)) return;
|
||||
if (shared_frames || fallback_frames || shared_waits || shared_restarts) {
|
||||
LOG_INFO(
|
||||
LOG_DEBUG(
|
||||
"Secure capture delivery: shared_fps={:.1f}, fallback_fps={:.1f}, "
|
||||
"pending_waits={}, shared_restarts={}, status_max_ms={}",
|
||||
shared_frames * 1000.0 / elapsed, fallback_frames * 1000.0 / elapsed,
|
||||
|
||||
@@ -12,7 +12,7 @@ void InitializePluginLogger() {
|
||||
static std::once_flag once;
|
||||
std::call_once(once, []() {
|
||||
crossdesk::PathManager path_manager("CrossDesk");
|
||||
crossdesk::InitLogger(path_manager.GetLogPath().string());
|
||||
crossdesk::InitLogger(path_manager.GetLogPath().string(), "crossdesk-wgc");
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
#include "platform/single_instance.h"
|
||||
|
||||
#include <windows.h>
|
||||
#include <sddl.h>
|
||||
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
namespace crossdesk::platform {
|
||||
|
||||
SingleInstanceGuard::SingleInstanceGuard(std::string application)
|
||||
: application_(std::move(application)) {}
|
||||
|
||||
SingleInstanceGuard::~SingleInstanceGuard() { Release(); }
|
||||
|
||||
InstanceResult SingleInstanceGuard::TryAcquire(InstanceRole role,
|
||||
std::string& error) {
|
||||
error.clear();
|
||||
if (handle_ != -1) return InstanceResult::kAcquired;
|
||||
if (!ValidInstanceApplication(application_)) {
|
||||
error = "Invalid single-instance application name";
|
||||
return InstanceResult::kError;
|
||||
}
|
||||
HANDLE token = nullptr;
|
||||
if (!OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &token)) {
|
||||
error = "Cannot read current user token: " + std::to_string(GetLastError());
|
||||
return InstanceResult::kError;
|
||||
}
|
||||
DWORD size = 0;
|
||||
GetTokenInformation(token, TokenUser, nullptr, 0, &size);
|
||||
std::vector<unsigned char> buffer(size);
|
||||
if (!GetTokenInformation(token, TokenUser, buffer.data(), size, &size)) {
|
||||
const DWORD code = GetLastError();
|
||||
CloseHandle(token);
|
||||
error = "Cannot read current user SID: " + std::to_string(code);
|
||||
return InstanceResult::kError;
|
||||
}
|
||||
CloseHandle(token);
|
||||
wchar_t* sid = nullptr;
|
||||
if (!ConvertSidToStringSidW(reinterpret_cast<TOKEN_USER*>(buffer.data())->User.Sid,
|
||||
&sid)) {
|
||||
error = "Cannot format current user SID: " + std::to_string(GetLastError());
|
||||
return InstanceResult::kError;
|
||||
}
|
||||
const std::string role_name = InstanceRoleName(role);
|
||||
const std::wstring name = L"Global\\" +
|
||||
std::wstring(application_.begin(), application_.end()) + L"." + sid + L"." +
|
||||
std::wstring(role_name.begin(), role_name.end());
|
||||
LocalFree(sid);
|
||||
// Object lifetime is the lease. No thread-owned mutex to transfer to a daemon.
|
||||
HANDLE mutex = CreateMutexW(nullptr, FALSE, name.c_str());
|
||||
const DWORD code = GetLastError();
|
||||
if (!mutex) {
|
||||
error = "Cannot create single-instance mutex: " + std::to_string(code);
|
||||
return InstanceResult::kError;
|
||||
}
|
||||
if (code == ERROR_ALREADY_EXISTS) {
|
||||
CloseHandle(mutex);
|
||||
return InstanceResult::kAlreadyRunning;
|
||||
}
|
||||
handle_ = reinterpret_cast<std::intptr_t>(mutex);
|
||||
return InstanceResult::kAcquired;
|
||||
}
|
||||
|
||||
void SingleInstanceGuard::Release() {
|
||||
if (handle_ == -1) return;
|
||||
CloseHandle(reinterpret_cast<HANDLE>(handle_));
|
||||
handle_ = -1;
|
||||
}
|
||||
|
||||
} // namespace crossdesk::platform
|
||||
@@ -1,11 +1,17 @@
|
||||
#include "thumbnail.h"
|
||||
|
||||
#ifdef _WIN32
|
||||
#include <windows.h>
|
||||
#endif
|
||||
|
||||
#include <openssl/aes.h>
|
||||
#include <openssl/crypto.h>
|
||||
#include <openssl/evp.h>
|
||||
#include <openssl/rand.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <chrono>
|
||||
#include <nlohmann/json.hpp>
|
||||
#include <fstream>
|
||||
#include <iomanip>
|
||||
#include <iostream>
|
||||
@@ -104,116 +110,217 @@ int Thumbnail::SetThumbnailDpiScale(float dpi_scale) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
int Thumbnail::SaveToThumbnail(const char* yuv420p, int width, int height,
|
||||
namespace {
|
||||
|
||||
bool IsRemoteId(const std::string& id) {
|
||||
return id.size() == 9 &&
|
||||
std::all_of(id.begin(), id.end(),
|
||||
[](unsigned char c) { return c >= '0' && c <= '9'; });
|
||||
}
|
||||
|
||||
std::string ConnectionKey(const Thumbnail::RecentConnection& connection) {
|
||||
return connection.remote_id + (connection.remember_password ? "Y" : "N") +
|
||||
connection.remote_host_name +
|
||||
(connection.remember_password ? "@" + connection.password : "");
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
bool Thumbnail::WriteRecord(const RecentConnection& connection) {
|
||||
const auto path =
|
||||
std::filesystem::path(save_path_) / (connection.remote_id + ".json");
|
||||
const auto temporary = path.string() + ".tmp";
|
||||
const nlohmann::json record = {
|
||||
{"remote_id", connection.remote_id},
|
||||
{"host_name", connection.remote_host_name},
|
||||
{"password",
|
||||
connection.remember_password
|
||||
? AES_encrypt(connection.password, aes128_key_, aes128_iv_)
|
||||
: ""},
|
||||
{"platform", HostPlatformName(connection.platform)}};
|
||||
std::ofstream output(temporary, std::ios::binary | std::ios::trunc);
|
||||
output << record.dump();
|
||||
output.close();
|
||||
if (!output) return false;
|
||||
std::error_code error;
|
||||
#ifdef _WIN32
|
||||
// Windows rename does not replace an existing destination.
|
||||
if (!MoveFileExW(std::filesystem::path(temporary).c_str(), path.c_str(),
|
||||
MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH)) {
|
||||
std::filesystem::remove(temporary, error);
|
||||
return false;
|
||||
}
|
||||
#else
|
||||
std::filesystem::rename(temporary, path, error);
|
||||
if (error) {
|
||||
std::filesystem::remove(temporary, error);
|
||||
return false;
|
||||
}
|
||||
#endif
|
||||
return true;
|
||||
}
|
||||
|
||||
bool Thumbnail::ReadRecord(const std::filesystem::path& path,
|
||||
RecentConnection* connection) {
|
||||
std::ifstream input(path, std::ios::binary);
|
||||
const auto record = nlohmann::json::parse(input, nullptr, false);
|
||||
if (!record.is_object()) return false;
|
||||
try {
|
||||
connection->remote_id = record.value("remote_id", "");
|
||||
if (!IsRemoteId(connection->remote_id) ||
|
||||
path.stem() != connection->remote_id)
|
||||
return false;
|
||||
connection->remote_host_name = record.value("host_name", "");
|
||||
const auto password = record.value("password", "");
|
||||
connection->remember_password = !password.empty();
|
||||
connection->password =
|
||||
password.empty() ? "" : AES_decrypt(password, aes128_key_, aes128_iv_);
|
||||
connection->platform =
|
||||
ParseHostPlatform(record.value("platform", "unknown"));
|
||||
const auto image = path.parent_path() / (connection->remote_id + ".png");
|
||||
if (save_previews_ && std::filesystem::is_regular_file(image)) {
|
||||
connection->image_path = image;
|
||||
}
|
||||
return true;
|
||||
} catch (const nlohmann::json::exception&) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
int Thumbnail::SetSavePreviews(bool enabled) {
|
||||
std::lock_guard lock(mutex_);
|
||||
save_previews_ = enabled;
|
||||
if (enabled) return 0;
|
||||
// Migrate legacy filenames before removing their image contents, preserving
|
||||
// credentials, names and recency even when previews have never been enabled.
|
||||
std::vector<std::pair<std::string, RecentConnection>> connections;
|
||||
return LoadThumbnailLocked(connections);
|
||||
}
|
||||
|
||||
int Thumbnail::SaveToThumbnail(const char* nv12, int width, int height,
|
||||
const std::string& remote_id,
|
||||
const std::string& host_name,
|
||||
const std::string& password) {
|
||||
if (!rgba_buffer_) {
|
||||
rgba_buffer_ = new char[thumbnail_width_ * thumbnail_height_ * 4];
|
||||
}
|
||||
const std::string& password,
|
||||
HostPlatform platform) {
|
||||
std::lock_guard lock(mutex_);
|
||||
// Preserve the existing opt-in via Remember password for recent connections.
|
||||
if (password.empty()) return 0;
|
||||
if (!IsRemoteId(remote_id)) return -1;
|
||||
RecentConnection connection;
|
||||
ReadRecord(std::filesystem::path(save_path_) / (remote_id + ".json"),
|
||||
&connection);
|
||||
connection.remote_id = remote_id;
|
||||
connection.remote_host_name = host_name;
|
||||
connection.password = password;
|
||||
connection.remember_password = true;
|
||||
if (platform != HostPlatform::Unknown) connection.platform = platform;
|
||||
if (!WriteRecord(connection)) return -1;
|
||||
|
||||
if (yuv420p) {
|
||||
ScaleNv12ToABGR((char*)yuv420p, width, height, thumbnail_width_,
|
||||
thumbnail_height_, rgba_buffer_);
|
||||
} else {
|
||||
// If yuv420p is null, fill the buffer with black pixels
|
||||
std::memset(rgba_buffer_, 0x00, thumbnail_width_ * thumbnail_height_ * 4);
|
||||
for (int i = 0; i < thumbnail_width_ * thumbnail_height_; ++i) {
|
||||
// Set alpha channel to opaque
|
||||
rgba_buffer_[i * 4 + 3] = static_cast<char>(0xFF);
|
||||
const auto image = std::filesystem::path(save_path_) / (remote_id + ".png");
|
||||
if (save_previews_ && nv12 && width > 0 && height > 0) {
|
||||
if (!rgba_buffer_) {
|
||||
rgba_buffer_ = new char[thumbnail_width_ * thumbnail_height_ * 4];
|
||||
}
|
||||
ScaleNv12ToABGR(const_cast<char*>(nv12), width, height, thumbnail_width_,
|
||||
thumbnail_height_, rgba_buffer_);
|
||||
if (!stbi_write_png(image.string().c_str(), thumbnail_width_,
|
||||
thumbnail_height_, 4, rgba_buffer_,
|
||||
thumbnail_width_ * 4))
|
||||
return -1;
|
||||
} else if (!save_previews_) {
|
||||
std::error_code error;
|
||||
std::filesystem::remove(image, error);
|
||||
if (error) return -1;
|
||||
}
|
||||
|
||||
std::string image_file_name;
|
||||
if (password.empty()) {
|
||||
return 0;
|
||||
} else {
|
||||
// delete the old thumbnail
|
||||
std::string filename_with_remote_id = remote_id;
|
||||
DeleteThumbnail(filename_with_remote_id);
|
||||
}
|
||||
|
||||
std::string cipher_password = AES_encrypt(password, aes128_key_, aes128_iv_);
|
||||
image_file_name = remote_id + 'Y' + host_name + '@' + cipher_password;
|
||||
std::string file_path = save_path_ + image_file_name;
|
||||
stbi_write_png(file_path.data(), thumbnail_width_, thumbnail_height_, 4,
|
||||
rgba_buffer_, thumbnail_width_ * 4);
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
int Thumbnail::LoadThumbnail(
|
||||
std::vector<std::pair<std::string, Thumbnail::RecentConnection>>&
|
||||
recent_connections,
|
||||
std::vector<std::pair<std::string, RecentConnection>>& recent_connections,
|
||||
int* width, int* height) {
|
||||
recent_connections.clear();
|
||||
std::lock_guard lock(mutex_);
|
||||
if (width) *width = thumbnail_width_;
|
||||
if (height) *height = thumbnail_height_;
|
||||
return LoadThumbnailLocked(recent_connections);
|
||||
}
|
||||
|
||||
std::vector<std::filesystem::path> image_paths =
|
||||
FindThumbnailPath(save_path_);
|
||||
|
||||
if (image_paths.size() == 0) {
|
||||
return -1;
|
||||
} else {
|
||||
for (int i = 0; i < image_paths.size(); i++) {
|
||||
// size_t pos1 = image_paths[i].string().find('/') + 1;
|
||||
std::string cipher_image_name = image_paths[i].filename().string();
|
||||
std::string remote_id;
|
||||
std::string cipher_password;
|
||||
std::string remote_host_name;
|
||||
std::string password;
|
||||
std::string original_image_name;
|
||||
bool remember_password = false;
|
||||
|
||||
if (cipher_image_name.size() > 9 && 'Y' == cipher_image_name[9] &&
|
||||
cipher_image_name.size() >= 16) {
|
||||
size_t pos_y = cipher_image_name.find('Y');
|
||||
size_t pos_at = cipher_image_name.find('@');
|
||||
|
||||
if (pos_y == std::string::npos || pos_at == std::string::npos ||
|
||||
pos_y >= pos_at) {
|
||||
LOG_ERROR("Invalid filename");
|
||||
continue;
|
||||
}
|
||||
|
||||
remote_id = cipher_image_name.substr(0, pos_y);
|
||||
remote_host_name =
|
||||
cipher_image_name.substr(pos_y + 1, pos_at - pos_y - 1);
|
||||
cipher_password = cipher_image_name.substr(pos_at + 1);
|
||||
password = AES_decrypt(cipher_password, aes128_key_, aes128_iv_);
|
||||
remember_password = true;
|
||||
|
||||
original_image_name = remote_id + 'Y' + remote_host_name + "@" +
|
||||
password;
|
||||
} else {
|
||||
size_t pos_n = cipher_image_name.find('N');
|
||||
// size_t pos_at = cipher_image_name.find('@');
|
||||
|
||||
if (pos_n == std::string::npos) {
|
||||
LOG_ERROR("Invalid filename");
|
||||
continue;
|
||||
}
|
||||
|
||||
remote_id = cipher_image_name.substr(0, pos_n);
|
||||
remote_host_name = cipher_image_name.substr(pos_n + 1);
|
||||
|
||||
original_image_name = remote_id + 'N' + remote_host_name;
|
||||
}
|
||||
|
||||
std::string image_path = save_path_ + cipher_image_name;
|
||||
Thumbnail::RecentConnection recent_connection;
|
||||
recent_connection.remote_id = remote_id;
|
||||
recent_connection.remote_host_name = remote_host_name;
|
||||
recent_connection.password = password;
|
||||
recent_connection.remember_password = remember_password;
|
||||
recent_connection.image_path = image_path;
|
||||
recent_connections.emplace_back(
|
||||
std::make_pair(original_image_name, recent_connection));
|
||||
}
|
||||
return 0;
|
||||
int Thumbnail::LoadThumbnailLocked(
|
||||
std::vector<std::pair<std::string, RecentConnection>>& recent_connections) {
|
||||
recent_connections.clear();
|
||||
int result = 0;
|
||||
std::map<std::string, RecentConnection> records;
|
||||
const auto paths = FindThumbnailPath(save_path_);
|
||||
for (const auto& path : paths) {
|
||||
if (path.extension() != ".json") continue;
|
||||
RecentConnection connection;
|
||||
if (ReadRecord(path, &connection))
|
||||
records.emplace(connection.remote_id, connection);
|
||||
}
|
||||
return 0;
|
||||
for (const auto& path : paths) {
|
||||
const auto filename = path.filename().string();
|
||||
if (filename.size() < 10 || !IsRemoteId(filename.substr(0, 9)) ||
|
||||
(filename[9] != 'Y' && filename[9] != 'N'))
|
||||
continue;
|
||||
const auto remote_id = filename.substr(0, 9);
|
||||
if (!records.count(remote_id)) {
|
||||
RecentConnection connection;
|
||||
connection.remote_id = remote_id;
|
||||
connection.remember_password = filename[9] == 'Y';
|
||||
const auto separator = filename.find('@', 10);
|
||||
if (connection.remember_password && separator == std::string::npos)
|
||||
continue;
|
||||
connection.remote_host_name =
|
||||
filename.substr(10, connection.remember_password ? separator - 10
|
||||
: std::string::npos);
|
||||
if (connection.remember_password) {
|
||||
connection.password = AES_decrypt(filename.substr(separator + 1),
|
||||
aes128_key_, aes128_iv_);
|
||||
}
|
||||
if (!WriteRecord(connection)) {
|
||||
result = -1;
|
||||
recent_connections.emplace_back(ConnectionKey(connection), connection);
|
||||
continue;
|
||||
}
|
||||
const auto metadata =
|
||||
std::filesystem::path(save_path_) / (remote_id + ".json");
|
||||
std::filesystem::last_write_time(metadata,
|
||||
std::filesystem::last_write_time(path));
|
||||
records.emplace(remote_id, connection);
|
||||
}
|
||||
auto& connection = records.at(remote_id);
|
||||
if (save_previews_ && connection.image_path.empty()) {
|
||||
const auto image =
|
||||
std::filesystem::path(save_path_) / (remote_id + ".png");
|
||||
std::error_code error;
|
||||
std::filesystem::copy_file(
|
||||
path, image, std::filesystem::copy_options::overwrite_existing,
|
||||
error);
|
||||
if (error) {
|
||||
// Keep the original preview available and retry migration next load.
|
||||
connection.image_path = path;
|
||||
result = -1;
|
||||
continue;
|
||||
}
|
||||
connection.image_path = image;
|
||||
}
|
||||
std::error_code error;
|
||||
std::filesystem::remove(path, error);
|
||||
if (error) result = -1;
|
||||
}
|
||||
// Sort by metadata timestamps, unaffected by preview deletion or migration.
|
||||
for (const auto& path : FindThumbnailPath(save_path_)) {
|
||||
if (path.extension() == ".json") {
|
||||
const auto it = records.find(path.stem().string());
|
||||
if (it != records.end()) {
|
||||
recent_connections.emplace_back(ConnectionKey(it->second), it->second);
|
||||
}
|
||||
} else if (!save_previews_ && path.extension() == ".png") {
|
||||
std::error_code error;
|
||||
std::filesystem::remove(path, error);
|
||||
if (error) result = -1;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
bool Thumbnail::DecodeImage(const std::filesystem::path& image_path,
|
||||
@@ -242,16 +349,17 @@ bool Thumbnail::DecodeImage(const std::filesystem::path& image_path,
|
||||
}
|
||||
|
||||
int Thumbnail::DeleteThumbnail(const std::string& filename_keyword) {
|
||||
std::lock_guard lock(mutex_);
|
||||
const auto remote_id = filename_keyword.substr(0, 9);
|
||||
if (!IsRemoteId(remote_id)) return -1;
|
||||
for (const auto& entry : std::filesystem::directory_iterator(save_path_)) {
|
||||
if (entry.is_regular_file()) {
|
||||
const std::string filename = entry.path().filename().string();
|
||||
std::string id_hostname = filename_keyword.substr(0, filename.find('@'));
|
||||
if (filename.find(id_hostname) != std::string::npos) {
|
||||
std::filesystem::remove(entry.path());
|
||||
}
|
||||
const auto name = entry.path().filename().string();
|
||||
if (entry.is_regular_file() && name.size() > 9 &&
|
||||
name.substr(0, 9) == remote_id &&
|
||||
(name[9] == '.' || name[9] == 'Y' || name[9] == 'N')) {
|
||||
std::filesystem::remove(entry.path());
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -280,6 +388,7 @@ std::vector<std::filesystem::path> Thumbnail::FindThumbnailPath(
|
||||
}
|
||||
|
||||
int Thumbnail::DeleteAllFilesInDirectory() {
|
||||
std::lock_guard lock(mutex_);
|
||||
if (std::filesystem::exists(save_path_) &&
|
||||
std::filesystem::is_directory(save_path_)) {
|
||||
for (const auto& entry : std::filesystem::directory_iterator(save_path_)) {
|
||||
|
||||
@@ -10,9 +10,12 @@
|
||||
#include <cstring>
|
||||
#include <filesystem>
|
||||
#include <map>
|
||||
#include <mutex>
|
||||
#include <unordered_map>
|
||||
#include <vector>
|
||||
|
||||
#include "remote_action.h"
|
||||
|
||||
namespace crossdesk {
|
||||
|
||||
class Thumbnail {
|
||||
@@ -23,6 +26,7 @@ class Thumbnail {
|
||||
std::string remote_host_name;
|
||||
std::string password;
|
||||
bool remember_password = false;
|
||||
HostPlatform platform = HostPlatform::Unknown;
|
||||
};
|
||||
|
||||
public:
|
||||
@@ -34,10 +38,14 @@ class Thumbnail {
|
||||
public:
|
||||
int SetThumbnailDpiScale(float dpi_scale);
|
||||
|
||||
int SaveToThumbnail(const char* yuv420p, int width, int height,
|
||||
// Serialized with writes so disabling also clears any in-flight preview.
|
||||
int SetSavePreviews(bool enabled);
|
||||
|
||||
int SaveToThumbnail(const char* nv12, int width, int height,
|
||||
const std::string& remote_id,
|
||||
const std::string& host_name,
|
||||
const std::string& password);
|
||||
const std::string& password,
|
||||
HostPlatform platform = HostPlatform::Unknown);
|
||||
|
||||
int LoadThumbnail(
|
||||
std::vector<std::pair<std::string, Thumbnail::RecentConnection>>&
|
||||
@@ -69,6 +77,11 @@ class Thumbnail {
|
||||
}
|
||||
|
||||
private:
|
||||
int LoadThumbnailLocked(
|
||||
std::vector<std::pair<std::string, RecentConnection>>& connections);
|
||||
bool WriteRecord(const RecentConnection& connection);
|
||||
bool ReadRecord(const std::filesystem::path& path, RecentConnection* connection);
|
||||
|
||||
std::vector<std::filesystem::path> FindThumbnailPath(
|
||||
const std::filesystem::path& directory);
|
||||
|
||||
@@ -79,6 +92,8 @@ class Thumbnail {
|
||||
unsigned char* iv);
|
||||
|
||||
private:
|
||||
std::mutex mutex_;
|
||||
bool save_previews_ = true;
|
||||
int thumbnail_width_ = 160;
|
||||
int thumbnail_height_ = 90;
|
||||
char* rgba_buffer_ = nullptr;
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
#include "update_checker.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <utility>
|
||||
|
||||
#include "rd_log.h"
|
||||
|
||||
namespace crossdesk {
|
||||
using namespace std::chrono_literals;
|
||||
|
||||
UpdateChecker::UpdateChecker(std::string current_version, Fetch fetch)
|
||||
: current_version_(std::move(current_version)), fetch_(std::move(fetch)) {}
|
||||
|
||||
UpdateChecker::~UpdateChecker() { Stop(); }
|
||||
|
||||
bool UpdateChecker::RequestCheck(TimePoint now) {
|
||||
if (stopped_ || pending_.valid()) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
pending_ = std::async(std::launch::async, fetch_);
|
||||
status_ = Status::Checking;
|
||||
return true;
|
||||
} catch (const std::exception& error) {
|
||||
LOG_WARN("Could not start update check: {}", error.what());
|
||||
ScheduleRetry(now);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
bool UpdateChecker::Poll(TimePoint now) {
|
||||
if (stopped_) {
|
||||
return false;
|
||||
}
|
||||
if (!pending_.valid()) {
|
||||
if (now >= next_check_) {
|
||||
RequestCheck(now);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if (pending_.wait_for(0ms) != std::future_status::ready) {
|
||||
return false;
|
||||
}
|
||||
|
||||
std::optional<VersionInfo> result;
|
||||
try {
|
||||
result = pending_.get();
|
||||
} catch (const std::exception& error) {
|
||||
LOG_WARN("Update check failed: {}", error.what());
|
||||
} catch (...) {
|
||||
LOG_WARN("Update check failed with an unknown error");
|
||||
}
|
||||
if (!result) {
|
||||
ScheduleRetry(now);
|
||||
return true;
|
||||
}
|
||||
latest_ = std::move(result);
|
||||
update_available_ =
|
||||
IsNewerVersionWithMetadata(current_version_, latest_->version,
|
||||
latest_->release_date, latest_->patch);
|
||||
status_ = update_available_ ? Status::Available : Status::UpToDate;
|
||||
failures_ = 0;
|
||||
next_check_ = now + 6h;
|
||||
return true;
|
||||
}
|
||||
|
||||
void UpdateChecker::ScheduleRetry(TimePoint now) {
|
||||
constexpr std::chrono::minutes delays[] = {5min, 30min, 60min};
|
||||
next_check_ = now + delays[std::min(failures_, 2u)];
|
||||
failures_ = std::min(failures_ + 1, 3u);
|
||||
status_ = Status::Failed;
|
||||
}
|
||||
|
||||
void UpdateChecker::Stop() {
|
||||
stopped_ = true;
|
||||
// Join before application/logging teardown. The HTTP client uses finite
|
||||
// connection/read/write and overall request timeouts; no worker is detached.
|
||||
if (pending_.valid()) {
|
||||
pending_.wait();
|
||||
pending_ = {};
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace crossdesk
|
||||
@@ -0,0 +1,62 @@
|
||||
/*
|
||||
* @Author: DI JUNKUN
|
||||
* @Date: 2026-09-24
|
||||
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
|
||||
*/
|
||||
|
||||
#ifndef _UPDATE_CHECKER_H_
|
||||
#define _UPDATE_CHECKER_H_
|
||||
|
||||
#include <chrono>
|
||||
#include <functional>
|
||||
#include <future>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
|
||||
#include "version_checker.h"
|
||||
|
||||
namespace crossdesk {
|
||||
|
||||
// Owned and polled by the UI thread. Only the fetch function runs on a worker;
|
||||
// it returns a value and never accesses application or UI state.
|
||||
class UpdateChecker {
|
||||
public:
|
||||
// Wall time includes suspend time, so an overdue check runs on the first UI
|
||||
// tick after wake, including on platforms whose steady clock pauses in sleep.
|
||||
using Clock = std::chrono::system_clock;
|
||||
using TimePoint = Clock::time_point;
|
||||
using Fetch = std::function<std::optional<VersionInfo>()>;
|
||||
enum class Status { Idle, Checking, UpToDate, Available, Failed };
|
||||
|
||||
explicit UpdateChecker(std::string current_version,
|
||||
Fetch fetch = CheckUpdate);
|
||||
~UpdateChecker();
|
||||
|
||||
// Manual requests bypass the schedule, but share an already running check.
|
||||
bool RequestCheck(TimePoint now = Clock::now());
|
||||
// Starts due checks and consumes ready results without blocking. Returns true
|
||||
// when a result was consumed; failure leaves the last successful info intact.
|
||||
bool Poll(TimePoint now = Clock::now());
|
||||
void Stop();
|
||||
|
||||
Status status() const { return status_; }
|
||||
bool update_available() const { return update_available_; }
|
||||
const std::optional<VersionInfo>& latest() const { return latest_; }
|
||||
|
||||
private:
|
||||
void ScheduleRetry(TimePoint now);
|
||||
|
||||
std::string current_version_;
|
||||
Fetch fetch_;
|
||||
std::future<std::optional<VersionInfo>> pending_;
|
||||
std::optional<VersionInfo> latest_;
|
||||
TimePoint next_check_{};
|
||||
Status status_ = Status::Idle;
|
||||
unsigned int failures_ = 0;
|
||||
bool update_available_ = false;
|
||||
bool stopped_ = false;
|
||||
};
|
||||
|
||||
} // namespace crossdesk
|
||||
|
||||
#endif
|
||||
@@ -7,229 +7,19 @@
|
||||
#include "version_checker.h"
|
||||
|
||||
#include <httplib.h>
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
#include "rd_log.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <cctype>
|
||||
#include <cstdlib>
|
||||
#include <filesystem>
|
||||
#include <iostream>
|
||||
#include <limits>
|
||||
#include <sstream>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace crossdesk {
|
||||
|
||||
static std::string latest_release_date_ = "";
|
||||
static bool latest_patch_available_ = false;
|
||||
static int latest_patch_ = 0;
|
||||
|
||||
std::vector<int> SplitVersion(const std::string& ver);
|
||||
|
||||
namespace {
|
||||
|
||||
constexpr size_t kMaxInlinePatchDigits = 4;
|
||||
|
||||
struct ParsedVersion {
|
||||
std::vector<int> numbers;
|
||||
std::string date;
|
||||
bool has_patch = false;
|
||||
int patch = 0;
|
||||
};
|
||||
|
||||
bool IsDigit(char c) {
|
||||
return std::isdigit(static_cast<unsigned char>(c)) != 0;
|
||||
}
|
||||
|
||||
bool IsAlphaNumeric(char c) {
|
||||
return std::isalnum(static_cast<unsigned char>(c)) != 0;
|
||||
}
|
||||
|
||||
bool IsAllDigits(const std::string& value) {
|
||||
if (value.empty()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (char c : value) {
|
||||
if (!IsDigit(c)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool TryParseNonNegativeInt(const std::string& value, int* result) {
|
||||
if (!IsAllDigits(value)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const long long parsed = std::stoll(value);
|
||||
if (parsed > std::numeric_limits<int>::max()) {
|
||||
return false;
|
||||
}
|
||||
*result = static_cast<int>(parsed);
|
||||
return true;
|
||||
} catch (...) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
bool TryParseInlinePatch(const std::string& value, int* result) {
|
||||
if (value.size() > kMaxInlinePatchDigits) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return TryParseNonNegativeInt(value, result);
|
||||
}
|
||||
|
||||
size_t FindNumericStart(const std::string& version) {
|
||||
size_t start = 0;
|
||||
while (start < version.size() && !IsDigit(version[start])) {
|
||||
start++;
|
||||
}
|
||||
return start;
|
||||
}
|
||||
|
||||
size_t FindNumericEnd(const std::string& version, size_t start) {
|
||||
size_t end = start;
|
||||
while (end < version.size() &&
|
||||
(IsDigit(version[end]) || version[end] == '.')) {
|
||||
end++;
|
||||
}
|
||||
return end;
|
||||
}
|
||||
|
||||
bool HasDigitBoundary(const std::string& value, size_t pos, size_t len) {
|
||||
const bool before_ok = pos == 0 || !IsDigit(value[pos - 1]);
|
||||
const size_t end = pos + len;
|
||||
const bool after_ok = end >= value.size() || !IsDigit(value[end]);
|
||||
return before_ok && after_ok;
|
||||
}
|
||||
|
||||
bool IsCompactDateAt(const std::string& value, size_t pos) {
|
||||
if (pos + 8 > value.size() || !HasDigitBoundary(value, pos, 8)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (size_t i = 0; i < 8; ++i) {
|
||||
if (!IsDigit(value[pos + i])) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
std::string CompactDateToIso(const std::string& compact_date) {
|
||||
return compact_date.substr(0, 4) + "-" + compact_date.substr(4, 2) + "-" +
|
||||
compact_date.substr(6, 2);
|
||||
}
|
||||
|
||||
bool ExtractDateFromText(const std::string& value,
|
||||
std::string* date,
|
||||
size_t* date_end) {
|
||||
for (size_t i = 0; i < value.size(); ++i) {
|
||||
if (IsCompactDateAt(value, i)) {
|
||||
*date = CompactDateToIso(value.substr(i, 8));
|
||||
*date_end = i + 8;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
ParsedVersion ParseVersion(const std::string& version) {
|
||||
const size_t numeric_start = FindNumericStart(version);
|
||||
const size_t numeric_end = FindNumericEnd(version, numeric_start);
|
||||
|
||||
ParsedVersion parsed;
|
||||
parsed.numbers = SplitVersion(version.substr(numeric_start,
|
||||
numeric_end - numeric_start));
|
||||
|
||||
const std::string suffix = version.substr(numeric_end);
|
||||
size_t pos = 0;
|
||||
while (pos < suffix.size()) {
|
||||
while (pos < suffix.size() && !IsAlphaNumeric(suffix[pos])) {
|
||||
pos++;
|
||||
}
|
||||
|
||||
const size_t token_start = pos;
|
||||
while (pos < suffix.size() && IsAlphaNumeric(suffix[pos])) {
|
||||
pos++;
|
||||
}
|
||||
|
||||
if (token_start == pos) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const std::string token = suffix.substr(token_start, pos - token_start);
|
||||
if (parsed.date.empty() && IsCompactDateAt(token, 0)) {
|
||||
parsed.date = CompactDateToIso(token);
|
||||
continue;
|
||||
}
|
||||
|
||||
int patch = 0;
|
||||
if (!parsed.has_patch && TryParseInlinePatch(token, &patch)) {
|
||||
parsed.has_patch = true;
|
||||
parsed.patch = patch;
|
||||
}
|
||||
}
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
int CompareNumericVersion(const std::vector<int>& current,
|
||||
const std::vector<int>& latest) {
|
||||
std::vector<int> current_parts = current;
|
||||
std::vector<int> latest_parts = latest;
|
||||
const size_t len = std::max(current_parts.size(), latest_parts.size());
|
||||
current_parts.resize(len, 0);
|
||||
latest_parts.resize(len, 0);
|
||||
|
||||
for (size_t i = 0; i < len; ++i) {
|
||||
if (latest_parts[i] > current_parts[i]) {
|
||||
return 1;
|
||||
}
|
||||
if (latest_parts[i] < current_parts[i]) {
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
void ResetLatestMetadata() {
|
||||
latest_release_date_ = "";
|
||||
latest_patch_available_ = false;
|
||||
latest_patch_ = 0;
|
||||
}
|
||||
|
||||
bool ReadPatchField(const nlohmann::json& json, int* patch) {
|
||||
if (!json.contains("patch")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const auto& patch_value = json["patch"];
|
||||
if (patch_value.is_number_integer()) {
|
||||
const long long parsed = patch_value.get<long long>();
|
||||
if (parsed < 0 || parsed > std::numeric_limits<int>::max()) {
|
||||
return false;
|
||||
}
|
||||
*patch = static_cast<int>(parsed);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (patch_value.is_string()) {
|
||||
return TryParseNonNegativeInt(patch_value.get<std::string>(), patch);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
void LogHttpError(const httplib::Result& result) {
|
||||
LOG_WARN("Failed to fetch version.json: error={}, message={}",
|
||||
static_cast<int>(result.error()), httplib::to_string(result.error()));
|
||||
@@ -311,91 +101,13 @@ void ConfigureLinuxCaCerts(httplib::Client* cli) {
|
||||
|
||||
} // namespace
|
||||
|
||||
std::string ExtractNumericPart(const std::string& ver) {
|
||||
const size_t start = FindNumericStart(ver);
|
||||
const size_t end = FindNumericEnd(ver, start);
|
||||
return ver.substr(start, end - start);
|
||||
}
|
||||
|
||||
std::vector<int> SplitVersion(const std::string& ver) {
|
||||
std::vector<int> nums;
|
||||
std::istringstream ss(ver);
|
||||
std::string token;
|
||||
while (std::getline(ss, token, '.')) {
|
||||
try {
|
||||
nums.push_back(std::stoi(token));
|
||||
} catch (...) {
|
||||
nums.push_back(0);
|
||||
}
|
||||
}
|
||||
return nums;
|
||||
}
|
||||
|
||||
// extract date from version string (format: v1.2.3-20251113-abc
|
||||
// or 1.2.3-20251113-abc)
|
||||
std::string ExtractDateFromVersion(const std::string& version) {
|
||||
const size_t numeric_start = FindNumericStart(version);
|
||||
const size_t numeric_end = FindNumericEnd(version, numeric_start);
|
||||
const std::string suffix = version.substr(numeric_end);
|
||||
std::string date;
|
||||
size_t date_end = 0;
|
||||
if (ExtractDateFromText(suffix, &date, &date_end)) {
|
||||
return date;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
// compare two dates in YYYY-MM-DD format
|
||||
bool IsNewerDate(const std::string& date1, const std::string& date2) {
|
||||
if (date1.empty() || date2.empty()) return false;
|
||||
// simple string comparison works for ISO date format (YYYY-MM-DD)
|
||||
return date2 > date1;
|
||||
}
|
||||
|
||||
bool IsNewerVersion(const std::string& current, const std::string& latest) {
|
||||
return IsNewerVersionWithMetadata(
|
||||
current, latest, latest_release_date_,
|
||||
latest_patch_available_ ? latest_patch_ : -1);
|
||||
}
|
||||
|
||||
bool IsNewerVersionWithMetadata(const std::string& current,
|
||||
const std::string& latest,
|
||||
const std::string& latest_date,
|
||||
int latest_patch) {
|
||||
(void)latest_date;
|
||||
|
||||
const ParsedVersion current_version = ParseVersion(current);
|
||||
const ParsedVersion latest_version = ParseVersion(latest);
|
||||
|
||||
const int numeric_compare =
|
||||
CompareNumericVersion(current_version.numbers, latest_version.numbers);
|
||||
if (numeric_compare > 0) {
|
||||
return true;
|
||||
}
|
||||
if (numeric_compare < 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const bool metadata_has_patch = latest_patch >= 0;
|
||||
const bool latest_has_patch = metadata_has_patch || latest_version.has_patch;
|
||||
if (latest_has_patch || current_version.has_patch) {
|
||||
const int resolved_latest_patch =
|
||||
metadata_has_patch ? latest_patch
|
||||
: (latest_version.has_patch ? latest_version.patch
|
||||
: 0);
|
||||
const int resolved_current_patch =
|
||||
current_version.has_patch ? current_version.patch : 0;
|
||||
return resolved_latest_patch > resolved_current_patch;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
nlohmann::json CheckUpdate() {
|
||||
std::optional<VersionInfo> CheckUpdate() {
|
||||
httplib::Client cli("https://version.crossdesk.cn");
|
||||
|
||||
cli.set_connection_timeout(5);
|
||||
cli.set_read_timeout(5);
|
||||
cli.set_write_timeout(5);
|
||||
cli.set_max_timeout(15000);
|
||||
cli.set_follow_location(true);
|
||||
|
||||
#if defined(CPPHTTPLIB_OPENSSL_SUPPORT) && defined(__linux__)
|
||||
@@ -406,33 +118,25 @@ nlohmann::json CheckUpdate() {
|
||||
if (res) {
|
||||
if (res->status == 200) {
|
||||
try {
|
||||
auto j = nlohmann::json::parse(res->body);
|
||||
if (j.contains("releaseDate") && j["releaseDate"].is_string()) {
|
||||
latest_release_date_ = j["releaseDate"];
|
||||
auto info = ParseVersionInfo(nlohmann::json::parse(res->body));
|
||||
if (info) {
|
||||
LOG_INFO("Fetched version.json: latest_version={}, releaseDate={}, patch={}",
|
||||
info->version, info->release_date, info->patch);
|
||||
} else {
|
||||
latest_release_date_ = "";
|
||||
LOG_WARN("version.json does not contain a valid version");
|
||||
}
|
||||
latest_patch_ = 0;
|
||||
latest_patch_available_ = ReadPatchField(j, &latest_patch_);
|
||||
LOG_INFO("Fetched version.json: latest_version={}, releaseDate={}, patch={}",
|
||||
j.value("latest_version", j.value("version", "")),
|
||||
j.value("releaseDate", ""),
|
||||
latest_patch_available_ ? latest_patch_ : -1);
|
||||
return j;
|
||||
return info;
|
||||
} catch (const std::exception& e) {
|
||||
LOG_WARN("Failed to parse version.json: {}", e.what());
|
||||
ResetLatestMetadata();
|
||||
return nlohmann::json{};
|
||||
return std::nullopt;
|
||||
}
|
||||
} else {
|
||||
LOG_WARN("Failed to fetch version.json: HTTP status={}", res->status);
|
||||
ResetLatestMetadata();
|
||||
return nlohmann::json{};
|
||||
return std::nullopt;
|
||||
}
|
||||
} else {
|
||||
LogHttpError(res);
|
||||
ResetLatestMetadata();
|
||||
return nlohmann::json{};
|
||||
return std::nullopt;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,20 +7,13 @@
|
||||
#ifndef _VERSION_CHECKER_H_
|
||||
#define _VERSION_CHECKER_H_
|
||||
|
||||
#include <nlohmann/json.hpp>
|
||||
#include <string>
|
||||
#include <nlohmann/json_fwd.hpp>
|
||||
#include <app_version.h>
|
||||
|
||||
namespace crossdesk {
|
||||
|
||||
nlohmann::json CheckUpdate();
|
||||
|
||||
bool IsNewerVersion(const std::string& current, const std::string& latest);
|
||||
|
||||
// Pass latest_patch < 0 when patch metadata is unavailable.
|
||||
bool IsNewerVersionWithMetadata(const std::string& current,
|
||||
const std::string& latest,
|
||||
const std::string& latest_date,
|
||||
int latest_patch);
|
||||
std::optional<VersionInfo> ParseVersionInfo(const nlohmann::json& json);
|
||||
std::optional<VersionInfo> CheckUpdate();
|
||||
|
||||
} // namespace crossdesk
|
||||
|
||||
|
||||
@@ -2,8 +2,10 @@
|
||||
#include "fa_solid_900.h"
|
||||
#include "ui/ui_localization.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cassert>
|
||||
#include <charconv>
|
||||
#include <chrono>
|
||||
#include <cstdlib>
|
||||
#include <fstream>
|
||||
#include <iostream>
|
||||
@@ -133,12 +135,13 @@ bool ConfigureDocumentationDemo(
|
||||
std::cerr << "Missing documentation thumbnail: " << path << '\n';
|
||||
return false;
|
||||
}
|
||||
crossdesk::ui::RecentConnection connection;
|
||||
crossdesk::ui::RecentConnection connection{};
|
||||
connection.remote_id = device.id;
|
||||
connection.display_name = device.name;
|
||||
connection.host_name = device.name;
|
||||
connection.online = true;
|
||||
connection.thumbnail = thumbnail;
|
||||
connection.has_thumbnail = true;
|
||||
connections.emplace_back(std::move(connection));
|
||||
}
|
||||
window->set_recent_connections(
|
||||
@@ -146,6 +149,12 @@ bool ConfigureDocumentationDemo(
|
||||
std::move(connections)));
|
||||
window->set_signal_connected(true);
|
||||
window->set_hardware_codec_available(true);
|
||||
#if defined(_WIN32) || defined(__APPLE__) || defined(__linux__)
|
||||
window->set_privacy_setting_available(true);
|
||||
#endif
|
||||
if (options.page == "about") {
|
||||
window->set_current_version("v1.5.2");
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -161,6 +170,7 @@ void ResetMainCaptureState(
|
||||
window->set_about_open(false);
|
||||
window->set_update_open(false);
|
||||
window->set_update_available(false);
|
||||
window->set_update_check_status(crossdesk::ui::UpdateCheckStatus::Idle);
|
||||
window->set_reset_password_open(false);
|
||||
window->set_reset_password_invalid(false);
|
||||
window->set_new_password_input("");
|
||||
@@ -176,6 +186,7 @@ void ResetMainCaptureState(
|
||||
window->set_permission_dialog_open(false);
|
||||
window->set_screen_recording_granted(true);
|
||||
window->set_accessibility_granted(true);
|
||||
window->set_wayland_notice_open(false);
|
||||
window->set_settings_session_active(false);
|
||||
window->set_portable_service_settings_visible(false);
|
||||
window->set_portable_service_dialog_open(false);
|
||||
@@ -213,9 +224,20 @@ void ConfigureMainCapturePage(
|
||||
} else if (options.page == "self-hosted") {
|
||||
window->set_settings_open(true);
|
||||
window->set_self_host_settings_open(true);
|
||||
} else if (options.page == "about") {
|
||||
window->set_current_version("0.0.0");
|
||||
} else if (options.page == "about" || options.page == "about-checking" ||
|
||||
options.page == "about-failed" || options.page == "about-update") {
|
||||
window->set_current_version("v1.3.5-1-20260924");
|
||||
window->set_about_open(true);
|
||||
window->set_update_check_status(crossdesk::ui::UpdateCheckStatus::UpToDate);
|
||||
if (options.page == "about-checking") {
|
||||
window->set_update_check_status(crossdesk::ui::UpdateCheckStatus::Checking);
|
||||
} else if (options.page == "about-failed" || options.page == "about-update") {
|
||||
window->set_update_available(true);
|
||||
window->set_latest_version("v1.3.6-1-20260924");
|
||||
window->set_update_check_status(options.page == "about-failed"
|
||||
? crossdesk::ui::UpdateCheckStatus::Failed
|
||||
: crossdesk::ui::UpdateCheckStatus::Available);
|
||||
}
|
||||
} else if (options.page == "update") {
|
||||
window->set_update_available(true);
|
||||
window->set_latest_version("v0.0.1");
|
||||
@@ -256,6 +278,8 @@ void ConfigureMainCapturePage(
|
||||
window->set_permission_dialog_open(true);
|
||||
window->set_screen_recording_granted(false);
|
||||
window->set_accessibility_granted(false);
|
||||
} else if (options.page == "wayland-notice") {
|
||||
window->set_wayland_notice_open(true);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -361,8 +385,40 @@ int RunCaptureMode(
|
||||
server->hide();
|
||||
|
||||
slint::Window *capture_window = &window->window();
|
||||
if (options.page == "stream") {
|
||||
const bool capture_stream =
|
||||
options.page == "stream" || options.page == "stream-video-settings" ||
|
||||
options.page == "stream-update" || options.page == "stream-update-legacy";
|
||||
if (capture_stream) {
|
||||
ConfigureStreamCapturePage(stream, options.language);
|
||||
if (!options.demo_assets_path.empty()) {
|
||||
// Reuse a public page capture as an explicitly illustrative remote frame.
|
||||
const auto frame = slint::Image::load_from_path(slint::SharedString(
|
||||
(options.demo_assets_path + "/fixtures/website.png").c_str()));
|
||||
if (frame.size().width == 0 || frame.size().height == 0) {
|
||||
return 8;
|
||||
}
|
||||
stream->set_frame(frame);
|
||||
crossdesk::ui::StreamTab tab;
|
||||
tab.remote_id = "246813579";
|
||||
tab.title = "Office PC";
|
||||
tab.connected = true;
|
||||
stream->set_tabs(
|
||||
std::make_shared<slint::VectorModel<crossdesk::ui::StreamTab>>(
|
||||
std::vector{tab}));
|
||||
stream->set_privacy_can_toggle(true);
|
||||
stream->set_privacy_off(true);
|
||||
stream->window().set_size(
|
||||
slint::LogicalSize(slint::Size<float>{960.0f, 600.0f}));
|
||||
}
|
||||
if (options.page == "stream-video-settings") {
|
||||
stream->set_video_settings_enabled(true);
|
||||
stream->set_video_menu_open(true);
|
||||
}
|
||||
if (options.page == "stream-update" || options.page == "stream-update-legacy") {
|
||||
stream->set_remote_update_visible(true);
|
||||
stream->on_dismiss_remote_update(
|
||||
[stream] { stream->set_remote_update_visible(false); });
|
||||
}
|
||||
window->hide();
|
||||
stream->show();
|
||||
capture_window = &stream->window();
|
||||
@@ -376,12 +432,21 @@ int RunCaptureMode(
|
||||
}
|
||||
|
||||
if (!options.snapshot_path.empty()) {
|
||||
return WriteWindowSnapshot(*capture_window, options.snapshot_path.c_str())
|
||||
? 0
|
||||
: 7;
|
||||
// Let the native backend apply its window size and display scale before
|
||||
// taking the snapshot; capturing immediately after show() can yield 1x.
|
||||
bool snapshot_written = false;
|
||||
slint::Timer snapshot_timer;
|
||||
snapshot_timer.start(slint::TimerMode::SingleShot,
|
||||
std::chrono::milliseconds(250), [&] {
|
||||
snapshot_written =
|
||||
WriteWindowSnapshot(*capture_window, options.snapshot_path.c_str());
|
||||
slint::quit_event_loop();
|
||||
});
|
||||
slint::run_event_loop();
|
||||
return snapshot_written ? 0 : 7;
|
||||
}
|
||||
|
||||
if (options.page == "stream" || options.page == "server") {
|
||||
if (capture_stream || options.page == "server") {
|
||||
slint::run_event_loop();
|
||||
} else {
|
||||
window->run();
|
||||
@@ -444,6 +509,24 @@ int main() {
|
||||
window->set_about_open(true);
|
||||
assert(window->get_settings_open());
|
||||
assert(window->get_about_open());
|
||||
window->set_update_available(true);
|
||||
if (window->get_update_open()) {
|
||||
std::cerr << "Automatic update checks must not open the update dialog\n";
|
||||
return 8;
|
||||
}
|
||||
bool update_check_requested = false;
|
||||
window->on_check_for_updates([&] {
|
||||
update_check_requested = true;
|
||||
window->set_update_check_status(crossdesk::ui::UpdateCheckStatus::Checking);
|
||||
});
|
||||
window->invoke_check_for_updates();
|
||||
if (!update_check_requested || window->get_update_check_status() !=
|
||||
crossdesk::ui::UpdateCheckStatus::Checking) {
|
||||
std::cerr << "Manual update check must expose the checking state\n";
|
||||
return 8;
|
||||
}
|
||||
window->set_update_available(false);
|
||||
window->set_update_check_status(crossdesk::ui::UpdateCheckStatus::Idle);
|
||||
if (const char *snapshot_path =
|
||||
std::getenv("CROSSDESK_SETTINGS_UI_SNAPSHOT")) {
|
||||
window->set_about_open(false);
|
||||
@@ -463,7 +546,7 @@ int main() {
|
||||
assert(std::string(window->get_remote_id_input()).empty());
|
||||
|
||||
std::vector<crossdesk::ui::RecentConnection> connections;
|
||||
crossdesk::ui::RecentConnection connection;
|
||||
crossdesk::ui::RecentConnection connection{};
|
||||
connection.remote_id = "987654321";
|
||||
connection.display_name = "Office PC";
|
||||
connection.host_name = "office-pc";
|
||||
@@ -566,6 +649,92 @@ int main() {
|
||||
assert(keyboard_focus_changed);
|
||||
assert(keyboard_input_received);
|
||||
|
||||
stream->window().dispatch_key_release_event("a");
|
||||
|
||||
// The modal owns keyboard and pointer input. Complete a gesture that was
|
||||
// already sent to the peer before showing it, then block new gestures.
|
||||
int remote_key_events = 0;
|
||||
int remote_pointer_presses = 0;
|
||||
int remote_pointer_releases = 0;
|
||||
int notice_dismissals = 0;
|
||||
stream->on_key_input(
|
||||
[&](slint::SharedString, bool, bool, bool, bool, bool) {
|
||||
++remote_key_events;
|
||||
});
|
||||
stream->on_pointer_input(
|
||||
[&](slint::PointerEventButton, slint::PointerEventKind kind, float, float) {
|
||||
if (kind == slint::PointerEventKind::Down) ++remote_pointer_presses;
|
||||
if (kind == slint::PointerEventKind::Up) ++remote_pointer_releases;
|
||||
});
|
||||
stream->on_dismiss_remote_update([&] {
|
||||
++notice_dismissals;
|
||||
stream->set_remote_update_visible(false);
|
||||
});
|
||||
const auto outside_notice =
|
||||
slint::LogicalPosition(slint::Point<float>{640.0f, 500.0f});
|
||||
stream->window().dispatch_pointer_press_event(
|
||||
outside_notice, slint::PointerEventButton::Left);
|
||||
stream->set_remote_update_visible(true);
|
||||
stream->window().dispatch_pointer_release_event(
|
||||
outside_notice, slint::PointerEventButton::Left);
|
||||
if (remote_pointer_presses != 1 || remote_pointer_releases != 1) {
|
||||
std::cerr << "Showing the upgrade dialog must finish an active remote gesture\n";
|
||||
return 9;
|
||||
}
|
||||
const float notice_y =
|
||||
std::max(64.0f, stream->get_video_area_height() * 0.18f) + 24.0f;
|
||||
if (!stream->invoke_is_local_control_area(640.0f, notice_y) ||
|
||||
!stream->invoke_is_local_control_area(640.0f, 500.0f)) {
|
||||
std::cerr << "Remote upgrade dialog and backdrop must consume local input\n";
|
||||
return 9;
|
||||
}
|
||||
stream->window().dispatch_pointer_press_event(
|
||||
outside_notice, slint::PointerEventButton::Left);
|
||||
stream->window().dispatch_pointer_release_event(
|
||||
outside_notice, slint::PointerEventButton::Left);
|
||||
stream->window().dispatch_key_press_event("a");
|
||||
stream->window().dispatch_key_release_event("a");
|
||||
if (remote_pointer_presses != 1 || remote_pointer_releases != 1 ||
|
||||
remote_key_events != 0 || notice_dismissals != 0) {
|
||||
std::cerr << "Upgrade dialog input must stay local\n";
|
||||
return 9;
|
||||
}
|
||||
stream->window().dispatch_key_press_event("\x1b");
|
||||
stream->window().dispatch_key_release_event("\x1b");
|
||||
if (stream->get_remote_update_visible() || notice_dismissals != 1 ||
|
||||
remote_key_events != 0) {
|
||||
std::cerr << "Escape must dismiss the dialog without forwarding the key\n";
|
||||
return 9;
|
||||
}
|
||||
if (stream->invoke_is_local_control_area(640.0f, notice_y)) {
|
||||
std::cerr << "Dismissed upgrade notice must release its input area\n";
|
||||
return 9;
|
||||
}
|
||||
stream->window().dispatch_key_press_event("a");
|
||||
stream->window().dispatch_key_release_event("a");
|
||||
stream->window().dispatch_pointer_press_event(
|
||||
outside_notice, slint::PointerEventButton::Left);
|
||||
stream->window().dispatch_pointer_release_event(
|
||||
outside_notice, slint::PointerEventButton::Left);
|
||||
if (remote_key_events != 2 || remote_pointer_presses != 2 ||
|
||||
remote_pointer_releases != 2) {
|
||||
std::cerr << "Dismissing the upgrade dialog must restore remote input\n";
|
||||
return 9;
|
||||
}
|
||||
|
||||
stream->set_remote_update_visible(true);
|
||||
const auto notice_button = slint::LogicalPosition(slint::Point<float>{
|
||||
816.0f, stream->get_video_area_top() + notice_y - 24.0f + 158.0f});
|
||||
stream->window().dispatch_pointer_press_event(
|
||||
notice_button, slint::PointerEventButton::Left);
|
||||
stream->window().dispatch_pointer_release_event(
|
||||
notice_button, slint::PointerEventButton::Left);
|
||||
if (stream->get_remote_update_visible() || notice_dismissals != 2 ||
|
||||
remote_pointer_presses != 2 || remote_pointer_releases != 2) {
|
||||
std::cerr << "Upgrade confirmation click must not reach the peer\n";
|
||||
return 9;
|
||||
}
|
||||
|
||||
crossdesk::ui::FileTransferEntry transfer;
|
||||
transfer.name = "archive.zip";
|
||||
transfer.status = "Sending";
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
#include "update_checker.h"
|
||||
|
||||
#include <atomic>
|
||||
#include <iostream>
|
||||
#include <stdexcept>
|
||||
#include <thread>
|
||||
|
||||
namespace {
|
||||
using namespace std::chrono_literals;
|
||||
using crossdesk::UpdateChecker;
|
||||
using crossdesk::VersionInfo;
|
||||
using Status = UpdateChecker::Status;
|
||||
using TimePoint = UpdateChecker::TimePoint;
|
||||
|
||||
bool Expect(const char* name, bool condition) {
|
||||
if (!condition) {
|
||||
std::cerr << "Failed: " << name << '\n';
|
||||
}
|
||||
return condition;
|
||||
}
|
||||
|
||||
bool Complete(UpdateChecker& checker, TimePoint now) {
|
||||
const auto deadline = std::chrono::steady_clock::now() + 5s;
|
||||
while (std::chrono::steady_clock::now() < deadline) {
|
||||
if (checker.Poll(now)) {
|
||||
return true;
|
||||
}
|
||||
std::this_thread::sleep_for(1ms);
|
||||
}
|
||||
return Expect("worker finishes without blocking UI polling", false);
|
||||
}
|
||||
|
||||
bool TestAsyncAndSchedule() {
|
||||
std::promise<void> release;
|
||||
const auto gate = release.get_future().share();
|
||||
std::promise<void> started;
|
||||
auto started_future = started.get_future();
|
||||
std::atomic<int> calls{0};
|
||||
UpdateChecker checker("1.0.0", [&]() -> std::optional<VersionInfo> {
|
||||
if (++calls == 1) {
|
||||
started.set_value();
|
||||
gate.wait();
|
||||
}
|
||||
return VersionInfo{"1.0.0"};
|
||||
});
|
||||
const TimePoint now{};
|
||||
bool ok = Expect("initial state has no result", !checker.latest());
|
||||
checker.Poll(now);
|
||||
ok &= Expect("startup check starts asynchronously",
|
||||
checker.status() == Status::Checking &&
|
||||
started_future.wait_for(5s) == std::future_status::ready);
|
||||
ok &= Expect("manual request shares running startup check",
|
||||
!checker.RequestCheck(now));
|
||||
checker.Poll(now + 8h);
|
||||
ok &= Expect("overdue timer cannot overlap active request", calls == 1);
|
||||
release.set_value();
|
||||
ok &= Complete(checker, now);
|
||||
ok &= Expect(
|
||||
"successful current version result",
|
||||
checker.status() == Status::UpToDate && !checker.update_available());
|
||||
checker.Poll(now + 6h - 1ms);
|
||||
ok &=
|
||||
Expect("no check before six hours", checker.status() == Status::UpToDate);
|
||||
checker.Poll(now + 6h);
|
||||
ok &= Complete(checker, now + 6h);
|
||||
ok &= Expect("periodic check runs once", calls == 2);
|
||||
// Simulate the process receiving no ticks while the machine is asleep.
|
||||
checker.Poll(now + 20h);
|
||||
ok &= Complete(checker, now + 20h);
|
||||
ok &= Expect("wake starts one overdue check without replaying missed checks",
|
||||
calls == 3);
|
||||
ok &= Expect("manual check bypasses six-hour interval",
|
||||
checker.RequestCheck(now + 20h + 1min));
|
||||
ok &= Complete(checker, now + 20h + 1min);
|
||||
checker.Stop();
|
||||
checker.Poll(now + 48h);
|
||||
ok &= Expect("stopped checker cannot restart",
|
||||
!checker.RequestCheck(now + 48h) && calls == 4);
|
||||
return ok;
|
||||
}
|
||||
|
||||
bool TestRetryAndCachedResult() {
|
||||
int calls = 0;
|
||||
UpdateChecker checker(
|
||||
"1.3.5-1-20260529", [&]() -> std::optional<VersionInfo> {
|
||||
++calls;
|
||||
if (calls <= 4 || calls == 7) {
|
||||
return std::nullopt;
|
||||
}
|
||||
if (calls == 5) {
|
||||
return VersionInfo{"1.3.5", "Patch release", "Fixes", "2026-05-29",
|
||||
2};
|
||||
}
|
||||
if (calls == 6) {
|
||||
throw std::runtime_error("simulated network failure");
|
||||
}
|
||||
return VersionInfo{"1.3.5", "", "", "", 1};
|
||||
});
|
||||
TimePoint now{};
|
||||
checker.Poll(now);
|
||||
bool ok = Complete(checker, now);
|
||||
ok &= Expect("startup failure is not up to date",
|
||||
checker.status() == Status::Failed && !checker.latest());
|
||||
for (const auto delay : {5min, 30min, 60min, 60min}) {
|
||||
checker.Poll(now + delay - 1ms);
|
||||
ok &= Expect("failure does not retry early",
|
||||
checker.status() == Status::Failed);
|
||||
now += delay;
|
||||
checker.Poll(now);
|
||||
ok &= Complete(checker, now);
|
||||
}
|
||||
ok &= Expect("metadata patch detects update after retries",
|
||||
checker.status() == Status::Available &&
|
||||
checker.update_available() && calls == 5);
|
||||
checker.RequestCheck(now + 1min);
|
||||
now += 1min;
|
||||
ok &= Complete(checker, now);
|
||||
ok &= Expect("exception is a failure and preserves previous update details",
|
||||
checker.status() == Status::Failed &&
|
||||
checker.update_available() && checker.latest() &&
|
||||
checker.latest()->release_name == "Patch release" &&
|
||||
checker.latest()->release_notes == "Fixes" &&
|
||||
checker.latest()->patch == 2);
|
||||
checker.Poll(now + 5min - 1ms);
|
||||
ok &= Expect("successful check resets backoff",
|
||||
checker.status() == Status::Failed);
|
||||
now += 5min;
|
||||
checker.Poll(now);
|
||||
ok &= Complete(checker, now);
|
||||
ok &= Expect("retry is due five minutes after failure", calls == 7);
|
||||
checker.RequestCheck(now + 1min);
|
||||
ok &= Complete(checker, now + 1min);
|
||||
ok &= Expect("new successful result replaces stale metadata",
|
||||
checker.status() == Status::UpToDate &&
|
||||
!checker.update_available() &&
|
||||
checker.latest()->release_notes.empty() &&
|
||||
checker.latest()->patch == 1);
|
||||
return ok;
|
||||
}
|
||||
|
||||
bool TestStopDuringFetch() {
|
||||
std::atomic<bool> finished{false};
|
||||
UpdateChecker checker("1.0.0", [&]() -> std::optional<VersionInfo> {
|
||||
std::this_thread::sleep_for(10ms);
|
||||
finished = true;
|
||||
return VersionInfo{"2.0.0"};
|
||||
});
|
||||
checker.RequestCheck();
|
||||
checker.Stop();
|
||||
return Expect("shutdown joins the worker and discards pending UI results",
|
||||
finished && !checker.Poll() && !checker.latest() &&
|
||||
!checker.RequestCheck());
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
int main() {
|
||||
bool ok = TestAsyncAndSchedule();
|
||||
ok &= TestRetryAndCachedResult();
|
||||
ok &= TestStopDuringFetch();
|
||||
return ok ? 0 : 1;
|
||||
}
|
||||
@@ -1,7 +1,10 @@
|
||||
#include "version_checker.h"
|
||||
|
||||
#include <nlohmann/json.hpp>
|
||||
#include <iostream>
|
||||
#include <limits>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace {
|
||||
|
||||
@@ -50,5 +53,81 @@ int main() {
|
||||
"v1.3.5-9-20260529", "v1.3.6-1-20260529", "", -1),
|
||||
true);
|
||||
|
||||
const auto info =
|
||||
crossdesk::ParseVersionInfo({{"latest_version", "v1.3.5-20260529"},
|
||||
{"patch", "10"},
|
||||
{"releaseName", "Release"},
|
||||
{"releaseNotes", "Changes"},
|
||||
{"releaseDate", "2026-05-29"}});
|
||||
ok &= ExpectEqual("valid metadata is normalized and retained",
|
||||
info && info->version == "1.3.5-20260529" &&
|
||||
info->patch == 10 && info->release_name == "Release" &&
|
||||
info->release_notes == "Changes" &&
|
||||
info->release_date == "2026-05-29",
|
||||
true);
|
||||
const auto legacy = crossdesk::ParseVersionInfo({{"version", "1.3.6"}});
|
||||
ok &= ExpectEqual("legacy version field is supported",
|
||||
legacy && legacy->version == "1.3.6" && legacy->patch == -1,
|
||||
true);
|
||||
for (const auto& json :
|
||||
std::vector<nlohmann::json>{nullptr,
|
||||
nlohmann::json::array(),
|
||||
nlohmann::json::object(),
|
||||
{{"version", false}},
|
||||
{{"version", ""}},
|
||||
{{"version", "unknown"}},
|
||||
{{"version", "1..2"}},
|
||||
{{"version", ".1.2"}},
|
||||
{{"version", "1.2."}},
|
||||
{{"version", "1.2-"}},
|
||||
{{"version", "999999999999999999999.2"}}}) {
|
||||
ok &= ExpectEqual("invalid response is a failed check: " + json.dump(),
|
||||
crossdesk::ParseVersionInfo(json).has_value(), false);
|
||||
}
|
||||
const auto malformed_optional = crossdesk::ParseVersionInfo(
|
||||
{{"version", "1.3.5"},
|
||||
{"patch", std::numeric_limits<uint64_t>::max()},
|
||||
{"releaseName", nullptr},
|
||||
{"releaseNotes", 123}});
|
||||
ok &= ExpectEqual("bad optional metadata does not leak into later checks",
|
||||
malformed_optional && malformed_optional->patch == -1 &&
|
||||
malformed_optional->release_name.empty() &&
|
||||
malformed_optional->release_notes.empty(),
|
||||
true);
|
||||
ok &= ExpectEqual("plain comparison has no hidden metadata from prior checks",
|
||||
crossdesk::IsNewerVersion("1.3.5", "1.3.5"), false);
|
||||
|
||||
const auto release = crossdesk::ParseVersionInfoJSON(
|
||||
R"({"latest_version":"v1.5.3-20260928","patch":2})");
|
||||
ok &= ExpectEqual("shared release JSON parser",
|
||||
release.has_value(), true);
|
||||
if (release) {
|
||||
for (const auto& version : {"1.5.3-2-20260927", "1.5.3-3-20260928", "1.6.0"}) {
|
||||
ok &= ExpectEqual(std::string("no remote update for ") + version,
|
||||
crossdesk::AvailableAppUpdate(version, *release).has_value(),
|
||||
false);
|
||||
}
|
||||
for (const auto& version : {"", " ", "unknown", "ios-native", "1..5", "1.5-"}) {
|
||||
ok &= ExpectEqual(std::string("unavailable peer version is legacy: ") + version,
|
||||
!crossdesk::IsValidAppVersion(version) &&
|
||||
crossdesk::AvailableAppUpdate(version, *release) == "1.5.3-2",
|
||||
true);
|
||||
}
|
||||
for (const auto& version : {"1.5.2", "v1.5.3-20260928", "1.5.3-1-20260929",
|
||||
"1.5.3-20260928-1"}) {
|
||||
ok &= ExpectEqual(std::string("remote update includes metadata patch for ") + version,
|
||||
crossdesk::AvailableAppUpdate(version, *release) == "1.5.3-2",
|
||||
true);
|
||||
}
|
||||
}
|
||||
ok &= ExpectEqual("legacy peer cannot supply a missing latest release label",
|
||||
crossdesk::AvailableAppUpdate("", crossdesk::VersionInfo{}).has_value(),
|
||||
false);
|
||||
for (const auto& response : {"", "not JSON", "{}", "[]",
|
||||
R"({"version":null})", R"({"version":"unknown"})"}) {
|
||||
ok &= ExpectEqual("invalid release response gives no update",
|
||||
crossdesk::ParseVersionInfoJSON(response).has_value(), false);
|
||||
}
|
||||
|
||||
return ok ? 0 : 1;
|
||||
}
|
||||
|
||||
@@ -38,7 +38,9 @@ function setup_options_and_dependencies()
|
||||
add_defines("USE_WAYLAND=" .. (is_config("USE_WAYLAND", true) and "1" or "0"))
|
||||
add_defines("USE_DRM=" .. (is_config("USE_DRM", true) and "1" or "0"))
|
||||
if is_mode("debug") then
|
||||
add_defines("CROSSDESK_DEBUG")
|
||||
add_defines("CROSSDESK_DEBUG", "SPDLOG_ACTIVE_LEVEL=SPDLOG_LEVEL_DEBUG")
|
||||
else
|
||||
add_defines("SPDLOG_ACTIVE_LEVEL=SPDLOG_LEVEL_INFO")
|
||||
end
|
||||
|
||||
add_requireconfs("**.python", {version = "3.12", override = true, configs = {pgo = false}})
|
||||
|
||||
@@ -24,6 +24,20 @@ local function collect_dbus_arch_include_dirs()
|
||||
end
|
||||
|
||||
function setup_platform_settings()
|
||||
-- Discard unreferenced functions/data without the extra build cost of LTO.
|
||||
-- Keep debug builds unchanged for predictable debugger behavior.
|
||||
if is_mode("release") then
|
||||
if is_os("macosx") then
|
||||
add_ldflags("-Wl,-dead_strip")
|
||||
elseif is_os("linux") then
|
||||
add_cxflags("-ffunction-sections", "-fdata-sections")
|
||||
add_ldflags("-Wl,--gc-sections")
|
||||
elseif is_os("windows") then
|
||||
add_cxflags("/Gy", "/Gw")
|
||||
add_ldflags("/OPT:REF", "/OPT:ICF")
|
||||
end
|
||||
end
|
||||
|
||||
if is_os("windows") then
|
||||
add_requires("libyuv", "miniaudio 0.11.21")
|
||||
add_defines("NOMINMAX")
|
||||
|
||||
@@ -1,53 +1,82 @@
|
||||
rule("slint")
|
||||
set_extensions(".slint")
|
||||
|
||||
on_config(function(target)
|
||||
local rule_name = "slint"
|
||||
if target:rule("c++.build") then
|
||||
local cpp_rule = target:rule("c++.build"):clone()
|
||||
cpp_rule:add("deps", rule_name, {order = true})
|
||||
target:rule_add(cpp_rule)
|
||||
end
|
||||
|
||||
after_load(function(target)
|
||||
local outputdir = path.join(target:autogendir(), "rules", "slint")
|
||||
os.mkdir(outputdir)
|
||||
target:add("includedirs", outputdir, {public = true})
|
||||
|
||||
local sourcebatch = target:sourcebatches()[rule_name]
|
||||
if sourcebatch and sourcebatch.sourcefiles then
|
||||
for _, sourcefile in ipairs(sourcebatch.sourcefiles) do
|
||||
-- Make the generated include discoverable during C++ dependency
|
||||
-- scanning; the real contents are produced before compilation.
|
||||
os.touch(path.join(outputdir, path.basename(sourcefile) .. ".h"))
|
||||
local sourcebatch = target:sourcebatches()["slint"]
|
||||
local outputs = {}
|
||||
for _, sourcefile in ipairs(sourcebatch and sourcebatch.sourcefiles or {}) do
|
||||
local header = path.join(outputdir, path.basename(sourcefile) .. ".h")
|
||||
local sources = {}
|
||||
-- Slint supports moving definitions out of the header and splitting
|
||||
-- them into independently compiled translation units.
|
||||
for index = 1, 4 do
|
||||
local cppfile = path.join(outputdir,
|
||||
path.basename(sourcefile) .. "_" .. index .. ".cpp")
|
||||
table.insert(sources, cppfile)
|
||||
target:add("files", cppfile, {always_added = true})
|
||||
end
|
||||
outputs[sourcefile] = {header = header, sources = sources}
|
||||
end
|
||||
target:data_set("slint.outputs", outputs)
|
||||
end)
|
||||
|
||||
on_config(function(target)
|
||||
if target:rule("c++.build") then
|
||||
local cpp_rule = target:rule("c++.build"):clone()
|
||||
cpp_rule:add("deps", "slint", {order = true})
|
||||
target:rule_add(cpp_rule)
|
||||
end
|
||||
end)
|
||||
|
||||
before_buildcmd_file(function(target, batchcmds, sourcefile, opt)
|
||||
local package = assert(target:pkg("slint"), "the slint package is required by this target")
|
||||
local compiler = path.join(package:installdir(), "bin", is_host("windows") and "slint-compiler.exe" or "slint-compiler")
|
||||
local outputdir = path.join(target:autogendir(), "rules", "slint")
|
||||
local outputfile = path.join(outputdir, path.basename(sourcefile) .. ".h")
|
||||
local depfile = outputfile .. ".d"
|
||||
before_build_file(function(target, sourcefile, opt)
|
||||
import("core.project.depend")
|
||||
import("core.tools.gcc.parse_deps", {alias = "parse_deps"})
|
||||
import("utils.progress")
|
||||
|
||||
batchcmds:show_progress(opt.progress, "${color.build.object}generating.slint %s", sourcefile)
|
||||
batchcmds:mkdir(outputdir)
|
||||
batchcmds:vrunv(compiler, {
|
||||
sourcefile,
|
||||
"-f", "cpp",
|
||||
"-o", outputfile,
|
||||
"--depfile", depfile,
|
||||
"--style", "fluent",
|
||||
"--embed-resources=embed-files",
|
||||
"--cpp-namespace", "crossdesk::ui"
|
||||
})
|
||||
-- The compiler's depfile is useful to external build systems, while
|
||||
-- xmake needs imported .slint files registered explicitly so editing a
|
||||
-- component regenerates the umbrella header without a forced rebuild.
|
||||
for _, dependency in ipairs(os.files(path.join(path.directory(sourcefile), "*.slint"))) do
|
||||
batchcmds:add_depfiles(dependency)
|
||||
local package = assert(target:pkg("slint"), "the slint package is required by this target")
|
||||
local compiler = path.join(package:installdir(), "bin",
|
||||
is_host("windows") and "slint-compiler.exe" or "slint-compiler")
|
||||
local outputs = target:data("slint.outputs")[sourcefile]
|
||||
local depfile = outputs.header .. ".d"
|
||||
-- Slint writes the -o path literally into each generated #include.
|
||||
-- The custom UI uses fixed light backgrounds. Keep default text and
|
||||
-- standard widgets light as well, regardless of the system theme.
|
||||
local argv = {sourcefile, "-f", "cpp", "-o", path.absolute(outputs.header),
|
||||
"--depfile", depfile, "--style", "fluent-light",
|
||||
"--embed-resources=embed-files", "--cpp-namespace", "crossdesk::ui"}
|
||||
local missing_output = not os.isfile(outputs.header)
|
||||
for _, cppfile in ipairs(outputs.sources) do
|
||||
table.insert(argv, "--cpp-file")
|
||||
table.insert(argv, path.absolute(cppfile))
|
||||
missing_output = missing_output or not os.isfile(cppfile)
|
||||
end
|
||||
batchcmds:set_depmtime(os.mtime(outputfile))
|
||||
batchcmds:set_depcache(target:dependfile(outputfile))
|
||||
|
||||
local function dependencies()
|
||||
local files = {sourcefile, compiler}
|
||||
if os.isfile(depfile) then
|
||||
-- Track imported components AND embedded images, including
|
||||
-- resources outside the UI directory, using the compiler output.
|
||||
table.join2(files, parse_deps(io.readfile(depfile, {continuation = "\\"})))
|
||||
end
|
||||
files = table.unique(files)
|
||||
table.sort(files)
|
||||
return files
|
||||
end
|
||||
|
||||
local depopts = {dependfile = target:dependfile(outputs.header),
|
||||
values = argv, files = dependencies(),
|
||||
changed = target:is_rebuilt() or missing_output or not os.isfile(depfile)}
|
||||
-- Slint preserves timestamps of unchanged outputs. Use the dependency
|
||||
-- cache's successful-generation time, not the oldest output's time.
|
||||
depend.on_changed(function()
|
||||
progress.show(opt.progress, "${color.build.object}generating.slint %s", sourcefile)
|
||||
os.mkdir(path.directory(outputs.header))
|
||||
os.vrunv(compiler, argv)
|
||||
-- Save the freshly discovered dependencies on the first build too.
|
||||
depopts.files = dependencies()
|
||||
end, depopts)
|
||||
end)
|
||||
rule_end()
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
function setup_targets()
|
||||
add_packages("spdlog", "libsdl3", "nlohmann_json")
|
||||
|
||||
includes("deps/submodules", "deps/thirdparty")
|
||||
|
||||
local crossdesk_version = get_config("CROSSDESK_VERSION") or "0.0.0"
|
||||
@@ -40,9 +38,23 @@ function setup_targets()
|
||||
local runtime_dir = path.join(slint:installdir(), "lib")
|
||||
local runtime_dll = path.join(runtime_dir, "slint_cpp.dll")
|
||||
assert(os.isfile(runtime_dll), "Slint runtime not found: " .. runtime_dll)
|
||||
import("utils.binary.deplibs")
|
||||
local dependencies = deplibs(runtime_dll, {plat = "windows", arch = target:arch()})
|
||||
assert(dependencies and #dependencies > 0, "Cannot inspect Slint runtime dependencies: " .. runtime_dll)
|
||||
for _, dependency in ipairs(dependencies) do
|
||||
local name = path.filename(dependency):lower()
|
||||
assert(not (name:match("^vcruntime%d.*%.dll$") or name:match("^msvcp%d.*%.dll$") or
|
||||
name:match("^msvcr%d.*%.dll$") or name:match("^concrt%d.*%.dll$")),
|
||||
"Slint runtime depends on " .. dependency .. "; rebuild the Slint package with static CRT")
|
||||
end
|
||||
os.cp(runtime_dll, target:targetdir())
|
||||
end
|
||||
|
||||
-- Keep runtime deployment independent of each target's after_build callback.
|
||||
rule("crossdesk.slint_runtime")
|
||||
after_build(copy_slint_runtime)
|
||||
rule_end()
|
||||
|
||||
-- Amyuni usbmmidd_v2 virtual display driver for headless Windows hosts.
|
||||
-- The installer and the portable archive pick it up from the target
|
||||
-- directory; the runtime looks for it next to the executables.
|
||||
@@ -65,15 +77,17 @@ function setup_targets()
|
||||
|
||||
target("rd_log")
|
||||
set_kind("object")
|
||||
add_packages("spdlog")
|
||||
add_packages("spdlog", {public = true})
|
||||
add_files("apps/desktop/src/log/rd_log.cpp")
|
||||
add_includedirs("apps/desktop/src/log", {public = true})
|
||||
|
||||
target("common")
|
||||
-- MiniRTC owns a target named "common". Reusing it merges the two targets
|
||||
-- and makes the media library inherit desktop sources and dependencies.
|
||||
target("desktop_common")
|
||||
set_kind("object")
|
||||
add_deps("rd_log", "crossdesk_wire")
|
||||
add_packages("libyuv")
|
||||
add_packages("concurrentqueue", {public = true})
|
||||
add_packages("concurrentqueue")
|
||||
add_files("apps/desktop/src/common/*.cpp")
|
||||
if is_os("windows") then
|
||||
add_files("apps/desktop/src/platform/windows/system_info.cpp")
|
||||
@@ -95,6 +109,7 @@ function setup_targets()
|
||||
add_files("apps/desktop/src/path_manager/*.cpp")
|
||||
if is_os("windows") then
|
||||
add_files("apps/desktop/src/platform/windows/path_backend.cpp")
|
||||
add_syslinks("Shell32", "Ole32", {public = true})
|
||||
elseif is_os("macosx") then
|
||||
add_files("apps/desktop/src/platform/macos/path_backend.cpp")
|
||||
elseif is_os("linux") then
|
||||
@@ -113,6 +128,7 @@ function setup_targets()
|
||||
"apps/desktop/src/path_manager/path_manager.cpp")
|
||||
if is_os("windows") then
|
||||
add_files("apps/desktop/src/platform/windows/path_backend.cpp")
|
||||
add_syslinks("Shell32", "Ole32")
|
||||
elseif is_os("macosx") then
|
||||
add_files("apps/desktop/src/platform/macos/path_backend.cpp")
|
||||
elseif is_os("linux") then
|
||||
@@ -133,6 +149,16 @@ function setup_targets()
|
||||
"apps/desktop/src/gui/runtime")
|
||||
add_files("apps/desktop/tests/cursor_position_test.cpp")
|
||||
|
||||
target("single_instance")
|
||||
set_kind("object")
|
||||
add_includedirs("apps/desktop/src", {public = true})
|
||||
if is_os("windows") then
|
||||
add_files("apps/desktop/src/platform/windows/single_instance.cpp")
|
||||
add_syslinks("Advapi32")
|
||||
else
|
||||
add_files("apps/desktop/src/platform/common/posix_single_instance.cpp")
|
||||
end
|
||||
|
||||
target("repository_structure_test")
|
||||
set_kind("binary")
|
||||
set_default(false)
|
||||
@@ -169,6 +195,21 @@ function setup_targets()
|
||||
add_includedirs("apps/desktop/src/platform/windows/service")
|
||||
add_files("apps/desktop/tests/windows_sas_guard_test.cpp")
|
||||
|
||||
target("crossdesk_ui")
|
||||
set_kind("static")
|
||||
set_languages("c++20")
|
||||
-- Generated bindings are not the video/rendering hot path. Optimizing
|
||||
-- them for size avoids expensive inlining of the large UI object tree.
|
||||
if is_mode("release") then
|
||||
set_optimize("smallest")
|
||||
end
|
||||
add_packages("slint", {public = true})
|
||||
add_rules("slint")
|
||||
add_files("apps/desktop/src/gui/ui/crossdesk_ui.slint")
|
||||
if is_os("windows") then
|
||||
add_cxxflags("/bigobj")
|
||||
end
|
||||
|
||||
target("slint_ui_smoke_test")
|
||||
set_kind("binary")
|
||||
set_languages("c++20")
|
||||
@@ -177,18 +218,17 @@ function setup_targets()
|
||||
add_cxxflags("/bigobj")
|
||||
end
|
||||
add_packages("slint")
|
||||
add_deps("crossdesk_ui")
|
||||
add_includedirs("apps/desktop/src/gui", "apps/desktop/src/gui/assets/fonts",
|
||||
"apps/desktop/src/gui/assets/localization")
|
||||
add_rules("slint")
|
||||
add_files("apps/desktop/src/gui/ui/crossdesk_ui.slint")
|
||||
add_files("apps/desktop/tests/slint_ui_smoke_test.cpp")
|
||||
after_build(copy_slint_runtime)
|
||||
add_rules("crossdesk.slint_runtime")
|
||||
|
||||
target("version_checker_test")
|
||||
set_kind("binary")
|
||||
set_default(false)
|
||||
add_packages("cpp-httplib")
|
||||
add_deps("rd_log")
|
||||
add_packages("cpp-httplib", "nlohmann_json")
|
||||
add_deps("rd_log", "crossdesk_wire")
|
||||
add_includedirs("apps/desktop/src/version_checker")
|
||||
add_files("apps/desktop/tests/version_checker_test.cpp",
|
||||
"apps/desktop/src/version_checker/version_checker.cpp")
|
||||
@@ -197,6 +237,12 @@ function setup_targets()
|
||||
add_frameworks("Security", "CoreFoundation")
|
||||
end
|
||||
|
||||
target("update_checker_test")
|
||||
set_kind("binary")
|
||||
set_default(false)
|
||||
add_deps("version_checker")
|
||||
add_files("apps/desktop/tests/update_checker_test.cpp")
|
||||
|
||||
target("privacy")
|
||||
set_kind("object")
|
||||
add_deps("rd_log", "crossdesk_wire")
|
||||
@@ -234,12 +280,12 @@ function setup_targets()
|
||||
|
||||
target("screen_capturer")
|
||||
set_kind("object")
|
||||
add_deps("rd_log", "common", "crossdesk_wire")
|
||||
add_deps("rd_log", "desktop_common", "crossdesk_wire")
|
||||
add_includedirs("apps/desktop/src/screen_capturer", {public = true})
|
||||
add_includedirs("deps/submodules/minirtc/src/api", {public = true})
|
||||
if is_os("windows") then
|
||||
add_deps("privacy")
|
||||
add_packages("libyuv")
|
||||
add_packages("libyuv", "nlohmann_json")
|
||||
add_files("apps/desktop/src/screen_capturer/captured_nv12_frame.cpp")
|
||||
add_files("apps/desktop/src/platform/windows/screen_capturer/screen_capturer_dxgi.cpp",
|
||||
"apps/desktop/src/platform/windows/screen_capturer/screen_capturer_gdi.cpp",
|
||||
@@ -274,6 +320,17 @@ function setup_targets()
|
||||
"apps/desktop/src/platform/linux/common")
|
||||
end
|
||||
|
||||
if is_os("linux") then
|
||||
target("headless_console")
|
||||
set_kind("object")
|
||||
add_deps("rd_log", "config_center", "assets")
|
||||
add_includedirs("apps/desktop/src", {public = true})
|
||||
add_files("apps/desktop/src/platform/linux/headless/headless_console.cpp",
|
||||
"apps/desktop/src/platform/linux/headless/headless_settings.cpp")
|
||||
target_end()
|
||||
|
||||
end
|
||||
|
||||
target("speaker_capturer")
|
||||
set_kind("object")
|
||||
add_deps("rd_log", "crossdesk_wire")
|
||||
@@ -295,7 +352,7 @@ function setup_targets()
|
||||
|
||||
target("device_controller")
|
||||
set_kind("object")
|
||||
add_deps("rd_log", "common", "crossdesk_wire")
|
||||
add_deps("rd_log", "desktop_common", "crossdesk_wire")
|
||||
add_includedirs("apps/desktop/src/device_controller", {public = true})
|
||||
add_includedirs("apps/desktop/src/platform/common/input")
|
||||
if is_os("windows") then
|
||||
@@ -329,8 +386,8 @@ function setup_targets()
|
||||
|
||||
target("thumbnail")
|
||||
set_kind("object")
|
||||
add_packages("libyuv", "openssl3")
|
||||
add_deps("rd_log", "common")
|
||||
add_packages("libyuv", "openssl3", "nlohmann_json")
|
||||
add_deps("rd_log", "desktop_common")
|
||||
add_files("apps/desktop/src/thumbnail/*.cpp")
|
||||
add_includedirs("apps/desktop/src/thumbnail", {public = true})
|
||||
|
||||
@@ -363,7 +420,8 @@ function setup_targets()
|
||||
target("version_checker")
|
||||
set_kind("object")
|
||||
add_packages("cpp-httplib")
|
||||
add_deps("rd_log")
|
||||
add_packages("nlohmann_json", {public = true})
|
||||
add_deps("rd_log", "crossdesk_wire")
|
||||
add_files("apps/desktop/src/version_checker/*.cpp")
|
||||
add_includedirs("apps/desktop/src/version_checker", {public = true})
|
||||
if is_os("macosx") then
|
||||
@@ -373,7 +431,7 @@ function setup_targets()
|
||||
|
||||
target("tools")
|
||||
set_kind("object")
|
||||
add_deps("rd_log", "common", "crossdesk_wire")
|
||||
add_deps("rd_log", "desktop_common", "crossdesk_wire")
|
||||
add_files("apps/desktop/src/tools/*.cpp")
|
||||
if is_os("windows") then
|
||||
add_files("apps/desktop/src/platform/windows/clipboard.cpp")
|
||||
@@ -392,24 +450,23 @@ function setup_targets()
|
||||
-- version and use fmt's supported runtime-parser fallback here.
|
||||
add_defines("FMT_CONSTEVAL=")
|
||||
add_packages("slint", {public = true})
|
||||
add_packages("libyuv", "tinyfiledialogs", "openssl3")
|
||||
add_rules("slint")
|
||||
add_packages("libyuv", "tinyfiledialogs", "openssl3", "libsdl3", "nlohmann_json")
|
||||
if is_config("CROSSDESK_PORTABLE", true) then
|
||||
-- RuntimeState changes layout in portable builds. Consumers of GUI
|
||||
-- headers must see the same definition, without affecting its deps.
|
||||
add_defines("CROSSDESK_PORTABLE=1", {public = true})
|
||||
end
|
||||
add_deps("rd_log", "common", "assets", "config_center", "minirtc",
|
||||
add_deps("rd_log", "desktop_common", "assets", "config_center", "minirtc",
|
||||
"path_manager", "screen_capturer", "speaker_capturer",
|
||||
"device_controller", "thumbnail", "version_checker", "tools",
|
||||
"crossdesk_wire", "privacy")
|
||||
"crossdesk_wire", "privacy", "crossdesk_ui")
|
||||
add_files("apps/desktop/src/gui/render.cpp", "apps/desktop/src/gui/application/gui_application.cpp",
|
||||
"apps/desktop/src/gui/rendering/*.cpp",
|
||||
"apps/desktop/src/gui/runtime/*.cpp",
|
||||
"apps/desktop/src/platform/common/gui/service_status_runtime.cpp",
|
||||
"apps/desktop/src/gui/features/devices/*.cpp", "apps/desktop/src/gui/features/input/*.cpp",
|
||||
"apps/desktop/src/gui/features/clipboard/*.cpp", "apps/desktop/src/gui/features/file_transfer/*.cpp",
|
||||
"apps/desktop/src/gui/features/settings/*.cpp", "apps/desktop/src/gui/ui/crossdesk_ui.slint")
|
||||
"apps/desktop/src/gui/features/settings/*.cpp")
|
||||
-- Only these translation units consume the application version. Keep
|
||||
-- version changes out of the flags for media and the rest of the GUI.
|
||||
add_files("apps/desktop/src/gui/application/gui_application.cpp",
|
||||
@@ -444,6 +501,7 @@ function setup_targets()
|
||||
"apps/desktop/src/platform/macos/gui/tray")
|
||||
elseif is_os("linux") then
|
||||
add_links("GL")
|
||||
add_deps("headless_console")
|
||||
add_files("apps/desktop/src/platform/linux/gui/tray/linux_tray.cpp",
|
||||
"apps/desktop/src/platform/linux/gui/cuda_graphics.cpp",
|
||||
"apps/desktop/src/platform/common/gui/opengl_video_renderer.cpp",
|
||||
@@ -476,6 +534,7 @@ function setup_targets()
|
||||
|
||||
target("crossdesk_service")
|
||||
set_kind("binary")
|
||||
add_packages("nlohmann_json")
|
||||
add_deps("rd_log", "path_manager")
|
||||
add_links("Advapi32", "Wtsapi32", "Ole32", "Userenv")
|
||||
add_files("apps/desktop/src/platform/windows/service/main.cpp",
|
||||
@@ -487,7 +546,7 @@ function setup_targets()
|
||||
|
||||
target("crossdesk_session_helper")
|
||||
set_kind("binary")
|
||||
add_packages("libyuv")
|
||||
add_packages("libyuv", "nlohmann_json")
|
||||
add_deps("rd_log", "path_manager")
|
||||
add_links("Advapi32", "User32", "Wtsapi32", "Gdi32", "Setupapi")
|
||||
add_files("apps/desktop/src/platform/windows/service/session_helper_main.cpp")
|
||||
@@ -505,7 +564,7 @@ function setup_targets()
|
||||
|
||||
target("crossdesk")
|
||||
set_kind("binary")
|
||||
add_deps("rd_log", "common", "gui")
|
||||
add_deps("rd_log", "desktop_common", "gui", "single_instance")
|
||||
add_files("apps/desktop/src/app/*.cpp")
|
||||
add_includedirs("apps/desktop/src", "apps/desktop/src/app", {public = true})
|
||||
if is_os("windows") then
|
||||
@@ -521,8 +580,9 @@ function setup_targets()
|
||||
"apps/desktop/src/platform/macos/daemon_backend.cpp")
|
||||
elseif is_os("linux") then
|
||||
add_files("apps/desktop/src/platform/common/posix_daemon_backend.cpp",
|
||||
"apps/desktop/src/platform/linux/daemon_backend.cpp")
|
||||
"apps/desktop/src/platform/linux/daemon_backend.cpp",
|
||||
"apps/desktop/src/platform/linux/headless/headless_session.cpp")
|
||||
end
|
||||
after_build(copy_slint_runtime)
|
||||
add_rules("crossdesk.slint_runtime")
|
||||
after_build(copy_usbmmidd_package)
|
||||
end
|
||||
|
||||
@@ -7,6 +7,9 @@
|
||||
objects = {
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
100000000000000000000018 /* RecentConnection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 200000000000000000000018 /* RecentConnection.swift */; };
|
||||
100000000000000000000016 /* RemoteVideoSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 200000000000000000000016 /* RemoteVideoSettings.swift */; };
|
||||
100000000000000000000017 /* RemoteNetworkStatistics.swift in Sources */ = {isa = PBXBuildFile; fileRef = 200000000000000000000017 /* RemoteNetworkStatistics.swift */; };
|
||||
100000000000000000000001 /* CrossDeskMobileApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 200000000000000000000001 /* CrossDeskMobileApp.swift */; };
|
||||
100000000000000000000002 /* RemoteSessionModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 200000000000000000000002 /* RemoteSessionModel.swift */; };
|
||||
100000000000000000000003 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 200000000000000000000003 /* ContentView.swift */; };
|
||||
@@ -15,9 +18,24 @@
|
||||
100000000000000000000006 /* NativeMetalVideoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 200000000000000000000009 /* NativeMetalVideoView.swift */; };
|
||||
100000000000000000000007 /* RemoteTouchInputView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20000000000000000000000A /* RemoteTouchInputView.swift */; };
|
||||
100000000000000000000008 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 20000000000000000000000D /* Assets.xcassets */; };
|
||||
100000000000000000000009 /* ThirdPartyLicensesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20000000000000000000000E /* ThirdPartyLicensesView.swift */; };
|
||||
10000000000000000000000A /* ThirdPartyLicenses.json in Resources */ = {isa = PBXBuildFile; fileRef = 20000000000000000000000F /* ThirdPartyLicenses.json */; };
|
||||
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 */; };
|
||||
100000000000000000000019 /* AnnouncementInbox.swift in Sources */ = {isa = PBXBuildFile; fileRef = 200000000000000000000019 /* AnnouncementInbox.swift */; };
|
||||
10000000000000000000001A /* AnnouncementText.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20000000000000000000001A /* AnnouncementText.swift */; };
|
||||
10000000000000000000001B /* AnnouncementInboxView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20000000000000000000001B /* AnnouncementInboxView.swift */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
200000000000000000000018 /* RecentConnection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecentConnection.swift; sourceTree = "<group>"; };
|
||||
200000000000000000000016 /* RemoteVideoSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteVideoSettings.swift; sourceTree = "<group>"; };
|
||||
200000000000000000000017 /* RemoteNetworkStatistics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteNetworkStatistics.swift; sourceTree = "<group>"; };
|
||||
200000000000000000000001 /* CrossDeskMobileApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CrossDeskMobileApp.swift; sourceTree = "<group>"; };
|
||||
200000000000000000000002 /* RemoteSessionModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteSessionModel.swift; sourceTree = "<group>"; };
|
||||
200000000000000000000003 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
|
||||
@@ -31,13 +49,37 @@
|
||||
20000000000000000000000B /* CrossDeskMobile.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = CrossDeskMobile.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
20000000000000000000000C /* build_minirtc_ios.sh */ = {isa = PBXFileReference; lastKnownFileType = text.script.sh; path = build_minirtc_ios.sh; sourceTree = "<group>"; };
|
||||
20000000000000000000000D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
||||
20000000000000000000000E /* ThirdPartyLicensesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThirdPartyLicensesView.swift; sourceTree = "<group>"; };
|
||||
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>"; };
|
||||
200000000000000000000019 /* AnnouncementInbox.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnnouncementInbox.swift; sourceTree = "<group>"; };
|
||||
20000000000000000000001A /* AnnouncementText.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnnouncementText.swift; sourceTree = "<group>"; };
|
||||
20000000000000000000001B /* AnnouncementInboxView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnnouncementInboxView.swift; sourceTree = "<group>"; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXCopyFilesBuildPhase section */
|
||||
300000000000000000000002 /* Embed Frameworks */ = {
|
||||
isa = PBXCopyFilesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
dstPath = "";
|
||||
dstSubfolderSpec = 10;
|
||||
files = (10000000000000000000000D /* OpenSSL.framework in Embed Frameworks */, );
|
||||
name = "Embed Frameworks";
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXCopyFilesBuildPhase section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
300000000000000000000001 /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
10000000000000000000000C /* OpenSSL.framework in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
@@ -57,8 +99,21 @@
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
200000000000000000000001 /* CrossDeskMobileApp.swift */,
|
||||
200000000000000000000015 /* PRIVACY.md */,
|
||||
200000000000000000000014 /* PrivacyViews.swift */,
|
||||
200000000000000000000013 /* ThumbnailFileStore.swift */,
|
||||
200000000000000000000012 /* PrivacyPreferences.swift */,
|
||||
200000000000000000000002 /* RemoteSessionModel.swift */,
|
||||
200000000000000000000019 /* AnnouncementInbox.swift */,
|
||||
20000000000000000000001A /* AnnouncementText.swift */,
|
||||
20000000000000000000001B /* AnnouncementInboxView.swift */,
|
||||
200000000000000000000018 /* RecentConnection.swift */,
|
||||
200000000000000000000016 /* RemoteVideoSettings.swift */,
|
||||
200000000000000000000017 /* RemoteNetworkStatistics.swift */,
|
||||
200000000000000000000003 /* ContentView.swift */,
|
||||
20000000000000000000000E /* ThirdPartyLicensesView.swift */,
|
||||
20000000000000000000000F /* ThirdPartyLicenses.json */,
|
||||
200000000000000000000010 /* PrivacyInfo.xcprivacy */,
|
||||
200000000000000000000004 /* RemoteSessionView.swift */,
|
||||
400000000000000000000003 /* Bridge */,
|
||||
400000000000000000000004 /* Rendering */,
|
||||
@@ -107,6 +162,7 @@
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
20000000000000000000000B /* CrossDeskMobile.app */,
|
||||
200000000000000000000011 /* OpenSSL.framework */,
|
||||
);
|
||||
name = Products;
|
||||
sourceTree = "<group>";
|
||||
@@ -122,6 +178,7 @@
|
||||
600000000000000000000001 /* Sources */,
|
||||
300000000000000000000001 /* Frameworks */,
|
||||
600000000000000000000002 /* Resources */,
|
||||
300000000000000000000002 /* Embed Frameworks */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
@@ -172,6 +229,9 @@
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
100000000000000000000008 /* Assets.xcassets in Resources */,
|
||||
10000000000000000000000A /* ThirdPartyLicenses.json in Resources */,
|
||||
10000000000000000000000B /* PrivacyInfo.xcprivacy in Resources */,
|
||||
100000000000000000000015 /* PRIVACY.md in Resources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
@@ -193,10 +253,16 @@
|
||||
);
|
||||
outputPaths = (
|
||||
"$(PROJECT_DIR)/Vendor/iphoneos/$(CONFIGURATION)/libCrossDeskMiniRTC.a",
|
||||
"$(BUILT_PRODUCTS_DIR)/OpenSSL.framework/OpenSSL",
|
||||
"$(BUILT_PRODUCTS_DIR)/OpenSSL.framework/Info.plist",
|
||||
"$(BUILT_PRODUCTS_DIR)/OpenSSL.framework/PrivacyInfo.xcprivacy",
|
||||
"$(BUILT_PRODUCTS_DIR)/OpenSSL.framework/LICENSE.txt",
|
||||
"$(BUILT_PRODUCTS_DIR)/OpenSSL.framework/BuildMetadata.json",
|
||||
"$(TARGET_BUILD_DIR)/$(UNLOCALIZED_RESOURCES_FOLDER_PATH)/SourceMetadata.json",
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/zsh;
|
||||
shellScript = "\"${PROJECT_DIR}/scripts/build_minirtc_ios.sh\"\n";
|
||||
shellScript = "set -e\npython3 \"${PROJECT_DIR}/scripts/generate_third_party_licenses.py\"\npython3 \"${PROJECT_DIR}/scripts/prepare_source_metadata.py\" --output \"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/SourceMetadata.json\"\n\"${PROJECT_DIR}/scripts/build_minirtc_ios.sh\"\npython3 \"${PROJECT_DIR}/scripts/generate_third_party_licenses.py\" --inputs \"${PROJECT_DIR}/Vendor/iphoneos/${CONFIGURATION}/libCrossDeskMiniRTC.a.inputs.sha256\" --target-info \"${PROJECT_DIR}/Vendor/iphoneos/${CONFIGURATION}/minirtc-target-info.txt\" --xmake-version \"${PROJECT_DIR}/Vendor/iphoneos/${CONFIGURATION}/xmake-version.txt\" --xmake-repository \"$(cat \"${PROJECT_DIR}/Vendor/iphoneos/${CONFIGURATION}/xmake-repository.txt\")\"\npython3 \"${PROJECT_DIR}/scripts/package_openssl_ios.py\" --inputs \"${PROJECT_DIR}/Vendor/iphoneos/${CONFIGURATION}/libCrossDeskMiniRTC.a.inputs.sha256\" --output \"${BUILT_PRODUCTS_DIR}/OpenSSL.framework\"\n";
|
||||
};
|
||||
/* End PBXShellScriptBuildPhase section */
|
||||
|
||||
@@ -207,11 +273,21 @@
|
||||
files = (
|
||||
100000000000000000000001 /* CrossDeskMobileApp.swift in Sources */,
|
||||
100000000000000000000002 /* RemoteSessionModel.swift in Sources */,
|
||||
100000000000000000000019 /* AnnouncementInbox.swift in Sources */,
|
||||
10000000000000000000001A /* AnnouncementText.swift in Sources */,
|
||||
10000000000000000000001B /* AnnouncementInboxView.swift in Sources */,
|
||||
100000000000000000000018 /* RecentConnection.swift in Sources */,
|
||||
100000000000000000000016 /* RemoteVideoSettings.swift in Sources */,
|
||||
100000000000000000000017 /* RemoteNetworkStatistics.swift in Sources */,
|
||||
100000000000000000000003 /* ContentView.swift in Sources */,
|
||||
100000000000000000000009 /* ThirdPartyLicensesView.swift in Sources */,
|
||||
100000000000000000000004 /* RemoteSessionView.swift in Sources */,
|
||||
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;
|
||||
};
|
||||
@@ -270,6 +346,7 @@
|
||||
ENABLE_BITCODE = NO;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||
GENERATE_INFOPLIST_FILE = NO;
|
||||
FRAMEWORK_SEARCH_PATHS = ("$(inherited)", "$(BUILT_PRODUCTS_DIR)", );
|
||||
HEADER_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"$(PROJECT_DIR)/../../deps/submodules/minirtc/src/api",
|
||||
@@ -319,6 +396,7 @@
|
||||
ENABLE_BITCODE = NO;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||
GENERATE_INFOPLIST_FILE = NO;
|
||||
FRAMEWORK_SEARCH_PATHS = ("$(inherited)", "$(BUILT_PRODUCTS_DIR)", );
|
||||
HEADER_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"$(PROJECT_DIR)/../../deps/submodules/minirtc/src/api",
|
||||
|
||||
@@ -0,0 +1,378 @@
|
||||
import Combine
|
||||
import CryptoKit
|
||||
import Foundation
|
||||
|
||||
struct Announcement: Decodable, Identifiable, Equatable {
|
||||
let id: Int64
|
||||
let revision: Int64
|
||||
let title: String
|
||||
let body: String
|
||||
let updated_at: Int64
|
||||
var read = false
|
||||
|
||||
// Reading state belongs to this device, never to the server response.
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case id, revision, title, body, updated_at
|
||||
}
|
||||
|
||||
var date: Date { Date(timeIntervalSince1970: TimeInterval(updated_at)) }
|
||||
}
|
||||
|
||||
struct AnnouncementSnapshot: Equatable {
|
||||
var items: [Announcement] = []
|
||||
var total = 0
|
||||
var unread = 0
|
||||
var loaded = false
|
||||
var loading = false
|
||||
var failed = false
|
||||
var readSaveFailed = false
|
||||
var deleteFailed = false
|
||||
}
|
||||
|
||||
/// Main-thread inbox using the desktop announcements_list protocol and local
|
||||
/// revision semantics. Summary pages count all unread items; bodies load in 20s.
|
||||
final class AnnouncementInbox: ObservableObject {
|
||||
@Published private(set) var state = AnnouncementSnapshot()
|
||||
var send: ((Data, String) -> Void)?
|
||||
|
||||
private struct Entry: Decodable {
|
||||
let id: Int64
|
||||
let revision: Int64
|
||||
}
|
||||
|
||||
private struct Response: Decodable {
|
||||
let request_id: String
|
||||
let summary_only: Bool
|
||||
let offset: Int
|
||||
let total: Int
|
||||
let catalog_revision: Int64
|
||||
let items: [Entry]
|
||||
}
|
||||
|
||||
private struct BodyResponse: Decodable { let items: [Announcement] }
|
||||
private struct Request: Encodable {
|
||||
let type = "announcements_list"
|
||||
let request_id: String
|
||||
let summary_only: Bool
|
||||
let offset: Int
|
||||
}
|
||||
|
||||
private struct LocalState: Codable {
|
||||
let version: Int
|
||||
let scope: String
|
||||
let reads: [String: Int64]
|
||||
let dismissed: [String: Int64]?
|
||||
}
|
||||
|
||||
private let directory: URL
|
||||
private var scope = ""
|
||||
private var file: URL?
|
||||
private var reads: [Int64: Int64] = [:]
|
||||
private var dismissed: [Int64: Int64] = [:]
|
||||
private var catalog: [Int64: Int64] = [:]
|
||||
private var pendingCatalog: [Int64: Int64] = [:]
|
||||
private var pageIDs: [Int: [Int64]] = [:]
|
||||
private var pageBodies: [Int: [Announcement]] = [:]
|
||||
private var catalogRevision: Int64 = -1
|
||||
private var pendingRevision: Int64 = -1
|
||||
private var pendingTotal = 0
|
||||
private var pageOffset = 0
|
||||
private var visibleLimit = 20
|
||||
private var summary = true
|
||||
private var connected = false
|
||||
private var requestID: String?
|
||||
private var timeout: Timer?
|
||||
|
||||
init(directory: URL = FileManager.default.urls(
|
||||
for: .applicationSupportDirectory, in: .userDomainMask)[0]
|
||||
.appendingPathComponent("AnnouncementReads", isDirectory: true)) {
|
||||
self.directory = directory
|
||||
}
|
||||
|
||||
deinit { timeout?.invalidate() }
|
||||
|
||||
func configure(host: String, port: Int, deviceID: String) {
|
||||
guard !host.isEmpty, port > 0, !deviceID.isEmpty else { return }
|
||||
var host = host.lowercased()
|
||||
if host.hasSuffix(".") { host.removeLast() }
|
||||
guard let data = try? JSONSerialization.data(withJSONObject: [host, port, deviceID],
|
||||
options: [.fragmentsAllowed]),
|
||||
let nextScope = String(data: data, encoding: .utf8),
|
||||
scope != nextScope else { return }
|
||||
let wasConnected = connected
|
||||
reset()
|
||||
connected = wasConnected
|
||||
scope = nextScope
|
||||
let name = SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined()
|
||||
file = directory.appendingPathComponent(name + ".json")
|
||||
loadLocalState()
|
||||
refresh()
|
||||
}
|
||||
|
||||
func reset() {
|
||||
cancelRequest()
|
||||
state = AnnouncementSnapshot()
|
||||
scope = ""
|
||||
file = nil
|
||||
reads = [:]
|
||||
dismissed = [:]
|
||||
catalog = [:]
|
||||
pendingCatalog = [:]
|
||||
pageIDs = [:]
|
||||
pageBodies = [:]
|
||||
catalogRevision = -1
|
||||
pendingRevision = -1
|
||||
pendingTotal = 0
|
||||
visibleLimit = 20
|
||||
connected = false
|
||||
}
|
||||
|
||||
func setConnected(_ value: Bool) {
|
||||
connected = value
|
||||
refresh()
|
||||
}
|
||||
|
||||
func refresh() {
|
||||
cancelRequest()
|
||||
pendingCatalog = [:]
|
||||
pendingRevision = -1
|
||||
pendingTotal = 0
|
||||
summary = true
|
||||
request(offset: 0)
|
||||
}
|
||||
|
||||
func loadMore() {
|
||||
guard connected, !state.loading, !state.failed, catalogRevision >= 0,
|
||||
visibleLimit < state.total else { return }
|
||||
visibleLimit += min(20, state.total - visibleLimit)
|
||||
rebuildVisibleItems()
|
||||
}
|
||||
|
||||
func markRead(_ item: Announcement) {
|
||||
guard state.items.contains(where: { $0.id == item.id && $0.revision == item.revision }),
|
||||
reads[item.id] != item.revision else { return }
|
||||
var updated = reads
|
||||
updated[item.id] = item.revision
|
||||
guard saveLocalState(reads: updated, dismissed: dismissed) else {
|
||||
state.readSaveFailed = true
|
||||
return
|
||||
}
|
||||
reads = updated
|
||||
state.readSaveFailed = false
|
||||
state.deleteFailed = false
|
||||
updateReadingState()
|
||||
}
|
||||
|
||||
func dismiss(_ item: Announcement) {
|
||||
// A confirmation can outlive the displayed revision.
|
||||
guard state.items.contains(where: { $0.id == item.id && $0.revision == item.revision }) else {
|
||||
state.deleteFailed = true
|
||||
return
|
||||
}
|
||||
var updated = dismissed
|
||||
updated[item.id] = item.revision
|
||||
guard saveLocalState(reads: reads, dismissed: updated) else {
|
||||
state.deleteFailed = true
|
||||
return
|
||||
}
|
||||
dismissed = updated
|
||||
state.deleteFailed = false
|
||||
state.readSaveFailed = false
|
||||
if summary && state.loading {
|
||||
state.items.removeAll { dismissed[$0.id] == $0.revision }
|
||||
updateReadingState()
|
||||
} else {
|
||||
cancelRequest()
|
||||
rebuildVisibleItems()
|
||||
}
|
||||
}
|
||||
|
||||
func failed(requestID: String) {
|
||||
guard self.requestID == requestID else { return }
|
||||
cancelRequest()
|
||||
state.failed = true
|
||||
}
|
||||
|
||||
func receive(_ data: Data) {
|
||||
guard let object = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any],
|
||||
let id = object["request_id"] as? String, id == requestID else { return }
|
||||
let decoder = JSONDecoder()
|
||||
guard object["error"] == nil, let response = try? decoder.decode(Response.self, from: data) else {
|
||||
failed(requestID: id)
|
||||
return
|
||||
}
|
||||
let limit = summary ? 200 : 20
|
||||
guard response.summary_only == summary, response.offset >= 0,
|
||||
response.total >= response.offset, response.total <= Int32.max,
|
||||
response.catalog_revision >= 0,
|
||||
response.items.count == min(limit, response.total - response.offset),
|
||||
response.items.allSatisfy({ $0.id > 0 && $0.revision > 0 }) else {
|
||||
failed(requestID: id)
|
||||
return
|
||||
}
|
||||
let expectedRevision = summary ? pendingRevision : catalogRevision
|
||||
if expectedRevision >= 0 && expectedRevision != response.catalog_revision {
|
||||
refresh()
|
||||
return
|
||||
}
|
||||
if summary {
|
||||
guard response.offset == pendingCatalog.count,
|
||||
pendingRevision < 0 || pendingTotal == response.total else {
|
||||
failed(requestID: id)
|
||||
return
|
||||
}
|
||||
for item in response.items {
|
||||
guard pendingCatalog.updateValue(item.revision, forKey: item.id) == nil else {
|
||||
failed(requestID: id)
|
||||
return
|
||||
}
|
||||
}
|
||||
pendingRevision = response.catalog_revision
|
||||
pendingTotal = response.total
|
||||
cancelRequest()
|
||||
if pendingCatalog.count < pendingTotal {
|
||||
request(offset: pendingCatalog.count)
|
||||
return
|
||||
}
|
||||
if catalogRevision != pendingRevision {
|
||||
pageIDs = [:]
|
||||
pageBodies = [:]
|
||||
}
|
||||
catalog = pendingCatalog
|
||||
catalogRevision = pendingRevision
|
||||
reads = reads.filter { catalog[$0.key] != nil }
|
||||
dismissed = dismissed.filter { catalog[$0.key] != nil }
|
||||
} else {
|
||||
guard response.offset == pageOffset, response.total == catalog.count,
|
||||
Set(response.items.map(\.id)).count == response.items.count,
|
||||
let bodies = try? decoder.decode(BodyResponse.self, from: data).items,
|
||||
bodies.allSatisfy({ item in
|
||||
catalog[item.id] == item.revision &&
|
||||
(1...32_503_680_000).contains(item.updated_at) &&
|
||||
item.title.utf8.count <= 240 && item.body.utf8.count <= 8000
|
||||
}) else {
|
||||
failed(requestID: id)
|
||||
return
|
||||
}
|
||||
cancelRequest()
|
||||
pageIDs[response.offset] = bodies.map(\.id)
|
||||
pageBodies[response.offset] = bodies
|
||||
}
|
||||
state.failed = false
|
||||
rebuildVisibleItems()
|
||||
}
|
||||
|
||||
private func cancelRequest() {
|
||||
timeout?.invalidate()
|
||||
timeout = nil
|
||||
requestID = nil
|
||||
state.loading = false
|
||||
}
|
||||
|
||||
private func request(offset: Int) {
|
||||
guard connected, !scope.isEmpty else { return }
|
||||
let id = UUID().uuidString
|
||||
guard let data = try? JSONEncoder().encode(Request(
|
||||
request_id: id, summary_only: summary, offset: offset)) else { return }
|
||||
requestID = id
|
||||
state.loading = true
|
||||
state.failed = false
|
||||
let timer = Timer(timeInterval: 12, repeats: false) { [weak self] _ in
|
||||
self?.failed(requestID: id)
|
||||
}
|
||||
timeout = timer
|
||||
RunLoop.main.add(timer, forMode: .common)
|
||||
send?(data, id)
|
||||
}
|
||||
|
||||
private func updateReadingState() {
|
||||
let visible = catalog.filter { dismissed[$0.key] != $0.value }
|
||||
state.total = visible.count
|
||||
state.unread = visible.filter { reads[$0.key] != $0.value }.count
|
||||
state.items = state.items.map { item in
|
||||
var item = item
|
||||
item.read = reads[item.id] == item.revision
|
||||
return item
|
||||
}
|
||||
}
|
||||
|
||||
private func rebuildVisibleItems() {
|
||||
updateReadingState()
|
||||
let wanted = min(visibleLimit, state.total)
|
||||
var visible: [Announcement] = []
|
||||
var retainedPages: Set<Int> = []
|
||||
var position = 0
|
||||
var nextOffset: Int?
|
||||
summary = false
|
||||
if wanted > 0 {
|
||||
for offset in stride(from: 0, to: catalog.count, by: 20) {
|
||||
guard let ids = pageIDs[offset] else { nextOffset = offset; break }
|
||||
var selection: Set<Int64> = []
|
||||
for id in ids {
|
||||
guard let revision = catalog[id], dismissed[id] != revision else { continue }
|
||||
if position < wanted { selection.insert(id) }
|
||||
position += 1
|
||||
}
|
||||
if selection.isEmpty { pageBodies[offset] = nil; continue }
|
||||
retainedPages.insert(offset)
|
||||
guard let bodies = pageBodies[offset] else { nextOffset = offset; break }
|
||||
visible += bodies.filter { selection.contains($0.id) }
|
||||
if visible.count == wanted { break }
|
||||
}
|
||||
}
|
||||
if visible.count == wanted {
|
||||
pageBodies = pageBodies.filter { retainedPages.contains($0.key) }
|
||||
} else {
|
||||
// Retain unchanged bodies while fetching a refreshed catalog.
|
||||
var retained = Set(visible.map(\.id))
|
||||
for item in state.items where catalog[item.id] == item.revision &&
|
||||
dismissed[item.id] != item.revision && retained.insert(item.id).inserted {
|
||||
visible.append(item)
|
||||
if visible.count == wanted { break }
|
||||
}
|
||||
}
|
||||
state.items = visible
|
||||
state.loaded = true
|
||||
updateReadingState()
|
||||
if let nextOffset {
|
||||
pageOffset = nextOffset
|
||||
request(offset: nextOffset)
|
||||
}
|
||||
}
|
||||
|
||||
private func loadLocalState() {
|
||||
guard let file,
|
||||
let size = try? file.resourceValues(forKeys: [.fileSizeKey]).fileSize,
|
||||
size <= 16 * 1024 * 1024,
|
||||
let data = try? Data(contentsOf: file),
|
||||
let saved = try? JSONDecoder().decode(LocalState.self, from: data),
|
||||
saved.scope == scope, [1, 2].contains(saved.version) else { return }
|
||||
func revisions(_ entries: [String: Int64]) -> [Int64: Int64] {
|
||||
var result: [Int64: Int64] = [:]
|
||||
for (key, revision) in entries {
|
||||
if let id = Int64(key), id > 0, revision > 0 { result[id] = revision }
|
||||
}
|
||||
return result
|
||||
}
|
||||
reads = revisions(saved.reads)
|
||||
dismissed = revisions(saved.dismissed ?? [:])
|
||||
}
|
||||
|
||||
private func saveLocalState(reads: [Int64: Int64], dismissed: [Int64: Int64]) -> Bool {
|
||||
guard let file else { return false }
|
||||
do {
|
||||
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||
var directory = directory
|
||||
var values = URLResourceValues()
|
||||
values.isExcludedFromBackup = true
|
||||
try directory.setResourceValues(values)
|
||||
let saved = LocalState(version: 2, scope: scope,
|
||||
reads: Dictionary(uniqueKeysWithValues: reads.map { (String($0.key), $0.value) }),
|
||||
dismissed: Dictionary(uniqueKeysWithValues: dismissed.map { (String($0.key), $0.value) }))
|
||||
try JSONEncoder().encode(saved).write(to: file, options: .atomic)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
import SwiftUI
|
||||
|
||||
struct AnnouncementBadge: View {
|
||||
@ObservedObject var inbox: AnnouncementInbox
|
||||
|
||||
var body: some View {
|
||||
Image(systemName: "bell")
|
||||
.font(.system(size: 18, weight: .semibold))
|
||||
.frame(width: 40, height: 40)
|
||||
.background(Color(.secondarySystemGroupedBackground),
|
||||
in: RoundedRectangle(cornerRadius: 10, style: .continuous))
|
||||
.overlay(alignment: .topTrailing) {
|
||||
if inbox.state.unread > 0 {
|
||||
Text(inbox.state.unread > 99 ? "99+" : "\(inbox.state.unread)")
|
||||
.font(.system(size: 10, weight: .bold))
|
||||
.foregroundStyle(.white)
|
||||
.padding(.horizontal, 4)
|
||||
.frame(minWidth: 16, minHeight: 16)
|
||||
.background(.red, in: Capsule())
|
||||
.offset(x: 5, y: -3)
|
||||
}
|
||||
}
|
||||
.accessibilityElement(children: .ignore)
|
||||
.accessibilityLabel("通知公告")
|
||||
.accessibilityValue(inbox.state.unread > 0 ? "\(inbox.state.unread) 条未读" : "无未读公告")
|
||||
}
|
||||
}
|
||||
|
||||
struct AnnouncementSelection: Hashable {
|
||||
let id: Int64
|
||||
let revision: Int64
|
||||
}
|
||||
|
||||
private enum AnnouncementDate {
|
||||
static let formatter: DateFormatter = {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "yyyy-MM-dd HH:mm"
|
||||
return formatter
|
||||
}()
|
||||
|
||||
static func string(_ item: Announcement) -> String { formatter.string(from: item.date) }
|
||||
}
|
||||
|
||||
struct AnnouncementInboxView: View {
|
||||
@ObservedObject var session: RemoteSessionModel
|
||||
@ObservedObject var inbox: AnnouncementInbox
|
||||
@State private var pendingDelete: Announcement?
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
if inbox.state.deleteFailed {
|
||||
Text("无法删除这条公告,请重试。")
|
||||
.foregroundStyle(.red)
|
||||
}
|
||||
if inbox.state.failed && session.announcementsConnected {
|
||||
HStack {
|
||||
Text("公告加载失败,请重试。")
|
||||
Spacer()
|
||||
Button("重试") { session.refreshAnnouncements() }
|
||||
.disabled(!session.announcementsConnected)
|
||||
}
|
||||
}
|
||||
Section {
|
||||
ForEach(inbox.state.items) { item in
|
||||
NavigationLink(value: HomeDestination.announcement(
|
||||
AnnouncementSelection(id: item.id, revision: item.revision))) {
|
||||
HStack(alignment: .top, spacing: 10) {
|
||||
Circle()
|
||||
.fill(item.read ? Color.clear : Color.accentColor)
|
||||
.frame(width: 7, height: 7)
|
||||
.padding(.top, 7)
|
||||
.accessibilityHidden(true)
|
||||
VStack(alignment: .leading, spacing: 7) {
|
||||
Text(verbatim: item.title)
|
||||
.fontWeight(item.read ? .regular : .semibold)
|
||||
.foregroundStyle(.primary)
|
||||
.lineLimit(2)
|
||||
Text(AnnouncementDate.string(item))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 5)
|
||||
.accessibilityElement(children: .combine)
|
||||
.accessibilityValue(item.read ? "已读" : "未读")
|
||||
}
|
||||
.swipeActions {
|
||||
Button("删除", role: .destructive) { pendingDelete = item }
|
||||
}
|
||||
.contextMenu {
|
||||
Button("删除公告", role: .destructive) { pendingDelete = item }
|
||||
}
|
||||
}
|
||||
if inbox.state.loading {
|
||||
HStack { Spacer(); ProgressView("加载中…"); Spacer() }
|
||||
} else if inbox.state.items.count < inbox.state.total && !inbox.state.failed {
|
||||
Button("加载更多") { inbox.loadMore() }
|
||||
.frame(maxWidth: .infinity)
|
||||
.disabled(!session.announcementsConnected)
|
||||
.onAppear { inbox.loadMore() }
|
||||
} else if inbox.state.loaded && inbox.state.total == 0 &&
|
||||
!inbox.state.failed && session.announcementsConnected {
|
||||
Text("暂无公告")
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(maxWidth: .infinity, minHeight: 120)
|
||||
}
|
||||
} header: {
|
||||
Text("共 \(inbox.state.total) 条 · \(inbox.state.unread) 条未读")
|
||||
}
|
||||
}
|
||||
.navigationTitle("通知公告")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button { session.refreshAnnouncements() } label: {
|
||||
Image(systemName: "arrow.clockwise")
|
||||
}
|
||||
.accessibilityLabel("刷新公告")
|
||||
.disabled(inbox.state.loading || !session.announcementsConnected)
|
||||
}
|
||||
}
|
||||
.refreshable {
|
||||
session.refreshAnnouncements()
|
||||
while inbox.state.loading && !Task.isCancelled {
|
||||
try? await Task.sleep(nanoseconds: 100_000_000)
|
||||
}
|
||||
}
|
||||
.onAppear { session.refreshAnnouncements() }
|
||||
.alert("删除公告", isPresented: Binding(
|
||||
get: { pendingDelete != nil },
|
||||
set: { if !$0 { pendingDelete = nil } }
|
||||
)) {
|
||||
Button("取消", role: .cancel) { pendingDelete = nil }
|
||||
Button("删除", role: .destructive) {
|
||||
if let item = pendingDelete { inbox.dismiss(item) }
|
||||
pendingDelete = nil
|
||||
}
|
||||
} message: {
|
||||
Text("删除这条公告?")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct AnnouncementDetailView: View {
|
||||
@ObservedObject var inbox: AnnouncementInbox
|
||||
let selection: AnnouncementSelection
|
||||
@Environment(\.openURL) private var openURL
|
||||
|
||||
private var item: Announcement? {
|
||||
inbox.state.items.first { $0.id == selection.id && $0.revision == selection.revision }
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
if let item {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
Text(verbatim: item.title)
|
||||
.font(.title2.weight(.semibold))
|
||||
Text("\(AnnouncementDate.string(item)) · \(item.read ? "已读" : "未读")")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
if inbox.state.readSaveFailed {
|
||||
Text("无法保存本地阅读状态,请重试。")
|
||||
.foregroundStyle(.red)
|
||||
Button("重试保存阅读状态") { inbox.markRead(item) }
|
||||
}
|
||||
Text(AnnouncementText.format(item.body))
|
||||
.textSelection(.enabled)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.environment(\.openURL, OpenURLAction { url in
|
||||
guard AnnouncementText.isWebURL(url) else { return .discarded }
|
||||
openURL(url)
|
||||
return .handled
|
||||
})
|
||||
}
|
||||
.padding(20)
|
||||
} else {
|
||||
Text("这条公告已更新或删除,请返回公告列表查看。")
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(20)
|
||||
}
|
||||
}
|
||||
.navigationTitle("公告详情")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.onAppear {
|
||||
if let item { inbox.markRead(item) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import Foundation
|
||||
|
||||
enum AnnouncementText {
|
||||
/// Match desktop: preserve plain text and line breaks; recognize only web
|
||||
/// URLs and [label](URL), without interpreting arbitrary Markdown or HTML.
|
||||
static func format(_ body: String) -> AttributedString {
|
||||
let text = Array(body.replacingOccurrences(of: "\r\n", with: "\n"))
|
||||
var output = AttributedString()
|
||||
var literal = ""
|
||||
var position = 0
|
||||
|
||||
func hasScheme(at start: Int) -> Bool {
|
||||
let prefix = String(text[start..<min(text.count, start + 8)]).lowercased()
|
||||
return prefix.hasPrefix("https://") || prefix.hasPrefix("http://")
|
||||
}
|
||||
|
||||
func urlEnd(from start: Int) -> Int {
|
||||
let punctuation = Set("<>\"'`\\{},。;:!?、()【】《》「」『』“”‘’")
|
||||
var parentheses = 0
|
||||
var brackets = 0
|
||||
var end = start
|
||||
while end < text.count {
|
||||
let character = text[end]
|
||||
if punctuation.contains(character) || character.unicodeScalars.contains(where: {
|
||||
$0.value <= 0x20 || $0.value == 0x7f
|
||||
}) { break }
|
||||
if character == "(" { parentheses += 1 }
|
||||
if character == ")" {
|
||||
if parentheses == 0 { break }
|
||||
parentheses -= 1
|
||||
}
|
||||
if character == "[" { brackets += 1 }
|
||||
if character == "]" {
|
||||
if brackets == 0 { break }
|
||||
brackets -= 1
|
||||
}
|
||||
end += 1
|
||||
}
|
||||
return end
|
||||
}
|
||||
|
||||
func appendLink(label: String, destination: String) -> Bool {
|
||||
guard let url = URL(string: destination), isWebURL(url) else { return false }
|
||||
output.append(AttributedString(literal))
|
||||
literal = ""
|
||||
var link = AttributedString(label)
|
||||
link.link = url
|
||||
output.append(link)
|
||||
return true
|
||||
}
|
||||
|
||||
while position < text.count {
|
||||
if text[position] == "[",
|
||||
let labelEnd = text[(position + 1)...].firstIndex(of: "]"),
|
||||
labelEnd > position + 1, labelEnd + 2 < text.count,
|
||||
text[labelEnd + 1] == "(",
|
||||
!text[(position + 1)..<labelEnd].contains(where: { "[]\r\n".contains($0) }),
|
||||
hasScheme(at: labelEnd + 2) {
|
||||
let end = urlEnd(from: labelEnd + 2)
|
||||
if end < text.count, text[end] == ")",
|
||||
appendLink(label: String(text[(position + 1)..<labelEnd]),
|
||||
destination: String(text[(labelEnd + 2)..<end])) {
|
||||
position = end + 1
|
||||
continue
|
||||
}
|
||||
}
|
||||
if hasScheme(at: position) {
|
||||
var end = urlEnd(from: position)
|
||||
while end > position, ".,;:!?".contains(text[end - 1]) { end -= 1 }
|
||||
let address = String(text[position..<end])
|
||||
if appendLink(label: address, destination: address) {
|
||||
position = end
|
||||
continue
|
||||
}
|
||||
}
|
||||
literal.append(text[position])
|
||||
position += 1
|
||||
}
|
||||
output.append(AttributedString(literal))
|
||||
return output
|
||||
}
|
||||
|
||||
static func isWebURL(_ url: URL) -> Bool {
|
||||
guard let scheme = url.scheme?.lowercased(), ["http", "https"].contains(scheme),
|
||||
let host = url.host, !host.isEmpty else { return false }
|
||||
return !url.absoluteString.unicodeScalars.contains {
|
||||
$0.value <= 0x20 || $0.value == 0x7f || "\\<>\"".unicodeScalars.contains($0)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ typedef NS_ENUM(NSInteger, CrossDeskSignalState) {
|
||||
CrossDeskSignalStateReconnecting,
|
||||
CrossDeskSignalStateServerClosed,
|
||||
CrossDeskSignalStateTLSCertificateError,
|
||||
CrossDeskSignalStateCredentialUnavailable,
|
||||
};
|
||||
|
||||
typedef NS_ENUM(NSInteger, CrossDeskConnectionState) {
|
||||
@@ -43,10 +44,35 @@ typedef NS_ENUM(NSInteger, CrossDeskVideoAdaptationPolicy) {
|
||||
|
||||
@class CrossDeskRTCBridge;
|
||||
|
||||
typedef NS_ENUM(NSInteger, CrossDeskTraversalMode) {
|
||||
CrossDeskTraversalModeUnknown = 0,
|
||||
CrossDeskTraversalModeDirect,
|
||||
CrossDeskTraversalModeRelay,
|
||||
};
|
||||
|
||||
typedef struct {
|
||||
uint32_t inboundBitrate;
|
||||
uint32_t outboundBitrate;
|
||||
float lossRate;
|
||||
} CrossDeskTrafficStats;
|
||||
|
||||
/// Value snapshot: no MiniRTC callback-owned memory crosses the main queue.
|
||||
typedef struct {
|
||||
CrossDeskTrafficStats video;
|
||||
CrossDeskTrafficStats audio;
|
||||
CrossDeskTrafficStats data;
|
||||
CrossDeskTrafficStats total;
|
||||
double rttMilliseconds;
|
||||
BOOL srtpActive;
|
||||
CrossDeskTraversalMode traversalMode;
|
||||
} CrossDeskNetworkStats;
|
||||
|
||||
@protocol CrossDeskRTCBridgeDelegate <NSObject>
|
||||
@optional
|
||||
- (void)rtcBridge:(CrossDeskRTCBridge *)bridge
|
||||
didChangeSignalState:(CrossDeskSignalState)state;
|
||||
- (void)rtcBridge:(CrossDeskRTCBridge *)bridge
|
||||
didChangeIdentityStorageError:(BOOL)hasError;
|
||||
- (void)rtcBridge:(CrossDeskRTCBridge *)bridge
|
||||
didChangeConnectionState:(CrossDeskConnectionState)state
|
||||
remoteID:(NSString *)remoteID;
|
||||
@@ -55,14 +81,28 @@ typedef NS_ENUM(NSInteger, CrossDeskVideoAdaptationPolicy) {
|
||||
- (void)rtcBridge:(CrossDeskRTCBridge *)bridge
|
||||
didReceivePixelBuffer:(CVPixelBufferRef)pixelBuffer
|
||||
width:(NSInteger)width
|
||||
height:(NSInteger)height;
|
||||
height:(NSInteger)height
|
||||
captureUptime:(NSTimeInterval)captureUptime;
|
||||
- (void)rtcBridge:(CrossDeskRTCBridge *)bridge
|
||||
didReceiveHostName:(NSString *)hostName
|
||||
appVersion:(NSString *)appVersion
|
||||
platform:(NSString *)platform
|
||||
displayNames:(NSArray<NSString *> *)displayNames
|
||||
displaySizes:(NSArray<NSValue *> *)displaySizes;
|
||||
displaySizes:(NSArray<NSValue *> *)displaySizes
|
||||
supportsVideoSettings:(BOOL)supportsVideoSettings;
|
||||
- (void)rtcBridge:(CrossDeskRTCBridge *)bridge
|
||||
didReceiveVideoSettingsQuality:(NSInteger)quality
|
||||
frameRate:(NSInteger)frameRate
|
||||
preference:(NSInteger)preference
|
||||
requestID:(uint32_t)requestID
|
||||
accepted:(BOOL)accepted;
|
||||
- (void)rtcBridge:(CrossDeskRTCBridge *)bridge
|
||||
didReceivePresence:(NSDictionary<NSString *, NSNumber *> *)presence
|
||||
snapshot:(BOOL)snapshot;
|
||||
- (void)rtcBridge:(CrossDeskRTCBridge *)bridge
|
||||
didReceiveAnnouncementMessage:(NSData *)message;
|
||||
- (void)rtcBridge:(CrossDeskRTCBridge *)bridge
|
||||
didFailAnnouncementRequest:(NSString *)requestID;
|
||||
- (void)rtcBridge:(CrossDeskRTCBridge *)bridge
|
||||
didReceiveCursorVisible:(BOOL)visible
|
||||
shape:(NSInteger)shape
|
||||
@@ -85,9 +125,8 @@ typedef NS_ENUM(NSInteger, CrossDeskVideoAdaptationPolicy) {
|
||||
- (void)rtcBridge:(CrossDeskRTCBridge *)bridge
|
||||
didReceiveFileAtURL:(NSURL *)fileURL;
|
||||
- (void)rtcBridge:(CrossDeskRTCBridge *)bridge
|
||||
didUpdateBitrate:(NSUInteger)bitsPerSecond
|
||||
lossRate:(float)lossRate
|
||||
usingTURN:(BOOL)usingTURN;
|
||||
didUpdateNetworkStats:(CrossDeskNetworkStats)stats
|
||||
NS_SWIFT_NAME(rtcBridge(_:didUpdateNetworkStats:));
|
||||
@end
|
||||
|
||||
/// Objective-C++ boundary around the native MiniRTC C API.
|
||||
@@ -98,17 +137,30 @@ typedef NS_ENUM(NSInteger, CrossDeskVideoAdaptationPolicy) {
|
||||
|
||||
@property(nonatomic, weak, nullable) id<CrossDeskRTCBridgeDelegate> delegate;
|
||||
|
||||
// Uses the same release/patch ordering as the desktop controller.
|
||||
+ (nullable NSString *)availableUpdateForAppVersion:(NSString *)appVersion
|
||||
releaseJSON:(NSData *)releaseJSON
|
||||
NS_SWIFT_NAME(availableUpdate(appVersion:releaseJSON:));
|
||||
|
||||
- (void)configureWithSignalHost:(NSString *)host
|
||||
signalPort:(NSInteger)signalPort;
|
||||
- (void)setHardwareAccelerationEnabled:(BOOL)enabled;
|
||||
- (void)setVideoAdaptationPolicy:(CrossDeskVideoAdaptationPolicy)policy;
|
||||
- (void)sendVideoSettingsWithQuality:(NSInteger)quality
|
||||
frameRate:(NSInteger)frameRate
|
||||
preference:(NSInteger)preference
|
||||
requestID:(uint32_t)requestID
|
||||
NS_SWIFT_NAME(sendVideoSettings(quality:frameRate:preference:requestID:));
|
||||
- (void)requestPresenceForRemoteIDs:(NSArray<NSString *> *)remoteIDs
|
||||
subscribe:(BOOL)subscribe
|
||||
NS_SWIFT_NAME(requestPresence(remoteIDs:subscribe:));
|
||||
- (void)invalidatePresence;
|
||||
- (void)sendAnnouncementRequest:(NSData *)request requestID:(NSString *)requestID
|
||||
NS_SWIFT_NAME(sendAnnouncementRequest(_:requestID:));
|
||||
|
||||
- (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
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#import "CrossDeskRTCBridge.h"
|
||||
|
||||
#import <CoreMedia/CoreMedia.h>
|
||||
#import <Security/Security.h>
|
||||
#import <UIKit/UIKit.h>
|
||||
|
||||
#include <algorithm>
|
||||
@@ -17,6 +18,7 @@
|
||||
#include <vector>
|
||||
|
||||
#include <display_stream_id.h>
|
||||
#include <app_version.h>
|
||||
#include <file_transfer_format.h>
|
||||
#include <remote_action.h>
|
||||
#include <stream_names.h>
|
||||
@@ -75,7 +77,6 @@ struct RTCState {
|
||||
CallbackContext controller_context;
|
||||
std::string signal_host;
|
||||
int signal_port = 0;
|
||||
bool hardware_acceleration = true;
|
||||
VideoDegradationPreference video_adaptation_policy =
|
||||
VideoDegradationPreference::MaintainResolution;
|
||||
bool identity_ready = false;
|
||||
@@ -102,6 +103,113 @@ bool IsReusableIdentity(const std::string &identity) {
|
||||
separator + 1 < identity.size();
|
||||
}
|
||||
|
||||
std::string ProvisionedIdentityCredential(const std::string &received,
|
||||
const std::string ¤t) {
|
||||
// A successful re-login can acknowledge the ID without echoing its password.
|
||||
// Retain the credential used to authenticate that same ID.
|
||||
if (!IsReusableIdentity(received) && IsReusableIdentity(current) &&
|
||||
current.substr(0, current.find('@')) == received) return current;
|
||||
return received;
|
||||
}
|
||||
|
||||
NSString *IdentityCredentialAccount(NSString *host, NSInteger port) {
|
||||
// Preserve the old server-scoped key so upgrades retain the same identity.
|
||||
return [NSString stringWithFormat:@"CrossDeskIdentity.%@.%ld", host, (long)port];
|
||||
}
|
||||
|
||||
NSMutableDictionary *IdentityCredentialQuery(NSString *account) {
|
||||
return [@{
|
||||
(__bridge id)kSecClass: (__bridge id)kSecClassGenericPassword,
|
||||
(__bridge id)kSecAttrService: @"cn.crossdesk.mobile.signaling-identity",
|
||||
(__bridge id)kSecAttrAccount: account,
|
||||
(__bridge id)kSecAttrSynchronizable: @NO,
|
||||
} mutableCopy];
|
||||
}
|
||||
|
||||
bool IsReusableIdentityValue(NSString *value) {
|
||||
return [value isKindOfClass:NSString.class] && value.UTF8String &&
|
||||
IsReusableIdentity(std::string(value.UTF8String));
|
||||
}
|
||||
|
||||
OSStatus SaveIdentityCredential(NSString *account, NSString *value) {
|
||||
if (!IsReusableIdentityValue(value)) return errSecParam;
|
||||
NSMutableDictionary *query = IdentityCredentialQuery(account);
|
||||
NSDictionary *attributes = @{
|
||||
(__bridge id)kSecValueData: [value dataUsingEncoding:NSUTF8StringEncoding],
|
||||
(__bridge id)kSecAttrAccessible:
|
||||
(__bridge id)kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly,
|
||||
};
|
||||
// Update in place: deleting first could lose a valid credential on failure.
|
||||
OSStatus status = SecItemUpdate((__bridge CFDictionaryRef)query,
|
||||
(__bridge CFDictionaryRef)attributes);
|
||||
if (status == errSecItemNotFound) {
|
||||
NSMutableDictionary *item = [query mutableCopy];
|
||||
[item addEntriesFromDictionary:attributes];
|
||||
status = SecItemAdd((__bridge CFDictionaryRef)item, nullptr);
|
||||
if (status == errSecDuplicateItem) {
|
||||
status = SecItemUpdate((__bridge CFDictionaryRef)query,
|
||||
(__bridge CFDictionaryRef)attributes);
|
||||
}
|
||||
}
|
||||
if (status == errSecSuccess) {
|
||||
[[NSUserDefaults standardUserDefaults] removeObjectForKey:account];
|
||||
}
|
||||
return status;
|
||||
}
|
||||
|
||||
OSStatus LoadIdentityCredential(NSString *account, NSString **value) {
|
||||
*value = nil;
|
||||
NSMutableDictionary *query = IdentityCredentialQuery(account);
|
||||
query[(__bridge id)kSecReturnData] = @YES;
|
||||
query[(__bridge id)kSecMatchLimit] = (__bridge id)kSecMatchLimitOne;
|
||||
CFTypeRef result = nullptr;
|
||||
OSStatus status = SecItemCopyMatching((__bridge CFDictionaryRef)query, &result);
|
||||
id object = CFBridgingRelease(result);
|
||||
NSUserDefaults *defaults = NSUserDefaults.standardUserDefaults;
|
||||
if (status == errSecSuccess) {
|
||||
NSString *stored = [object isKindOfClass:NSData.class]
|
||||
? [[NSString alloc] initWithData:object encoding:NSUTF8StringEncoding] : nil;
|
||||
if (!IsReusableIdentityValue(stored)) return errSecDecode;
|
||||
*value = stored;
|
||||
// A Keychain value wins over a stale copy left by an interrupted migration.
|
||||
[defaults removeObjectForKey:account];
|
||||
return errSecSuccess;
|
||||
}
|
||||
if (status != errSecItemNotFound) return status;
|
||||
|
||||
NSString *legacy = [defaults objectForKey:account];
|
||||
if (!legacy) return errSecItemNotFound;
|
||||
if (!IsReusableIdentityValue(legacy)) {
|
||||
// Older passwordless IDs cannot authenticate on a subsequent app launch.
|
||||
[defaults removeObjectForKey:account];
|
||||
return errSecItemNotFound;
|
||||
}
|
||||
status = SaveIdentityCredential(account, legacy);
|
||||
if (status == errSecSuccess) *value = legacy;
|
||||
return status;
|
||||
}
|
||||
|
||||
OSStatus MigrateLegacyIdentityCredentials() {
|
||||
OSStatus first_error = errSecSuccess;
|
||||
for (NSString *key in NSUserDefaults.standardUserDefaults.dictionaryRepresentation) {
|
||||
if (![key hasPrefix:@"CrossDeskIdentity."]) continue;
|
||||
NSString *unused = nil;
|
||||
OSStatus status = LoadIdentityCredential(key, &unused);
|
||||
if (status != errSecSuccess && status != errSecItemNotFound &&
|
||||
first_error == errSecSuccess) first_error = status;
|
||||
}
|
||||
return first_error;
|
||||
}
|
||||
|
||||
OSStatus RemoveIdentityCredential(NSString *account) {
|
||||
OSStatus status = SecItemDelete((__bridge CFDictionaryRef)IdentityCredentialQuery(account));
|
||||
if (status == errSecSuccess || status == errSecItemNotFound) {
|
||||
[[NSUserDefaults standardUserDefaults] removeObjectForKey:account];
|
||||
return errSecSuccess;
|
||||
}
|
||||
return status;
|
||||
}
|
||||
|
||||
void CopyCString(char *destination, size_t capacity,
|
||||
const std::string &source) {
|
||||
if (!destination || capacity == 0) return;
|
||||
@@ -217,20 +325,27 @@ void DispatchMain(dispatch_block_t block) {
|
||||
sourceIDSize:(size_t)sourceIDSize;
|
||||
- (void)enqueueVideoPixelBuffer:(CVPixelBufferRef)pixelBuffer
|
||||
width:(NSInteger)width
|
||||
height:(NSInteger)height;
|
||||
height:(NSInteger)height
|
||||
captureUptime:(NSTimeInterval)captureUptime;
|
||||
- (void)resetVideoDelivery;
|
||||
- (void)notifyVideoSettings:(crossdesk::VideoSettings)settings
|
||||
generation:(uint64_t)generation;
|
||||
- (void)handleAudio:(const char *)data size:(size_t)size;
|
||||
- (void)handleData:(const char *)data
|
||||
size:(size_t)size
|
||||
sourceID:(const char *)sourceID
|
||||
sourceIDSize:(size_t)sourceIDSize;
|
||||
- (void)handleStats:(const MiniRtcNetTrafficStats *)stats mode:(TraversalMode)mode;
|
||||
sourceIDSize:(size_t)sourceIDSize
|
||||
generation:(uint64_t)generation;
|
||||
- (void)handleStats:(const MiniRtcNetTrafficStats *)stats
|
||||
mode:(TraversalMode)mode
|
||||
generation:(uint64_t)generation;
|
||||
- (void)sendMessage:(const std::string &)message
|
||||
reliable:(BOOL)reliable
|
||||
stream:(const char *)stream;
|
||||
- (void)createIdentityPeer;
|
||||
- (void)createControllerPeer;
|
||||
- (void)destroyIdentityPeer;
|
||||
- (void)reportIdentityStorageStatus:(OSStatus)status;
|
||||
- (void)destroyControllerPeer;
|
||||
@end
|
||||
|
||||
@@ -269,7 +384,8 @@ void OnDataBuffer(const char *data, size_t size, const char *, size_t,
|
||||
[owner handleData:data
|
||||
size:size
|
||||
sourceID:source_id
|
||||
sourceIDSize:source_id_size];
|
||||
sourceIDSize:source_id_size
|
||||
generation:context->generation];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -320,7 +436,7 @@ void OnNetworkStats(const char *peer_id, size_t peer_id_size,
|
||||
generation:context->generation];
|
||||
} else if (context->role == PeerRole::Controller && stats &&
|
||||
[owner isControllerGenerationActive:context->generation]) {
|
||||
[owner handleStats:stats mode:mode];
|
||||
[owner handleStats:stats mode:mode generation:context->generation];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -332,7 +448,8 @@ Params MakeParams(const RTCState &state, const std::string &user_id,
|
||||
state.signal_host);
|
||||
params.signal_server_port = state.signal_port;
|
||||
CopyCString(params.log_path, sizeof(params.log_path), state.log_path);
|
||||
params.hardware_acceleration = state.hardware_acceleration;
|
||||
// iOS H.264 is provided exclusively by VideoToolbox.
|
||||
params.hardware_acceleration = true;
|
||||
params.native_video_output = true;
|
||||
params.av1_encoding = false;
|
||||
params.turn_mode = TurnMode::TurnAutoUdpTcp;
|
||||
@@ -367,6 +484,7 @@ Params MakeParams(const RTCState &state, const std::string &user_id,
|
||||
CVPixelBufferRef _pendingVideoBuffer;
|
||||
NSInteger _pendingVideoWidth;
|
||||
NSInteger _pendingVideoHeight;
|
||||
NSTimeInterval _pendingVideoCaptureUptime;
|
||||
bool _videoDeliveryScheduled;
|
||||
uint64_t _videoDeliveryGeneration;
|
||||
std::mutex _fileMutex;
|
||||
@@ -377,6 +495,17 @@ 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
|
||||
releaseJSON:(NSData *)releaseJSON {
|
||||
const auto latest = crossdesk::ParseVersionInfoJSON(std::string_view(
|
||||
static_cast<const char *>(releaseJSON.bytes), releaseJSON.length));
|
||||
if (!latest) return nil;
|
||||
const auto update = crossdesk::AvailableAppUpdate(appVersion.UTF8String ?: "",
|
||||
*latest);
|
||||
return update ? [NSString stringWithUTF8String:update->c_str()] : nil;
|
||||
}
|
||||
|
||||
- (instancetype)init {
|
||||
@@ -397,6 +526,7 @@ Params MakeParams(const RTCState &state, const std::string &user_id,
|
||||
_pendingVideoBuffer = nullptr;
|
||||
_pendingVideoWidth = 0;
|
||||
_pendingVideoHeight = 0;
|
||||
_pendingVideoCaptureUptime = 0;
|
||||
_videoDeliveryScheduled = false;
|
||||
_videoDeliveryGeneration = 0;
|
||||
_selectedDisplay.store(0);
|
||||
@@ -406,6 +536,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;
|
||||
}
|
||||
@@ -451,11 +582,22 @@ 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;
|
||||
if (unchanged) return;
|
||||
const OSStatus migration_status = MigrateLegacyIdentityCredentials();
|
||||
[self reportIdentityStorageStatus:migration_status];
|
||||
if (unchanged) {
|
||||
if (IsReusableIdentity(self->_state->identity_with_password)) {
|
||||
NSString *value = [NSString stringWithUTF8String:self->_state->identity_with_password.c_str()];
|
||||
[self reportIdentityStorageStatus:SaveIdentityCredential(
|
||||
IdentityCredentialAccount(trimmed, signalPort), value)];
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
[self destroyControllerPeer];
|
||||
[self destroyIdentityPeer];
|
||||
@@ -467,37 +609,33 @@ Params MakeParams(const RTCState &state, const std::string &user_id,
|
||||
self->_state->identity_with_password.clear();
|
||||
self->_state->identity_base.clear();
|
||||
|
||||
NSString *key = [NSString stringWithFormat:@"CrossDeskIdentity.%@.%ld",
|
||||
trimmed, (long)signalPort];
|
||||
NSString *cached = [[NSUserDefaults standardUserDefaults]
|
||||
stringForKey:key];
|
||||
if (cached.length > 0) {
|
||||
const std::string cached_identity = cached.UTF8String ?: "";
|
||||
if (IsReusableIdentity(cached_identity)) {
|
||||
self->_state->identity_with_password = cached_identity;
|
||||
self->_state->identity_base = BaseIdentity(cached_identity);
|
||||
DispatchMain(^{
|
||||
id<CrossDeskRTCBridgeDelegate> delegate = self.delegate;
|
||||
if ([delegate respondsToSelector:
|
||||
@selector(rtcBridge:didProvisionIdentity:)]) {
|
||||
[delegate rtcBridge:self didProvisionIdentity:cached];
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// A bare ID cannot authenticate after its first signaling session.
|
||||
// Older iOS builds stored it anyway, causing every later launch to be
|
||||
// rejected as "Incorrect password". Drop it and request a fresh
|
||||
// server-issued ID/password pair.
|
||||
[[NSUserDefaults standardUserDefaults] removeObjectForKey:key];
|
||||
}
|
||||
NSString *cached = nil;
|
||||
OSStatus load_status = LoadIdentityCredential(
|
||||
IdentityCredentialAccount(trimmed, signalPort), &cached);
|
||||
if (load_status == errSecSuccess) {
|
||||
self->_state->identity_with_password = cached.UTF8String;
|
||||
self->_state->identity_base = BaseIdentity(self->_state->identity_with_password);
|
||||
} else if (load_status != errSecItemNotFound) {
|
||||
[self reportIdentityStorageStatus:load_status];
|
||||
// Locked/unavailable Keychain is not the same as a missing identity.
|
||||
[self handleSignalState:CrossDeskSignalStateCredentialUnavailable
|
||||
role:PeerRole::Identity
|
||||
generation:self->_identityGeneration.load()];
|
||||
return;
|
||||
}
|
||||
[self createIdentityPeer];
|
||||
});
|
||||
}
|
||||
|
||||
- (void)setHardwareAccelerationEnabled:(BOOL)enabled {
|
||||
dispatch_async(_rtcQueue, ^{
|
||||
self->_state->hardware_acceleration = enabled;
|
||||
if (load_status == errSecSuccess) {
|
||||
NSString *identifier = [NSString stringWithUTF8String:self->_state->identity_base.c_str()];
|
||||
const uint64_t identity_generation = self->_identityGeneration.load();
|
||||
DispatchMain(^{
|
||||
if (networking_generation != self->_networkingGeneration.load() ||
|
||||
identity_generation != self->_identityGeneration.load()) return;
|
||||
id<CrossDeskRTCBridgeDelegate> delegate = self.delegate;
|
||||
if ([delegate respondsToSelector:@selector(rtcBridge:didProvisionIdentity:)]) {
|
||||
[delegate rtcBridge:self didProvisionIdentity:identifier];
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -525,6 +663,72 @@ Params MakeParams(const RTCState &state, const std::string &user_id,
|
||||
_presenceGeneration.fetch_add(1);
|
||||
}
|
||||
|
||||
- (void)sendAnnouncementRequest:(NSData *)request requestID:(NSString *)requestID {
|
||||
NSData *payload = [request copy];
|
||||
NSString *identifier = [requestID copy];
|
||||
const uint64_t generation = _identityGeneration.load();
|
||||
const uint64_t signal_generation = _signalGeneration.load();
|
||||
const uint64_t networking_generation = _networkingGeneration.load();
|
||||
dispatch_async(_rtcQueue, ^{
|
||||
if (generation != self->_identityGeneration.load() ||
|
||||
signal_generation != self->_signalGeneration.load() ||
|
||||
networking_generation != self->_networkingGeneration.load()) return;
|
||||
if (self->_state->identity_peer && self->_state->identity_ready &&
|
||||
!self->_state->identity_base.empty() &&
|
||||
SendSignalMessage(self->_state->identity_peer,
|
||||
static_cast<const char *>(payload.bytes), payload.length) == 0) return;
|
||||
DispatchMain(^{
|
||||
if (generation != self->_identityGeneration.load() ||
|
||||
signal_generation != self->_signalGeneration.load() ||
|
||||
networking_generation != self->_networkingGeneration.load()) return;
|
||||
id<CrossDeskRTCBridgeDelegate> delegate = self.delegate;
|
||||
if ([delegate respondsToSelector:@selector(rtcBridge:didFailAnnouncementRequest:)]) {
|
||||
[delegate rtcBridge:self didFailAnnouncementRequest:identifier];
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
- (void)sendVideoSettingsWithQuality:(NSInteger)quality
|
||||
frameRate:(NSInteger)frameRate
|
||||
preference:(NSInteger)preference
|
||||
requestID:(uint32_t)requestID {
|
||||
if (quality < 0 || quality > 2 || (frameRate != 30 && frameRate != 60) ||
|
||||
preference < 0 || preference > 2) return;
|
||||
const uint64_t generation = _activeControllerGeneration.load();
|
||||
dispatch_async(_rtcQueue, ^{
|
||||
if (![self isControllerGenerationActive:generation] ||
|
||||
!self->_state->controller_peer) return;
|
||||
RemoteAction action{};
|
||||
action.type = ControlType::video_settings;
|
||||
action.vs = {static_cast<int>(quality), static_cast<int>(frameRate),
|
||||
static_cast<int>(preference), requestID, false};
|
||||
const auto message = action.to_json();
|
||||
if (SendReliableDataFrame(self->_state->controller_peer, message.data(),
|
||||
message.size(), kControlStream) != 0) {
|
||||
[self notifyVideoSettings:action.vs generation:generation];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
- (void)notifyVideoSettings:(crossdesk::VideoSettings)settings
|
||||
generation:(uint64_t)generation {
|
||||
DispatchMain(^{
|
||||
if (![self isControllerGenerationActive:generation]) return;
|
||||
id<CrossDeskRTCBridgeDelegate> delegate = self.delegate;
|
||||
if ([delegate respondsToSelector:
|
||||
@selector(rtcBridge:didReceiveVideoSettingsQuality:frameRate:
|
||||
preference:requestID:accepted:)]) {
|
||||
[delegate rtcBridge:self
|
||||
didReceiveVideoSettingsQuality:settings.quality
|
||||
frameRate:settings.frame_rate
|
||||
preference:settings.preference
|
||||
requestID:settings.request_id
|
||||
accepted:settings.accepted];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
- (void)requestPresenceForRemoteIDs:(NSArray<NSString *> *)remoteIDs
|
||||
subscribe:(BOOL)subscribe {
|
||||
NSArray<NSString *> *owned_ids = [remoteIDs copy];
|
||||
@@ -607,6 +811,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) {
|
||||
@@ -848,6 +1070,20 @@ Params MakeParams(const RTCState &state, const std::string &user_id,
|
||||
});
|
||||
}
|
||||
|
||||
- (void)reportIdentityStorageStatus:(OSStatus)status {
|
||||
const BOOL has_error = status != errSecSuccess;
|
||||
if (has_error) {
|
||||
// Log only the OSStatus, never an account, credential or Keychain payload.
|
||||
NSLog(@"CrossDesk identity Keychain operation failed (%d)", (int)status);
|
||||
}
|
||||
DispatchMain(^{
|
||||
id<CrossDeskRTCBridgeDelegate> delegate = self.delegate;
|
||||
if ([delegate respondsToSelector:@selector(rtcBridge:didChangeIdentityStorageError:)]) {
|
||||
[delegate rtcBridge:self didChangeIdentityStorageError:has_error];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
- (void)createIdentityPeer {
|
||||
if (_state->identity_peer || _state->signal_host.empty()) return;
|
||||
_state->identity_context.generation = _identityGeneration.fetch_add(1) + 1;
|
||||
@@ -968,10 +1204,16 @@ Params MakeParams(const RTCState &state, const std::string &user_id,
|
||||
self->_state->identity_recovery_attempted = true;
|
||||
NSString *host = [NSString stringWithUTF8String:
|
||||
self->_state->signal_host.c_str()];
|
||||
NSString *key = [NSString stringWithFormat:@"CrossDeskIdentity.%@.%d",
|
||||
host,
|
||||
self->_state->signal_port];
|
||||
[[NSUserDefaults standardUserDefaults] removeObjectForKey:key];
|
||||
OSStatus status = RemoveIdentityCredential(
|
||||
IdentityCredentialAccount(host, self->_state->signal_port));
|
||||
[self reportIdentityStorageStatus:status];
|
||||
if (status != errSecSuccess) {
|
||||
[self destroyIdentityPeer];
|
||||
[self handleSignalState:CrossDeskSignalStateCredentialUnavailable
|
||||
role:PeerRole::Identity
|
||||
generation:self->_identityGeneration.load()];
|
||||
return;
|
||||
}
|
||||
[self destroyIdentityPeer];
|
||||
self->_state->identity_with_password.clear();
|
||||
self->_state->identity_base.clear();
|
||||
@@ -1005,10 +1247,11 @@ Params MakeParams(const RTCState &state, const std::string &user_id,
|
||||
size:(size_t)size
|
||||
role:(PeerRole)role
|
||||
generation:(uint64_t)generation {
|
||||
constexpr size_t kMaxPresenceMessageSize = 256 * 1024;
|
||||
// Twenty 8 KiB bodies may expand substantially when JSON-escaped.
|
||||
constexpr size_t kMaxSignalMessageSize = 1024 * 1024;
|
||||
if (role != PeerRole::Identity || generation != _identityGeneration.load() ||
|
||||
!message || size == 0 ||
|
||||
size > kMaxPresenceMessageSize) {
|
||||
size > kMaxSignalMessageSize) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1024,6 +1267,19 @@ Params MakeParams(const RTCState &state, const std::string &user_id,
|
||||
NSString *type = object[@"type"];
|
||||
if (![type isKindOfClass:NSString.class]) return;
|
||||
|
||||
if ([type isEqualToString:@"announcements"] ||
|
||||
[type isEqualToString:@"announcements_changed"]) {
|
||||
DispatchMain(^{
|
||||
if (generation != self->_identityGeneration.load() ||
|
||||
signal_generation != self->_signalGeneration.load()) return;
|
||||
id<CrossDeskRTCBridgeDelegate> delegate = self.delegate;
|
||||
if ([delegate respondsToSelector:@selector(rtcBridge:didReceiveAnnouncementMessage:)]) {
|
||||
[delegate rtcBridge:self didReceiveAnnouncementMessage:payload];
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
NSMutableDictionary<NSString *, NSNumber *> *presence =
|
||||
[NSMutableDictionary dictionary];
|
||||
if ([type isEqualToString:@"presence"]) {
|
||||
@@ -1084,25 +1340,25 @@ Params MakeParams(const RTCState &state, const std::string &user_id,
|
||||
const std::string identity_copy = identity;
|
||||
dispatch_async(_rtcQueue, ^{
|
||||
if (generation != self->_identityGeneration.load()) return;
|
||||
self->_state->identity_with_password = identity_copy;
|
||||
self->_state->identity_base = BaseIdentity(identity_copy);
|
||||
const std::string credential = ProvisionedIdentityCredential(
|
||||
identity_copy, self->_state->identity_with_password);
|
||||
self->_state->identity_with_password = credential;
|
||||
self->_state->identity_base = BaseIdentity(credential);
|
||||
NSString *host = [NSString stringWithUTF8String:
|
||||
self->_state->signal_host.c_str()];
|
||||
NSString *key = [NSString stringWithFormat:@"CrossDeskIdentity.%@.%d",
|
||||
host, self->_state->signal_port];
|
||||
NSString *value = [NSString stringWithUTF8String:identity_copy.c_str()];
|
||||
if (IsReusableIdentity(identity_copy)) {
|
||||
[[NSUserDefaults standardUserDefaults] setObject:value forKey:key];
|
||||
} else {
|
||||
// Keep a passwordless identity only for this process. It is valid for
|
||||
// the current login but is not safe to reuse on the next app launch.
|
||||
[[NSUserDefaults standardUserDefaults] removeObjectForKey:key];
|
||||
}
|
||||
NSString *account = IdentityCredentialAccount(host, self->_state->signal_port);
|
||||
NSString *value = [NSString stringWithUTF8String:credential.c_str()];
|
||||
OSStatus status = IsReusableIdentity(credential)
|
||||
? SaveIdentityCredential(account, value)
|
||||
: RemoveIdentityCredential(account);
|
||||
[self reportIdentityStorageStatus:status];
|
||||
// Only the public device ID crosses into UI state; keep its password here.
|
||||
NSString *identifier = [NSString stringWithUTF8String:self->_state->identity_base.c_str()];
|
||||
DispatchMain(^{
|
||||
if (generation != self->_identityGeneration.load()) return;
|
||||
id<CrossDeskRTCBridgeDelegate> delegate = self.delegate;
|
||||
if ([delegate respondsToSelector:@selector(rtcBridge:didProvisionIdentity:)]) {
|
||||
[delegate rtcBridge:self didProvisionIdentity:value];
|
||||
[delegate rtcBridge:self didProvisionIdentity:identifier];
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1132,9 +1388,14 @@ Params MakeParams(const RTCState &state, const std::string &user_id,
|
||||
: "iPhone-" + self->_state->identity_base;
|
||||
RemoteAction action{};
|
||||
action.type = ControlType::host_infomation;
|
||||
action.i.platform = crossdesk::HostPlatform::IOS;
|
||||
CopyCString(action.i.host_name, sizeof(action.i.host_name),
|
||||
controller_name);
|
||||
action.i.host_name_size = std::strlen(action.i.host_name);
|
||||
NSString *appVersion = [[NSBundle mainBundle]
|
||||
objectForInfoDictionaryKey:@"CFBundleShortVersionString"];
|
||||
CopyCString(action.i.app_version, sizeof(action.i.app_version),
|
||||
appVersion.UTF8String ?: "");
|
||||
action.i.display_list = nullptr;
|
||||
action.i.display_num = 0;
|
||||
action.i.left = nullptr;
|
||||
@@ -1189,6 +1450,17 @@ Params MakeParams(const RTCState &state, const std::string &user_id,
|
||||
static_cast<size_t>(_selectedDisplay.load()));
|
||||
if (source != expected) return;
|
||||
}
|
||||
// Translate the transport's calibrated capture time once, while the peer is
|
||||
// alive. Carry it with the exact pixels through coalescing and UI submission.
|
||||
const NSTimeInterval now = NSProcessInfo.processInfo.systemUptime;
|
||||
const int64_t peer_now = GetSystemTimeMicros(_state->controller_peer);
|
||||
NSTimeInterval capture_uptime = 0;
|
||||
if (frame->captured_timestamp > 0 && peer_now > 0 &&
|
||||
frame->captured_timestamp <= static_cast<uint64_t>(peer_now) &&
|
||||
static_cast<uint64_t>(peer_now) - frame->captured_timestamp <= 5'000'000) {
|
||||
capture_uptime = now -
|
||||
(static_cast<uint64_t>(peer_now) - frame->captured_timestamp) / 1'000'000.0;
|
||||
}
|
||||
const MiniRtcNativeVideoFrame *native_frame = frame->native_frame;
|
||||
const bool has_native_pixel_buffer =
|
||||
native_frame &&
|
||||
@@ -1202,7 +1474,8 @@ Params MakeParams(const RTCState &state, const std::string &user_id,
|
||||
native_frame->payload.cv_pixel_buffer);
|
||||
[self enqueueVideoPixelBuffer:pixel_buffer
|
||||
width:frame->width
|
||||
height:frame->height];
|
||||
height:frame->height
|
||||
captureUptime:capture_uptime];
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1294,13 +1567,15 @@ Params MakeParams(const RTCState &state, const std::string &user_id,
|
||||
|
||||
[self enqueueVideoPixelBuffer:pixel_buffer
|
||||
width:frame_width
|
||||
height:frame_height];
|
||||
height:frame_height
|
||||
captureUptime:capture_uptime];
|
||||
CVPixelBufferRelease(pixel_buffer);
|
||||
}
|
||||
|
||||
- (void)enqueueVideoPixelBuffer:(CVPixelBufferRef)pixelBuffer
|
||||
width:(NSInteger)width
|
||||
height:(NSInteger)height {
|
||||
height:(NSInteger)height
|
||||
captureUptime:(NSTimeInterval)captureUptime {
|
||||
if (!pixelBuffer) return;
|
||||
|
||||
CVPixelBufferRetain(pixelBuffer);
|
||||
@@ -1314,6 +1589,7 @@ Params MakeParams(const RTCState &state, const std::string &user_id,
|
||||
_pendingVideoBuffer = pixelBuffer;
|
||||
_pendingVideoWidth = width;
|
||||
_pendingVideoHeight = height;
|
||||
_pendingVideoCaptureUptime = captureUptime;
|
||||
generation = _videoDeliveryGeneration;
|
||||
if (!_videoDeliveryScheduled) {
|
||||
_videoDeliveryScheduled = true;
|
||||
@@ -1326,15 +1602,18 @@ Params MakeParams(const RTCState &state, const std::string &user_id,
|
||||
CVPixelBufferRef latest_buffer = nullptr;
|
||||
NSInteger latest_width = 0;
|
||||
NSInteger latest_height = 0;
|
||||
NSTimeInterval latest_capture_uptime = 0;
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(self->_pendingVideoMutex);
|
||||
if (generation != self->_videoDeliveryGeneration) return;
|
||||
latest_buffer = self->_pendingVideoBuffer;
|
||||
latest_width = self->_pendingVideoWidth;
|
||||
latest_height = self->_pendingVideoHeight;
|
||||
latest_capture_uptime = self->_pendingVideoCaptureUptime;
|
||||
self->_pendingVideoBuffer = nullptr;
|
||||
self->_pendingVideoWidth = 0;
|
||||
self->_pendingVideoHeight = 0;
|
||||
self->_pendingVideoCaptureUptime = 0;
|
||||
self->_videoDeliveryScheduled = false;
|
||||
}
|
||||
|
||||
@@ -1347,11 +1626,12 @@ Params MakeParams(const RTCState &state, const std::string &user_id,
|
||||
}
|
||||
id<CrossDeskRTCBridgeDelegate> delegate = self.delegate;
|
||||
if ([delegate respondsToSelector:
|
||||
@selector(rtcBridge:didReceivePixelBuffer:width:height:)]) {
|
||||
@selector(rtcBridge:didReceivePixelBuffer:width:height:captureUptime:)]) {
|
||||
[delegate rtcBridge:self
|
||||
didReceivePixelBuffer:latest_buffer
|
||||
width:latest_width
|
||||
height:latest_height];
|
||||
height:latest_height
|
||||
captureUptime:latest_capture_uptime];
|
||||
}
|
||||
CVPixelBufferRelease(latest_buffer);
|
||||
});
|
||||
@@ -1366,6 +1646,7 @@ Params MakeParams(const RTCState &state, const std::string &user_id,
|
||||
_pendingVideoBuffer = nullptr;
|
||||
_pendingVideoWidth = 0;
|
||||
_pendingVideoHeight = 0;
|
||||
_pendingVideoCaptureUptime = 0;
|
||||
_videoDeliveryScheduled = false;
|
||||
}
|
||||
if (pending_buffer) CVPixelBufferRelease(pending_buffer);
|
||||
@@ -1386,19 +1667,27 @@ Params MakeParams(const RTCState &state, const std::string &user_id,
|
||||
- (void)handleData:(const char *)data
|
||||
size:(size_t)size
|
||||
sourceID:(const char *)sourceID
|
||||
sourceIDSize:(size_t)sourceIDSize {
|
||||
sourceIDSize:(size_t)sourceIDSize
|
||||
generation:(uint64_t)generation {
|
||||
if (!data || size == 0 || !sourceID) return;
|
||||
const std::string source(sourceID, sourceIDSize);
|
||||
if (source == kControlStream) {
|
||||
RemoteAction action{};
|
||||
if (!action.from_json(std::string(data, size)) ||
|
||||
action.type != ControlType::host_infomation) {
|
||||
if (!action.from_json(std::string(data, size))) return;
|
||||
if (action.type == ControlType::video_settings_status) {
|
||||
[self notifyVideoSettings:action.vs generation:generation];
|
||||
return;
|
||||
}
|
||||
if (action.type != ControlType::host_infomation) return;
|
||||
const BOOL supports_video_settings = action.i.supports_video_settings;
|
||||
NSString *platform = [NSString stringWithUTF8String:
|
||||
crossdesk::HostPlatformName(action.i.platform)];
|
||||
NSString *host_name = [[NSString alloc]
|
||||
initWithBytes:action.i.host_name
|
||||
length:action.i.host_name_size
|
||||
encoding:NSUTF8StringEncoding];
|
||||
NSString *app_version = crossdesk::IsValidAppVersion(action.i.app_version)
|
||||
? [NSString stringWithUTF8String:action.i.app_version] : nil;
|
||||
NSMutableArray<NSString *> *display_names =
|
||||
[NSMutableArray arrayWithCapacity:action.i.display_num];
|
||||
NSMutableArray<NSValue *> *display_sizes =
|
||||
@@ -1419,14 +1708,18 @@ Params MakeParams(const RTCState &state, const std::string &user_id,
|
||||
}
|
||||
crossdesk::FreeRemoteAction(action);
|
||||
DispatchMain(^{
|
||||
if (![self isControllerGenerationActive:generation]) return;
|
||||
id<CrossDeskRTCBridgeDelegate> delegate = self.delegate;
|
||||
if ([delegate respondsToSelector:
|
||||
@selector(rtcBridge:didReceiveHostName:displayNames:
|
||||
displaySizes:)]) {
|
||||
@selector(rtcBridge:didReceiveHostName:appVersion:platform:displayNames:
|
||||
displaySizes:supportsVideoSettings:)]) {
|
||||
[delegate rtcBridge:self
|
||||
didReceiveHostName:host_name ?: @""
|
||||
appVersion:app_version ?: @""
|
||||
platform:platform
|
||||
displayNames:display_names
|
||||
displaySizes:display_sizes];
|
||||
displaySizes:display_sizes
|
||||
supportsVideoSettings:supports_video_settings];
|
||||
}
|
||||
});
|
||||
return;
|
||||
@@ -1649,19 +1942,31 @@ Params MakeParams(const RTCState &state, const std::string &user_id,
|
||||
}
|
||||
}
|
||||
|
||||
- (void)handleStats:(const MiniRtcNetTrafficStats *)stats mode:(TraversalMode)mode {
|
||||
- (void)handleStats:(const MiniRtcNetTrafficStats *)stats
|
||||
mode:(TraversalMode)mode
|
||||
generation:(uint64_t)generation {
|
||||
if (!stats) return;
|
||||
const NSUInteger bitrate = stats->total_inbound_stats.bitrate;
|
||||
const float loss = stats->video_inbound_stats.loss_rate;
|
||||
const BOOL using_turn = mode == TraversalMode::Relay;
|
||||
const auto traffic = [](const MiniRtcInboundStats &inbound,
|
||||
const MiniRtcOutboundStats &outbound) {
|
||||
return CrossDeskTrafficStats{inbound.bitrate, outbound.bitrate,
|
||||
inbound.loss_rate};
|
||||
};
|
||||
const CrossDeskNetworkStats snapshot{
|
||||
traffic(stats->video_inbound_stats, stats->video_outbound_stats),
|
||||
traffic(stats->audio_inbound_stats, stats->audio_outbound_stats),
|
||||
traffic(stats->data_inbound_stats, stats->data_outbound_stats),
|
||||
traffic(stats->total_inbound_stats, stats->total_outbound_stats),
|
||||
stats->rtt_ms, static_cast<BOOL>(stats->srtp_active),
|
||||
mode == TraversalMode::P2P ? CrossDeskTraversalModeDirect
|
||||
: mode == TraversalMode::Relay ? CrossDeskTraversalModeRelay
|
||||
: CrossDeskTraversalModeUnknown};
|
||||
DispatchMain(^{
|
||||
// Disconnect/reconnect can happen while this report waits on the UI queue.
|
||||
if (![self isControllerGenerationActive:generation]) return;
|
||||
id<CrossDeskRTCBridgeDelegate> delegate = self.delegate;
|
||||
if ([delegate respondsToSelector:
|
||||
@selector(rtcBridge:didUpdateBitrate:lossRate:usingTURN:)]) {
|
||||
[delegate rtcBridge:self
|
||||
didUpdateBitrate:bitrate
|
||||
lossRate:loss
|
||||
usingTURN:using_turn];
|
||||
@selector(rtcBridge:didUpdateNetworkStats:)]) {
|
||||
[delegate rtcBridge:self didUpdateNetworkStats:snapshot];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
enum HomeDestination: Hashable {
|
||||
case settings
|
||||
case announcements
|
||||
case announcement(AnnouncementSelection)
|
||||
}
|
||||
|
||||
struct ContentView: View {
|
||||
@Environment(\.scenePhase) private var scenePhase
|
||||
@ObservedObject var session: RemoteSessionModel
|
||||
@@ -11,12 +17,40 @@ struct ContentView: View {
|
||||
RemoteSessionView(session: session)
|
||||
.transition(.opacity)
|
||||
} else {
|
||||
ConnectionHomeView(session: session)
|
||||
.transition(.opacity)
|
||||
NavigationStack {
|
||||
ConnectionHomeView(session: session)
|
||||
.navigationTitle("")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
// Keep inbox and detail in the same value-based stack.
|
||||
// Mixing an isPresented inbox with a value-based detail
|
||||
// can reinsert the inbox above the detail on updates.
|
||||
.navigationDestination(for: HomeDestination.self) { destination in
|
||||
switch destination {
|
||||
case .settings:
|
||||
ServerSettingsView(session: session)
|
||||
case .announcements:
|
||||
AnnouncementInboxView(session: session, inbox: session.announcements)
|
||||
case .announcement(let selection):
|
||||
AnnouncementDetailView(inbox: session.announcements, selection: selection)
|
||||
}
|
||||
}
|
||||
}
|
||||
.toolbarBackground(Color(.systemGroupedBackground), for: .navigationBar)
|
||||
.toolbarBackground(.visible, for: .navigationBar)
|
||||
.transition(.opacity)
|
||||
}
|
||||
}
|
||||
.animation(.easeInOut(duration: 0.2), value: session.sessionVisible)
|
||||
.preferredColorScheme(session.sessionVisible ? .dark : .light)
|
||||
.sheet(isPresented: Binding(
|
||||
get: { session.privacyNoticeVisible },
|
||||
set: { if !$0 { session.dismissPrivacyNotice() } }
|
||||
)) {
|
||||
PrivacyConsentView(session: session)
|
||||
.presentationDetents([.large])
|
||||
.presentationDragIndicator(.hidden)
|
||||
.interactiveDismissDisabled()
|
||||
}
|
||||
.alert("连接提示", isPresented: Binding(
|
||||
get: { session.connectionFailureMessage != nil },
|
||||
set: { if !$0 { session.dismissConnectionFailure() } }
|
||||
@@ -30,17 +64,14 @@ struct ContentView: View {
|
||||
AppOrientation.update(to: .portrait)
|
||||
}
|
||||
if scenePhase == .active {
|
||||
session.refreshRecentConnectionPresenceAfterForeground()
|
||||
session.resumeVideoAfterForeground()
|
||||
session.applicationDidBecomeActive()
|
||||
}
|
||||
}
|
||||
.onChange(of: scenePhase) { phase in
|
||||
if phase == .active {
|
||||
session.refreshRecentConnectionPresenceAfterForeground()
|
||||
session.resumeVideoAfterForeground()
|
||||
session.applicationDidBecomeActive()
|
||||
} else if phase == .background {
|
||||
session.suspendVideoForBackground()
|
||||
session.suspendPresenceMonitoring()
|
||||
session.applicationDidEnterBackground()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -130,7 +161,6 @@ private struct ConnectionHomeView: View {
|
||||
@ObservedObject var session: RemoteSessionModel
|
||||
@FocusState private var remoteIDFocused: Bool
|
||||
@State private var passwordPromptVisible = false
|
||||
@State private var settingsVisible = false
|
||||
@State private var promptRemoteID = ""
|
||||
@State private var promptPassword = ""
|
||||
@State private var promptRememberPassword = false
|
||||
@@ -197,7 +227,6 @@ private struct ConnectionHomeView: View {
|
||||
Color(.systemGroupedBackground).ignoresSafeArea()
|
||||
|
||||
VStack(spacing: 0) {
|
||||
header
|
||||
remoteConnectionPanel
|
||||
.padding(.top, 24)
|
||||
recentConnectionsPanel
|
||||
@@ -212,6 +241,13 @@ private struct ConnectionHomeView: View {
|
||||
}
|
||||
|
||||
}
|
||||
.toolbar {
|
||||
if #available(iOS 26.0, *) {
|
||||
navigationToolbar.sharedBackgroundVisibility(.hidden)
|
||||
} else {
|
||||
navigationToolbar
|
||||
}
|
||||
}
|
||||
.background {
|
||||
KeyboardDismissTapView {
|
||||
remoteIDFocused = false
|
||||
@@ -230,11 +266,6 @@ private struct ConnectionHomeView: View {
|
||||
.presentationDetents([.height(340)])
|
||||
.presentationDragIndicator(.visible)
|
||||
}
|
||||
.sheet(isPresented: $settingsVisible) {
|
||||
ServerSettingsView(session: session)
|
||||
.presentationDetents([.fraction(0.80)])
|
||||
.presentationDragIndicator(.visible)
|
||||
}
|
||||
.alert("设备离线", isPresented: Binding(
|
||||
get: { session.deviceOfflineAlertVisible },
|
||||
set: { visible in
|
||||
@@ -251,32 +282,36 @@ private struct ConnectionHomeView: View {
|
||||
session.remoteID = formatted
|
||||
}
|
||||
}
|
||||
.onDisappear { remoteIDFocused = false }
|
||||
}
|
||||
|
||||
private var header: some View {
|
||||
HStack(spacing: 8) {
|
||||
Label {
|
||||
Text(session.signalStatus)
|
||||
.font(.caption.weight(.semibold))
|
||||
.lineLimit(1)
|
||||
} icon: {
|
||||
Image(systemName: signalSymbol)
|
||||
.font(.caption.weight(.bold))
|
||||
@ToolbarContentBuilder
|
||||
private var navigationToolbar: some ToolbarContent {
|
||||
ToolbarItem(placement: .navigationBarLeading) {
|
||||
if session.hasNetworkConsent {
|
||||
signalStatusBadge
|
||||
.fixedSize(horizontal: true, vertical: false)
|
||||
} else {
|
||||
Button {
|
||||
remoteIDFocused = false
|
||||
session.showPrivacyNotice()
|
||||
} label: {
|
||||
signalStatusBadge
|
||||
.fixedSize(horizontal: true, vertical: false)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityHint("打开隐私政策")
|
||||
}
|
||||
.foregroundStyle(signalTint)
|
||||
.padding(.horizontal, 11)
|
||||
.frame(height: 34)
|
||||
.background(signalTint.opacity(0.11), in: Capsule())
|
||||
.overlay {
|
||||
Capsule()
|
||||
.stroke(signalTint.opacity(0.22), lineWidth: 1)
|
||||
}
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
NavigationLink(value: HomeDestination.announcements) {
|
||||
AnnouncementBadge(inbox: session.announcements)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Button {
|
||||
settingsVisible = true
|
||||
} label: {
|
||||
.foregroundStyle(.primary)
|
||||
.disabled(session.isConnecting)
|
||||
}
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
NavigationLink(value: HomeDestination.settings) {
|
||||
Image(systemName: "gearshape")
|
||||
.font(.system(size: 18, weight: .semibold))
|
||||
.frame(width: 40, height: 40)
|
||||
@@ -285,9 +320,30 @@ private struct ConnectionHomeView: View {
|
||||
style: .continuous))
|
||||
}
|
||||
.foregroundStyle(.primary)
|
||||
.disabled(session.isConnecting)
|
||||
.accessibilityLabel("设置")
|
||||
}
|
||||
.padding(.top, 8)
|
||||
}
|
||||
|
||||
private var signalStatusBadge: some View {
|
||||
HStack(spacing: 6) {
|
||||
if session.hasNetworkConsent {
|
||||
Image(systemName: signalSymbol)
|
||||
.font(.caption.weight(.bold))
|
||||
}
|
||||
Text(session.signalStatus)
|
||||
.font(.caption.weight(.semibold))
|
||||
.lineLimit(1)
|
||||
}
|
||||
.foregroundStyle(signalTint)
|
||||
.padding(.horizontal, 11)
|
||||
.frame(height: 34)
|
||||
.background(signalTint.opacity(0.11), in: Capsule())
|
||||
.overlay {
|
||||
Capsule()
|
||||
.stroke(signalTint.opacity(0.22), lineWidth: 1)
|
||||
}
|
||||
.contentShape(Capsule())
|
||||
}
|
||||
|
||||
private var remoteConnectionPanel: some View {
|
||||
@@ -470,6 +526,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
|
||||
@@ -511,6 +572,26 @@ private struct RecentConnectionCard: View {
|
||||
let connect: () -> Void
|
||||
let delete: () -> Void
|
||||
|
||||
private var platformColors: [Color] {
|
||||
switch connection.platform {
|
||||
case .windows:
|
||||
return [Color(red: 0.12, green: 0.38, blue: 0.70),
|
||||
Color(red: 0.20, green: 0.48, blue: 0.77)]
|
||||
case .linux:
|
||||
return [Color(red: 0.63, green: 0.31, blue: 0.08),
|
||||
Color(red: 0.73, green: 0.39, blue: 0.12)]
|
||||
case .macos:
|
||||
return [Color(red: 0.43, green: 0.29, blue: 0.68),
|
||||
Color(red: 0.55, green: 0.39, blue: 0.77)]
|
||||
case .ios:
|
||||
return [Color(red: 0.08, green: 0.43, blue: 0.40),
|
||||
Color(red: 0.12, green: 0.51, blue: 0.47)]
|
||||
case .unknown:
|
||||
return [Color(red: 0.35, green: 0.40, blue: 0.46),
|
||||
Color(red: 0.45, green: 0.49, blue: 0.55)]
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Button(action: connect) {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
@@ -520,13 +601,16 @@ private struct RecentConnectionCard: View {
|
||||
.resizable()
|
||||
.scaledToFill()
|
||||
} else {
|
||||
LinearGradient(colors: [
|
||||
Color(red: 0.18, green: 0.48, blue: 0.86),
|
||||
Color(red: 0.38, green: 0.68, blue: 0.96)
|
||||
], startPoint: .topLeading, endPoint: .bottomTrailing)
|
||||
Image(systemName: "display")
|
||||
.font(.system(size: 34, weight: .medium))
|
||||
.foregroundStyle(.white.opacity(0.9))
|
||||
LinearGradient(colors: platformColors,
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing)
|
||||
Text(connection.platform.displayName)
|
||||
.font(.title3.weight(.semibold))
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.8)
|
||||
.foregroundStyle(.white)
|
||||
.padding(8)
|
||||
.accessibilityLabel("被控端平台:\(connection.platform.displayName)")
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
@@ -669,87 +753,178 @@ 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)
|
||||
.submitLabel(.next)
|
||||
.onSubmit { focusedServerField = .port }
|
||||
.accessibilityLabel("服务器地址")
|
||||
}
|
||||
LabeledContent("端口") {
|
||||
TextField("填写端口", text: $customServerPort)
|
||||
.keyboardType(.numberPad)
|
||||
.multilineTextAlignment(.trailing)
|
||||
.focused($focusedServerField, equals: .port)
|
||||
.accessibilityLabel("服务器端口")
|
||||
}
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
|
||||
Text(session.mouseControlMode.detail)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Section("视频编解码") {
|
||||
Picker("处理方式", selection: $session.videoCodecMode) {
|
||||
ForEach(VideoCodecMode.allCases) { mode in
|
||||
Text(mode.title).tag(mode)
|
||||
}
|
||||
if usesCustomServer {
|
||||
Text("结束编辑后生效。关闭开关可立即切回默认配置。")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
|
||||
Text(session.videoCodecMode.detail)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
Text("修改后从下一次连接开始生效。")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
Section("画面偏好") {
|
||||
Picker("偏好模式", selection: $session.videoAdaptationPolicy) {
|
||||
ForEach(VideoAdaptationPolicy.allCases) { policy in
|
||||
Text(policy.title).tag(policy)
|
||||
}
|
||||
} label: {
|
||||
HStack {
|
||||
Text("服务器")
|
||||
Spacer()
|
||||
Text(usesCustomServer ? "自定义" : "默认")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
|
||||
Text(session.videoAdaptationPolicy.detail)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
Text("修改后从下一次连接开始生效。")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
Section {
|
||||
TextField("信令服务器", text: $session.signalHost)
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
TextField("信令端口", text: $session.signalPort)
|
||||
.keyboardType(.numberPad)
|
||||
} header: {
|
||||
Text("服务器")
|
||||
} footer: {
|
||||
Text(session.localIdentity.isEmpty
|
||||
? "正在获取本机 ID…"
|
||||
: "本机 ID \(session.localIdentity)")
|
||||
.font(.caption2.monospacedDigit())
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
.padding(.top, 16)
|
||||
.textSelection(.enabled)
|
||||
} footer: {
|
||||
if let serverConfigurationError {
|
||||
Text(serverConfigurationError)
|
||||
.foregroundStyle(.red)
|
||||
}
|
||||
if let warning = session.identityStorageWarning {
|
||||
Text(warning)
|
||||
.foregroundStyle(.orange)
|
||||
}
|
||||
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)
|
||||
.scrollDismissesKeyboard(.interactively)
|
||||
.background {
|
||||
KeyboardDismissTapView {
|
||||
focusedServerField = nil
|
||||
}
|
||||
.allowsHitTesting(false)
|
||||
}
|
||||
.onChange(of: serverConfigurationExpanded) { expanded in
|
||||
if !expanded {
|
||||
focusedServerField = nil
|
||||
validateAndApplyServerConfiguration()
|
||||
}
|
||||
}
|
||||
.onChange(of: usesCustomServer) { isCustom in
|
||||
if !isCustom { useDefaultServer() }
|
||||
}
|
||||
.onChange(of: focusedServerField) { field in
|
||||
if field == nil { validateAndApplyServerConfiguration() }
|
||||
}
|
||||
.onDisappear {
|
||||
// Native back navigation can remove the view before focus updates.
|
||||
focusedServerField = nil
|
||||
validateAndApplyServerConfiguration()
|
||||
}
|
||||
.toolbar {
|
||||
ToolbarItemGroup(placement: .keyboard) {
|
||||
Spacer()
|
||||
Button("完成") {
|
||||
focusedServerField = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func useDefaultServer() {
|
||||
serverConfigurationError = nil
|
||||
focusedServerField = nil
|
||||
customServerHost = ""
|
||||
customServerPort = ""
|
||||
guard !session.usesOfficialServer else { return }
|
||||
session.restoreOfficialServerConfiguration()
|
||||
session.configureBridge()
|
||||
}
|
||||
|
||||
private func validateAndApplyServerConfiguration() {
|
||||
guard usesCustomServer else { return }
|
||||
let host = customServerHost.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let port = customServerPort.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !host.isEmpty else {
|
||||
serverConfigurationError = "请输入服务器地址,或关闭“使用自定义服务器”。"
|
||||
return
|
||||
}
|
||||
guard let portNumber = Int(port), (1...65535).contains(portNumber) else {
|
||||
serverConfigurationError = "请输入 1–65535 范围内的端口,或关闭“使用自定义服务器”。"
|
||||
return
|
||||
}
|
||||
serverConfigurationError = nil
|
||||
customServerHost = host
|
||||
customServerPort = String(portNumber)
|
||||
if session.signalHost != host || session.signalPort != customServerPort {
|
||||
session.signalHost = host
|
||||
session.signalPort = customServerPort
|
||||
session.configureBridge()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>NSPrivacyTracking</key>
|
||||
<false/>
|
||||
<key>NSPrivacyTrackingDomains</key>
|
||||
<array/>
|
||||
<key>NSPrivacyAccessedAPITypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>NSPrivacyAccessedAPIType</key>
|
||||
<string>NSPrivacyAccessedAPICategoryUserDefaults</string>
|
||||
<key>NSPrivacyAccessedAPITypeReasons</key>
|
||||
<array>
|
||||
<string>CA92.1</string>
|
||||
</array>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>NSPrivacyAccessedAPIType</key>
|
||||
<string>NSPrivacyAccessedAPICategoryFileTimestamp</string>
|
||||
<key>NSPrivacyAccessedAPITypeReasons</key>
|
||||
<array>
|
||||
<string>C617.1</string>
|
||||
<string>3B52.1</string>
|
||||
</array>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>NSPrivacyAccessedAPIType</key>
|
||||
<string>NSPrivacyAccessedAPICategorySystemBootTime</string>
|
||||
<key>NSPrivacyAccessedAPITypeReasons</key>
|
||||
<array>
|
||||
<string>35F9.1</string>
|
||||
<string>8FFB.1</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,56 @@
|
||||
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 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)
|
||||
}
|
||||
}
|
||||
|
||||
/// Refusal dismisses the notice for this visit only. A background/foreground
|
||||
/// round trip starts another visit; transient inactive states do not.
|
||||
struct PrivacyNoticeState {
|
||||
private(set) var isVisible: Bool
|
||||
private var needsPresentationOnActivation = false
|
||||
|
||||
init(hasNetworkConsent: Bool) {
|
||||
isVisible = !hasNetworkConsent
|
||||
}
|
||||
|
||||
mutating func show() {
|
||||
isVisible = true
|
||||
}
|
||||
|
||||
mutating func dismiss() {
|
||||
isVisible = false
|
||||
}
|
||||
|
||||
mutating func didEnterBackground() {
|
||||
needsPresentationOnActivation = true
|
||||
}
|
||||
|
||||
mutating func didBecomeActive(hasNetworkConsent: Bool) {
|
||||
guard needsPresentationOnActivation else { return }
|
||||
needsPresentationOnActivation = false
|
||||
isVisible = !hasNetworkConsent
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
import SwiftUI
|
||||
|
||||
struct PrivacyConsentView: View {
|
||||
@ObservedObject var session: RemoteSessionModel
|
||||
@State private var hasReadPolicy = false
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
ScrollView {
|
||||
PrivacyPolicyDocument()
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.vertical, 20)
|
||||
}
|
||||
.background(Color(.systemBackground))
|
||||
.safeAreaInset(edge: .bottom, spacing: 0) {
|
||||
consentActions
|
||||
}
|
||||
.navigationTitle("隐私政策")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
}
|
||||
|
||||
private var consentActions: some View {
|
||||
VStack(spacing: 0) {
|
||||
Divider()
|
||||
|
||||
VStack(spacing: 12) {
|
||||
Toggle(isOn: $hasReadPolicy) {
|
||||
Text("我已阅读并同意《隐私政策》")
|
||||
.font(.subheadline)
|
||||
}
|
||||
.toggleStyle(ConsentCheckboxStyle())
|
||||
|
||||
Button {
|
||||
guard hasReadPolicy else { return }
|
||||
session.acceptNetworkConsent()
|
||||
} label: {
|
||||
Text("同意并继续")
|
||||
.font(.body.weight(.semibold))
|
||||
.frame(maxWidth: .infinity, minHeight: 24)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.controlSize(.large)
|
||||
.tint(.blue)
|
||||
.disabled(!hasReadPolicy || !PrivacyPolicyDocument.isAvailable)
|
||||
|
||||
Button {
|
||||
session.declineNetworkConsent()
|
||||
} label: {
|
||||
Text("暂不同意")
|
||||
.font(.body)
|
||||
.frame(maxWidth: .infinity, minHeight: 44)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.foregroundStyle(.secondary)
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.vertical, 12)
|
||||
}
|
||||
.background(Color(.systemBackground))
|
||||
}
|
||||
}
|
||||
|
||||
private struct ConsentCheckboxStyle: ToggleStyle {
|
||||
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: 44, 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(.headline)
|
||||
.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(.body)
|
||||
.lineSpacing(4)
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
}
|
||||
|
||||
struct PrivacyPolicyView: View {
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
PrivacyPolicyDocument().padding()
|
||||
}
|
||||
.navigationTitle("隐私政策")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import Foundation
|
||||
|
||||
enum RemoteHostPlatform: String, Codable {
|
||||
case unknown, windows, macos, linux, ios
|
||||
|
||||
var displayName: String {
|
||||
switch self {
|
||||
case .windows: return "Windows"
|
||||
case .macos: return "macOS"
|
||||
case .linux: return "Linux"
|
||||
case .ios: return "iOS"
|
||||
case .unknown: return "未知"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct RecentConnection: Codable, Identifiable, Equatable {
|
||||
let remoteID: String
|
||||
var displayName: String
|
||||
var lastConnectedAt: Date
|
||||
var remembersPassword: Bool
|
||||
var thumbnailFileName: String?
|
||||
var platform: RemoteHostPlatform
|
||||
|
||||
var id: String { remoteID }
|
||||
|
||||
init(remoteID: String, displayName: String, lastConnectedAt: Date,
|
||||
remembersPassword: Bool, thumbnailFileName: String?,
|
||||
platform: RemoteHostPlatform = .unknown) {
|
||||
self.remoteID = remoteID
|
||||
self.displayName = displayName
|
||||
self.lastConnectedAt = lastConnectedAt
|
||||
self.remembersPassword = remembersPassword
|
||||
self.thumbnailFileName = thumbnailFileName
|
||||
self.platform = platform
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case remoteID, displayName, lastConnectedAt, remembersPassword, thumbnailFileName, platform
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let values = try decoder.container(keyedBy: CodingKeys.self)
|
||||
remoteID = try values.decode(String.self, forKey: .remoteID)
|
||||
displayName = try values.decode(String.self, forKey: .displayName)
|
||||
lastConnectedAt = try values.decode(Date.self, forKey: .lastConnectedAt)
|
||||
remembersPassword = try values.decode(Bool.self, forKey: .remembersPassword)
|
||||
thumbnailFileName = try values.decodeIfPresent(String.self, forKey: .thumbnailFileName)
|
||||
// Adding platform metadata must not discard records saved by older apps.
|
||||
platform = (try? values.decode(RemoteHostPlatform.self, forKey: .platform)) ?? .unknown
|
||||
}
|
||||
|
||||
mutating func updateHostInfo(name: String, platform: RemoteHostPlatform) {
|
||||
let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !trimmed.isEmpty { displayName = trimmed }
|
||||
self.platform = platform
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import Foundation
|
||||
|
||||
struct RemoteTrafficStatistics {
|
||||
var inboundBitrate: UInt32 = 0
|
||||
var outboundBitrate: UInt32 = 0
|
||||
var lossRate: Float = 0
|
||||
}
|
||||
|
||||
struct RemoteNetworkReport {
|
||||
enum Mode { case unknown, direct, relay }
|
||||
|
||||
var video = RemoteTrafficStatistics()
|
||||
var audio = RemoteTrafficStatistics()
|
||||
var data = RemoteTrafficStatistics()
|
||||
var total = RemoteTrafficStatistics()
|
||||
var mode: Mode = .unknown
|
||||
var srtpActive = false
|
||||
var rttMilliseconds: Double = -1
|
||||
}
|
||||
|
||||
struct RemoteNetworkSnapshot {
|
||||
var report: RemoteNetworkReport?
|
||||
var framesPerSecond = 0
|
||||
var videoLatencyMilliseconds: Double?
|
||||
var rttMilliseconds: Double?
|
||||
|
||||
var connectionMode: String {
|
||||
switch report?.mode {
|
||||
case .direct: return "P2P 直连"
|
||||
case .relay: return "TURN 中继"
|
||||
default: return "—"
|
||||
}
|
||||
}
|
||||
|
||||
static func bitrate(_ value: UInt32?) -> String {
|
||||
guard let value else { return "—" }
|
||||
if value >= 1_000_000 {
|
||||
return String(format: "%.1f Mbps", Double(value) / 1_000_000)
|
||||
}
|
||||
if value >= 1_000 {
|
||||
return String(format: "%.0f Kbps", Double(value) / 1_000)
|
||||
}
|
||||
return "\(value) bps"
|
||||
}
|
||||
|
||||
static func lossRate(_ value: Float?) -> String {
|
||||
guard let value, value.isFinite, value >= 0 else { return "—" }
|
||||
// MiniRTC's total is the sum of media fractions, as on desktop.
|
||||
return String(format: "%.1f%%", value * 100)
|
||||
}
|
||||
|
||||
static func latency(_ value: Double?) -> String {
|
||||
guard let value, value.isFinite, value >= 0 else { return "—" }
|
||||
return value < 1 ? "<1 ms" : String(format: "%.0f ms", value)
|
||||
}
|
||||
}
|
||||
|
||||
/// Main-thread presentation statistics. Time is monotonic and injectable so
|
||||
/// missing reports, idle video and reconnections have deterministic behavior.
|
||||
struct RemoteNetworkStatistics {
|
||||
private var report: RemoteNetworkReport?
|
||||
private var reportTime: TimeInterval = 0
|
||||
private var averageRTT: Double?
|
||||
private var rttTime: TimeInterval = 0
|
||||
private var lastFrameID: UInt64 = 0
|
||||
private var frames: [(time: TimeInterval, latency: Double?)] = []
|
||||
private var displayedSnapshot = RemoteNetworkSnapshot()
|
||||
private var displayTime: TimeInterval?
|
||||
|
||||
mutating func receive(_ report: RemoteNetworkReport, at now: TimeInterval) {
|
||||
self.report = report
|
||||
reportTime = now
|
||||
let rtt = report.rttMilliseconds
|
||||
guard rtt.isFinite, rtt >= 0, rtt <= 2_000 else { return }
|
||||
if let previous = averageRTT, now - rttTime < 3 {
|
||||
averageRTT = previous + 0.25 * (rtt - previous)
|
||||
} else {
|
||||
averageRTT = rtt
|
||||
}
|
||||
rttTime = now
|
||||
}
|
||||
|
||||
mutating func recordSubmittedFrame(id: UInt64, captureUptime: TimeInterval,
|
||||
at now: TimeInterval) {
|
||||
// Cached redraws and foreground resubmission are not new video frames.
|
||||
guard id > lastFrameID else { return }
|
||||
lastFrameID = id
|
||||
let latency = (now - captureUptime) * 1_000
|
||||
let valid = captureUptime > 0 && latency.isFinite && (0...5_000).contains(latency)
|
||||
frames.removeAll { now - $0.time >= 1 }
|
||||
frames.append((now, valid ? latency : nil))
|
||||
if frames.count > 240 { frames.removeFirst(frames.count - 240) }
|
||||
}
|
||||
|
||||
mutating func resetVideo() {
|
||||
frames.removeAll(keepingCapacity: true)
|
||||
displayedSnapshot.framesPerSecond = 0
|
||||
displayedSnapshot.videoLatencyMilliseconds = nil
|
||||
}
|
||||
|
||||
/// SwiftUI also redraws this menu for every video frame. A periodic view
|
||||
/// schedules updates but does not throttle those redraws, so freeze the
|
||||
/// displayed measurements between one-second samples.
|
||||
mutating func displaySnapshot(at now: TimeInterval) -> RemoteNetworkSnapshot {
|
||||
if let displayTime, now - displayTime < 1 {
|
||||
return displayedSnapshot
|
||||
}
|
||||
displayedSnapshot = snapshot(at: now)
|
||||
displayTime = now
|
||||
return displayedSnapshot
|
||||
}
|
||||
|
||||
func snapshot(at now: TimeInterval) -> RemoteNetworkSnapshot {
|
||||
let recentFrames = frames.filter { now - $0.time < 1 }
|
||||
let delays = recentFrames.compactMap(\.latency)
|
||||
return RemoteNetworkSnapshot(
|
||||
report: now - reportTime < 3 ? report : nil,
|
||||
framesPerSecond: recentFrames.count,
|
||||
videoLatencyMilliseconds: delays.isEmpty ? nil : delays.reduce(0, +) / Double(delays.count),
|
||||
rttMilliseconds: now - rttTime < 3 ? averageRTT : nil
|
||||
)
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user