Compare commits

...
16 Commits
Author SHA1 Message Date
dijunkun 3d2235ad4e [fix] statically link the Windows CRT in Slint 2026-09-27 03:35:06 +08:00
dijunkun 0b2eb5b106 [fix] bundle missing Slint runtime in Windows packages 2026-09-27 02:52:56 +08:00
dijunkun 94760b2a3c [fix] keep desktop UI readable under dark system themes, refs #103 2026-09-27 02:34:14 +08:00
dijunkun bbbcfb9d6f [fix] correct video latency measurement and smooth connection RTT 2026-09-26 03:15:47 +08:00
dijunkun 9ef64cc00f [fix] reduce release log verbosity and enforce single instance 2026-09-26 02:58:55 +08:00
dijunkun b8389a8aa0 [feat] add connection latency to network statistics 2026-09-26 02:04:18 +08:00
dijunkun 7ac8163931 [feat] add video latency to network statistics 2026-09-26 00:39:05 +08:00
dijunkun 704938a157 [feat] update minirtc with adaptive media FEC 2026-09-25 23:39:23 +08:00
dijunkun 572a018a72 [docs] refresh README guides and desktop and web screenshots 2026-09-25 03:26:57 +08:00
dijunkun 41cab6fa0b [fix] render toolbar off-state slashes correctly in software mode 2026-09-25 03:26:49 +08:00
dijunkun f73a57baaa [fix] restore missing includes for Linux and Windows builds 2026-09-25 02:20:02 +08:00
dijunkun e62d473b64 [perf] reduce desktop build dependencies and binary size 2026-09-25 01:47:20 +08:00
dijunkun 3c5b3f8e38 [fix] add missing JSON include for Windows service runtime 2026-09-25 00:51:08 +08:00
kunkundi 191ba26772 [feat] add Linux headless mode and interactive console 2026-09-25 00:29:24 +08:00
dijunkun c661319890 [feat] add background and manual update checks 2026-09-24 17:36:41 +08:00
dijunkun e0cd4c0567 [feat] add live remote video settings to the toolbar 2026-09-24 15:44:02 +08:00
100 changed files with 4313 additions and 846 deletions
+11 -14
View File
@@ -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 }}
@@ -684,7 +689,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 +792,14 @@ 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'
cat > "$RUNNER_TEMP/release-notes.md" << 'EOF'
Binary release only. Source code is not included.
## 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 +825,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 +843,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 +895,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
+35 -16
View File
@@ -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,52 @@ 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 +226,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 发布包提供代码签名。
+35 -16
View File
@@ -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,52 @@ 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 |
| 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 +226,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.
+6 -1
View File
@@ -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
+4 -2
View File
@@ -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
View File
@@ -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_;
+156 -237
View File
@@ -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,25 @@ 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));
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 +138,89 @@ 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_, "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 +231,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 +241,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 +251,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 +285,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;
}
@@ -412,9 +346,9 @@ int ConfigCenter::SetPrivacyScreen(bool enable_privacy_screen) {
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 +360,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 +425,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
+9 -19
View File
@@ -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);
@@ -59,10 +52,7 @@ class ConfigCenter {
// 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();
@@ -86,16 +76,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;
@@ -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>
@@ -54,11 +55,14 @@
#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/ui_localization.h"
#include "version_checker.h"
#include "update_checker.h"
#include "window_geometry.h"
namespace crossdesk {
@@ -989,34 +993,16 @@ int GuiApplication::Run() {
"%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 +1016,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(); });
@@ -1155,14 +1142,6 @@ void GuiApplication::InitializeUi() {
ui_->main->set_custom_titlebar(use_x11_custom_titlebar_);
ui_->main->set_wayland_titlebar(use_xwayland_gui_);
#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);
@@ -1250,10 +1229,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();
@@ -1402,6 +1377,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) {
@@ -1623,6 +1602,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 +1766,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 +1852,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 +2029,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 +2043,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 +2061,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 +2069,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 +2102,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 +2123,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 +2163,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 +2194,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 +2228,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");
@@ -2231,6 +2300,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_));
@@ -2672,6 +2758,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 +2803,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 +2892,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 +2908,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 +2963,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 +3211,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,22 +3223,14 @@ 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_);
#ifdef _WIN32
@@ -3132,11 +3267,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 +3297,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 +3581,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 +3625,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,6 +50,7 @@ private:
void BindStreamCallbacks();
void BindServerCallbacks();
void Tick();
void PollUpdateCheck();
void ShareLocalCursorState();
bool RequestPasswordChange(const std::string &password);
void HandlePasswordChangeResult();
@@ -68,6 +71,7 @@ private:
void UpdateLocalization();
void ResetSettingsUi();
void SaveSettingsFromUi();
void ApplySettingsFromConfig(bool reconnect);
#if _WIN32 && CROSSDESK_PORTABLE
void CheckPortableWindowsService();
void StartPortableWindowsServiceInstall();
@@ -88,6 +92,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,65 @@ struct TranslationRow {
// Single source of truth for all UI strings.
#define CROSSDESK_LOCALIZATION_ALL(X) \
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(privacy_screen, u8"隐私屏", "Privacy screen", u8"Приватный экран") \
X(privacy_enable, u8"开启隐私屏", "Enable privacy screen", u8"Включить приватный экран") \
X(privacy_disable, u8"关闭隐私屏", "Disable privacy screen", u8"Выключить приватный экран") \
@@ -131,6 +190,10 @@ 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(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"Частота захвата видео:") \
@@ -195,6 +258,15 @@ struct TranslationRow {
X(notification, u8"通知", "Notification", 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 +305,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"Релейный") \
@@ -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,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
@@ -417,8 +417,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();
+16 -22
View File
@@ -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;
+3 -2
View File
@@ -8,6 +8,7 @@
#define _GUI_RUNTIME_H_
#include <atomic>
#include <cstdint>
#include <deque>
#include <memory>
#include <string>
@@ -44,8 +45,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,
@@ -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,9 @@
#include <SDL3/SDL_timer.h>
#include "runtime/peer_event_handler.h"
#include <remote_action.h>
#include <nlohmann/json.hpp>
#include <algorithm>
#include <chrono>
@@ -146,6 +149,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 +294,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 +318,25 @@ void PeerEventHandler::OnReceiveDataBuffer(
}
if (is_client_mode) {
// 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"
@@ -267,6 +271,7 @@ void PeerEventHandler::OnConnectionStatus(ConnectionStatus status,
RemoteAction remote_action = MakeHostInformation(
GetHostName(), runtime->devices_.host_display_list(),
/*supports_privacy_screen=*/true);
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]);
@@ -295,8 +300,15 @@ void PeerEventHandler::OnConnectionStatus(ConnectionStatus status,
runtime->keyboard_.ReleaseRemotePressedKeys(remote_id,
"connection_closed");
props->connection_established_ = 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_ = {};
@@ -325,6 +337,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 &&
@@ -382,6 +396,7 @@ void PeerEventHandler::OnConnectionStatus(ConnectionStatus status,
RemoteAction remote_action = MakeHostInformation(
GetHostName(), runtime->devices_.host_display_list(),
/*supports_privacy_screen=*/true);
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 +502,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 +522,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
+17 -1
View File
@@ -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; }
@@ -115,10 +118,21 @@ 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_;
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 +156,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;
+1 -1
View File
@@ -1,5 +1,5 @@
// 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 { 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";
+91 -44
View File
@@ -14,6 +14,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 +40,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";
@@ -65,6 +61,10 @@ 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> 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";
@@ -84,9 +84,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";
@@ -266,6 +263,11 @@ export component MainWindow inherits Window {
in property <bool> signal-tls-error: false;
in property <[RecentConnection]> recent-connections;
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: "";
@@ -292,11 +294,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;
@@ -333,6 +332,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 +352,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: "";
@@ -1493,21 +1493,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; }
@@ -1621,10 +1606,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 +1623,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; } }
}
}
+170 -29
View File
@@ -23,6 +23,18 @@ export struct NetworkStatsRow {
}
export global StreamStrings {
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 +56,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 +117,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 +140,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");
@@ -168,11 +209,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 +257,31 @@ 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; } }
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; }
@@ -305,7 +364,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 +386,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) => {
@@ -697,7 +760,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 +792,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 +808,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 +831,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 +907,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 +931,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 +1043,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 +1074,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 +1098,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:
+25 -14
View File
@@ -53,19 +53,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]));
@@ -90,6 +80,12 @@ 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_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]));
@@ -121,10 +117,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(
@@ -147,6 +139,23 @@ inline int ApplyStreamWindowStrings(
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 +175,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(
+21 -24
View File
@@ -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);
});
+9 -15
View File
@@ -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
@@ -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>
@@ -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
+7 -3
View File
@@ -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;
}
@@ -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
@@ -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,6 +7,7 @@
#include "version_checker.h"
#include <httplib.h>
#include <nlohmann/json.hpp>
#include "rd_log.h"
@@ -23,10 +24,6 @@
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 {
@@ -202,18 +199,20 @@ int CompareNumericVersion(const std::vector<int>& current,
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_unsigned()) {
const auto parsed = patch_value.get<unsigned long long>();
if (parsed > static_cast<unsigned long long>(std::numeric_limits<int>::max())) {
return false;
}
*patch = static_cast<int>(parsed);
return true;
}
if (patch_value.is_number_integer()) {
const long long parsed = patch_value.get<long long>();
if (parsed < 0 || parsed > std::numeric_limits<int>::max()) {
@@ -353,9 +352,7 @@ bool IsNewerDate(const std::string& date1, const std::string& date2) {
}
bool IsNewerVersion(const std::string& current, const std::string& latest) {
return IsNewerVersionWithMetadata(
current, latest, latest_release_date_,
latest_patch_available_ ? latest_patch_ : -1);
return IsNewerVersionWithMetadata(current, latest, "", -1);
}
bool IsNewerVersionWithMetadata(const std::string& current,
@@ -391,11 +388,54 @@ bool IsNewerVersionWithMetadata(const std::string& current,
return false;
}
nlohmann::json CheckUpdate() {
std::optional<VersionInfo> ParseVersionInfo(const nlohmann::json& json) {
if (!json.is_object()) {
return std::nullopt;
}
const auto read_string = [&](const char* key) -> std::string {
const auto it = json.find(key);
return it != json.end() && it->is_string() ? it->get<std::string>()
: std::string{};
};
VersionInfo info;
info.version = read_string("latest_version");
if (info.version.empty()) {
info.version = read_string("version");
}
if (!info.version.empty() &&
(info.version.front() == 'v' || info.version.front() == 'V')) {
info.version.erase(0, 1);
}
// Invalid/missing version data is a failed check, never "up to date".
const auto numeric_end = FindNumericEnd(info.version, 0);
if (numeric_end == 0 || info.version[numeric_end - 1] == '.' ||
(numeric_end < info.version.size() &&
(numeric_end + 1 == info.version.size() ||
(info.version[numeric_end] != '-' && info.version[numeric_end] != '+')))) {
return std::nullopt;
}
std::istringstream parts(info.version.substr(0, numeric_end));
std::string part;
int number = 0;
while (std::getline(parts, part, '.')) {
if (!TryParseNonNegativeInt(part, &number)) {
return std::nullopt;
}
}
info.release_name = read_string("releaseName");
info.release_notes = read_string("releaseNotes");
info.release_date = read_string("releaseDate");
ReadPatchField(json, &info.patch);
return info;
}
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 +446,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,12 +7,22 @@
#ifndef _VERSION_CHECKER_H_
#define _VERSION_CHECKER_H_
#include <nlohmann/json.hpp>
#include <nlohmann/json_fwd.hpp>
#include <optional>
#include <string>
namespace crossdesk {
nlohmann::json CheckUpdate();
struct VersionInfo {
std::string version;
std::string release_name;
std::string release_notes;
std::string release_date;
int patch = -1;
};
std::optional<VersionInfo> ParseVersionInfo(const nlohmann::json& json);
std::optional<VersionInfo> CheckUpdate();
bool IsNewerVersion(const std::string& current, const std::string& latest);
+79 -7
View File
@@ -4,6 +4,7 @@
#include <cassert>
#include <charconv>
#include <chrono>
#include <cstdlib>
#include <fstream>
#include <iostream>
@@ -146,6 +147,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 +168,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("");
@@ -213,9 +221,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");
@@ -361,8 +380,34 @@ 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";
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);
}
window->hide();
stream->show();
capture_window = &stream->window();
@@ -376,12 +421,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 +498,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);
+162
View File
@@ -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,49 @@ 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);
return ok ? 0 : 1;
}
+3 -1
View File
@@ -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}})
+14
View File
@@ -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")
+68 -39
View File
@@ -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()
+83 -23
View File
@@ -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,17 +218,16 @@ 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_packages("cpp-httplib", "nlohmann_json")
add_deps("rd_log")
add_includedirs("apps/desktop/src/version_checker")
add_files("apps/desktop/tests/version_checker_test.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
@@ -330,7 +387,7 @@ function setup_targets()
target("thumbnail")
set_kind("object")
add_packages("libyuv", "openssl3")
add_deps("rd_log", "common")
add_deps("rd_log", "desktop_common")
add_files("apps/desktop/src/thumbnail/*.cpp")
add_includedirs("apps/desktop/src/thumbnail", {public = true})
@@ -363,6 +420,7 @@ function setup_targets()
target("version_checker")
set_kind("object")
add_packages("cpp-httplib")
add_packages("nlohmann_json", {public = true})
add_deps("rd_log")
add_files("apps/desktop/src/version_checker/*.cpp")
add_includedirs("apps/desktop/src/version_checker", {public = true})
@@ -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
+18 -1
View File
@@ -8,6 +8,10 @@ package("slint")
add_versions("1.17.1", "68222567f8c70ff677cd4a98cd94fb4765ac0f797eb8f8608a646911c908dc2a")
add_configs("shared", {description = "Build the Slint runtime as a shared library", default = true, type = "boolean", readonly = true})
if is_plat("windows") then
-- Include CRT linkage in the package hash so cached /MD builds are not reused.
add_configs("static_crt", {description = "Statically link the Windows C runtime", default = true, type = "boolean", readonly = true})
end
add_deps("cmake~slint >=3.21 <4.0", {host = true, private = true, system = false})
add_deps("rust 1.92.0", {host = true, private = true, system = false})
@@ -51,13 +55,26 @@ package("slint")
"-DSLINT_STYLE=fluent",
"-DBUILD_SHARED_LIBS=ON"
}
local cmake = import("package.tools.cmake")
local envs
if package:is_plat("windows") then
local rc = assert(package:build_getenv("mrc"),
"failed to find the Windows resource compiler")
rc = rc:gsub("\\", "/")
table.insert(configs, "-DCMAKE_RC_COMPILER=" .. rc)
table.insert(configs, "-DCMAKE_MSVC_RUNTIME_LIBRARY=MultiThreaded")
-- Slint is a Rust cdylib: CMake's /MT setting alone does not affect it.
-- Encoded flags take precedence over RUSTFLAGS set by Corrosion.
envs = cmake.buildenvs(package)
local rustflags = os.getenv("CARGO_ENCODED_RUSTFLAGS")
if rustflags == nil then
rustflags = table.concat((os.getenv("RUSTFLAGS") or ""):split("%s+"), "\31")
end
envs.CARGO_ENCODED_RUSTFLAGS = (rustflags ~= "" and rustflags .. "\31" or "") ..
"-Ctarget-feature=+crt-static"
end
import("package.tools.cmake").install(package, configs)
cmake.install(package, configs, {envs = envs})
end)
on_test(function(package)
+2
View File
@@ -90,7 +90,9 @@ RUN apt-get update \
python3 \
python3-pip \
unzip \
xfce4 \
xutils-dev \
xvfb \
xz-utils \
yasm \
zip \
+2 -2
View File
@@ -57,13 +57,13 @@ xmake r crossdesk
## Linux
Linux 构建支持 Ubuntu 20.04 及以上版本的 amd64 和 arm64。发布安装包以
Ubuntu 20.04/glibc 2.31 为兼容基线。以下依赖命令以 Ubuntu 20.04 为例;其他发行版或较新版本需按其软件源调整包名和工具链,复现 CI 可使用下文的构建镜像。先安装基础编译依赖:
Ubuntu 20.04/glibc 2.31 为兼容基线。以下依赖命令以 Ubuntu 20.04 为例;其他发行版或较新版本需按其软件源调整包名和工具链,复现 CI 可使用下文的构建镜像。先安装基础编译和无头运行依赖:
```bash
sudo apt-get update
sudo apt-get install -y \
git curl unzip build-essential gcc-10 g++-10 python3-pip \
pkg-config binutils dpkg-dev \
pkg-config binutils dpkg-dev xvfb xfce4 dbus \
libx11-dev libxext-dev libxrender-dev libxft-dev libxrandr-dev \
libxinerama-dev libxcursor-dev libxi-dev libxfixes-dev libxv-dev \
libxtst-dev libxcb-randr0-dev libxcb-xtest0-dev libxcb-xinerama0-dev \
+2 -2
View File
@@ -60,13 +60,13 @@ Linux builds support Ubuntu 20.04 or later on amd64 and arm64. Release packages
use Ubuntu 20.04/glibc 2.31 as their compatibility baseline. The dependency
commands below target Ubuntu 20.04; adapt package names and toolchains for
other distributions or newer versions, or use the build image below to
reproduce CI. Install the base build dependencies first:
reproduce CI. Install the base build and headless runtime dependencies first:
```bash
sudo apt-get update
sudo apt-get install -y \
git curl unzip build-essential gcc-10 g++-10 python3-pip \
pkg-config binutils dpkg-dev \
pkg-config binutils dpkg-dev xvfb xfce4 dbus \
libx11-dev libxext-dev libxrender-dev libxft-dev libxrandr-dev \
libxinerama-dev libxcursor-dev libxi-dev libxfixes-dev libxv-dev \
libxtst-dev libxcb-randr0-dev libxcb-xtest0-dev libxcb-xinerama0-dev \
+5
View File
@@ -24,6 +24,10 @@
Wayland 画面捕获依赖启用了 Wayland 支持的构建、宿主系统 PipeWire 0.3 和桌面门户授权;无法捕获时也可在 X11 会话中对比验证。构建选项见 [Linux 构建说明](BUILD.md#linux)。
## Linux 电脑未连接显示器或没有图形会话
正常启动 CrossDesk 即可自动发现并选择当前用户的已有 X11 桌面,保留原来的 Dock 和应用,无需知道显示编号;多个会话会根据登录状态和活动状态自动选择。只看到 CrossDesk、背景一片黑,通常是创建了未运行桌面环境的裸 Xvfb;请改为复用原桌面。需要独立虚拟桌面时安装 `xvfb xfce4 dbus`,使用 `--headless-size 1920x1080 --headless-session startxfce4`。部署与限制见 [Linux 无头运行](HEADLESS.md)。
## Windows 锁屏后无法操作
在被控电脑检查 CrossDesk Service 是否安装、是否运行,并保持 CrossDesk 客户端运行。便携版可在“设置 → 锁屏控制服务”安装。服务状态检查命令见 [Windows 服务说明](../README.md#windows-service)。
@@ -84,6 +88,7 @@ Windows 安装包和便携包内置了 Amyuni 的 `usbmmidd_v2` 虚拟显示器
| P2P connection failed | Enable TURN relay in Settings and reconnect. For self-hosting, check Coturn and relay/media firewall ports. Review [release compatibility notes](https://github.com/kunkundi/crossdesk/releases). |
| Black screen / no input on macOS | Grant Screen Recording and Accessibility, then reopen the app. A changed app path or signature may require fresh permission grants. |
| No capture on Linux Wayland | Check the Wayland build option, host PipeWire 0.3, and desktop portal permission. Compare with an X11 session. See [Linux build instructions](BUILD_EN.md#linux). |
| Linux host has no monitor or graphical session | Start CrossDesk normally to automatically discover and select your existing desktop, preserving its Dock and apps; no display number is needed. A bare Xvfb shows only CrossDesk on a black background; use an existing session or install Xfce and specify `--headless-session startxfce4`. See [Linux headless operation](HEADLESS_EN.md). |
| Cannot control a Windows lock screen | Install/start CrossDesk Service and keep the host client running. See [service commands](../README_EN.md#windows-service). |
| Windows host has no monitor attached | The Windows installer and portable archive bundle Amyuni's `usbmmidd_v2` virtual display driver (in the `usbmmidd_v2` folder with its License.txt). On each connection to a host without a monitor (capture method Auto), CrossDesk starts streaming in compatibility mode and, in the background, asks the CrossDesk Service session helper to install the Microsoft-signed driver (first time only) and plug one virtual monitor at 1920×1080; capture then switches to DXGI/WGC on that monitor and it is unplugged when the session ends. Without the service only an elevated CrossDesk can plug it itself; otherwise the host stays in **headless compatibility mode**: GDI capture at an OS-fixed resolution (often 1024×768) with reduced frame rate; the host log records why. Input, privacy screen and file transfer still work. A real monitor or an HDMI/DP dummy plug also restores GPU capture. |
| Removing the bundled virtual display driver | The uninstaller removes the driver when CrossDesk installed it and leaves a driver you installed yourself. For the portable build run `deviceinstaller64 enableidd 0`, `deviceinstaller64 stop usbmmidd` and `deviceinstaller64 remove usbmmidd` as administrator from the `usbmmidd_v2` folder, or uninstall "USB Mobile Monitor Virtual Display" in Device Manager. |
+155
View File
@@ -0,0 +1,155 @@
# Linux 无头运行
[返回 README](../README.md) · [English](HEADLESS_EN.md)
无显示器的 Linux 主机可复用已有 X11 桌面,也可通过 Xvfb 创建独立虚拟桌面。画面采集、键鼠、光标和剪贴板使用同一个显示会话。
## 保留原来的 Ubuntu 桌面和应用
正常打开 CrossDesk 即可,程序会自动发现并选择当前用户的已有桌面,保留 Dock、任务栏、壁纸和已打开应用。无需知道 `:0`、`:10` 等显示编号,也无需设置 `DISPLAY`:
```bash
# 安装包
crossdesk
# 源码构建
xmake r crossdesk
```
多个可用桌面同时存在时也会自动选择:优先启动环境中的桌面,其次是当前登录会话、系统标记的活动主桌面、其他活动桌面和仍在线的桌面。没有系统会话信息或优先级相同时,按稳定的显示顺序选择,避免每次启动切到不同桌面。失效会话与没有窗口管理器的空白 Xvfb 不会作为已有桌面选中。
复用已有桌面不依赖 Xvfb,也不会在退出 CrossDesk 时结束桌面或其他应用。若原会话已锁屏,远程会先看到系统锁屏界面,需要用该用户的系统密码解锁。
自动发现使用系统登录会话信息和当前用户的 X11 套接字,并验证窗口管理器确实可用。没有 systemd/logind 的发行版仍可自动发现并选择桌面。认证使用现有 `XAUTHORITY`(未设置时使用 Xlib 默认认证位置),必要时尝试当前用户的 GDM 认证文件。不会自动选择其他用户的桌面或登录欢迎界面。
`--headless` 可用于显式触发这套自动选择流程,例如忽略终端里失效的 Wayland 环境变量。`--headless-display` 仅供需要覆盖默认选择的高级使用者或调试使用:
```bash
crossdesk --headless-display :10 # 示例编号,以实际会话为准
```
此覆盖选项只连接指定的已有桌面;无法连接或没有运行窗口管理器时会报错,不会悄悄创建另一块屏幕。
**不要添加 `--headless-size` 或 `--headless-session` 来连接原桌面**:它们用于创建独立 Xvfb。原桌面的分辨率由原来的显示服务管理。
## 命令行控制台
无头模式下,终端只显示设备 ID、连接密码、在线状态和操作结果,常规日志写入文件。在 SSH / 无图形环境下普通启动会自动进入此模式;也可显式启用:
```bash
xmake r crossdesk --headless
```
程序运行后,在同一个终端输入命令并回车:
| 命令 | 作用 |
| --- | --- |
| `status` | 显示本机 ID、当前连接密码和在线状态 |
| `settings` | 打开设置菜单,输入编号或名称修改;`back` 返回 |
| `settings show` | 仅查看设置 |
| `settings set 名称 值` | 直接修改一个设置 |
| `password` | 提示输入新密码;6 位英文字母或数字,输入 `cancel` 取消 |
| `password Ab12Cd` | 直接提交新密码(示例,请自行选择密码) |
| `random` | 生成随机密码并提交修改 |
| `help` | 显示帮助 |
| `quit` | 关闭 CrossDesk;复用模式下不会关闭原桌面 |
设置菜单与 GUI 共用 `config.ini`,每项修改校验通过后立即保存,无需另执行保存命令。输入 `cancel` 放弃正在编辑的值。控制台状态、菜单、密码提示和帮助跟随语言设置,支持中文、英文和俄文;命令名称保持不变。
| 设置名称 | 可选值 / 格式 | 生效方式 |
| --- | --- | --- |
| `language` | `zh-CN` / `en-US` / `ru-RU` | 立即切换控制台和 GUI 语言,下次启动保留 |
| `codec` | `h264` / `av1` | 保存后重新连接 |
| `hardware` | `on` / `off` | 保存后重新连接;构建不支持时拒绝开启 |
| `turn` | `off` / `auto` / `udp` / `tcp` | 保存后重新连接;`udp` / `tcp` 表示强制中继 |
| `server_host` | 主机名或 IP,不含协议与路径 | 自托管未启用时保存备用,已启用时重连 |
| `server_port` | `1`–`65535` | 同上 |
| `self_hosted` | `on` / `off` | 先配置地址、端口,再开启;保存后重连 |
| `privacy` | `on` / `off` | 后续远程连接的自动隐私屏行为 |
| `file_path` | 已存在且可写的绝对目录;`default` 恢复默认 | 后续接收的文件;路径可含空格,无需额外引号 |
| `autostart` | `on` / `off` | 下次图形桌面登录时启动;不等于系统服务开机启动 |
| `daemon` | `on` / `off` | 普通模式下次启动生效;无头控制台仍保持前台 |
例如,在运行中的控制台输入:
```text
settings
1
en-US
back
settings set codec av1
settings set file_path /home/user/Received Files
```
存在远程会话(含正在连接的会话)或尚未完成的密码修改时,连接相关设置会被拒绝,避免中断会话。修改服务器地址和端口前,应先结束远程会话。切换自托管可能切换到该服务器对应的 ID 和密码,以重新连接后的 `status` 为准。画质和帧率由控制端管理,Linux 无头模式采用 X11 采集,这些不提供额外的全局修改入口。
修改密码需要连接到信令服务器。控制台会显示提交、确认和重新连接的结果;仅在服务器确认后采用新密码,并沿用 GUI 的凭据持久化与超时恢复流程。离线、密码格式错误或修改尚未完成时,会给出明确提示。密码修改会触发重新连接。
日志路径会在启动时显示,继续使用原有的 `crossdesk-YYYYMMDD-HHMMSS.log` 和 `minirtc-YYYYMMDD-HHMMSS.log`,文件命名和原有轮转规则不变,不额外创建无头模式日志文件。无头启动诊断写入 CrossDesk 日志;终端日志副本和第三方组件直接写向标准输出/标准错误的内容被静默处理。ID/密码状态输出直接送往终端,不经过日志系统。非交互运行(例如 systemd、重定向输出)默认不显示明文密码;标准输入关闭不会终止后台服务。
交互控制台在前台运行,内置 daemon 设置在此模式下不会将进程分离。当前已有旧版本进程时,需要退出旧实例后启动新版本才能使用控制台。
## 创建独立虚拟桌面
Debian/Ubuntu 安装包将 Xvfb、Xfce 和 D-Bus 会话工具声明为强依赖,使用 APT 安装时会自动补齐,即使指定 `--no-install-recommends` 也不会跳过。源码直接运行时,需在开发环境中准备同样的运行依赖;项目构建镜像和 CI 已包含这些组件。
原来没有图形会话,或者希望单独开一套桌面时,可启动独立 Xfce 会话。Ubuntu / Debian 示例(安装包用户无需重复安装依赖):
```bash
sudo apt install xvfb xfce4 dbus
crossdesk --headless-size 1920x1080 --headless-session startxfce4
```
默认分辨率为 1920×1080,宽高必须是 320–8192 范围内的偶数。`--headless-session` 接收一个可执行程序名称或路径,通过独立的 `dbus-run-session` 启动。需要参数时,创建可执行脚本,在脚本中用 `exec` 启动桌面或窗口管理器。
仅做画面采集调试时,可以明确启动裸 Xvfb:
```bash
sudo apt install xvfb
crossdesk --headless-session none
```
裸 Xvfb 只提供屏幕,**不会生成 Dock、任务栏或原来的应用窗口**。只使用 `--headless-size` 而未指定桌面会话也属于这一情况,启动诊断日志会记录提示。此前“只看到 CrossDesk,其余一片黑”就是因为进入了这种独立裸显示。
## 自动选择与限制
- 普通图形启动继续使用现有 `DISPLAY` 或 Wayland 会话。X11 没有活动 RandR 输出或没有 RandR 扩展时,采集可用的根窗口。
- `--headless`,或没有可连接的 `DISPLAY` 且未设置 Wayland 环境时,会先自动选择已有 X11 桌面,再回退到独立 Xvfb;没有桌面环境时,回退的裸 Xvfb 会明确提示其不包含 Dock 和原应用。
- `--headless-display` 与 `--headless-size` / `--headless-session` 不能混用。
- `--no-headless` 禁用自动发现和回退;`--headless-help` 查看命令说明。
- 不会在显示会话之间迁移应用,也不会把运行中的会话自动切换到另一块屏幕。物理会话已经失效时,另建 Xvfb 无法恢复其应用。
## 后台运行
创建独立 Xvfb 时,在前台保留一个管理进程,监控 Xvfb、桌面会话和 CrossDesk。退出程序、发送 SIGINT / SIGTERM / SIGHUP,或任一受监控进程退出时,会清理该次会话的子进程与临时认证文件。显示编号由 Xvfb 自动分配,避免占用已有显示;TCP 监听关闭,X11 连接使用随机认证 cookie,认证文件仅当前用户可读。
启动诊断日志会记录 `DISPLAY` 和 `XAUTHORITY` 路径;在同一用户的终端设置这两个变量后,可以将其他应用启动到虚拟桌面。认证文件会随会话退出被删除。
需要开机后保持服务,可创建用户级 systemd 单元 `~/.config/systemd/user/crossdesk-headless.service`,并将 `ExecStart` 改为实际程序路径:
```ini
[Unit]
Description=CrossDesk virtual desktop
[Service]
Type=simple
ExecStart=/usr/bin/crossdesk --headless-size 1920x1080 --headless-session startxfce4
Restart=on-failure
RestartSec=5
KillMode=mixed
TimeoutStopSec=15
[Install]
WantedBy=default.target
```
```bash
systemctl --user daemon-reload
systemctl --user enable --now crossdesk-headless.service
journalctl --user -u crossdesk-headless.service -f
```
需要复用原桌面的服务应将 `ExecStart` 改为 `/usr/bin/crossdesk`(自动发现和选择;确保已有图形会话登录)。如需在用户未登录时运行用户服务,管理员需为该用户启用 linger:`sudo loginctl enable-linger 用户名`。独立 Xvfb 模式下内置 daemon 选项不会生效;systemd 负责重启整个虚拟桌面。停止该模式会结束虚拟桌面中的应用,请先保存工作。
## 运行限制
虚拟显示的 GUI 使用软件渲染,画面走 X11 → NV12 采集。视频编码仍遵循 CrossDesk 设置和本机编码器能力。声音依赖当前用户可用的音频服务;Xvfb 本身不提供虚拟声卡。此实现针对 Linux;Windows 无显示器支持见 [常见问题](FAQ.md)。
+152
View File
@@ -0,0 +1,152 @@
# Linux headless operation
[README](../README_EN.md) · [中文](HEADLESS.md)
CrossDesk can reuse an existing X11 desktop or create a separate Xvfb desktop on a Linux host without a monitor. Capture, input, cursor and clipboard use the same display.
## Preserve the existing desktop and applications
Start CrossDesk normally. It automatically discovers and selects your existing desktop, preserving its Dock, panel, wallpaper and open applications. No display number or `DISPLAY` configuration is required:
```bash
crossdesk
# Source build:
xmake r crossdesk
```
With multiple desktops, selection prefers the startup environment's desktop, then the current login session, the active primary desktop, other active desktops, and online desktops. Equal-priority desktops use a stable display order, also used when systemd/logind metadata is unavailable. Inaccessible sessions and bare Xvfb displays without a window manager are excluded from existing-desktop selection.
Reusing a desktop does not require Xvfb. Exiting CrossDesk does not terminate that desktop or its applications. A locked session initially shows the system lock screen; unlock it with that user's system password.
Discovery combines the current user's login-session metadata with their X11 sockets and verifies a live window manager. It works without systemd/logind as well. It respects `XAUTHORITY` or Xlib's default location when unset, and can try the user's GDM authority file. Other users' desktops and login greeters are not automatically selected.
`--headless` explicitly requests automatic selection, for example when the shell has stale Wayland environment variables. The display override is retained only for advanced use and debugging:
```bash
crossdesk --headless-display :10 # Example display, not needed for normal startup.
```
The override fails if the specified desktop is inaccessible or has no window manager; it never substitutes a blank Xvfb.
Do not add `--headless-size` or `--headless-session` to reuse an existing desktop: those options create a separate Xvfb. The existing display server controls its resolution.
## Terminal console
Headless mode keeps routine logs in files and displays the device ID, connection password, online status and command results in the terminal. SSH launches without a graphical environment enable this automatically; to request it explicitly:
```bash
xmake r crossdesk --headless
```
Enter commands in that terminal while CrossDesk is running:
| Command | Action |
| --- | --- |
| `status` | Show the device ID, current password and connection state |
| `settings` | Open the numbered settings menu; `back` returns |
| `settings show` | Show settings without entering the menu |
| `settings set KEY VALUE` | Change a setting directly |
| `password` | Prompt for a new password; six ASCII letters or digits, or `cancel` |
| `password Ab12Cd` | Submit a password directly (example only) |
| `random` | Generate and submit a random password |
| `help` | Print command help |
| `quit` | Exit CrossDesk, preserving a reused desktop |
The menu shares the GUI's `config.ini`. Each valid change is saved immediately; `cancel` discards the value being edited. Console status, menus, password messages and help follow the saved Chinese, English or Russian language setting. Command names remain the same.
| Key | Values / format | When applied |
| --- | --- | --- |
| `language` | `zh-CN` / `en-US` / `ru-RU` | Immediately, including the GUI; retained on restart |
| `codec` | `h264` / `av1` | Reconnect after saving |
| `hardware` | `on` / `off` | Reconnect; enabling requires a build with hardware codec support |
| `turn` | `off` / `auto` / `udp` / `tcp` | Reconnect; UDP/TCP force relay use |
| `server_host` | Hostname or IP without URL scheme/path | Reconnect if self-hosting is enabled; otherwise saved for later |
| `server_port` | `1`–`65535` | Same as server host |
| `self_hosted` | `on` / `off` | Configure host/port first, then enable; reconnect |
| `privacy` | `on` / `off` | Automatic privacy screen on subsequent connections |
| `file_path` | Existing writable absolute directory, or `default` | Subsequent received files; spaces need no extra quotes |
| `autostart` | `on` / `off` | Next graphical desktop login, not a system boot service |
| `daemon` | `on` / `off` | Next normal-mode start; the headless console remains in the foreground |
Example commands inside the running console:
```text
settings
1
en-US
back
settings set codec av1
settings set file_path /home/user/Received Files
```
Connection settings cannot change while a remote session is connected/connecting or a password change is unresolved. Switching signaling servers may change the device identity; use `status` after reconnection. Quality/frame rate are controlled by the viewer, and headless Linux capture uses X11; those options are not additional mutable global settings.
Password changes require a signaling connection. The console reports submission, server confirmation and reconnection, using the GUI's existing persistence and timeout recovery flow. Invalid input, offline changes and pending requests produce concise messages. A password change reconnects the host.
Startup prints the existing log location. Logs retain their original `crossdesk-YYYYMMDD-HHMMSS.log` and `minirtc-YYYYMMDD-HHMMSS.log` names and rotation policy, with no extra headless log file. Bootstrap diagnostics use the CrossDesk logger; terminal log copies and direct third-party stdout/stderr output are silenced. Credential display goes directly to the terminal, not through the logger. Noninteractive output (services or redirection) hides the plaintext password. End-of-input does not terminate a background service.
The interactive console stays in the foreground regardless of the built-in daemon setting. Exit any older instance and start the new build to use these commands.
## Create a separate virtual desktop
Debian/Ubuntu packages require Xvfb, Xfce and the D-Bus session tools through `Depends`. APT installs them even with `--no-install-recommends`. Source builds need the same runtime prerequisites in the development environment; the project build image and CI include them.
For a host without a desktop session, or to start an independent Xfce session (package users do not need to reinstall the dependencies):
```bash
sudo apt install xvfb xfce4 dbus
crossdesk --headless-size 1920x1080 --headless-session startxfce4
```
The default size is 1920×1080; dimensions must be even, between 320 and 8192. `--headless-session` accepts an executable name or path and runs it under a private `dbus-run-session`. Use an executable script ending in `exec` if arguments are required.
For capture debugging, explicitly request a bare display:
```bash
sudo apt install xvfb
crossdesk --headless-session none
```
Bare Xvfb only provides a screen. It has no Dock, panel or existing application windows. Specifying only `--headless-size` also creates a bare display and records a diagnostic in the log. This explains a screen that shows CrossDesk against a black background.
## Selection and limits
- Ordinary graphical launches retain the supplied X11 or Wayland session. X11 root capture works without active RandR outputs or a RandR extension.
- `--headless`, or startup without an accessible X11 display or advertised Wayland session, automatically selects an existing desktop before falling back to Xvfb. Bare Xvfb fallback explicitly reports that it has no desktop environment or existing apps.
- `--headless-display` cannot be combined with `--headless-size` or `--headless-session`.
- `--no-headless` disables automatic discovery/fallback; `--headless-help` prints usage.
- Applications are not migrated between sessions. Creating Xvfb cannot recover applications from an unavailable graphical session.
## Run as a service
When creating a private display, the foreground supervisor owns Xvfb, the optional desktop session and CrossDesk. Application exit, SIGINT/SIGTERM/SIGHUP, or a monitored process exiting tears down that session's child processes and temporary authorization file. Xvfb allocates an unused display number, disables TCP listening and requires a random authentication cookie. The authority file is private to the current user. Startup diagnostic logs record `DISPLAY` and `XAUTHORITY`; use them from a terminal as the same user to launch additional applications on the virtual desktop.
Example `~/.config/systemd/user/crossdesk-headless.service` (adjust the executable path):
```ini
[Unit]
Description=CrossDesk virtual desktop
[Service]
Type=simple
ExecStart=/usr/bin/crossdesk --headless-size 1920x1080 --headless-session startxfce4
Restart=on-failure
RestartSec=5
KillMode=mixed
TimeoutStopSec=15
[Install]
WantedBy=default.target
```
```bash
systemctl --user daemon-reload
systemctl --user enable --now crossdesk-headless.service
journalctl --user -u crossdesk-headless.service -f
```
To reuse an existing desktop instead, set `ExecStart=/usr/bin/crossdesk`; discovery and selection are automatic, but a graphical session must already be logged in. An administrator can enable a user service before login with `sudo loginctl enable-linger USERNAME`. The built-in daemon setting is bypassed when creating a private Xvfb; systemd can restart the whole session. Save work before stopping the service, which also closes applications on the virtual desktop.
## Runtime limitations
The GUI uses software rendering and capture uses X11 → NV12. Video encoding follows the configured codec and available encoders. Audio requires an available user audio service; Xvfb does not provide a sound device. This implementation targets Linux. For Windows hosts without monitors, see the [FAQ](FAQ.md).
+2 -2
View File
@@ -44,12 +44,12 @@ sudo docker compose ps
1. 断开现有会话,点击主窗口右上角 **☰ → 设置**。
2. 在设置列表中向下滚动,点击 **自托管配置**。
3. 填写 **服务器地址**、**信令服务端口**、**中继服务端口**,点击“确认”。地址只填主机名或 IP,不加 `https://`、路径或端口。
3. 填写 **服务器地址**、**信令服务端口**,点击“确认”。地址只填主机名或 IP,不加 `https://`、路径或端口。
4. 回到设置窗口,勾选 **自托管配置** 右侧复选框,再点击“确认”保存。
5. 在控制端和被控端执行相同配置。等待两端都显示“已连接服务器”,再使用被控端当前显示的 ID 发起连接。
<p align="center">
<img src="images/desktop-self-hosted-zh.png" width="720" alt="当前 Slint 客户端自托管设置:服务器地址、信令端口、中继端口" />
<img src="images/desktop-self-hosted-zh.png" width="720" alt="当前 Slint 客户端自托管设置:服务器地址与信令端口" />
</p>
切换服务器后请重新核对本机 ID;设备身份与服务端相关,旧服务上的 ID 不应直接套用。
+2 -2
View File
@@ -44,12 +44,12 @@ For a self-signed deployment, complete the [certificate trust steps](#3-trust-th
1. Disconnect existing sessions and open **☰ → Settings**.
2. Scroll down and click **Self-Hosted Config**.
3. Enter **Server Address**, **Signal Service Port**, and **Relay Service Port**, then confirm. Enter only the hostname or IP in the address field, without a URL scheme, path, or port.
3. Enter **Server Address** and **Signal Service Port**, then confirm. Enter only the hostname or IP in the address field, without a URL scheme, path, or port.
4. Enable the checkbox beside **Self-Hosted Config**, then click **OK** in the parent settings window.
5. Configure both the controller and host this way. Once both report a server connection, connect using the host's currently displayed ID.
<p align="center">
<img src="images/desktop-self-hosted-en.png" width="720" alt="Current Slint self-hosting dialog for server address, signaling port, and relay port" />
<img src="images/desktop-self-hosted-en.png" width="720" alt="Current Slint self-hosting dialog for server address and signaling port" />
</p>
Recheck the device ID after changing servers. Device identities belong to their server; do not assume an old ID carries over.
+16 -7
View File
@@ -4,14 +4,19 @@
## 来源 / Sources
- 生成日期:2026-09-07。
- 桌面 UI 源代码:`abc8f907`(`ios-support` 分支),Slint 1.17.1,macOS arm64;截图工具增加了可选的文档演示数据。
- 桌面与 Web 首页截图生成日期:2026-09-25;`fixtures/source.png` 和 `fixtures/website.png` 保留 2026-09-07 的公开页面截图。
- 桌面 UI 源代码:`f73a57ba` 加本次工具栏关闭状态斜杠渲染修复,Slint 1.17.1,macOS arm64;本次扩展截图工具以捕获会话画面设置,并等待窗口完成尺寸与缩放初始化后导出。
- 桌面图片来自 [`slint_ui_smoke_test`](../../apps/desktop/tests/slint_ui_smoke_test.cpp) 的 UI 捕获模式,直接渲染仓库中的 Slint 组件;不是旧版本截图或重新绘制的界面。
- 主窗口展示“已连接服务器”和三台在线设备的完整示例状态。ID `123 456 789`、密码 `123456`、连接状态及设备记录均为演示数据,不是真实设备凭据或真实远程会话。示例设置不代表应用运行时默认值。
- 本次主窗口截图包含随机密码刷新按钮;设置截图包含强制中继与自动隐私屏选项;自托管弹窗仅显示服务器地址和信令端口。画质、帧率和画面偏好在会话工具栏的“画面设置”菜单中展示。
- 会话截图使用 `fixtures/website.png` 作为示例远端画面,展示真实工具栏和打开的画面设置菜单,不建立远程连接。“关于”截图中的 `v1.5.2` 和“已是最新版本”均为演示数据,不代表对线上最新版本的检查结果。
- 会话工具栏的隐私屏按钮展示“已关闭”状态。关闭标记使用直接绘制的斜线路径,避免软件渲染器将旋转矩形显示为竖线。
- 设备缩略图使用可公开的页面截图:`fixtures/source.png` 来自 [CrossDesk 的 Xmake 源码页](https://github.com/kunkundi/crossdesk/blob/ios-support/xmake.lua),`fixtures/website.png` 来自 [CrossDesk 官网](https://www.crossdesk.cn/),第三张复用 `web-client.png`。它们用于展示缩略图效果,不代表这些页面正在对应的远程电脑上运行。
- `web-client.png`:2026-09-07 在 [web.crossdesk.cn](https://web.crossdesk.cn/) 截取的连接首页,视口为 1280 × 720,没有发起远程连接。
- `web-client.png`:2026-09-25 在 [web.crossdesk.cn](https://web.crossdesk.cn/) 重新截取的连接首页,页面显示版本 `2026.09.24.2`,视口为 1280 × 720。ID 与密码输入框为空,没有发起远程连接;使用该图片作为设备缩略图的桌面截图也已重新生成。
Desktop images render the actual Slint components with an illustrative connected state and three populated recent-device cards. The thumbnails are public browser-page captures, not private remote desktops or evidence of live sessions. IDs, device names, statuses, and settings are demonstration data. The Web image captures the public connection page. Native iOS screenshots are not included because this update did not capture a signed app running on a physical device.
Desktop images were refreshed on 2026-09-25 from UI source `f73a57ba`, using Slint 1.17.1 on macOS arm64. They render actual components with demonstration data, including the refreshed password controls, client settings, live video menu, and manual update check. The source-code and website fixtures remain public page captures from 2026-09-07. The Web connection page was recaptured on 2026-09-25 at 1280 × 720, showing version `2026.09.24.2` with empty credential fields; no remote connection was initiated. Desktop images using its thumbnail were regenerated as well. Desktop IDs, device names, settings, version numbers, and update results are illustrative. Native iOS screenshots are not included because this update did not capture a signed app running on a physical device.
The session images also include this change's toolbar slash-rendering fix. The privacy button shows the off state using diagonal paths that render correctly without rectangle rotation.
## 文件 / Files
@@ -20,12 +25,16 @@ Desktop images render the actual Slint components with an illustrative connected
| `desktop-main-zh.png` / `desktop-main-en.png` | Main window / 主窗口 | 中文 / English |
| `desktop-settings-zh.png` / `desktop-settings-en.png` | Settings / 设置 | 中文 / English |
| `desktop-self-hosted-zh.png` / `desktop-self-hosted-en.png` | Self-hosting / 自托管设置 | 中文 / English |
| `desktop-stream-video-settings-zh.png` / `desktop-stream-video-settings-en.png` | Session toolbar and video settings / 会话工具栏与画面设置 | 中文 / English |
| `desktop-about-zh.png` / `desktop-about-en.png` | About and manual update check / 关于与检查更新 | 中文 / English |
| `web-client.png` | Web connection page / Web 连接首页 | 中文 |
桌面图片为 1280 × 900 PNG,使用 2× 缩放保持文字清晰。Markdown 只设置展示宽度,保持原始比例。设置列表可滚动,截图仅展示首屏。演示配置显示硬件编解码器可用;实际可用性由平台、设备与构建决定。
主窗口及其弹窗图片为 1280 × 900 PNG,会话图片为 1920 × 1200 PNG,均使用 2× 原生渲染缩放保持文字清晰,未放大旧图。Markdown 只设置展示宽度,保持原始比例。设置列表可滚动,截图仅展示首屏。演示配置显示硬件编解码器可用;实际可用性由平台、设备与构建决定。Windows 专属的采集方式与便携版服务入口不会出现在这些 macOS 截图中。
## 重新生成 / Regenerate
更新 Web 图片时,在浏览器中打开 [web.crossdesk.cn](https://web.crossdesk.cn/),等待连接表单和底部版本号加载完成,保持 ID 与密码为空,截取页面视口。当前图片使用 1280 × 720 视口;浏览器导出的 JPEG 仅转换为 PNG,未修改页面布局或内容。替换 `web-client.png` 后,重新生成下方桌面截图以同步设备卡片缩略图,并更新本页的日期与页面版本号。
以下命令在仓库根目录、已完成依赖配置的 macOS 开发环境运行。`sips` 仅将 Slint 导出的 PPM 无损转换为 PNG,不修改 UI。其他平台可以使用支持 PPM 的图片格式转换工具。
```bash
@@ -36,7 +45,7 @@ mkdir -p build/readme-capture docs/images
for locale in zh en; do
language=0
if [ "$locale" = en ]; then language=1; fi
for page in main settings self-hosted; do
for page in main settings self-hosted about stream-video-settings; do
snapshot="$PWD/build/readme-capture/desktop-$page-$locale.ppm"
SLINT_BACKEND=winit-software SLINT_SCALE_FACTOR=2 \
CROSSDESK_UI_CAPTURE=1 \
@@ -51,6 +60,6 @@ for locale in zh en; do
done
```
`CROSSDESK_UI_CAPTURE_DEMO_ASSETS` 必须指向包含上述图片的目录。缺失图片会让捕获命令失败,避免再次导出空白缩略图;不设置该变量时,仍保留原有测试状态。
`CROSSDESK_UI_CAPTURE_DEMO_ASSETS` 必须指向包含上述图片的目录。缺失图片会让捕获命令失败,避免再次导出空白缩略图;不设置该变量时,仍保留原有测试状态。捕获工具会先运行窗口事件循环,等待尺寸与缩放初始化;直接在 `show()` 后截图可能得到 1× 图片。
捕获模式兼容 `/tmp/crossdesk-ui-capture-page` 和 `/tmp/crossdesk-ui-capture-language` 两个旧覆盖文件;如存在,它们会优先于环境变量,运行前请检查。更新后逐张检查文字、布局、敏感信息和文档引用,并同步修改本页日期与源码版本。
Binary file not shown.

After

Width:  |  Height:  |  Size: 143 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 142 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 140 KiB

After

Width:  |  Height:  |  Size: 141 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 138 KiB

After

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 145 KiB

After

Width:  |  Height:  |  Size: 145 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 142 KiB

After

Width:  |  Height:  |  Size: 145 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 153 KiB

After

Width:  |  Height:  |  Size: 149 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 150 KiB

After

Width:  |  Height:  |  Size: 152 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 361 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 359 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 91 KiB

After

Width:  |  Height:  |  Size: 94 KiB

-12
View File
@@ -1,12 +0,0 @@
# **更新内容**
1. Windows 新增无显示器远程控制支持,内置虚拟显示器驱动,可自动创建 1920×1080 虚拟屏幕;无法启用时自动切换至兼容采集模式。
2. 设置中新增“强制中继”选项,方便在直连不稳定的网络中使用中继连接。
3. 主窗口新增随机密码刷新,优化密码重试时的输入体验,并统一为 6 位密码。
4. 改善断线重连,修复旧会话阻碍重新连接及已保存密码未正确复用的问题。
5. 修复 Windows 远程控制和 Web 控制中的键鼠输入、光标显示及会话路由问题。
6. 改善 Windows 显示器热插拔后的屏幕编号、鼠标定位和隐私屏稳定性。
7. 修复远程画面缩放后的鼠标位置偏差,以及部分视频帧无法正常显示的问题。
8. 修复展开网络状态后屏幕切换菜单的位置偏移。
PC 客户端版本为 v1.5.2。Windows 无屏自动适配需将采集方式设为“自动”;自动安装和插入虚拟显示器需安装并运行 CrossDesk Service,或以管理员身份运行客户端。
+18
View File
@@ -29,6 +29,8 @@ enum ControlType {
cursor_state = 8,
privacy_command = 9,
privacy_status = 10,
video_settings = 11,
video_settings_status = 12,
};
enum MouseFlag {
@@ -125,6 +127,7 @@ struct HostInfo {
int* bottom;
// Optional capability; absent in legacy desktop and web host information.
bool supports_privacy_screen;
bool supports_video_settings;
};
struct ServiceStatus {
@@ -153,6 +156,20 @@ struct PrivacyStatus {
char reason[256];
};
struct VideoSettings {
int quality; // 0: low, 1: medium, 2: high
int frame_rate; // 30 or 60
int preference; // 0: frame rate, 1: quality, 2: balanced
uint32_t request_id;
bool accepted; // Only meaningful in video_settings_status.
};
inline bool ValidVideoSettings(const VideoSettings& settings) {
return settings.quality >= 0 && settings.quality <= 2 &&
(settings.frame_rate == 30 || settings.frame_rate == 60) &&
settings.preference >= 0 && settings.preference <= 2;
}
struct RemoteAction {
ControlType type = ControlType::invalid;
union {
@@ -167,6 +184,7 @@ struct RemoteAction {
ServiceCommand c;
PrivacyCommand pc;
PrivacyStatus ps;
VideoSettings vs;
};
std::string to_json() const;
+38 -5
View File
@@ -23,6 +23,7 @@ void ResetHostInfo(HostInfo& info) {
info.right = nullptr;
info.bottom = nullptr;
info.supports_privacy_screen = false;
info.supports_video_settings = false;
}
bool AllocateHostDisplays(HostInfo& info, std::size_t count) {
@@ -134,6 +135,15 @@ std::string RemoteAction::ToJson(const RemoteAction& action) {
{"render_mode", static_cast<int>(action.cs.render_mode)},
{"hidden_reason", static_cast<int>(action.cs.hidden_reason)}};
break;
case ControlType::video_settings:
case ControlType::video_settings_status:
if (!ValidVideoSettings(action.vs)) return {};
object["video_settings"] = {{"quality", action.vs.quality},
{"frame_rate", action.vs.frame_rate},
{"preference", action.vs.preference},
{"request_id", action.vs.request_id},
{"accepted", action.vs.accepted}};
break;
case ControlType::audio_capture:
object["audio_capture"] = action.a;
break;
@@ -172,11 +182,12 @@ std::string RemoteAction::ToJson(const RemoteAction& action) {
{"right", action.i.right ? action.i.right[index] : 0},
{"bottom", action.i.bottom ? action.i.bottom[index] : 0}});
}
object["host_info"] = {{"host_name", action.i.host_name},
{"display_num", action.i.display_num},
{"displays", displays},
{"supports_privacy_screen",
action.i.supports_privacy_screen}};
object["host_info"] = {
{"host_name", action.i.host_name},
{"display_num", action.i.display_num},
{"displays", displays},
{"supports_privacy_screen", action.i.supports_privacy_screen},
{"supports_video_settings", action.i.supports_video_settings}};
break;
}
case ControlType::invalid:
@@ -277,6 +288,26 @@ bool RemoteAction::FromJson(const std::string& json_string,
std::clamp(output.cs.visual_offset_y, -1.0f, 1.0f);
break;
}
case ControlType::video_settings:
case ControlType::video_settings_status: {
const auto& settings = object.at("video_settings");
for (const auto* field : {"quality", "frame_rate", "preference"}) {
if (!settings.at(field).is_number_integer()) return false;
const auto value = settings.at(field).get<int64_t>();
if (value < 0 || value > 60) return false;
}
output.vs = {settings.at("quality").get<int>(),
settings.at("frame_rate").get<int>(),
settings.at("preference").get<int>()};
if (!ValidVideoSettings(output.vs)) return false;
const auto& request_id = settings.at("request_id");
if (!request_id.is_number_integer() || request_id.get<int64_t>() < 0 ||
request_id.get<uint64_t>() > UINT32_MAX)
return false;
output.vs.request_id = request_id.get<uint32_t>();
output.vs.accepted = settings.value("accepted", false);
break;
}
case ControlType::audio_capture:
output.a = object.at("audio_capture").get<bool>();
break;
@@ -334,6 +365,8 @@ bool RemoteAction::FromJson(const std::string& json_string,
output.i.supports_privacy_screen =
privacy_support != host_info_object.end() &&
privacy_support->is_boolean() && privacy_support->get<bool>();
output.i.supports_video_settings =
host_info_object.value("supports_video_settings", false);
const std::string host_name =
host_info_object.at("host_name").get<std::string>();
std::strncpy(output.i.host_name, host_name.c_str(),