Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
47e6385f9a | ||
|
|
9f610bfc9b | ||
|
|
006526389a | ||
|
|
e76d2bf903 | ||
|
|
2cf0d79543 | ||
|
|
3043b5f110 | ||
|
|
5721222724 | ||
|
|
3ee0d42494 | ||
|
|
417ef7d58c | ||
|
|
0a6356085d | ||
|
|
61ff9b2787 | ||
|
|
4855b2d3b6 | ||
|
|
2ac197c2de | ||
|
|
b90359b08d | ||
|
|
5199b2df11 | ||
|
|
fd86dba212 | ||
|
|
2a9887ab8a | ||
|
|
435feb419c | ||
|
|
4e394ca650 | ||
|
|
4dfd8c3005 | ||
|
|
de16eb3b92 | ||
|
|
3bd0676672 | ||
|
|
73b4d0f645 | ||
|
|
54dbd04a8d | ||
|
|
c8907a00f9 | ||
|
|
c9e0078bfb | ||
|
|
e156ff5277 | ||
|
|
b44a203b19 |
@@ -1,7 +1,7 @@
|
||||
<div align="center">
|
||||
<img src="apps/desktop/resources/linux/crossdesk_128x128.png" width="80" alt="CrossDesk 标志" />
|
||||
<h1>CrossDesk</h1>
|
||||
<p>轻量、跨平台的远程桌面,让电脑、浏览器和 iPhone / iPad 连接到同一张桌面。</p>
|
||||
<p>轻量、跨平台的远程桌面,支持电脑、浏览器和移动端连接,用桌面墙集中查看与操控多台设备。</p>
|
||||
<p><a href="https://www.crossdesk.cn/">官方网站</a> · <a href="https://github.com/kunkundi/crossdesk/releases">下载客户端</a> · <a href="https://web.crossdesk.cn/">打开 Web 客户端</a> · <a href="README_EN.md">English</a></p>
|
||||
</div>
|
||||
|
||||
@@ -15,11 +15,11 @@
|
||||
|
||||
</div>
|
||||
|
||||
CrossDesk 支持 Windows、macOS、Linux 之间的远程访问,也可从浏览器或原生 iOS / Android 客户端控制电脑。项目基于 [MiniRTC](https://github.com/kunkundi/minirtc),提供实时音视频传输、键鼠控制、文件传输与自托管能力,仍在持续开发中。
|
||||
CrossDesk 支持 Windows、macOS、Linux 之间的远程访问,也可从浏览器或原生 iOS / Android 客户端控制电脑。桌面端新增 **桌面墙(DeskHub)**,可在独立窗口中同时查看多台远程桌面,按分组管理设备,并进行批量操作与群控。项目基于 [MiniRTC](https://github.com/kunkundi/minirtc),提供实时音视频传输、键鼠控制、文件传输与自托管能力,仍在持续开发中。
|
||||
|
||||
> 本文按当前仓库代码整理。截图展示当前 Slint 桌面界面,使用演示 ID、密码、在线状态和连接记录;缩略图来自公开页面;下载包的功能与系统要求请同时核对对应 Release 说明。原生 iOS 客户端的构建、签名方式见下文。
|
||||
|
||||
[界面预览](#preview) · [下载与安装](#download) · [快速连接](#quick-start) · [会话操作](#session) · [设置](#settings) · [iOS](#ios) · [Android](#android) · [自托管](#self-hosting) · [源码构建](docs/BUILD.md) · [常见问题](docs/FAQ.md)
|
||||
[界面预览](#preview) · [下载与安装](#download) · [快速连接](#quick-start) · [桌面墙](#deskhub) · [会话操作](#session) · [设置](#settings) · [iOS](#ios) · [Android](#android) · [自托管](#self-hosting) · [源码构建](docs/BUILD.md) · [常见问题](docs/FAQ.md)
|
||||
|
||||
<a id="preview"></a>
|
||||
|
||||
@@ -27,10 +27,18 @@ CrossDesk 支持 Windows、macOS、Linux 之间的远程访问,也可从浏览
|
||||
|
||||
### 桌面客户端
|
||||
|
||||
主窗口集中展示本机 ID、连接密码、远程连接入口和近期连接记录。
|
||||
主窗口以“本桌面”和“远程桌面”两张卡片展示本机凭据与连接入口,下方是可按分组筛选的近期连接。左上角打开桌面墙,右上角菜单进入设置、消息与关于。
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/images/desktop-main-zh.png" width="800" alt="当前桌面客户端示例:已连接服务器,三台在线设备均显示页面缩略图" />
|
||||
<img src="docs/images/desktop-main-zh.png" width="800" alt="新版主窗口:桌面墙入口、本机凭据、远程连接卡片和近期连接分组筛选;数据为演示" />
|
||||
</p>
|
||||
|
||||
### 桌面墙(DeskHub)
|
||||
|
||||
在独立窗口中同时查看多台设备,支持分组、可调整大小的网格、精简模式、全屏与批量操作。点击画面可放大查看,再按需接管操控。
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/images/desktop-deskhub-zh.png" width="960" alt="桌面墙网格:六台演示设备、分组筛选、添加、选择、精简模式和网络状态入口" />
|
||||
</p>
|
||||
|
||||
### Web 客户端
|
||||
@@ -38,7 +46,7 @@ CrossDesk 支持 Windows、macOS、Linux 之间的远程访问,也可从浏览
|
||||
在浏览器中输入远程设备 ID 和密码,无需在控制端安装桌面客户端。
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/images/web-client.png" width="800" alt="CrossDesk Web 客户端当前连接首页" />
|
||||
<img src="docs/images/web-client.png" width="800" alt="CrossDesk Web 客户端连接首页(2026-09-25 截图)" />
|
||||
</p>
|
||||
|
||||
图片来源与更新方式见 [截图说明](docs/images/README.md)。
|
||||
@@ -49,7 +57,8 @@ CrossDesk 支持 Windows、macOS、Linux 之间的远程访问,也可从浏览
|
||||
| --- | --- |
|
||||
| 跨平台控制 | Windows / macOS / Linux 桌面互控;浏览器与原生 iOS / Android 作为控制端 |
|
||||
| 实时画面与声音 | H.264 / AV1、硬件编解码、远端声音播放;会话中可调整画质、30 / 60 fps 与画面偏好 |
|
||||
| 多设备与多显示器 | 近期连接、设备别名、会话标签与远端显示器切换 |
|
||||
| 多设备与多显示器 | 近期连接、设备别名与分组筛选、会话标签与远端显示器切换 |
|
||||
| 桌面墙(DeskHub) | 多桌面实时预览、分组与拖动排序、放大接管、群控、批量连接 / 断开 / 静音 / 文件分发;可配置分辨率、帧率与视频总带宽 |
|
||||
| 输入与协作 | 键鼠输入、远端光标同步、组合键、文本剪贴板同步、文件传输 |
|
||||
| 网络与部署 | P2P 直连、TURN 中继与强制中继、SRTP 媒体加密、自托管信令与中继服务 |
|
||||
| 隐私屏 | 被连接时自动开启,或在会话工具栏切换;可用性取决于被控端系统与采集方式 |
|
||||
@@ -103,9 +112,9 @@ sudo apt install "./crossdesk-virtual-desktop-xfce-linux-amd64-<version>.deb"
|
||||
### 从另一台电脑连接
|
||||
|
||||
1. **准备被控电脑。** 安装并运行 CrossDesk,等待底部显示“已连接服务器”。在左侧“本桌面”查看 **本机 ID** 和 **密码**,提供给控制端。
|
||||
2. **输入对端 ID。** 在控制端右侧“远程桌面 → 对端 ID”输入被控电脑的 ID,点击 **→**。
|
||||
2. **输入对端 ID。** 在控制端右侧“远程桌面 → 对端 ID”输入被控电脑的 **9 位 ID**,点击 **连接**。
|
||||
3. **完成密码验证。** 按弹窗输入被控电脑当前的连接密码,确认后等待远程画面出现。需要保存密码时勾选“记住密码”。
|
||||
4. **再次连接。** 成功连接过的设备会出现在“近期连接”中,可通过卡片重连,也可编辑别名或删除记录。
|
||||
4. **再次连接。** 成功连接过的设备会出现在“近期连接”中,可通过卡片重连。悬停卡片可编辑别名、修改分组、删除记录或连接 / 断开;标题右侧可按分组筛选设备。
|
||||
|
||||
本机密码旁的眼睛按钮用于显示 / 隐藏密码,刷新按钮用于生成新的随机密码,铅笔按钮用于手动修改。密码统一为 **6 位数字或英文字母**。修改或刷新时需保持连接服务器;等待修改成功、客户端重新连接后,再复制当前密码。已保存旧密码的控制端需重新输入。
|
||||
|
||||
@@ -117,6 +126,27 @@ sudo apt install "./crossdesk-virtual-desktop-xfce-linux-amd64-<version>.deb"
|
||||
|
||||
如果使用自建服务器,需要让控制端与被控端接入同一服务;Web 部署配置见 [CrossDesk Web Client](https://github.com/kunkundi/crossdesk-web-client)。
|
||||
|
||||
<a id="deskhub"></a>
|
||||
|
||||
## 桌面墙:集中查看与操控多台设备
|
||||
|
||||
点击主窗口左上角 **桌面墙**,打开独立窗口;连接窗口仍可用于普通远程连接和全局设置。桌面墙会保存设备成员、分组、排序与窗口布局,重启后需要手动连接设备。
|
||||
|
||||
1. **添加设备。** 点击 **添加 → 通过设备 ID 添加**,填写 ID、密码及可选分组,点击“添加并连接”;只有勾选“记住密码”才保存凭据。也可选择 **从近期连接添加**,多选已有记录并导入;导入后用“选择 → 连接”发起连接。
|
||||
2. **查看与整理。** 从“全部设备”切换到指定分组;拖动网格分隔线统一调整画面大小,拖动设备到另一台设备上交换位置。精简模式隐藏名称和管理工具栏,全屏用于扩大查看区域;翻页保留连接。
|
||||
3. **放大与操控。** 点击设备画面进入只读放大视图,上方缩略图可切换设备,拖动中间分隔条可调整上下区域。点击 **接管操控** 后才发送键鼠;点击“释放操控”或按 **Ctrl / ⌘ + Shift + Esc** 释放。切换设备或窗口失焦也会释放操控。
|
||||
4. **批量操作。** 点击 **选择**,勾选设备或“全选”,执行连接、断开,或从“更多”中静音 / 取消静音、分发文件。“全选”覆盖当前分组的全部分页;切换分组会清空选择。
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/images/desktop-deskhub-focus-zh.png" width="960" alt="桌面墙放大视图:上方设备缩略图、下方主画面,以及只读预览、开启群控和接管操控入口;画面为演示" />
|
||||
</p>
|
||||
|
||||
**群控:** 在放大视图点击“开启群控”,将当前设备的键鼠操作同步到 **所有已连接设备**,包含其他分组、分页及未显示在墙内的连接;范围不受批量勾选限制。各设备接收相同坐标与按键,屏幕尺寸不同时不会另做比例适配。释放操控会同时结束群控。
|
||||
|
||||
桌面墙连接默认静音,需要声音时通过“选择 → 更多 → 取消静音”开启。底部 **网络状态** 可查看所有已连接设备的直连 / 中继、加密状态、收发速率、视频延时、丢包率、分辨率与帧率。需要显示器切换、隐私屏等完整单会话工具时,可在主窗口“近期连接”中点击已连接设备,打开独立会话窗口。
|
||||
|
||||
**关闭桌面墙会断开全部远程设备连接,并关闭共用连接的独立会话窗口;最小化不会断开。** 分组、分页和选择范围不会限制关闭时的断开范围。更多操作、预览策略与版本兼容性见 [桌面墙说明](docs/deskhub.md)。
|
||||
|
||||
<a id="session"></a>
|
||||
|
||||
## 会话中的常用操作
|
||||
@@ -136,7 +166,7 @@ sudo apt install "./crossdesk-virtual-desktop-xfce-linux-amd64-<version>.deb"
|
||||
| 全屏 | 进入 / 退出全屏 |
|
||||
| 断开连接 | 结束当前远程会话 |
|
||||
|
||||
桌面端可同步文本剪贴板。接收文件的位置可在“设置 → 文件保存路径”调整。关闭主窗口会隐藏窗口并保持程序运行;需要完全退出时使用系统托盘或菜单栏中的退出入口。
|
||||
桌面端可同步文本剪贴板。接收文件的位置可在“设置 → 常规 → 文件保存路径”调整。关闭主窗口会隐藏窗口并保持程序运行;需要完全退出时使用系统托盘或菜单栏中的退出入口。
|
||||
|
||||
### 在会话中调整画面
|
||||
|
||||
@@ -150,10 +180,10 @@ sudo apt install "./crossdesk-virtual-desktop-xfce-linux-amd64-<version>.deb"
|
||||
|
||||
## 客户端设置与更新
|
||||
|
||||
点击主窗口右上角 **☰ → 设置**,修改后点击“确认”保存。编码与连接选项建议在建立连接前调整;会话进行中部分选项不可修改。设置列表可向下滚动,查看自托管、启动方式与文件保存路径。画质、帧率和画面偏好在远程会话工具栏中调整。
|
||||
点击主窗口右上角 **☰ → 设置**,顶部切换 **常规 / 桌面墙**,修改后点击 **保存**。切换选项卡会保留未保存的修改,点击“取消”放弃修改。“常规”按界面、视频、网络连接等类别组织选项,可向下滚动查看其余设置。编码与连接选项建议在建立连接前调整;会话进行中部分选项不可修改。普通会话的画质、帧率和画面偏好在远程会话工具栏中调整。
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/images/desktop-settings-zh.png" width="720" alt="当前客户端设置:编码格式、硬件编解码、中继、强制中继与自动隐私屏" />
|
||||
<img src="docs/images/desktop-settings-zh.png" width="720" alt="新版常规设置:常规与桌面墙选项卡、语言、视频编码、硬件编解码及网络连接设置" />
|
||||
</p>
|
||||
|
||||
| 设置 | 说明 |
|
||||
@@ -169,6 +199,22 @@ sudo apt install "./crossdesk-virtual-desktop-xfce-linux-amd64-<version>.deb"
|
||||
| 开机自启 / 启用守护进程 | 调整启动与进程守护行为,按界面提示重启后生效 |
|
||||
| 文件保存路径 | 选择接收文件的目录 |
|
||||
|
||||
### 桌面墙画面传输
|
||||
|
||||
在 **设置 → 桌面墙** 中统一配置墙内设备,保存后立即应用到当前桌面墙会话,并用于后续连接;普通连接不受影响。
|
||||
|
||||
| 设置 | 说明 |
|
||||
| --- | --- |
|
||||
| 分辨率上限 | 自动、320 × 180、640 × 360、1280 × 720、1920 × 1080、2560 × 1440、3840 × 2160 或原始分辨率;自动模式按网格 / 放大视图调整,固定上限保持比例且不放大源画面 |
|
||||
| 帧率 | 跟随设备设置,或指定 15 / 30 / 60 FPS |
|
||||
| 视频总带宽 | 默认 20 Mbps,可选 2 / 5 / 10 / 20 / 50 / 100 Mbps;由支持该能力的桌面墙会话共享,放大画面优先分配,不包含音频、文件传输与普通连接,也不是瞬时流量的硬上限 |
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/images/desktop-settings-deskhub-zh.png" width="720" alt="桌面墙传输设置:自动分辨率、跟随设备帧率和 20 Mbps 视频总带宽" />
|
||||
</p>
|
||||
|
||||
支持预览订阅的设备在不可见时可暂停视频传输,重新可见时恢复;旧版被控端按其能力回退,不保证所有分辨率、帧率或带宽选项都生效。
|
||||
|
||||
### 检查更新
|
||||
|
||||
客户端启动后会在后台检查版本,发现新版本时自动弹出更新提示,可查看更新说明并前往下载页面。关闭提示后,本次运行中的后台检查不会再次弹窗,主窗口菜单入口仍会显示更新提示;重新启动客户端后,如仍有新版本则再次提示。打开 **☰ → 关于** 可查看版本;有新版本时,点击版本信息下方的 **更新日志** 可再次打开更新提示,也可点击 **检查更新** 手动检查。
|
||||
@@ -256,7 +302,7 @@ Windows 的“开机自启”在当前用户登录后启动客户端。安装版
|
||||
|
||||
## 自托管服务器
|
||||
|
||||
可自行部署信令服务与 TURN 中继,并在桌面客户端 **☰ → 设置 → 自托管配置** 中填写连接信息。控制端与被控端都需要启用同一套配置。
|
||||
可自行部署信令服务与 TURN 中继,并在桌面客户端 **☰ → 设置 → 常规 → 自托管配置** 中填写连接信息。控制端与被控端都需要启用同一套配置。
|
||||
|
||||
- [自托管指南](docs/SELF_HOSTING.md):当前 Compose 部署入口、客户端设置、证书信任与排查。
|
||||
- [CrossDesk Server](https://github.com/kunkundi/crossdesk-server):服务端源码与发布配置。
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<div align="center">
|
||||
<img src="apps/desktop/resources/linux/crossdesk_128x128.png" width="80" alt="CrossDesk logo" />
|
||||
<h1>CrossDesk</h1>
|
||||
<p>A lightweight, cross-platform remote desktop for computers, browsers, and iPhone / iPad.</p>
|
||||
<p>A lightweight, cross-platform remote desktop for computers, browsers, and mobile devices, with DeskHub for viewing and controlling multiple desktops.</p>
|
||||
<p><a href="https://www.crossdesk.cn/">Website</a> · <a href="https://github.com/kunkundi/crossdesk/releases">Download</a> · <a href="https://web.crossdesk.cn/">Open Web Client</a> · <a href="README.md">中文</a></p>
|
||||
</div>
|
||||
|
||||
@@ -15,11 +15,11 @@
|
||||
|
||||
</div>
|
||||
|
||||
CrossDesk connects Windows, macOS, and Linux desktops, with browser and native iOS / Android clients for controlling computers. Built on [MiniRTC](https://github.com/kunkundi/minirtc), it supports real-time video and audio, keyboard and mouse control, file transfer, and self-hosting. The project is under active development.
|
||||
CrossDesk connects Windows, macOS, and Linux desktops, with browser and native iOS / Android clients for controlling computers. The desktop client now includes **DeskHub**, a separate window for viewing multiple remote desktops, organizing devices into groups, and performing batch actions or synchronized control. Built on [MiniRTC](https://github.com/kunkundi/minirtc), it supports real-time video and audio, keyboard and mouse control, file transfer, and self-hosting. The project is under active development.
|
||||
|
||||
> This guide follows the current repository source. Desktop screenshots render the current Slint UI with sample IDs, passwords, online statuses, and recent devices; thumbnails show public pages. Check each release's notes for the features and requirements of its binaries. Native iOS build and signing instructions are below.
|
||||
|
||||
[Preview](#preview) · [Download](#download) · [Quick start](#quick-start) · [Session controls](#session) · [Settings](#settings) · [iOS](#ios) · [Self-hosting](#self-hosting) · [Build](docs/BUILD_EN.md) · [FAQ](docs/FAQ.md#english)
|
||||
[Preview](#preview) · [Download](#download) · [Quick start](#quick-start) · [DeskHub](#deskhub) · [Session controls](#session) · [Settings](#settings) · [iOS](#ios) · [Self-hosting](#self-hosting) · [Build](docs/BUILD_EN.md) · [FAQ](docs/FAQ.md#english)
|
||||
|
||||
<a id="preview"></a>
|
||||
|
||||
@@ -27,10 +27,18 @@ CrossDesk connects Windows, macOS, and Linux desktops, with browser and native i
|
||||
|
||||
### Desktop client
|
||||
|
||||
The main window brings together your device ID, connection password, remote connection entry, and recent devices.
|
||||
The main window presents your credentials and connection controls in Local Desktop and Remote Desktop cards, with recent devices and a group filter below. Open DeskHub at the top left, or use the top-right menu for settings, messages, and About.
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/images/desktop-main-en.png" width="800" alt="Current desktop UI example showing a connected server and three online devices with page thumbnails" />
|
||||
<img src="docs/images/desktop-main-en.png" width="800" alt="Updated main window with DeskHub access, local credentials, remote connection controls, and recent-device group filtering; data is illustrative" />
|
||||
</p>
|
||||
|
||||
### DeskHub
|
||||
|
||||
View multiple devices in a separate window with groups, a resizable grid, compact and fullscreen views, and batch actions. Click a preview to enlarge it, then take control when needed.
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/images/desktop-deskhub-en.png" width="960" alt="DeskHub grid with six demonstration devices, group filtering, Add, Select, Compact, and Network controls" />
|
||||
</p>
|
||||
|
||||
### Web client
|
||||
@@ -38,7 +46,7 @@ The main window brings together your device ID, connection password, remote conn
|
||||
Enter a remote device ID and password in your browser without installing the desktop app on the controlling device.
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/images/web-client.png" width="800" alt="Current CrossDesk Web Client connection page in Chinese" />
|
||||
<img src="docs/images/web-client.png" width="800" alt="CrossDesk Web Client connection page in Chinese, captured on 2026-09-25" />
|
||||
</p>
|
||||
|
||||
See the [screenshot notes](docs/images/README.md) for image sources and refresh instructions.
|
||||
@@ -49,7 +57,8 @@ See the [screenshot notes](docs/images/README.md) for image sources and refresh
|
||||
| --- | --- |
|
||||
| Cross-platform control | Windows / macOS / Linux desktop control; browser and native iOS / Android controllers |
|
||||
| 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 |
|
||||
| Devices and displays | Recent connections, device aliases and group filtering, session tabs, and remote display switching |
|
||||
| DeskHub | Live multi-desktop previews, groups and drag reordering, enlarged views with control, synchronized input, and batch connection / disconnection / audio / file actions; configurable resolution, frame rate, and shared video budget |
|
||||
| Input and sharing | Keyboard/mouse input, remote cursor synchronization, shortcuts, text clipboard synchronization, and file transfer |
|
||||
| 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 |
|
||||
@@ -103,9 +112,9 @@ Both contain the same complete application, replace each other and preserve user
|
||||
### Connect from another computer
|
||||
|
||||
1. **Prepare the host.** Install and run CrossDesk on the computer to control. Wait for the bottom status bar to show that the server is connected. Share the **Local ID** and **Password** shown under **Local Desktop** with the controller.
|
||||
2. **Enter the remote ID.** On the controlling computer, enter the host's ID in **Remote Desktop → Remote ID**, then click **→**.
|
||||
2. **Enter the remote ID.** On the controlling computer, enter the host's **9-digit ID** in **Remote Desktop → Remote ID**, then click **Connect**.
|
||||
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.
|
||||
4. **Reconnect later.** Previously connected devices appear under **Recent Connections**. Reconnect from a card, or hover to rename it, edit its groups, remove its record, or connect / disconnect. Use the group filter beside the heading to narrow the list.
|
||||
|
||||
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.
|
||||
|
||||
@@ -117,6 +126,27 @@ The eye button shows or hides the local password, the refresh button generates a
|
||||
|
||||
For self-hosting, connect both sides to the same service. Browser deployment is documented in [CrossDesk Web Client](https://github.com/kunkundi/crossdesk-web-client).
|
||||
|
||||
<a id="deskhub"></a>
|
||||
|
||||
## DeskHub: view and control multiple devices
|
||||
|
||||
Click **DeskHub** at the top left of the main window to open a separate window. The connection window remains available for ordinary remote sessions and global settings. DeskHub saves its device membership, groups, order, and window layout; connections must be started manually after relaunching.
|
||||
|
||||
1. **Add devices.** Choose **Add → Add by device ID**, enter an ID, password, and optional group, then click **Add and connect**. Credentials are saved only when you select **Remember password**. Alternatively, choose **Add from recent** and import multiple records; use **Select → Connect** to connect after importing.
|
||||
2. **Arrange the view.** Switch from **All devices** to a group, drag grid dividers to resize all previews, or drag a device onto another to swap positions. **Compact** hides device names and management controls; fullscreen provides more viewing space. Changing pages keeps connections open.
|
||||
3. **Enlarge and control.** Click a preview to open a read-only enlarged view. Switch devices with the thumbnails above it, and drag the divider to resize the two areas. Click **Control** to send keyboard and mouse input. Click **Release** or press **Ctrl / ⌘ + Shift + Esc** to release; switching devices or losing window focus also releases control.
|
||||
4. **Apply batch actions.** Click **Select**, choose devices or **Select all**, and connect, disconnect with **Close**, or use **More** to mute / unmute or send a file. **Select all** includes every page in the current group; switching groups clears the selection.
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/images/desktop-deskhub-focus-en.png" width="960" alt="DeskHub enlarged view with thumbnails above the main preview and View only status, Group control, and Control buttons; screens are illustrative" />
|
||||
</p>
|
||||
|
||||
**Group control:** click **Group control** in the enlarged view to mirror input from the current device to **all connected devices**, including other groups, pages, and connections not displayed in the wall. The scope is independent of the batch selection. Devices receive the same coordinates and keys without additional scaling for different screen sizes. Releasing control also ends group control.
|
||||
|
||||
DeskHub connections are muted by default. Enable sound with **Select → More → Unmute**. The bottom **Network** button shows direct / relay mode, encryption, receive/send rates, video latency, packet loss, resolution, and frame rate for all connected devices. For the full single-session toolbar, including display switching and privacy screens, click a connected device under **Recent Connections** in the main window to open its separate session window.
|
||||
|
||||
**Closing DeskHub disconnects all remote devices and closes session windows sharing those connections; minimizing it keeps connections open.** The disconnect scope includes all groups and pages, regardless of selection. See the [DeskHub guide](docs/deskhub.md) (Chinese) for further controls, preview behavior, and compatibility details.
|
||||
|
||||
<a id="session"></a>
|
||||
|
||||
## Session controls
|
||||
@@ -136,7 +166,7 @@ Switch devices using session tabs. Expand the control bar if it is collapsed, an
|
||||
| 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.
|
||||
Desktop clients synchronize text clipboard contents. Configure the receiving directory under **Settings → General → 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.
|
||||
|
||||
### Adjust video during a session
|
||||
|
||||
@@ -150,10 +180,10 @@ After connecting, open **Video settings** (the sliders icon) in the toolbar. Cho
|
||||
|
||||
## 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.
|
||||
Open **☰ → Settings** in the top-right corner, choose **General / DeskHub**, and click **Save** to apply changes. Switching tabs preserves pending changes; **Cancel** discards them. General settings are grouped into interface, video, networking, and further sections available by scrolling. Configure codecs and connections before connecting; some settings are disabled during an active session. Adjust ordinary session quality, frame rate, and video preference from its 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" />
|
||||
<img src="docs/images/desktop-settings-en.png" width="720" alt="Updated General settings with General and DeskHub tabs, language, video codec, hardware codecs, and networking options" />
|
||||
</p>
|
||||
|
||||
| Setting | Purpose |
|
||||
@@ -169,6 +199,22 @@ Open **☰ → Settings** in the top-right corner and click **OK** to save. Conf
|
||||
| Auto Start / Enable Daemon | Configure startup and process supervision; restart as indicated by the UI |
|
||||
| File Save Path | Choose where received files are saved |
|
||||
|
||||
### DeskHub video transmission
|
||||
|
||||
Use **Settings → DeskHub** to configure wall sessions together. Saving applies changes immediately to current DeskHub sessions and future connections; ordinary connections are unaffected.
|
||||
|
||||
| Setting | Purpose |
|
||||
| --- | --- |
|
||||
| Resolution limit | Auto, 320 × 180, 640 × 360, 1280 × 720, 1920 × 1080, 2560 × 1440, 3840 × 2160, or Original resolution; Auto adapts to grid / enlarged views, while fixed limits preserve aspect ratio without upscaling the source |
|
||||
| Frame rate | Follow device settings, or choose 15 / 30 / 60 FPS |
|
||||
| Video budget | Defaults to 20 Mbps; choose 2 / 5 / 10 / 20 / 50 / 100 Mbps, shared by compatible DeskHub sessions with priority for enlarged views. Audio, file transfers, and ordinary connections are excluded; this is not a hard limit on instantaneous traffic |
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/images/desktop-settings-deskhub-en.png" width="720" alt="DeskHub video settings with Auto resolution, Follow device settings frame rate, and a 20 Mbps video budget" />
|
||||
</p>
|
||||
|
||||
Devices supporting preview subscriptions can pause video while their views are hidden and resume when visible. Older hosts fall back according to their capabilities, so not every resolution, frame-rate, or bandwidth setting will apply.
|
||||
|
||||
### Check for updates
|
||||
|
||||
The client checks for updates in the background after launch and automatically opens the update dialog when a new version is found, showing release notes and a link to the download page. After you dismiss the dialog, background checks will not reopen it during the current run, while the main window menu keeps its update indicator. Restarting the client shows the dialog again if an update is still available. Open **☰ → About** to view the version. When an update is available, click **Release notes** below the new version to reopen the update dialog, or click **Check for updates** to check manually.
|
||||
@@ -248,7 +294,7 @@ If **Remote Windows service unavailable** appears, check installation and servic
|
||||
|
||||
## Self-hosting
|
||||
|
||||
Host your own signaling and TURN services, then enter their connection details under **☰ → Settings → Self-Hosted Config**. Enable the same server configuration on both the controller and host.
|
||||
Host your own signaling and TURN services, then enter their connection details under **☰ → Settings → General → Self-Hosted Config**. Enable the same server configuration on both the controller and host.
|
||||
|
||||
- [Self-hosting guide](docs/SELF_HOSTING_EN.md): current Compose deployment, client settings, certificate trust, and troubleshooting.
|
||||
- [CrossDesk Server](https://github.com/kunkundi/crossdesk-server): server source and release configuration.
|
||||
|
||||
@@ -50,7 +50,7 @@ private:
|
||||
void InitializeUi();
|
||||
void InitializeSystemTray();
|
||||
bool MinimizeMainWindowToTray();
|
||||
void BindMainCallbacks();
|
||||
void BindMainCallbacks(bool gallery_window = false);
|
||||
void BindStreamCallbacks();
|
||||
void BindServerCallbacks();
|
||||
void Tick();
|
||||
@@ -60,6 +60,25 @@ private:
|
||||
void HandlePasswordChangeResult();
|
||||
void HandleCredentialRecovery();
|
||||
void SyncMainWindow();
|
||||
void ShowGalleryWindow();
|
||||
void CloseGalleryWindow();
|
||||
void SyncWindowGeometry(bool gallery_window);
|
||||
void ToggleGalleryFullscreen();
|
||||
void RestoreWindowGeometry(bool before_show = false,
|
||||
bool gallery_window = false);
|
||||
void SyncGallery();
|
||||
void GalleryAction(const std::string& action, const std::string& value,
|
||||
bool gallery_window = true);
|
||||
bool AddGalleryDevice(const std::string& id, const std::string& password,
|
||||
const std::string& group, bool remember_password);
|
||||
bool ImportGalleryRecent(const std::string& group);
|
||||
void DeviceGroupAction(const std::string& action, const std::string& value,
|
||||
bool gallery_window);
|
||||
bool SaveDeviceGroups(const std::string& new_group, bool gallery_window);
|
||||
void ReleasePressedInput();
|
||||
void ReleaseControl();
|
||||
void SyncAudioPlayback();
|
||||
void SaveGalleryWindowState();
|
||||
void SyncAnnouncements();
|
||||
void SyncConnectionDialog();
|
||||
void SyncPlatformDialogs();
|
||||
@@ -76,9 +95,9 @@ private:
|
||||
void SyncXWaylandWindowActivation();
|
||||
#endif
|
||||
void UpdateLocalization();
|
||||
void ResetSettingsUi();
|
||||
bool ValidateSelfHostedSettingsFromUi();
|
||||
bool SaveSettingsFromUi();
|
||||
void ResetSettingsUi(bool gallery_window = false);
|
||||
bool ValidateSelfHostedSettingsFromUi(bool gallery_window = false);
|
||||
bool SaveSettingsFromUi(bool gallery_window = false);
|
||||
void ApplySettingsFromConfig(bool reconnect);
|
||||
#if _WIN32 && CROSSDESK_PORTABLE
|
||||
void CheckPortableWindowsService();
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Regenerate message_spacing.ttf with fontTools (only needed for regeneration).
|
||||
|
||||
Slint 1.17 pins line height to the requested font's ascent, descent and leading,
|
||||
but StyledText does not expose line-height. This metrics-only font supplies 1.5em
|
||||
line boxes without replacing any visible glyphs: Slint's normal system fallback
|
||||
fonts still render the text and links. Use this family for message and release-note
|
||||
bodies without changing the typography of other controls.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from fontTools.fontBuilder import FontBuilder
|
||||
from fontTools.pens.ttGlyphPen import TTGlyphPen
|
||||
|
||||
|
||||
def main():
|
||||
font = FontBuilder(1000, isTTF=True)
|
||||
glyphs = [".notdef", "zeroWidthSpace"]
|
||||
font.setupGlyphOrder(glyphs)
|
||||
font.setupCharacterMap({0x200B: "zeroWidthSpace"})
|
||||
font.setupGlyf({name: TTGlyphPen(None).glyph() for name in glyphs})
|
||||
font.setupHorizontalMetrics({name: (0, 0) for name in glyphs})
|
||||
font.setupHorizontalHeader(ascent=1000, descent=-250, lineGap=250)
|
||||
font.setupOS2(
|
||||
version=4,
|
||||
sTypoAscender=1000,
|
||||
sTypoDescender=-250,
|
||||
sTypoLineGap=250,
|
||||
usWinAscent=1000,
|
||||
usWinDescent=250,
|
||||
fsSelection=0xC0, # Regular, use typographic metrics.
|
||||
)
|
||||
font.setupNameTable({
|
||||
"familyName": "CrossDesk Message Spacing",
|
||||
"styleName": "Regular",
|
||||
"uniqueFontIdentifier": "CrossDesk-Message-Spacing-1.0",
|
||||
"fullName": "CrossDesk Message Spacing",
|
||||
"psName": "CrossDeskMessageSpacing-Regular",
|
||||
"version": "Version 1.0",
|
||||
"copyright": "Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.",
|
||||
})
|
||||
font.setupPost()
|
||||
# Fixed timestamps keep the generated asset reproducible.
|
||||
font.font["head"].created = font.font["head"].modified = 3874867200
|
||||
font.font.recalcTimestamp = False
|
||||
font.save(Path(__file__).with_name("message_spacing.ttf"))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -23,10 +23,11 @@ struct TranslationRow {
|
||||
X(messages, u8"消息", "Messages", u8"Сообщения") \
|
||||
X(message_unread, u8"未读", "Unread", u8"Не прочитано") \
|
||||
X(message_read, u8"已读", "Read", u8"Прочитано") \
|
||||
X(message_read_all, u8"全部已读", "Mark all as read", u8"Отметить всё прочитанным") \
|
||||
X(message_empty, u8"暂无公告", "No announcements", u8"Нет объявлений") \
|
||||
X(message_select, u8"选择公告查看详情", "Select an announcement to read", u8"Выберите объявление") \
|
||||
X(message_loading, u8"正在加载…", "Loading…", u8"Загрузка…") \
|
||||
X(message_read_save_error, u8"无法保存本地阅读状态,请重新打开公告重试。", "Unable to save reading status locally. Open the announcement again to retry.", u8"Не удалось сохранить статус чтения. Откройте объявление снова.") \
|
||||
X(message_read_save_error, u8"无法保存本地阅读状态,请重试。", "Unable to save reading status locally. Please try again.", u8"Не удалось сохранить статус чтения. Повторите попытку.") \
|
||||
X(message_close, u8"关闭", "Close", u8"Закрыть") \
|
||||
X(message_delete, u8"删除", "Delete", u8"Удалить") \
|
||||
X(message_delete_title, u8"删除公告", "Delete announcement", u8"Удалить объявление") \
|
||||
|
||||
@@ -46,7 +46,8 @@ void ClipboardController::Shutdown() {
|
||||
pending_remote_text_.reset();
|
||||
}
|
||||
|
||||
void ClipboardController::QueueRemoteText(const char *data, size_t size) {
|
||||
void ClipboardController::QueueRemoteText(const char* data, size_t size,
|
||||
const std::string& remote_id) {
|
||||
if (!events_enabled_.load(std::memory_order_acquire)) {
|
||||
return;
|
||||
}
|
||||
@@ -62,6 +63,11 @@ void ClipboardController::QueueRemoteText(const char *data, size_t size) {
|
||||
return;
|
||||
}
|
||||
|
||||
const auto session =
|
||||
remote_id.empty() ? nullptr : owner_.FindRemoteSession(remote_id);
|
||||
if (!remote_id.empty() && (!session || !session->clipboard_allowed_.load()))
|
||||
return;
|
||||
const auto generation = session ? session->input_generation_.load() : 0;
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(pending_mutex_);
|
||||
if (!events_enabled_.load(std::memory_order_relaxed)) {
|
||||
@@ -70,6 +76,9 @@ void ClipboardController::QueueRemoteText(const char *data, size_t size) {
|
||||
// Only the newest clipboard value matters. Replacing it also bounds the
|
||||
// amount of memory a busy or malicious peer can queue.
|
||||
pending_remote_text_ = std::string(data, size);
|
||||
pending_remote_id_ = remote_id;
|
||||
pending_session_ = session;
|
||||
pending_generation_ = generation;
|
||||
}
|
||||
|
||||
SDL_Event event{};
|
||||
@@ -87,9 +96,22 @@ void ClipboardController::ApplyPendingRemoteText() {
|
||||
}
|
||||
|
||||
std::optional<std::string> pending_text;
|
||||
std::string source_id;
|
||||
std::weak_ptr<gui_detail::RemoteSession> source_session;
|
||||
uint64_t generation = 0;
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(pending_mutex_);
|
||||
pending_text.swap(pending_remote_text_);
|
||||
source_id.swap(pending_remote_id_);
|
||||
source_session.swap(pending_session_);
|
||||
generation = pending_generation_;
|
||||
}
|
||||
if (!source_id.empty()) {
|
||||
auto props = owner_.FindRemoteSession(source_id);
|
||||
if (!props || props != source_session.lock() ||
|
||||
!props->clipboard_allowed_.load() ||
|
||||
props->input_generation_.load() != generation)
|
||||
return;
|
||||
}
|
||||
if (!pending_text || *pending_text == last_text_) {
|
||||
return;
|
||||
@@ -145,7 +167,7 @@ int ClipboardController::SendToPeers(const std::string &text) {
|
||||
std::shared_lock lock(owner_.remote_sessions_mutex_);
|
||||
for (const auto &[remote_id, props] : owner_.remote_sessions_) {
|
||||
if (!props || !props->peer_ || !props->connection_established_ ||
|
||||
!props->enable_mouse_control_) {
|
||||
!props->clipboard_allowed_.load()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
@@ -10,10 +10,15 @@
|
||||
#include <atomic>
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <memory>
|
||||
#include <mutex>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
|
||||
namespace crossdesk::gui_detail {
|
||||
struct RemoteSession;
|
||||
}
|
||||
|
||||
namespace crossdesk {
|
||||
|
||||
class GuiRuntime;
|
||||
@@ -29,7 +34,8 @@ public:
|
||||
|
||||
void Initialize();
|
||||
void Shutdown();
|
||||
void QueueRemoteText(const char *data, size_t size);
|
||||
void QueueRemoteText(const char* data, size_t size,
|
||||
const std::string& remote_id = {});
|
||||
void ApplyPendingRemoteText();
|
||||
void HandleLocalUpdate();
|
||||
|
||||
@@ -41,6 +47,9 @@ private:
|
||||
std::atomic<bool> events_enabled_{false};
|
||||
std::mutex pending_mutex_;
|
||||
std::optional<std::string> pending_remote_text_;
|
||||
std::string pending_remote_id_;
|
||||
std::weak_ptr<gui_detail::RemoteSession> pending_session_;
|
||||
uint64_t pending_generation_ = 0;
|
||||
std::string last_text_;
|
||||
};
|
||||
|
||||
|
||||
@@ -49,7 +49,13 @@ SessionDeviceManager::SessionDeviceManager(GuiRuntime &owner)
|
||||
frame.data = reinterpret_cast<const char *>(data);
|
||||
frame.size = size;
|
||||
frame.captured_timestamp = GetSystemTimeMicros(owner_.peer_);
|
||||
SendAudioFrame(owner_.peer_, &frame, owner_.audio_label_.c_str());
|
||||
std::shared_lock lock(owner_.connection_status_mutex_);
|
||||
for (const auto& [id, status] : owner_.connection_status_) {
|
||||
if (status == ConnectionStatus::Connected)
|
||||
SendAudioFrameToPeer(owner_.peer_, &frame,
|
||||
owner_.audio_label_.c_str(), id.data(),
|
||||
id.size());
|
||||
}
|
||||
}) {}
|
||||
|
||||
SessionDeviceManager::~SessionDeviceManager() { speaker_capture_.Shutdown(); }
|
||||
@@ -245,6 +251,8 @@ int SessionDeviceManager::InitializeScreenCapturer() {
|
||||
const auto send_started = std::chrono::steady_clock::now();
|
||||
uint64_t send_errors = 0;
|
||||
for (const std::string &remote_id : connected_remote_ids) {
|
||||
if (!owner_.preview_subscriptions_.ShouldSend(remote_id, now_time,
|
||||
stream_id)) continue;
|
||||
if (SendVideoFrameToPeer(owner_.peer_, &frame, stream_id.c_str(),
|
||||
remote_id.data(), remote_id.size()) != 0)
|
||||
++send_errors;
|
||||
@@ -531,13 +539,13 @@ int SessionDeviceManager::InitializeAudioOutput() {
|
||||
desired_out.format = SDL_AUDIO_S16;
|
||||
desired_out.channels = 1;
|
||||
|
||||
auto open_stream = [&]() {
|
||||
output_stream_ = SDL_OpenAudioDeviceStream(
|
||||
SDL_AUDIO_DEVICE_DEFAULT_PLAYBACK, &desired_out, nullptr, nullptr);
|
||||
return output_stream_ != nullptr;
|
||||
auto open_device = [&]() {
|
||||
output_device_ = SDL_OpenAudioDevice(
|
||||
SDL_AUDIO_DEVICE_DEFAULT_PLAYBACK, &desired_out);
|
||||
return output_device_ != 0;
|
||||
};
|
||||
|
||||
if (!open_stream()) {
|
||||
if (!open_device()) {
|
||||
#if defined(__linux__) && !defined(__APPLE__)
|
||||
LOG_WARN("Failed to open output stream with driver [{}]: {}",
|
||||
getenv("SDL_AUDIODRIVER") ? getenv("SDL_AUDIODRIVER")
|
||||
@@ -551,7 +559,7 @@ int SessionDeviceManager::InitializeAudioOutput() {
|
||||
SDL_GetError());
|
||||
return -1;
|
||||
}
|
||||
if (!open_stream()) {
|
||||
if (!open_device()) {
|
||||
LOG_ERROR("Failed to open output stream with dummy driver: {}",
|
||||
SDL_GetError());
|
||||
return -1;
|
||||
@@ -563,27 +571,50 @@ int SessionDeviceManager::InitializeAudioOutput() {
|
||||
#endif
|
||||
}
|
||||
|
||||
SDL_ResumeAudioDevice(SDL_GetAudioStreamDevice(output_stream_));
|
||||
SDL_ResumeAudioDevice(output_device_);
|
||||
return 0;
|
||||
}
|
||||
|
||||
int SessionDeviceManager::DestroyAudioOutput() {
|
||||
if (output_stream_) {
|
||||
SDL_CloseAudioDevice(SDL_GetAudioStreamDevice(output_stream_));
|
||||
SDL_DestroyAudioStream(output_stream_);
|
||||
output_stream_ = nullptr;
|
||||
for (const auto& [_, stream] : output_streams_)
|
||||
SDL_DestroyAudioStream(stream);
|
||||
output_streams_.clear();
|
||||
if (output_device_) {
|
||||
SDL_CloseAudioDevice(output_device_);
|
||||
output_device_ = 0;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
void SessionDeviceManager::PushAudio(const char *data, size_t size) {
|
||||
if (!output_stream_) {
|
||||
return;
|
||||
void SessionDeviceManager::ClearAudio(const std::string& remote_id) {
|
||||
const auto found = output_streams_.find(remote_id);
|
||||
if (found == output_streams_.end()) return;
|
||||
SDL_DestroyAudioStream(found->second);
|
||||
output_streams_.erase(found);
|
||||
}
|
||||
|
||||
void SessionDeviceManager::PushAudio(const std::string& remote_id,
|
||||
const char *data, size_t size) {
|
||||
if (!output_device_) return;
|
||||
auto found = output_streams_.find(remote_id);
|
||||
if (found == output_streams_.end()) {
|
||||
SDL_AudioSpec spec{};
|
||||
spec.freq = 48000;
|
||||
spec.format = SDL_AUDIO_S16;
|
||||
spec.channels = 1;
|
||||
auto* stream = SDL_CreateAudioStream(&spec, &spec);
|
||||
if (!stream) {
|
||||
LOG_ERROR("Failed to create output stream: {}", SDL_GetError());
|
||||
return;
|
||||
}
|
||||
if (!SDL_BindAudioStream(output_device_, stream)) {
|
||||
LOG_ERROR("Failed to bind output stream: {}", SDL_GetError());
|
||||
SDL_DestroyAudioStream(stream);
|
||||
return;
|
||||
}
|
||||
found = output_streams_.emplace(remote_id, stream).first;
|
||||
}
|
||||
const int pushed = SDL_PutAudioStreamData(
|
||||
output_stream_, reinterpret_cast<const Uint8 *>(data),
|
||||
static_cast<int>(size));
|
||||
if (pushed < 0) {
|
||||
if (!SDL_PutAudioStreamData(found->second, data, static_cast<int>(size))) {
|
||||
LOG_ERROR("Failed to push audio data: {}", SDL_GetError());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
#include <cstdint>
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
#include <unordered_map>
|
||||
#include <vector>
|
||||
|
||||
#include <remote_action.h>
|
||||
@@ -67,7 +68,10 @@ public:
|
||||
|
||||
int InitializeAudioOutput();
|
||||
int DestroyAudioOutput();
|
||||
void PushAudio(const char *data, size_t size);
|
||||
// Serialized by GuiRuntime::audio_delivery_mutex_. Each peer has its own
|
||||
// stream so SDL mixes simultaneous playback and muting clears only that peer.
|
||||
void ClearAudio(const std::string& remote_id);
|
||||
void PushAudio(const std::string& remote_id, const char *data, size_t size);
|
||||
|
||||
bool SendKeyboardCommand(int key_code, bool is_down, uint32_t scan_code,
|
||||
bool extended);
|
||||
@@ -93,7 +97,8 @@ private:
|
||||
bool from_secure_desktop);
|
||||
|
||||
GuiRuntime &owner_;
|
||||
SDL_AudioStream *output_stream_ = nullptr;
|
||||
uint32_t output_device_ = 0;
|
||||
std::unordered_map<std::string, SDL_AudioStream*> output_streams_;
|
||||
ScreenCapturerFactory *screen_capturer_factory_ = nullptr;
|
||||
ScreenCapturer *screen_capturer_ = nullptr;
|
||||
std::atomic<bool> cursor_capture_dirty_{true};
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
/*
|
||||
* @Author: DI JUNKUN
|
||||
* @Date: 2026-10-12
|
||||
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
|
||||
*/
|
||||
|
||||
#ifndef _GALLERY_FULLSCREEN_TRANSITION_H_
|
||||
#define _GALLERY_FULLSCREEN_TRANSITION_H_
|
||||
|
||||
#include <chrono>
|
||||
#include <cstdint>
|
||||
|
||||
namespace crossdesk::gallery {
|
||||
|
||||
// Guard input while native geometry settles, without hiding the live content.
|
||||
// Driven by the existing UI tick; never pumps events or creates nested timers.
|
||||
class FullscreenTransition {
|
||||
public:
|
||||
using Clock = std::chrono::steady_clock;
|
||||
enum class Action { Idle, Apply, Finished };
|
||||
|
||||
bool Begin(bool fullscreen, Clock::time_point now) {
|
||||
if (Active())
|
||||
return false;
|
||||
target_ = fullscreen;
|
||||
phase_ = Phase::Pending;
|
||||
phase_at_ = now;
|
||||
width_ = height_ = 0;
|
||||
scale_ = 0;
|
||||
geometry_changed_ = false;
|
||||
return true;
|
||||
}
|
||||
|
||||
Action Advance(Clock::time_point now, uint32_t width, uint32_t height,
|
||||
float scale, bool fullscreen, bool restoring) {
|
||||
using namespace std::chrono_literals;
|
||||
if (phase_ == Phase::Pending) {
|
||||
if (restoring && now - phase_at_ >= 1500ms) {
|
||||
Cancel();
|
||||
return Action::Finished;
|
||||
}
|
||||
// Apply on the next UI tick as soon as window restoration is complete.
|
||||
if (restoring)
|
||||
return Action::Idle;
|
||||
phase_ = Phase::Settling;
|
||||
phase_at_ = stable_at_ = now;
|
||||
width_ = width;
|
||||
height_ = height;
|
||||
scale_ = scale;
|
||||
return Action::Apply;
|
||||
}
|
||||
if (phase_ == Phase::Settling) {
|
||||
const bool changed =
|
||||
width != width_ || height != height_ || scale != scale_;
|
||||
if (changed && width > 0 && height > 0)
|
||||
geometry_changed_ = true;
|
||||
if (changed || fullscreen != target_ || restoring) {
|
||||
width_ = width;
|
||||
height_ = height;
|
||||
scale_ = scale;
|
||||
stable_at_ = now;
|
||||
}
|
||||
// Some backends report the requested flag before delivering new bounds.
|
||||
// Also allow fullscreen modes whose client dimensions genuinely match.
|
||||
if ((width > 0 && height > 0 && fullscreen == target_ && !restoring &&
|
||||
(geometry_changed_ || now - phase_at_ >= 300ms) &&
|
||||
now - stable_at_ >= 32ms) ||
|
||||
now - phase_at_ >= 1500ms) {
|
||||
Cancel();
|
||||
return Action::Finished;
|
||||
}
|
||||
}
|
||||
return Action::Idle;
|
||||
}
|
||||
|
||||
void Cancel() { phase_ = Phase::Idle; }
|
||||
bool Active() const { return phase_ != Phase::Idle; }
|
||||
bool Target() const { return target_; }
|
||||
|
||||
private:
|
||||
enum class Phase { Idle, Pending, Settling };
|
||||
Phase phase_ = Phase::Idle;
|
||||
bool target_ = false;
|
||||
bool geometry_changed_ = false;
|
||||
uint32_t width_ = 0, height_ = 0;
|
||||
float scale_ = 0;
|
||||
Clock::time_point phase_at_{}, stable_at_{};
|
||||
};
|
||||
|
||||
} // namespace crossdesk::gallery
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,37 @@
|
||||
#include "gallery_storage.h"
|
||||
|
||||
#include <fstream>
|
||||
#ifdef _WIN32
|
||||
#ifndef NOMINMAX
|
||||
#define NOMINMAX
|
||||
#endif
|
||||
#include <windows.h>
|
||||
#endif
|
||||
namespace crossdesk::gallery {
|
||||
bool WriteAtomically(const std::filesystem::path &path,
|
||||
const std::string &text) {
|
||||
auto temporary = path;
|
||||
temporary += ".tmp";
|
||||
try {
|
||||
{
|
||||
std::ofstream output;
|
||||
output.exceptions(std::ios::failbit | std::ios::badbit);
|
||||
output.open(temporary, std::ios::binary | std::ios::trunc);
|
||||
output.write(text.data(), text.size());
|
||||
output.close();
|
||||
}
|
||||
#ifdef _WIN32
|
||||
if (MoveFileExW(temporary.c_str(), path.c_str(),
|
||||
MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH))
|
||||
return true;
|
||||
#else
|
||||
std::filesystem::rename(temporary, path);
|
||||
return true;
|
||||
#endif
|
||||
} catch (...) {
|
||||
}
|
||||
std::error_code ignored;
|
||||
std::filesystem::remove(temporary, ignored);
|
||||
return false;
|
||||
}
|
||||
} // namespace crossdesk::gallery
|
||||
@@ -0,0 +1,18 @@
|
||||
/*
|
||||
* @Author: DI JUNKUN
|
||||
* @Date: 2026-10-12
|
||||
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
|
||||
*/
|
||||
|
||||
#ifndef _GALLERY_STORAGE_H_
|
||||
#define _GALLERY_STORAGE_H_
|
||||
|
||||
#include <filesystem>
|
||||
#include <string>
|
||||
|
||||
namespace crossdesk::gallery {
|
||||
bool WriteAtomically(const std::filesystem::path &path,
|
||||
const std::string &text);
|
||||
} // namespace crossdesk::gallery
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,93 @@
|
||||
/*
|
||||
* @Author: DI JUNKUN
|
||||
* @Date: 2026-10-12
|
||||
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
|
||||
*/
|
||||
|
||||
#ifndef _GALLERY_STREAM_SETTINGS_H_
|
||||
#define _GALLERY_STREAM_SETTINGS_H_
|
||||
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <cstdint>
|
||||
#include <nlohmann/json.hpp>
|
||||
#include <optional>
|
||||
#include <preview_subscription.h>
|
||||
|
||||
#include "preview_resolution.h"
|
||||
|
||||
namespace crossdesk::gallery {
|
||||
struct StreamSettings {
|
||||
// Persisted preset order: automatic, fixed bounds, then original size.
|
||||
static constexpr std::array<PreviewResolution, 8> kResolutions{{
|
||||
{}, {320, 180}, {640, 360}, {1280, 720}, {1920, 1080},
|
||||
{2560, 1440}, {3840, 2160}, {}}};
|
||||
static constexpr std::array<int, 4> kFrameRates{0, 15, 30, 60};
|
||||
static constexpr std::array<int, 6> kBandwidthMbps{2, 5, 10, 20, 50, 100};
|
||||
int resolution = 0;
|
||||
int frame_rate = 0; // 0 follows the session's existing 30/60 FPS preference.
|
||||
int bandwidth_mbps = 20;
|
||||
|
||||
static std::optional<StreamSettings> FromIndices(int resolution, int fps,
|
||||
int bandwidth = 3) {
|
||||
if (resolution < 0 || resolution >= int(kResolutions.size()) ||
|
||||
fps < 0 || fps >= int(kFrameRates.size()) ||
|
||||
bandwidth < 0 || bandwidth >= int(kBandwidthMbps.size()))
|
||||
return {};
|
||||
return StreamSettings{resolution, kFrameRates[fps], kBandwidthMbps[bandwidth]};
|
||||
}
|
||||
int FrameRateIndex() const {
|
||||
return int(std::find(kFrameRates.begin(), kFrameRates.end(), frame_rate) -
|
||||
kFrameRates.begin());
|
||||
}
|
||||
int BandwidthIndex() const {
|
||||
return int(std::find(kBandwidthMbps.begin(), kBandwidthMbps.end(), bandwidth_mbps) -
|
||||
kBandwidthMbps.begin());
|
||||
}
|
||||
bool Valid() const {
|
||||
return resolution >= 0 && resolution < int(kResolutions.size()) &&
|
||||
FrameRateIndex() < int(kFrameRates.size()) &&
|
||||
BandwidthIndex() < int(kBandwidthMbps.size());
|
||||
}
|
||||
static StreamSettings FromJson(const nlohmann::json& json) {
|
||||
try {
|
||||
if (!json.at("resolution").is_number_integer() ||
|
||||
!json.at("frame_rate").is_number_integer()) return {};
|
||||
const auto resolution = json.at("resolution").get<int64_t>();
|
||||
const auto fps = json.at("frame_rate").get<int64_t>();
|
||||
if (resolution < 0 || resolution >= int(kResolutions.size()) ||
|
||||
fps < 0 || fps > 60) return {};
|
||||
// Retired sparse presets migrate to the lowest current setting.
|
||||
StreamSettings result{int(resolution), fps == 1 || fps == 8 ? 15 : int(fps)};
|
||||
if (json.contains("bandwidth_mbps") && json.at("bandwidth_mbps").is_number_integer()) {
|
||||
const auto bandwidth = json.at("bandwidth_mbps").get<int64_t>();
|
||||
if (std::find(kBandwidthMbps.begin(), kBandwidthMbps.end(), bandwidth) !=
|
||||
kBandwidthMbps.end()) result.bandwidth_mbps = int(bandwidth);
|
||||
}
|
||||
return result.Valid() ? result : StreamSettings{};
|
||||
} catch (...) {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
nlohmann::json ToJson() const {
|
||||
return {{"resolution", resolution}, {"frame_rate", frame_rate},
|
||||
{"bandwidth_mbps", bandwidth_mbps}};
|
||||
}
|
||||
PreviewResolution Resolution(bool wall_managed, PreviewResolution wall_view,
|
||||
PreviewResolution stream_view) const {
|
||||
if (!wall_managed) return {};
|
||||
if (resolution != 0 && Valid()) return kResolutions[resolution];
|
||||
// Keep a bounded fallback until a rendered viewport is available.
|
||||
if (!wall_view.width && !stream_view.width) return {320, 320};
|
||||
return {std::max(wall_view.width, stream_view.width),
|
||||
std::max(wall_view.height, stream_view.height)};
|
||||
}
|
||||
void ApplyFrameRate(PreviewSubscription& demand, bool supports_fps15) const {
|
||||
if (!Valid() || !frame_rate) return;
|
||||
// Older peers reject fps=15. Keep their existing interactive wire format.
|
||||
demand.fps = frame_rate == 15 && supports_fps15 ? 15 : 60;
|
||||
demand.frame_rate = frame_rate <= 30 ? 30 : 60;
|
||||
}
|
||||
};
|
||||
} // namespace crossdesk::gallery
|
||||
#endif
|
||||
@@ -0,0 +1,178 @@
|
||||
/*
|
||||
* @Author: DI JUNKUN
|
||||
* @Date: 2026-10-12
|
||||
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
|
||||
*/
|
||||
|
||||
#ifndef _GALLERY_WINDOW_STATE_H_
|
||||
#define _GALLERY_WINDOW_STATE_H_
|
||||
|
||||
#include <slint.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <optional>
|
||||
|
||||
namespace crossdesk::gallery {
|
||||
|
||||
struct WindowState {
|
||||
bool wall_fullscreen = false;
|
||||
bool wall_maximized = false;
|
||||
// Store client dimensions in logical pixels, independent of the display DPI.
|
||||
std::optional<slint::LogicalSize> wall_size;
|
||||
std::optional<slint::PhysicalPosition> wall_position, connection_position;
|
||||
|
||||
void Observe(bool wall, slint::PhysicalPosition position,
|
||||
slint::PhysicalSize size, float scale, bool fullscreen,
|
||||
bool maximized, float compact_height = 486,
|
||||
float compact_width = 640) {
|
||||
if (Restoring())
|
||||
return;
|
||||
if (!wall) {
|
||||
connection_position = position;
|
||||
return;
|
||||
}
|
||||
if (!fullscreen && !maximized &&
|
||||
IsUndersized(size, scale, compact_height, compact_width))
|
||||
return;
|
||||
scale = std::max(scale, 1.0f);
|
||||
wall_fullscreen = fullscreen;
|
||||
wall_maximized = !fullscreen && maximized;
|
||||
if (!fullscreen && !maximized) {
|
||||
wall_size = slint::LogicalSize(
|
||||
slint::Size<float>{size.width / scale, size.height / scale});
|
||||
wall_position = position;
|
||||
}
|
||||
}
|
||||
|
||||
bool RecoverUndersized(bool wall, slint::PhysicalPosition position,
|
||||
slint::PhysicalSize size, float scale, bool visible,
|
||||
bool minimized, bool fullscreen, bool maximized,
|
||||
float compact_height = 486,
|
||||
float compact_width = 640) {
|
||||
// Hidden/minimized/uninitialized windows can report temporary tiny bounds.
|
||||
// Leave those alone, and let an in-flight restoration retain its target.
|
||||
if (Restoring() || !visible || minimized || fullscreen || maximized ||
|
||||
size.width == 0 || size.height == 0 ||
|
||||
!IsUndersized(size, scale, compact_height, compact_width))
|
||||
return false;
|
||||
if (wall) {
|
||||
wall_position = position;
|
||||
wall_fullscreen = false;
|
||||
wall_maximized = false;
|
||||
} else {
|
||||
connection_position = position;
|
||||
}
|
||||
// Keep the last valid logical size; only the current anchor is captured.
|
||||
BeginRestore();
|
||||
return true;
|
||||
}
|
||||
|
||||
slint::PhysicalSize RestoreSize(bool wall, float scale,
|
||||
float compact_height = 486,
|
||||
float compact_width = 640) const {
|
||||
const float width =
|
||||
wall && wall_size ? std::max(compact_width, wall_size->width)
|
||||
: compact_width;
|
||||
const float height = wall && wall_size
|
||||
? std::max(compact_height, wall_size->height)
|
||||
: compact_height;
|
||||
scale = std::max(scale, 1.0f);
|
||||
return slint::PhysicalSize(
|
||||
slint::Size<uint32_t>{uint32_t(std::lround(width * scale)),
|
||||
uint32_t(std::lround(height * scale))});
|
||||
}
|
||||
|
||||
void BeginRestore() {
|
||||
phase_ = Phase::Normal;
|
||||
target_.reset();
|
||||
last_size_.reset();
|
||||
matches_ = 0;
|
||||
}
|
||||
|
||||
void PrepareForShow(slint::Window &window, bool wall,
|
||||
slint::PhysicalSize size,
|
||||
std::optional<slint::PhysicalPosition> position,
|
||||
float initial_y_offset = 0) {
|
||||
const float scale = std::max(window.scale_factor(), 1.0f);
|
||||
// The actual display scale is resolved when the native window is created.
|
||||
// Give the backend logical geometry so its first visible frame is correct.
|
||||
window.set_size(slint::LogicalSize(
|
||||
slint::Size<float>{size.width / scale, size.height / scale}));
|
||||
if (position) {
|
||||
(wall ? wall_position : connection_position) = position;
|
||||
window.set_position(slint::LogicalPosition(slint::Point<float>{
|
||||
position->x / scale, position->y / scale + initial_y_offset}));
|
||||
}
|
||||
}
|
||||
bool Restoring() const { return phase_ != Phase::Idle; }
|
||||
bool RestoringNormal() const { return phase_ == Phase::Normal; }
|
||||
|
||||
// A set_size request can complete asynchronously, after layout constraints
|
||||
// and the backing scale have changed. Acknowledge observations on later
|
||||
// ticks.
|
||||
bool NormalSizeRestored(slint::PhysicalSize actual,
|
||||
slint::PhysicalSize target) {
|
||||
if (!RestoringNormal())
|
||||
return false;
|
||||
if (target_ && SameSize(*target_, target) && SameSize(actual, target))
|
||||
++matches_;
|
||||
else
|
||||
matches_ = 0;
|
||||
target_ = target;
|
||||
if (matches_ < 2)
|
||||
return false;
|
||||
phase_ = Phase::Presentation;
|
||||
matches_ = 0;
|
||||
last_size_.reset();
|
||||
return true;
|
||||
}
|
||||
|
||||
bool FinishRestore(bool wall, slint::PhysicalSize actual, bool fullscreen,
|
||||
bool maximized) {
|
||||
if (phase_ != Phase::Presentation)
|
||||
return false;
|
||||
const bool want_fullscreen = wall && wall_fullscreen;
|
||||
const bool want_maximized = wall && !want_fullscreen && wall_maximized;
|
||||
const bool normal = !want_fullscreen && !want_maximized;
|
||||
if (normal && !fullscreen && !maximized && target_ &&
|
||||
!SameSize(actual, *target_)) {
|
||||
// A late constraint/DPI update can invalidate an earlier acknowledgement.
|
||||
// Reissue the normal size instead of getting stuck or saving that size.
|
||||
BeginRestore();
|
||||
return false;
|
||||
}
|
||||
const bool ready = fullscreen == want_fullscreen &&
|
||||
(want_fullscreen || maximized == want_maximized) &&
|
||||
(!normal || (target_ && SameSize(actual, *target_)));
|
||||
if (ready && last_size_ && SameSize(actual, *last_size_))
|
||||
++matches_;
|
||||
else
|
||||
matches_ = 0;
|
||||
last_size_ = actual;
|
||||
if (matches_ < 2)
|
||||
return false;
|
||||
phase_ = Phase::Idle;
|
||||
return true;
|
||||
}
|
||||
|
||||
private:
|
||||
enum class Phase { Idle, Normal, Presentation };
|
||||
Phase phase_ = Phase::Idle;
|
||||
std::optional<slint::PhysicalSize> target_, last_size_;
|
||||
int matches_ = 0;
|
||||
static bool IsUndersized(slint::PhysicalSize size, float scale,
|
||||
float compact_height, float compact_width) {
|
||||
scale = std::max(scale, 1.0f);
|
||||
return double(size.width) + 1 < compact_width * scale ||
|
||||
double(size.height) + 1 < compact_height * scale;
|
||||
}
|
||||
static bool SameSize(slint::PhysicalSize a, slint::PhysicalSize b) {
|
||||
return std::abs(double(a.width) - b.width) <= 1 &&
|
||||
std::abs(double(a.height) - b.height) <= 1;
|
||||
}
|
||||
};
|
||||
|
||||
} // namespace crossdesk::gallery
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,306 @@
|
||||
/*
|
||||
* @Author: DI JUNKUN
|
||||
* @Date: 2026-10-12
|
||||
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
|
||||
*/
|
||||
|
||||
#ifndef _GALLERY_WORKSPACE_H_
|
||||
#define _GALLERY_WORKSPACE_H_
|
||||
|
||||
#include <algorithm>
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
#include <nlohmann/json.hpp>
|
||||
#include <optional>
|
||||
#include <set>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "gallery_storage.h"
|
||||
#include "gallery_stream_settings.h"
|
||||
|
||||
namespace crossdesk::gallery {
|
||||
struct Member {
|
||||
std::string id;
|
||||
std::string group;
|
||||
};
|
||||
|
||||
// Layout contains no credentials and never reconnects devices when loaded.
|
||||
struct Workspace {
|
||||
// Group assignments are shared by history and the wall. Wall enrollment is
|
||||
// independent, so assigning a group alone must never add a device to it.
|
||||
std::vector<Member> members;
|
||||
std::vector<std::string> groups;
|
||||
std::set<std::string> selected;
|
||||
int page = 0;
|
||||
int capacity = 6;
|
||||
StreamSettings stream_settings;
|
||||
|
||||
static std::string NormalizeId(const std::string &input) {
|
||||
std::string result;
|
||||
for (size_t i = 0; i < input.size();) {
|
||||
const auto ch = static_cast<unsigned char>(input[i]);
|
||||
if (ch == ' ' || ch == '\t' || ch == '\r' || ch == '\n') {
|
||||
++i;
|
||||
continue;
|
||||
}
|
||||
if (i + 2 < input.size() && ch == 0xEF &&
|
||||
static_cast<unsigned char>(input[i + 1]) == 0xBC &&
|
||||
static_cast<unsigned char>(input[i + 2]) >= 0x90 &&
|
||||
static_cast<unsigned char>(input[i + 2]) <= 0x99) {
|
||||
result += char('0' + static_cast<unsigned char>(input[i + 2]) - 0x90);
|
||||
i += 3;
|
||||
} else if (input.compare(i, 3, "\xe3\x80\x80") == 0)
|
||||
i += 3;
|
||||
else
|
||||
result += input[i++];
|
||||
}
|
||||
return result;
|
||||
}
|
||||
static bool ValidId(const std::string &id) {
|
||||
return id.size() == 9 &&
|
||||
std::all_of(id.begin(), id.end(),
|
||||
[](unsigned char c) { return c >= '0' && c <= '9'; });
|
||||
}
|
||||
bool Contains(const std::string &id) const {
|
||||
return wall_ids_.count(id) != 0;
|
||||
}
|
||||
bool Add(const std::string &id, const std::string &group = {}) {
|
||||
if (!ValidId(id))
|
||||
return false;
|
||||
const bool joined = wall_ids_.insert(id).second;
|
||||
// A device grouped earlier in history joins at the end when explicitly
|
||||
// added, while retaining its existing group assignments.
|
||||
if (joined)
|
||||
std::stable_partition(members.begin(), members.end(),
|
||||
[&](const auto &m) { return m.id != id; });
|
||||
if (group.empty() && HasAssignments(id))
|
||||
return joined;
|
||||
return AddAssignment(id, group) || joined;
|
||||
}
|
||||
std::vector<std::string> Ids(const std::string &group = {}) const {
|
||||
std::vector<std::string> ids;
|
||||
std::set<std::string> seen;
|
||||
for (const auto &m : members)
|
||||
if (wall_ids_.count(m.id) && (group.empty() || m.group == group) &&
|
||||
seen.insert(m.id).second)
|
||||
ids.push_back(m.id);
|
||||
return OrderIds(ids);
|
||||
}
|
||||
// Order is independent of membership, group assignments and live sessions.
|
||||
std::vector<std::string> OrderIds(const std::vector<std::string> &ids) const {
|
||||
if (device_order_.empty())
|
||||
return ids;
|
||||
std::set<std::string> remaining(ids.begin(), ids.end());
|
||||
std::vector<std::string> ordered;
|
||||
ordered.reserve(ids.size());
|
||||
for (const auto &id : device_order_)
|
||||
if (remaining.erase(id)) ordered.push_back(id);
|
||||
for (const auto &id : ids)
|
||||
if (remaining.erase(id)) ordered.push_back(id);
|
||||
return ordered;
|
||||
}
|
||||
bool SwapPositions(const std::string &source, const std::string &target,
|
||||
const std::vector<std::string> &visible_ids) {
|
||||
if (source == target || !ValidId(source) || !ValidId(target) ||
|
||||
std::find(visible_ids.begin(), visible_ids.end(), source) == visible_ids.end() ||
|
||||
std::find(visible_ids.begin(), visible_ids.end(), target) == visible_ids.end())
|
||||
return false;
|
||||
auto ordered = device_order_;
|
||||
std::set<std::string> known(ordered.begin(), ordered.end());
|
||||
// Include hidden groups before applying a swap in a filtered view.
|
||||
for (const auto &id : Ids())
|
||||
if (known.insert(id).second) ordered.push_back(id);
|
||||
// Active-only devices can be arranged without enrolling them in the wall.
|
||||
for (const auto &id : visible_ids)
|
||||
if (ValidId(id) && known.insert(id).second) ordered.push_back(id);
|
||||
std::iter_swap(std::find(ordered.begin(), ordered.end(), source),
|
||||
std::find(ordered.begin(), ordered.end(), target));
|
||||
device_order_ = std::move(ordered);
|
||||
return true;
|
||||
}
|
||||
// History filtering uses group assignments, independently of wall enrollment.
|
||||
// nullopt means all devices; an empty name means no named group.
|
||||
bool MatchesGroup(const std::string &id,
|
||||
const std::optional<std::string> &group) const {
|
||||
if (!group)
|
||||
return true;
|
||||
if (group->empty())
|
||||
return std::none_of(members.begin(), members.end(), [&](const auto &m) {
|
||||
return m.id == id && !m.group.empty();
|
||||
});
|
||||
return std::any_of(members.begin(), members.end(), [&](const auto &m) {
|
||||
return m.id == id && m.group == *group;
|
||||
});
|
||||
}
|
||||
size_t AddMany(const std::vector<std::string> &ids,
|
||||
const std::string &group = {}) {
|
||||
size_t added = 0;
|
||||
for (const auto &id : ids) {
|
||||
// Without a target group, an existing membership already puts the device
|
||||
// on the wall. An explicit group may reuse that same device/session.
|
||||
if (group.empty() && wall_ids_.count(id))
|
||||
continue;
|
||||
if (Add(id, group))
|
||||
++added;
|
||||
}
|
||||
return added;
|
||||
}
|
||||
// Both editors replace the same group assignments without changing wall
|
||||
// enrollment. Keep the position of devices already on the wall untouched.
|
||||
bool SetGroups(const std::string &id, const std::vector<std::string> &names) {
|
||||
if (!ValidId(id))
|
||||
return false;
|
||||
std::vector<Member> replacements;
|
||||
std::set<std::string> seen;
|
||||
for (const auto &name : names)
|
||||
if (!name.empty() && seen.insert(name).second)
|
||||
replacements.push_back({id, name});
|
||||
if (replacements.empty())
|
||||
replacements.push_back({id, ""});
|
||||
std::vector<Member> updated;
|
||||
bool inserted = false;
|
||||
for (const auto &member : members) {
|
||||
if (member.id != id) {
|
||||
updated.push_back(member);
|
||||
} else if (!inserted) {
|
||||
updated.insert(updated.end(), replacements.begin(), replacements.end());
|
||||
inserted = true;
|
||||
}
|
||||
}
|
||||
if (!inserted)
|
||||
updated.insert(updated.end(), replacements.begin(), replacements.end());
|
||||
members = std::move(updated);
|
||||
for (const auto &member : replacements)
|
||||
if (!member.group.empty() &&
|
||||
std::find(groups.begin(), groups.end(), member.group) == groups.end())
|
||||
groups.push_back(member.group);
|
||||
PruneGroups();
|
||||
return true;
|
||||
}
|
||||
void Remove(const std::string &id, const std::string &group = {}) {
|
||||
if (group.empty()) {
|
||||
// Removing from the wall leaves history's group assignments intact.
|
||||
wall_ids_.erase(id);
|
||||
std::erase(device_order_, id);
|
||||
} else if (MatchesGroup(id, group)) {
|
||||
// Removing the last named group leaves an ungrouped wall member.
|
||||
// Reuse the editor path so enrollment and card order stay unchanged.
|
||||
std::vector<std::string> remaining;
|
||||
for (const auto &member : members)
|
||||
if (member.id == id && member.group != group)
|
||||
remaining.push_back(member.group);
|
||||
SetGroups(id, remaining);
|
||||
}
|
||||
selected.erase(id);
|
||||
}
|
||||
void ResizePage(int new_capacity, int count) {
|
||||
const int first = page * capacity;
|
||||
capacity = std::max(1, new_capacity);
|
||||
page = std::clamp(first / capacity, 0, std::max(0, (count - 1) / capacity));
|
||||
}
|
||||
// The caller supplies every ID in the current filter, across all pages.
|
||||
void ToggleSelectAll(const std::vector<std::string> &ids) {
|
||||
std::set<std::string> targets(ids.begin(), ids.end());
|
||||
if (selected == targets)
|
||||
selected.clear();
|
||||
else
|
||||
selected = std::move(targets);
|
||||
}
|
||||
bool Load(const std::filesystem::path &path) {
|
||||
try {
|
||||
if (!std::filesystem::exists(path))
|
||||
return true;
|
||||
std::ifstream file(path);
|
||||
const auto doc = nlohmann::json::parse(file);
|
||||
const int version = doc.at("version").get<int>();
|
||||
if ((version != 1 && version != 2) || !doc.at("members").is_array())
|
||||
return false;
|
||||
if (version == 2 && !doc.at("wall_ids").is_array())
|
||||
return false;
|
||||
Workspace loaded;
|
||||
if (doc.contains("stream_settings"))
|
||||
loaded.stream_settings = StreamSettings::FromJson(doc["stream_settings"]);
|
||||
for (const auto &member : doc.at("members")) {
|
||||
const auto id = member.at("id").get<std::string>();
|
||||
loaded.AddAssignment(id, member.value("group", ""));
|
||||
// Every saved member in the old format was already on the wall.
|
||||
if (version == 1 && ValidId(id))
|
||||
loaded.wall_ids_.insert(id);
|
||||
}
|
||||
if (version == 2)
|
||||
for (const auto &value : doc.at("wall_ids")) {
|
||||
const auto id = value.get<std::string>();
|
||||
if (ValidId(id) && loaded.HasAssignments(id))
|
||||
loaded.wall_ids_.insert(id);
|
||||
}
|
||||
if (doc.contains("device_order") && doc["device_order"].is_array()) {
|
||||
std::set<std::string> seen;
|
||||
for (const auto &value : doc["device_order"]) {
|
||||
if (!value.is_string()) continue;
|
||||
const auto id = value.get<std::string>();
|
||||
if (ValidId(id) && seen.insert(id).second)
|
||||
loaded.device_order_.push_back(id);
|
||||
}
|
||||
}
|
||||
members = std::move(loaded.members);
|
||||
groups = std::move(loaded.groups);
|
||||
wall_ids_ = std::move(loaded.wall_ids_);
|
||||
device_order_ = std::move(loaded.device_order_);
|
||||
stream_settings = loaded.stream_settings;
|
||||
return true;
|
||||
} catch (...) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
bool Save(const std::filesystem::path &path) const {
|
||||
try {
|
||||
nlohmann::json doc{{"version", 2},
|
||||
{"members", nlohmann::json::array()},
|
||||
{"wall_ids", wall_ids_},
|
||||
{"device_order", device_order_},
|
||||
{"stream_settings", stream_settings.ToJson()}};
|
||||
for (const auto &m : members)
|
||||
doc["members"].push_back({{"id", m.id}, {"group", m.group}});
|
||||
return WriteAtomically(path, doc.dump(2));
|
||||
} catch (...) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private:
|
||||
std::set<std::string> wall_ids_;
|
||||
std::vector<std::string> device_order_;
|
||||
|
||||
bool HasAssignments(const std::string &id) const {
|
||||
return std::any_of(members.begin(), members.end(),
|
||||
[&](const auto &m) { return m.id == id; });
|
||||
}
|
||||
bool AddAssignment(const std::string &id, const std::string &group) {
|
||||
if (!ValidId(id))
|
||||
return false;
|
||||
if (std::any_of(members.begin(), members.end(), [&](const auto &m) {
|
||||
return m.id == id && m.group == group;
|
||||
}))
|
||||
return false;
|
||||
if (!group.empty() &&
|
||||
std::find(groups.begin(), groups.end(), group) == groups.end())
|
||||
groups.push_back(group);
|
||||
members.push_back({id, group});
|
||||
return true;
|
||||
}
|
||||
void PruneGroups() {
|
||||
groups.erase(std::remove_if(groups.begin(), groups.end(),
|
||||
[&](const auto &name) {
|
||||
return std::none_of(members.begin(),
|
||||
members.end(),
|
||||
[&](const auto &m) {
|
||||
return m.group == name;
|
||||
});
|
||||
}),
|
||||
groups.end());
|
||||
}
|
||||
};
|
||||
} // namespace crossdesk::gallery
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,99 @@
|
||||
/*
|
||||
* @Author: DI JUNKUN
|
||||
* @Date: 2026-10-13
|
||||
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
|
||||
*/
|
||||
|
||||
#ifndef _PREVIEW_BANDWIDTH_H_
|
||||
#define _PREVIEW_BANDWIDTH_H_
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <vector>
|
||||
|
||||
namespace crossdesk::gallery {
|
||||
struct PreviewBandwidthDemand {
|
||||
int desired_bps = 0;
|
||||
bool foreground = false;
|
||||
|
||||
bool operator==(const PreviewBandwidthDemand& other) const {
|
||||
return desired_bps == other.desired_bps && foreground == other.foreground;
|
||||
}
|
||||
|
||||
static int Estimate(int width, int height, int fps) {
|
||||
// A demand ceiling, not a throughput measurement. Include room for video
|
||||
// repair traffic; the sender's congestion control may allocate less.
|
||||
const int64_t pixels = width > 0 && height > 0
|
||||
? int64_t(width) * height : 3840LL * 2160;
|
||||
const int64_t estimate = pixels * std::clamp(fps, 1, 60) * 15 / 100;
|
||||
return int(std::clamp<int64_t>((estimate + 49'999) / 50'000 * 50'000,
|
||||
150'000, 20'000'000));
|
||||
}
|
||||
};
|
||||
|
||||
inline std::vector<int> AllocatePreviewBandwidth(
|
||||
const std::vector<PreviewBandwidthDemand>& demands, int budget_bps) {
|
||||
std::vector<int> result(demands.size(), 0);
|
||||
int remaining = std::max(0, budget_bps);
|
||||
int active = 0;
|
||||
int64_t desired_total = 0;
|
||||
for (const auto& demand : demands) {
|
||||
active += demand.desired_bps > 0;
|
||||
desired_total += std::max(0, demand.desired_bps);
|
||||
}
|
||||
if (!active || !remaining) return result;
|
||||
if (desired_total <= remaining) {
|
||||
for (std::size_t i = 0; i < demands.size(); ++i)
|
||||
result[i] = std::max(0, demands[i].desired_bps);
|
||||
return result;
|
||||
}
|
||||
// First preserve a small fair share for every visible device, then perform
|
||||
// weighted max-min allocation. Satisfied devices return unused capacity.
|
||||
const int baseline = std::min(100'000, remaining / active);
|
||||
for (std::size_t i = 0; i < demands.size(); ++i) {
|
||||
result[i] = std::min(std::max(0, demands[i].desired_bps), baseline);
|
||||
remaining -= result[i];
|
||||
}
|
||||
while (remaining > 0) {
|
||||
int weights = 0;
|
||||
for (std::size_t i = 0; i < demands.size(); ++i)
|
||||
if (result[i] < demands[i].desired_bps)
|
||||
weights += demands[i].foreground ? 4 : 1;
|
||||
if (!weights) break;
|
||||
const int available = remaining;
|
||||
for (std::size_t i = 0; i < demands.size() && remaining > 0; ++i) {
|
||||
if (result[i] >= demands[i].desired_bps) continue;
|
||||
const int share = int(int64_t(available) *
|
||||
(demands[i].foreground ? 4 : 1) / weights);
|
||||
const int extra = std::min({remaining, demands[i].desired_bps - result[i],
|
||||
std::max(1, share)});
|
||||
result[i] += extra;
|
||||
remaining -= extra;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// UI refreshes much more often than video demand changes. Keep the previous
|
||||
// result while membership, dimensions, cadence, priority and budget are stable.
|
||||
class PreviewBandwidthAllocator {
|
||||
public:
|
||||
const std::vector<int>& Allocate(
|
||||
const std::vector<PreviewBandwidthDemand>& demands, int budget_bps) {
|
||||
if (budget_bps_ != budget_bps || demands_ != demands) {
|
||||
demands_ = demands;
|
||||
budget_bps_ = budget_bps;
|
||||
allocations_ = AllocatePreviewBandwidth(demands, budget_bps);
|
||||
}
|
||||
return allocations_;
|
||||
}
|
||||
|
||||
private:
|
||||
int budget_bps_ = -1;
|
||||
std::vector<PreviewBandwidthDemand> demands_;
|
||||
std::vector<int> allocations_;
|
||||
};
|
||||
} // namespace crossdesk::gallery
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,55 @@
|
||||
/*
|
||||
* @Author: DI JUNKUN
|
||||
* @Date: 2026-10-12
|
||||
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
|
||||
*/
|
||||
|
||||
#ifndef _PREVIEW_RESOLUTION_H_
|
||||
#define _PREVIEW_RESOLUTION_H_
|
||||
|
||||
#include <algorithm>
|
||||
#include <chrono>
|
||||
#include <cmath>
|
||||
|
||||
namespace crossdesk::gallery {
|
||||
struct PreviewResolution {
|
||||
int width = 0;
|
||||
int height = 0;
|
||||
|
||||
bool operator==(const PreviewResolution& other) const {
|
||||
return width == other.width && height == other.height;
|
||||
}
|
||||
static PreviewResolution Viewport(float width, float height, float scale) {
|
||||
if (!std::isfinite(width) || !std::isfinite(height) ||
|
||||
!std::isfinite(scale) || width <= 0 || height <= 0 || scale <= 0)
|
||||
return {};
|
||||
return {int(std::clamp(std::ceil(double(width) * scale), 2.0, 16384.0)),
|
||||
int(std::clamp(std::ceil(double(height) * scale), 2.0, 16384.0))};
|
||||
}
|
||||
};
|
||||
|
||||
class PreviewResolutionDebouncer {
|
||||
public:
|
||||
using Clock = std::chrono::steady_clock;
|
||||
PreviewResolution Update(PreviewResolution desired, Clock::time_point now,
|
||||
bool reset = false) {
|
||||
if (!initialized_ || reset) {
|
||||
initialized_ = true;
|
||||
applied_ = candidate_ = desired;
|
||||
changed_at_ = now;
|
||||
} else if (!(candidate_ == desired)) {
|
||||
candidate_ = desired;
|
||||
changed_at_ = now;
|
||||
} else if (now - changed_at_ >= std::chrono::milliseconds(200)) {
|
||||
applied_ = candidate_;
|
||||
}
|
||||
return applied_;
|
||||
}
|
||||
|
||||
private:
|
||||
bool initialized_ = false;
|
||||
PreviewResolution applied_, candidate_;
|
||||
Clock::time_point changed_at_{};
|
||||
};
|
||||
} // namespace crossdesk::gallery
|
||||
#endif
|
||||
@@ -160,27 +160,7 @@ void KeyboardController::SendHeartbeat(bool force) {
|
||||
action.ks.pressed_count = index;
|
||||
}
|
||||
|
||||
const std::string target_id = owner_.controlled_remote_id_.empty()
|
||||
? owner_.focused_remote_id_
|
||||
: owner_.controlled_remote_id_;
|
||||
std::shared_lock sessions_lock(owner_.remote_sessions_mutex_);
|
||||
const auto props_it = owner_.remote_sessions_.find(target_id);
|
||||
if (target_id.empty() || props_it == owner_.remote_sessions_.end() ||
|
||||
props_it->second->connection_status_.load() !=
|
||||
ConnectionStatus::Connected ||
|
||||
!props_it->second->peer_) {
|
||||
last_heartbeat_tick_ = now;
|
||||
return;
|
||||
}
|
||||
|
||||
const std::string message = action.to_json();
|
||||
const int result = SendReliableDataFrame(
|
||||
props_it->second->peer_, message.c_str(), message.size(),
|
||||
props_it->second->keyboard_label_.c_str());
|
||||
if (result != 0) {
|
||||
LOG_WARN("Send keyboard heartbeat failed, remote_id={}, ret={}", target_id,
|
||||
result);
|
||||
}
|
||||
owner_.SendRemoteInput(action);
|
||||
last_heartbeat_tick_ = now;
|
||||
}
|
||||
|
||||
@@ -200,24 +180,7 @@ int KeyboardController::SendKeyCommand(int key_code, bool is_down,
|
||||
action.k.scan_code = scan_code;
|
||||
action.k.extended = extended;
|
||||
|
||||
const std::string target_id = owner_.controlled_remote_id_.empty()
|
||||
? owner_.focused_remote_id_
|
||||
: owner_.controlled_remote_id_;
|
||||
std::shared_lock sessions_lock(owner_.remote_sessions_mutex_);
|
||||
const auto props_it = owner_.remote_sessions_.find(target_id);
|
||||
if (!target_id.empty() && props_it != owner_.remote_sessions_.end() &&
|
||||
props_it->second->connection_status_.load() ==
|
||||
ConnectionStatus::Connected &&
|
||||
props_it->second->peer_) {
|
||||
const std::string message = action.to_json();
|
||||
const int result = SendReliableDataFrame(
|
||||
props_it->second->peer_, message.c_str(), message.size(),
|
||||
props_it->second->keyboard_label_.c_str());
|
||||
if (result != 0) {
|
||||
LOG_WARN("Send keyboard command failed, remote_id={}, ret={}", target_id,
|
||||
result);
|
||||
}
|
||||
}
|
||||
owner_.SendRemoteInput(action);
|
||||
|
||||
TrackPressedKey(key_code, is_down, scan_code, extended);
|
||||
return 0;
|
||||
|
||||
@@ -154,6 +154,29 @@ bool AnnouncementInbox::Read(int64_t id, int64_t revision) {
|
||||
}
|
||||
return false;
|
||||
}
|
||||
bool AnnouncementInbox::ReadAll() {
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
if (catalog_revision_ < 0) return false;
|
||||
auto updated = reads_;
|
||||
// Use the complete catalog, including announcements whose bodies have not
|
||||
// been paged in. Persist once before changing any visible reading state.
|
||||
for (const auto& [id, revision] : catalog_) {
|
||||
if (!IsDismissed(id, revision)) updated[id] = revision;
|
||||
}
|
||||
if (updated == reads_) return true;
|
||||
if (!SaveLocalState(updated, dismissed_)) {
|
||||
state_.read_save_failed = true;
|
||||
++state_.version;
|
||||
return false;
|
||||
}
|
||||
reads_ = std::move(updated);
|
||||
state_.read_save_failed = false;
|
||||
state_.delete_failed = false;
|
||||
UpdateReadingState();
|
||||
++state_.version;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool AnnouncementInbox::IsDismissed(int64_t id, int64_t revision) const {
|
||||
const auto found = dismissed_.find(id);
|
||||
return found != dismissed_.end() && found->second == revision;
|
||||
|
||||
@@ -38,6 +38,7 @@ class AnnouncementInbox {
|
||||
void Refresh();
|
||||
void LoadMore();
|
||||
bool Read(int64_t id, int64_t revision);
|
||||
bool ReadAll();
|
||||
bool Dismiss(int64_t id, int64_t revision);
|
||||
nlohmann::json TakePendingRequest(std::chrono::steady_clock::time_point now =
|
||||
std::chrono::steady_clock::now());
|
||||
|
||||
@@ -35,6 +35,30 @@ bool GuiRuntime::HasActiveSession() {
|
||||
});
|
||||
}
|
||||
|
||||
void GuiRuntime::MuteRemoteAudio(const std::shared_ptr<RemoteSession>& props) {
|
||||
if (!props) return;
|
||||
std::lock_guard delivery_lock(audio_delivery_mutex_);
|
||||
props->audio_capture_button_pressed_ = false;
|
||||
props->audio_allowed_ = false;
|
||||
devices_.ClearAudio(props->remote_id_);
|
||||
// Capable peers stop sending to this controller on the next subscription;
|
||||
// old peers remain muted by the local receive gate above.
|
||||
props->preview_needs_sync_ = true;
|
||||
}
|
||||
|
||||
void GuiRuntime::UnmuteRemoteAudio(const std::shared_ptr<RemoteSession>& props) {
|
||||
if (!props || props->closing_) return;
|
||||
props->audio_capture_button_pressed_ = true;
|
||||
props->preview_needs_sync_ = true;
|
||||
// Also resume capture on old peers, or after a legacy controller stopped it.
|
||||
RemoteAction action{};
|
||||
action.type = ControlType::audio_capture;
|
||||
action.a = true;
|
||||
const auto message = action.to_json();
|
||||
SendReliableDataFrame(props->peer_, message.data(), message.size(),
|
||||
props->control_data_label_.c_str());
|
||||
}
|
||||
|
||||
void GuiRuntime::HandleConnectionStatusChange() {
|
||||
if (!signal_connected_ || !peer_) {
|
||||
return;
|
||||
@@ -51,7 +75,8 @@ void GuiRuntime::HandleConnectionStatusChange() {
|
||||
}
|
||||
last_presence_request_attempt_at_ = now;
|
||||
const auto message =
|
||||
BuildDevicePresenceRequest(client_id_, recent_connection_ids_, true).dump();
|
||||
BuildDevicePresenceRequest(client_id_, recent_connection_ids_, true, {},
|
||||
gallery_presence_ids_).dump();
|
||||
if (SendSignalMessage(peer_, message.data(), message.size()) == 0) {
|
||||
next_presence_refresh_at_ = now + kPresenceRefreshInterval;
|
||||
} else {
|
||||
@@ -59,6 +84,39 @@ void GuiRuntime::HandleConnectionStatusChange() {
|
||||
}
|
||||
}
|
||||
|
||||
void GuiRuntime::HandlePendingRejoins() {
|
||||
need_to_rejoin_ = false;
|
||||
std::vector<std::shared_ptr<RemoteSession>> pending;
|
||||
{
|
||||
std::shared_lock lock(remote_sessions_mutex_);
|
||||
for (const auto& [_, props] : remote_sessions_) {
|
||||
if (props && props->rejoin_ && !props->closing_)
|
||||
pending.push_back(props);
|
||||
}
|
||||
}
|
||||
for (const auto& props : pending) {
|
||||
// Password submission already entered the validating state. Let Join
|
||||
// report its result even when this device is absent from the presence
|
||||
// cache (for example, a newly added DeskHub device). A failed presence
|
||||
// probe has no auth callback and would leave the prompt validating forever.
|
||||
ConnectTo(props->remote_id_, props->remote_password_,
|
||||
props->remember_password_, true, props->wall_managed_.load());
|
||||
}
|
||||
}
|
||||
|
||||
void GuiRuntime::CancelPendingConnection(const std::string& remote_id) {
|
||||
pending_reconnects_.erase(remote_id);
|
||||
if (auto props = FindRemoteSession(remote_id)) props->rejoin_ = false;
|
||||
std::lock_guard lock(pending_presence_probe_mutex_);
|
||||
if (pending_presence_remote_id_ != remote_id) return;
|
||||
pending_presence_probe_ = false;
|
||||
pending_presence_result_ready_ = false;
|
||||
pending_presence_online_ = false;
|
||||
pending_presence_remote_id_.clear();
|
||||
pending_presence_password_.clear();
|
||||
pending_presence_remember_password_ = false;
|
||||
}
|
||||
|
||||
void GuiRuntime::HandlePendingPresenceProbe() {
|
||||
bool has_action = false;
|
||||
bool should_connect = false;
|
||||
@@ -130,6 +188,7 @@ void GuiRuntime::HandlePresenceProbeTimeout() {
|
||||
|
||||
void GuiRuntime::HandleServerControllerDisconnected(
|
||||
const std::string& remote_id, const char* reason) {
|
||||
preview_subscriptions_.Remove(remote_id);
|
||||
keyboard_.ReleaseRemotePressedKeys(remote_id, reason);
|
||||
#ifdef _WIN32
|
||||
devices_.ReleaseRemoteMouseButtons();
|
||||
@@ -210,7 +269,8 @@ int GuiRuntime::RequestSingleDevicePresence(const std::string& remote_id,
|
||||
}
|
||||
|
||||
const auto s = BuildDevicePresenceRequest(
|
||||
client_id_, recent_connection_ids_, false, remote_id)
|
||||
client_id_, recent_connection_ids_, false, remote_id,
|
||||
gallery_presence_ids_)
|
||||
.dump();
|
||||
|
||||
int ret = SendSignalMessage(peer_, s.data(), s.size());
|
||||
@@ -237,6 +297,7 @@ int GuiRuntime::ForceSignalReconnect() {
|
||||
|
||||
void GuiRuntime::CloseRemoteSession(std::shared_ptr<RemoteSession> props) {
|
||||
if (!props || props->closing_.exchange(true)) return;
|
||||
MuteRemoteAudio(props);
|
||||
if (props->peer_events_) props->peer_events_->Deactivate();
|
||||
props->connection_established_ = false;
|
||||
props->streaming_ = false;
|
||||
@@ -417,7 +478,8 @@ void GuiRuntime::HandleSessionCleanup() {
|
||||
recent_connection_image_save_time_ = 0;
|
||||
}
|
||||
for (const auto& [id, request] : reconnects) {
|
||||
ConnectTo(id, request.password.c_str(), request.remember_password);
|
||||
ConnectTo(id, request.password.c_str(), request.remember_password, false,
|
||||
request.background);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -447,6 +509,12 @@ void GuiRuntime::WaitForSessionCleanup() {
|
||||
void GuiRuntime::ResetRemoteSessionResources(
|
||||
std::shared_ptr<RemoteSession> props) {
|
||||
props->net_traffic_stats_.Reset();
|
||||
props->received_video_stats_.Reset();
|
||||
{
|
||||
std::lock_guard lock(props->gallery_frame_mutex_);
|
||||
props->gallery_video_latency_->Reset();
|
||||
props->gallery_frame_timing_ = {};
|
||||
}
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(props->video_frame_mutex_);
|
||||
props->video_latency_->Reset();
|
||||
@@ -486,23 +554,28 @@ std::shared_ptr<GuiRuntime::RemoteSession> GuiRuntime::FindRemoteSession(
|
||||
}
|
||||
|
||||
int GuiRuntime::ConnectTo(const std::string& remote_id, const char* password,
|
||||
bool remember_password, bool bypass_presence_check) {
|
||||
bool remember_password, bool bypass_presence_check,
|
||||
bool background) {
|
||||
// Do not recreate a failed peer or send another probe during a server cooldown.
|
||||
if (const auto current = FindRemoteSession(remote_id);
|
||||
current && current->connection_auth_.Get().retry_after > 0) {
|
||||
focused_remote_id_ = remote_id;
|
||||
show_connection_status_window_ = true;
|
||||
if (!background) focused_remote_id_ = remote_id;
|
||||
if (!background) show_connection_status_window_ = true;
|
||||
current->rejoin_ = false;
|
||||
return 0;
|
||||
}
|
||||
if (session_cleanup_tasks_.count(remote_id)) {
|
||||
pending_reconnects_[remote_id] = {password ? password : "", remember_password};
|
||||
pending_reconnects_[remote_id] = {password ? password : "",
|
||||
remember_password, background};
|
||||
LOG_INFO("[{}] Reconnect queued until previous peer is destroyed", remote_id);
|
||||
return 0;
|
||||
}
|
||||
if (!bypass_presence_check && !device_presence_cache_.IsOnline(remote_id)) {
|
||||
int ret =
|
||||
RequestSingleDevicePresence(remote_id, password, remember_password);
|
||||
// Wall requests, including deferred reconnects, must not replace the single
|
||||
// interactive presence probe. Join reports availability for each session.
|
||||
if (!background && !bypass_presence_check &&
|
||||
!device_presence_cache_.IsOnline(remote_id)) {
|
||||
int ret = RequestSingleDevicePresence(remote_id, password,
|
||||
remember_password);
|
||||
if (ret != 0) {
|
||||
offline_warning_text_ =
|
||||
localization::device_offline[localization_language_index_];
|
||||
@@ -515,7 +588,7 @@ int GuiRuntime::ConnectTo(const std::string& remote_id, const char* password,
|
||||
}
|
||||
|
||||
LOG_INFO("Connect to [{}]", remote_id);
|
||||
focused_remote_id_ = remote_id;
|
||||
if (!background) focused_remote_id_ = remote_id;
|
||||
|
||||
auto stale = FindRemoteSession(remote_id);
|
||||
bool exists = stale != nullptr;
|
||||
@@ -545,7 +618,7 @@ int GuiRuntime::ConnectTo(const std::string& remote_id, const char* password,
|
||||
pending_reconnects_[remote_id] = {
|
||||
keep_stored_password ? std::string(stale->remote_password_)
|
||||
: std::string(password),
|
||||
remember_password};
|
||||
remember_password, background};
|
||||
CloseRemoteSession(stale);
|
||||
LOG_INFO("[{}] Reconnecting after terminal peer status {}", remote_id,
|
||||
static_cast<int>(status));
|
||||
@@ -565,6 +638,11 @@ int GuiRuntime::ConnectTo(const std::string& remote_id, const char* password,
|
||||
auto props = remote_sessions_[remote_id];
|
||||
props->local_id_ = "C-" + std::string(client_id_);
|
||||
props->remote_id_ = remote_id;
|
||||
props->wall_managed_ = background;
|
||||
props->tab_opened_ = !background;
|
||||
props->control_mouse_ = !background;
|
||||
props->enable_mouse_control_ = !background;
|
||||
props->audio_capture_button_pressed_ = !background;
|
||||
memcpy(&props->params_, ¶ms_, sizeof(Params));
|
||||
props->params_.user_id = props->local_id_.c_str();
|
||||
props->peer_events_ = std::make_shared<PeerEventHandler>(*this);
|
||||
@@ -572,7 +650,7 @@ int GuiRuntime::ConnectTo(const std::string& remote_id, const char* password,
|
||||
props->peer_ = CreatePeer(&props->params_);
|
||||
|
||||
props->connection_status_.store(ConnectionStatus::Connecting);
|
||||
show_connection_status_window_ = true;
|
||||
if (!background) show_connection_status_window_ = true;
|
||||
|
||||
if (!props->peer_) {
|
||||
LOG_INFO("Create peer [{}] instance failed", props->local_id_);
|
||||
@@ -610,8 +688,13 @@ int GuiRuntime::ConnectTo(const std::string& remote_id, const char* password,
|
||||
int ret = -1;
|
||||
auto props = FindRemoteSession(remote_id);
|
||||
if (!props || props->closing_) return -1;
|
||||
if (background) {
|
||||
// This also covers reusing a foreground connection that is awaiting auth.
|
||||
props->wall_managed_ = true;
|
||||
MuteRemoteAudio(props);
|
||||
}
|
||||
if (!props->connection_established_) {
|
||||
show_connection_status_window_ = true;
|
||||
if (!background) show_connection_status_window_ = true;
|
||||
|
||||
props->remember_password_ = remember_password;
|
||||
if (password && *password &&
|
||||
|
||||
@@ -33,6 +33,15 @@ class CursorPresentation {
|
||||
const bool separate =
|
||||
state.render_mode == CursorRenderMode::separate ||
|
||||
(state.render_mode == CursorRenderMode::legacy && state.visible);
|
||||
system_hidden_ = state.render_mode == CursorRenderMode::hidden &&
|
||||
state.hidden_reason == CursorHiddenReason::system_hidden;
|
||||
if (separate) {
|
||||
mac_locator_allowed_ = state.shape != RemoteCursorShape::none;
|
||||
} else if (!system_hidden_) {
|
||||
// Embedded cursors and other hiding policies end the separate-cursor
|
||||
// history. A later system_hidden sample must not revive that overlay.
|
||||
mac_locator_allowed_ = false;
|
||||
}
|
||||
// Old senders cannot distinguish embedded from hidden. Only explicit
|
||||
// metadata from a new sender can establish the no-device compatibility
|
||||
// case.
|
||||
@@ -47,9 +56,21 @@ class CursorPresentation {
|
||||
|
||||
void NoteInput(uint64_t now) { last_input_ = now; }
|
||||
|
||||
RemoteCursorShape Resolve(uint64_t now) {
|
||||
RemoteCursorShape Resolve(uint64_t now, HostPlatform remote_platform) {
|
||||
if (!stale_at_) return RemoteCursorShape::default_cursor;
|
||||
|
||||
// macOS can keep its system cursor hidden after typing even while remote
|
||||
// mouse events move it. Keep a previously separate desktop locator across
|
||||
// those heartbeats; the video has no pointer to fall back to. On a new
|
||||
// connection, use an arrow until the first visible shape arrives. The
|
||||
// protocol cannot distinguish typing from other system_hidden cases;
|
||||
// restrict this policy to identified Mac hosts and stop on any explicit
|
||||
// embedded cursor or other hiding policy.
|
||||
if (remote_platform == HostPlatform::MacOS && system_hidden_ &&
|
||||
mac_locator_allowed_) {
|
||||
return last_visible_shape_;
|
||||
}
|
||||
|
||||
const bool recent_input =
|
||||
last_input_ && now - *last_input_ < kInputActivityMs;
|
||||
const bool can_recover =
|
||||
@@ -64,6 +85,8 @@ class CursorPresentation {
|
||||
|
||||
private:
|
||||
bool sample_unavailable_ = false;
|
||||
bool system_hidden_ = false;
|
||||
bool mac_locator_allowed_ = true;
|
||||
bool no_device_ = false;
|
||||
bool fallback_engaged_ = false;
|
||||
std::optional<uint64_t> stale_at_;
|
||||
@@ -74,4 +97,4 @@ class CursorPresentation {
|
||||
|
||||
} // namespace crossdesk
|
||||
|
||||
#endif
|
||||
#endif
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
|
||||
#include <chrono>
|
||||
#include <mutex>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <unordered_map>
|
||||
|
||||
@@ -33,12 +34,19 @@ public:
|
||||
}
|
||||
}
|
||||
|
||||
bool IsOnline(const std::string &device_id,
|
||||
Clock::time_point now = Clock::now()) const {
|
||||
std::optional<bool> GetOnline(const std::string &device_id,
|
||||
Clock::time_point now = Clock::now()) const {
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
const auto it = cache_.find(device_id);
|
||||
return signal_connected_ && it != cache_.end() && it->second.online &&
|
||||
now - it->second.updated_at < kMaxAge;
|
||||
if (!signal_connected_ || it == cache_.end() ||
|
||||
now - it->second.updated_at >= kMaxAge)
|
||||
return std::nullopt;
|
||||
return it->second.online;
|
||||
}
|
||||
|
||||
bool IsOnline(const std::string &device_id,
|
||||
Clock::time_point now = Clock::now()) const {
|
||||
return GetOnline(device_id, now).value_or(false);
|
||||
}
|
||||
|
||||
void Clear() {
|
||||
@@ -58,4 +66,4 @@ private:
|
||||
|
||||
} // namespace crossdesk
|
||||
|
||||
#endif
|
||||
#endif
|
||||
|
||||
@@ -16,22 +16,25 @@ namespace crossdesk {
|
||||
|
||||
inline nlohmann::json BuildDevicePresenceRequest(
|
||||
const std::string& client_id, const std::vector<std::string>& recent_ids,
|
||||
bool subscribe, const std::string& probe_id = {}) {
|
||||
bool subscribe, const std::string& probe_id = {},
|
||||
const std::vector<std::string>& additional_ids = {}) {
|
||||
std::vector<std::string> devices;
|
||||
for (const auto& id : recent_ids) {
|
||||
const auto append = [&](const std::string& id) {
|
||||
// Recent connection keys contain Y/N plus display settings after the ID.
|
||||
const std::string pure_id = id.substr(0, id.find_first_of("YN"));
|
||||
if (!pure_id.empty() &&
|
||||
std::find(devices.begin(), devices.end(), pure_id) == devices.end()) {
|
||||
devices.push_back(pure_id);
|
||||
}
|
||||
}
|
||||
};
|
||||
for (const auto& id : recent_ids) append(id);
|
||||
for (const auto& id : additional_ids) append(id);
|
||||
if (!probe_id.empty() &&
|
||||
std::find(devices.begin(), devices.end(), probe_id) == devices.end()) {
|
||||
devices.push_back(probe_id);
|
||||
}
|
||||
// Include recent devices even in a one-shot probe: older servers ignore
|
||||
// subscribe=false and replace their subscriptions with this device list.
|
||||
// Keep recent and wall devices even in a one-shot probe: older servers
|
||||
// ignore subscribe=false and replace subscriptions with this device list.
|
||||
return {{"type", "recent_connections_presence"},
|
||||
{"user_id", client_id},
|
||||
{"devices", devices},
|
||||
@@ -40,4 +43,4 @@ inline nlohmann::json BuildDevicePresenceRequest(
|
||||
|
||||
} // namespace crossdesk
|
||||
|
||||
#endif
|
||||
#endif
|
||||
|
||||
@@ -28,6 +28,49 @@ GuiRuntime::GuiRuntime()
|
||||
|
||||
GuiRuntime::~GuiRuntime() { WaitForSessionCleanup(); }
|
||||
|
||||
void GuiRuntime::SendRemoteInput(const RemoteAction& action) {
|
||||
const bool mouse = action.type == ControlType::mouse;
|
||||
if (!mouse && action.type != ControlType::keyboard &&
|
||||
action.type != ControlType::keyboard_state)
|
||||
return;
|
||||
const std::string& target_id = controlled_remote_id_.empty()
|
||||
? focused_remote_id_ : controlled_remote_id_;
|
||||
if (target_id.empty()) return;
|
||||
const bool group = gallery_group_control_id_ == target_id;
|
||||
const bool releasing = mouse
|
||||
? action.m.flag == MouseFlag::left_up ||
|
||||
action.m.flag == MouseFlag::right_up ||
|
||||
action.m.flag == MouseFlag::middle_up
|
||||
: action.type == ControlType::keyboard
|
||||
? action.k.flag == KeyFlag::key_up : action.ks.pressed_count == 0;
|
||||
const auto available = [](const auto& props) {
|
||||
return props && props->peer_ && !props->closing_ &&
|
||||
props->connection_status_ == ConnectionStatus::Connected;
|
||||
};
|
||||
std::shared_lock lock(remote_sessions_mutex_);
|
||||
const auto source = remote_sessions_.find(target_id);
|
||||
// A lost leader must stop new input, but its followers still need key/button
|
||||
// releases before group control is cleared by the UI.
|
||||
if (!releasing &&
|
||||
(source == remote_sessions_.end() || !available(source->second)))
|
||||
return;
|
||||
const auto message = action.to_json();
|
||||
for (const auto& [id, props] : remote_sessions_) {
|
||||
if ((!group && id != target_id) || !available(props)) continue;
|
||||
if (mouse) {
|
||||
std::lock_guard cursor_lock(props->remote_cursor_state_mutex_);
|
||||
props->cursor_presentation_.NoteInput(SDL_GetTicks());
|
||||
}
|
||||
// Reuse exactly the leader's mapped coordinates and key metadata. Neither
|
||||
// gallery filters nor the follower's preview dimensions affect input.
|
||||
const int result = SendReliableDataFrame(
|
||||
props->peer_, message.data(), message.size(),
|
||||
mouse ? props->mouse_label_.c_str() : props->keyboard_label_.c_str());
|
||||
if (result != 0)
|
||||
LOG_WARN("Send remote input failed, remote_id={}, ret={}", id, result);
|
||||
}
|
||||
}
|
||||
|
||||
int GuiRuntime::CreateConnectionPeer() {
|
||||
// Tick retries after the old peer's leave notification and socket shutdown.
|
||||
if (connection_peer_cleanup_.valid()) return 0;
|
||||
@@ -206,6 +249,35 @@ int GuiRuntime::CreateConnectionPeer() {
|
||||
}
|
||||
|
||||
void GuiRuntime::HandleRecentConnections() {
|
||||
if (thumbnail_ && session_cleanup_tasks_.empty()) {
|
||||
std::vector<std::shared_ptr<RemoteSession>> sessions;
|
||||
{
|
||||
std::shared_lock lock(remote_sessions_mutex_);
|
||||
for (const auto& [_, props] : remote_sessions_)
|
||||
if (props && !props->closing_ && props->was_connected_ &&
|
||||
props->recent_connection_dirty_.exchange(false))
|
||||
sessions.push_back(props);
|
||||
}
|
||||
for (const auto& props : sessions) {
|
||||
std::string host_name;
|
||||
HostPlatform platform;
|
||||
{
|
||||
std::lock_guard lock(props->remote_version_mutex_);
|
||||
host_name = props->remote_host_name_;
|
||||
platform = props->recent_platform_;
|
||||
}
|
||||
// Record a successful connection before a preview or host info arrives.
|
||||
// The close path still updates its final thumbnail. Remember password
|
||||
// controls only credential storage, not whether this device is listed.
|
||||
if (thumbnail_->SaveToThumbnail(
|
||||
nullptr, 0, 0, props->remote_id_, host_name,
|
||||
props->remember_password_ ? props->remote_password_ : "",
|
||||
platform) == 0)
|
||||
reload_recent_connections_ = true;
|
||||
else
|
||||
LOG_ERROR("[{}] Failed to save recent connection", props->remote_id_);
|
||||
}
|
||||
}
|
||||
if (session_cleanup_tasks_.empty() && reload_recent_connections_ && thumbnail_) {
|
||||
uint32_t now_time = SDL_GetTicks();
|
||||
if (now_time - recent_connection_image_save_time_ >= 50) {
|
||||
|
||||
@@ -51,13 +51,19 @@ class GuiRuntime : protected gui_detail::GuiState {
|
||||
|
||||
int CreateConnectionPeer();
|
||||
int ConnectTo(const std::string& remote_id, const char* password,
|
||||
bool remember_password, bool bypass_presence_check = false);
|
||||
bool remember_password, bool bypass_presence_check = false,
|
||||
bool background = false);
|
||||
// UI thread: mute this receiver without stopping audio for other controllers.
|
||||
void MuteRemoteAudio(const std::shared_ptr<RemoteSession>& props);
|
||||
void UnmuteRemoteAudio(const std::shared_ptr<RemoteSession>& props);
|
||||
int RequestSingleDevicePresence(const std::string& remote_id,
|
||||
const char* password, bool remember_password);
|
||||
int ForceSignalReconnect();
|
||||
|
||||
void HandleRecentConnections();
|
||||
void HandleConnectionStatusChange();
|
||||
void HandlePendingRejoins();
|
||||
void CancelPendingConnection(const std::string& remote_id);
|
||||
void HandlePendingPresenceProbe();
|
||||
void HandlePresenceProbeTimeout();
|
||||
bool HasActiveSession();
|
||||
@@ -81,6 +87,10 @@ class GuiRuntime : protected gui_detail::GuiState {
|
||||
void WaitForSessionCleanup();
|
||||
std::shared_ptr<RemoteSession> FindRemoteSession(
|
||||
const std::string& remote_id);
|
||||
// UI thread: send mapped mouse/keyboard input to the current control target,
|
||||
// or every connected session while that target leads DeskHub group control.
|
||||
void SendRemoteInput(const RemoteAction& action);
|
||||
std::string gallery_group_control_id_;
|
||||
|
||||
void ResetRemoteServiceStatus(RemoteSession& props);
|
||||
void ApplyRemoteServiceStatus(RemoteSession& props,
|
||||
|
||||
@@ -24,6 +24,8 @@ class NetTrafficStatsCache {
|
||||
"Network statistics require lock-free scalar atomics");
|
||||
|
||||
public:
|
||||
using Clock = std::chrono::steady_clock;
|
||||
|
||||
void Store(const MiniRtcNetTrafficStats& stats) {
|
||||
video_inbound_stats_.Store(stats.video_inbound_stats);
|
||||
video_outbound_stats_.Store(stats.video_outbound_stats);
|
||||
@@ -36,6 +38,10 @@ class NetTrafficStatsCache {
|
||||
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);
|
||||
last_report_ms_.store(
|
||||
std::chrono::duration_cast<std::chrono::milliseconds>(
|
||||
Clock::now().time_since_epoch()).count(),
|
||||
std::memory_order_relaxed);
|
||||
}
|
||||
|
||||
MiniRtcNetTrafficStats Load() const {
|
||||
@@ -65,9 +71,17 @@ class NetTrafficStatsCache {
|
||||
|
||||
void ResetLatency() { connection_latency_.Reset(); }
|
||||
|
||||
bool HasRecentReport(Clock::time_point now = Clock::now()) const {
|
||||
const auto last_report = last_report_ms_.load(std::memory_order_relaxed);
|
||||
const auto now_ms = std::chrono::duration_cast<std::chrono::milliseconds>(
|
||||
now.time_since_epoch()).count();
|
||||
return last_report > 0 && now_ms - last_report < 3000;
|
||||
}
|
||||
|
||||
void Reset() {
|
||||
Store(MiniRtcNetTrafficStats{});
|
||||
ResetLatency();
|
||||
last_report_ms_.store(0, std::memory_order_relaxed);
|
||||
}
|
||||
|
||||
private:
|
||||
@@ -114,6 +128,7 @@ class NetTrafficStatsCache {
|
||||
OutboundStats total_outbound_stats_;
|
||||
std::atomic<bool> srtp_active_{false};
|
||||
std::atomic<double> rtt_ms_{-1};
|
||||
std::atomic<int64_t> last_report_ms_{0};
|
||||
ConnectionLatencyStats connection_latency_;
|
||||
};
|
||||
|
||||
|
||||
@@ -139,11 +139,12 @@ void PeerEventHandler::OnReceiveDataBuffer(
|
||||
std::string remote_user_id(user_id, user_id_size);
|
||||
auto props =
|
||||
runtime->FindRemoteSession(remote_user_id);
|
||||
if (props && !props->enable_mouse_control_) {
|
||||
if (props && !props->clipboard_allowed_.load()) {
|
||||
return;
|
||||
}
|
||||
|
||||
runtime->clipboard_.QueueRemoteText(data, size);
|
||||
runtime->clipboard_.QueueRemoteText(
|
||||
data, size, props ? remote_user_id : std::string{});
|
||||
}
|
||||
return;
|
||||
} else if (source_id == runtime->file_feedback_label_) {
|
||||
@@ -151,14 +152,90 @@ void PeerEventHandler::OnReceiveDataBuffer(
|
||||
return;
|
||||
}
|
||||
|
||||
const std::string remote_id(user_id, user_id_size);
|
||||
std::string json_str(data, size);
|
||||
const auto preview_json =
|
||||
json_str.find("\"deskhub_preview") != std::string::npos
|
||||
? nlohmann::json::parse(json_str, nullptr, false)
|
||||
: nlohmann::json{};
|
||||
if (preview_json.is_object() &&
|
||||
preview_json.value("type", nlohmann::json{}) == "deskhub_preview") {
|
||||
if (source_id != runtime->control_data_label_ ||
|
||||
!runtime->IsAuthorizedController(remote_id))
|
||||
return;
|
||||
const auto subscription = PreviewSubscription::Parse(preview_json);
|
||||
if (!subscription) return;
|
||||
const auto previous = runtime->preview_subscriptions_.Get(remote_id);
|
||||
bool accepted = true;
|
||||
// Apply encoder quality to this controller only; other controllers retain
|
||||
// their own demand. MiniRTC owns per-peer video/audio pause state.
|
||||
if (!previous || subscription->IsNewerThan(*previous)) {
|
||||
if (subscription->fps > 0)
|
||||
accepted = UpdateVideoSettings(
|
||||
runtime->peer_, remote_id.data(), remote_id.size(),
|
||||
static_cast<VideoQuality>(subscription->quality),
|
||||
subscription->frame_rate,
|
||||
static_cast<VideoDegradationPreference>(
|
||||
subscription->EncoderPreference())) == 0;
|
||||
if (accepted && (!previous || previous->max_bitrate_bps.value_or(0) !=
|
||||
subscription->max_bitrate_bps.value_or(0)))
|
||||
accepted = UpdateVideoBitrateLimit(
|
||||
runtime->peer_, remote_id.data(), remote_id.size(),
|
||||
subscription->max_bitrate_bps.value_or(0)) == 0;
|
||||
// Rebalancing bandwidth alone must not force every device to send a
|
||||
// keyframe. The pause API requests its own keyframes on resume.
|
||||
if (accepted && (!previous || previous->width != subscription->width ||
|
||||
previous->height != subscription->height))
|
||||
accepted = UpdateVideoResolution(
|
||||
runtime->peer_, remote_id.data(), remote_id.size(),
|
||||
subscription->width, subscription->height) == 0;
|
||||
if (accepted)
|
||||
accepted = SetVideoSendPaused(runtime->peer_, remote_id.data(),
|
||||
remote_id.size(), subscription->fps == 0) == 0;
|
||||
if (accepted)
|
||||
accepted = SetAudioSendPaused(runtime->peer_, remote_id.data(),
|
||||
remote_id.size(), !subscription->audio) == 0;
|
||||
if (accepted && subscription->audio)
|
||||
runtime->devices_.StartSpeakerCapturer();
|
||||
}
|
||||
if (accepted) runtime->preview_subscriptions_.Set(remote_id, *subscription);
|
||||
const auto reply = nlohmann::json{
|
||||
{"type", "deskhub_preview_status"},
|
||||
{"version", subscription->version},
|
||||
{"request_id", subscription->request_id},
|
||||
{"accepted",
|
||||
accepted}}.dump();
|
||||
SendReliableDataFrameToPeer(runtime->peer_, reply.data(), reply.size(),
|
||||
runtime->control_data_label_.c_str(),
|
||||
remote_id.data(), remote_id.size());
|
||||
return;
|
||||
}
|
||||
if (preview_json.is_object() &&
|
||||
preview_json.value("type", nlohmann::json{}) ==
|
||||
"deskhub_preview_status") {
|
||||
if (auto props = runtime->FindRemoteSession(remote_id);
|
||||
props && source_id == props->control_data_label_) {
|
||||
try {
|
||||
if ((preview_json.at("version") == 1 ||
|
||||
preview_json.at("version") == 2) &&
|
||||
preview_json.at("request_id").is_number_unsigned() &&
|
||||
preview_json.at("request_id").get<uint64_t>() > 0 &&
|
||||
preview_json.at("request_id").get<uint64_t>() <= UINT32_MAX &&
|
||||
preview_json.at("accepted").is_boolean()) {
|
||||
props->preview_accepted_ = preview_json.at("accepted").get<bool>();
|
||||
props->preview_ack_ = preview_json.at("request_id").get<uint32_t>();
|
||||
}
|
||||
} catch (...) {
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
RemoteAction remote_action{};
|
||||
if (!remote_action.from_json(json_str)) {
|
||||
LOG_ERROR("Failed to parse RemoteAction JSON payload");
|
||||
return;
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -342,6 +419,12 @@ void PeerEventHandler::OnReceiveDataBuffer(
|
||||
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->preview_subscription_supported_ =
|
||||
remote_action.i.supports_deskhub_preview;
|
||||
props->preview_resolution_supported_ =
|
||||
remote_action.i.supports_deskhub_resolution;
|
||||
props->preview_fps15_supported_ = remote_action.i.supports_deskhub_fps15;
|
||||
props->preview_bandwidth_supported_ = remote_action.i.supports_deskhub_bandwidth;
|
||||
props->video_settings_supported_ =
|
||||
remote_action.i.supports_video_settings;
|
||||
if (first_advertisement && props->video_settings_supported_) {
|
||||
@@ -357,17 +440,21 @@ void PeerEventHandler::OnReceiveDataBuffer(
|
||||
}
|
||||
}
|
||||
// client mode
|
||||
if (props && props->remote_host_name_.empty()) {
|
||||
props->remote_host_name_ = std::string(remote_action.i.host_name,
|
||||
remote_action.i.host_name_size);
|
||||
LOG_INFO("Remote hostname: [{}]", props->remote_host_name_);
|
||||
if (props) {
|
||||
std::lock_guard lock(props->remote_version_mutex_);
|
||||
if (props->remote_host_name_.empty()) {
|
||||
props->remote_host_name_ = std::string(remote_action.i.host_name,
|
||||
remote_action.i.host_name_size);
|
||||
LOG_INFO("Remote hostname: [{}]", props->remote_host_name_);
|
||||
|
||||
for (int i = 0; i < remote_action.i.display_num; i++) {
|
||||
props->display_info_list_.push_back(
|
||||
DisplayInfo(remote_action.i.display_list[i],
|
||||
remote_action.i.left[i], remote_action.i.top[i],
|
||||
remote_action.i.right[i], remote_action.i.bottom[i]));
|
||||
for (int i = 0; i < remote_action.i.display_num; i++) {
|
||||
props->display_info_list_.push_back(
|
||||
DisplayInfo(remote_action.i.display_list[i],
|
||||
remote_action.i.left[i], remote_action.i.top[i],
|
||||
remote_action.i.right[i], remote_action.i.bottom[i]));
|
||||
}
|
||||
}
|
||||
props->recent_connection_dirty_ = true;
|
||||
}
|
||||
FreeRemoteAction(remote_action);
|
||||
} else {
|
||||
@@ -455,9 +542,16 @@ void PeerEventHandler::OnReceiveDataBuffer(
|
||||
runtime->devices_.SendMouseCommand(remote_action,
|
||||
runtime->selected_display_);
|
||||
} else if (remote_action.type == ControlType::audio_capture) {
|
||||
// A negotiated subscription remains authoritative if a legacy capture
|
||||
// command arrives after it (e.g. when unmuting an inactive window).
|
||||
const auto subscription = runtime->preview_subscriptions_.Get(remote_id);
|
||||
const bool paused = subscription ? !subscription->audio : !remote_action.a;
|
||||
const int ret = SetAudioSendPaused(runtime->peer_, remote_id.data(),
|
||||
remote_id.size(), paused);
|
||||
if (remote_action.a)
|
||||
runtime->devices_.StartSpeakerCapturer();
|
||||
else
|
||||
else if (ret != 0)
|
||||
// Legacy/web transports without the per-peer API retain their fallback.
|
||||
runtime->devices_.StopSpeakerCapturer();
|
||||
} else if (remote_action.type == ControlType::keyboard) {
|
||||
runtime->keyboard_.ApplyRemoteEvent(remote_id, remote_action);
|
||||
|
||||
@@ -268,7 +268,7 @@ void PeerEventHandler::OnConnectionStatus(ConnectionStatus status,
|
||||
|
||||
if (props) {
|
||||
runtime->is_client_mode_ = true;
|
||||
runtime->show_connection_status_window_ = true;
|
||||
if (!props->wall_managed_) runtime->show_connection_status_window_ = true;
|
||||
props->connection_status_.store(status);
|
||||
if (status != ConnectionStatus::Connecting && status != ConnectionStatus::Gathering)
|
||||
props->connection_auth_.Complete();
|
||||
@@ -288,6 +288,10 @@ void PeerEventHandler::OnConnectionStatus(ConnectionStatus status,
|
||||
/*supports_privacy_screen=*/!runtime->unattended_host_,
|
||||
CROSSDESK_VERSION, kClientPlatform);
|
||||
remote_action.i.supports_video_settings = true;
|
||||
remote_action.i.supports_deskhub_preview = true;
|
||||
remote_action.i.supports_deskhub_resolution = true;
|
||||
remote_action.i.supports_deskhub_fps15 = true;
|
||||
remote_action.i.supports_deskhub_bandwidth = 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]);
|
||||
@@ -303,12 +307,13 @@ void PeerEventHandler::OnConnectionStatus(ConnectionStatus status,
|
||||
FreeRemoteAction(remote_action);
|
||||
}
|
||||
|
||||
if (!runtime->need_to_create_stream_window_) {
|
||||
if (!props->wall_managed_ && !runtime->need_to_create_stream_window_) {
|
||||
runtime->need_to_create_stream_window_ = true;
|
||||
}
|
||||
props->connection_established_ = true;
|
||||
props->was_connected_ = true;
|
||||
runtime->start_keyboard_capturer_ = true;
|
||||
props->recent_connection_dirty_ = true;
|
||||
if (!props->wall_managed_) runtime->start_keyboard_capturer_ = true;
|
||||
break;
|
||||
}
|
||||
case ConnectionStatus::Disconnected:
|
||||
@@ -325,7 +330,23 @@ void PeerEventHandler::OnConnectionStatus(ConnectionStatus status,
|
||||
props->remote_update_dismissed_ = false;
|
||||
}
|
||||
props->net_traffic_stats_.Reset();
|
||||
props->received_video_stats_.Reset();
|
||||
{
|
||||
std::lock_guard lock(props->gallery_frame_mutex_);
|
||||
props->gallery_video_latency_->Reset();
|
||||
props->gallery_frame_timing_ = {};
|
||||
}
|
||||
props->traversal_mode_ = TraversalMode::UnknownMode;
|
||||
props->enable_mouse_control_ = false;
|
||||
props->preview_subscription_supported_ = false;
|
||||
props->preview_resolution_supported_ = false;
|
||||
props->preview_fps15_supported_ = false;
|
||||
props->preview_bandwidth_supported_ = false;
|
||||
props->preview_needs_sync_ = true;
|
||||
props->preview_ack_ = 0;
|
||||
props->preview_accepted_ = false;
|
||||
props->audio_allowed_ = false;
|
||||
props->clipboard_allowed_ = false;
|
||||
runtime->ResetRemoteServiceStatus(*props);
|
||||
{
|
||||
std::lock_guard lock(props->video_settings_mutex_);
|
||||
@@ -413,6 +434,7 @@ void PeerEventHandler::OnConnectionStatus(ConnectionStatus status,
|
||||
|
||||
switch (status) {
|
||||
case ConnectionStatus::Connected: {
|
||||
runtime->preview_subscriptions_.Remove(remote_id);
|
||||
#if _WIN32
|
||||
runtime->last_windows_service_status_tick_ = 0;
|
||||
#endif
|
||||
@@ -422,6 +444,10 @@ void PeerEventHandler::OnConnectionStatus(ConnectionStatus status,
|
||||
/*supports_privacy_screen=*/!runtime->unattended_host_,
|
||||
CROSSDESK_VERSION, kClientPlatform);
|
||||
remote_action.i.supports_video_settings = true;
|
||||
remote_action.i.supports_deskhub_preview = true;
|
||||
remote_action.i.supports_deskhub_resolution = true;
|
||||
remote_action.i.supports_deskhub_fps15 = true;
|
||||
remote_action.i.supports_deskhub_bandwidth = 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]);
|
||||
@@ -538,7 +564,7 @@ void PeerEventHandler::OnNetStatusReport(
|
||||
if (props->traversal_mode_ != mode) {
|
||||
props->net_traffic_stats_.ResetLatency();
|
||||
props->traversal_mode_ = mode;
|
||||
LOG_INFO("Net mode: [{}]", int(props->traversal_mode_));
|
||||
LOG_INFO("Net mode: [{}]", int(props->traversal_mode_.load()));
|
||||
}
|
||||
|
||||
if (!net_traffic_stats) {
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
#include <libyuv.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <chrono>
|
||||
#include <cstring>
|
||||
#include <memory>
|
||||
@@ -5,6 +8,7 @@
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "nv12_scaler.h"
|
||||
#include "platform/video_renderer.h"
|
||||
#include "runtime/gui_runtime.h"
|
||||
#include "runtime/peer_event_handler.h"
|
||||
@@ -46,9 +50,80 @@ void PeerEventHandler::OnReceiveVideoBuffer(
|
||||
|
||||
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_),
|
||||
// Pair the clocks before preview conversion so its cost is included.
|
||||
const auto local_now_us = GetSystemTimeMicros(props->peer_);
|
||||
const auto* received_native = GetNativeVideoFrame(video_frame);
|
||||
props->received_video_stats_.Record(
|
||||
received_native ? received_native->width : video_frame->width,
|
||||
received_native ? received_native->height : video_frame->height,
|
||||
timing_now);
|
||||
// Convert only visible thumbnails, off the UI thread. A session retains
|
||||
// one latest RGBA image, not an unbounded queue of frames.
|
||||
const int preview_width = props->gallery_preview_width_.load();
|
||||
// Multiple video streams may decode concurrently for the same session.
|
||||
// Serialize access to the shared conversion buffers. Visible previews
|
||||
// consume incoming frames at the negotiated interactive cadence.
|
||||
std::unique_lock conversion_lock(props->gallery_conversion_mutex_,
|
||||
std::defer_lock);
|
||||
if (preview_width > 0) conversion_lock.lock();
|
||||
if (conversion_lock.owns_lock()) {
|
||||
const auto gallery_timing = props->gallery_video_latency_->Frame(
|
||||
video_frame->captured_timestamp, local_now_us, timing_now);
|
||||
const auto* native = GetNativeVideoFrame(video_frame);
|
||||
const int w = native ? native->width : video_frame->width;
|
||||
const int h = native ? native->height : video_frame->height;
|
||||
if (w > 0 && h > 0 && w <= 16384 && h <= 16384 && !(w % 2) && !(h % 2)) {
|
||||
const size_t bytes = static_cast<size_t>(w) * h * 3 / 2;
|
||||
const uint8_t* data =
|
||||
reinterpret_cast<const uint8_t*>(video_frame->data);
|
||||
if (native) {
|
||||
props->gallery_nv12_.resize(bytes);
|
||||
data = native->copy_to_nv12(native->owner,
|
||||
props->gallery_nv12_.data(), bytes) == 0
|
||||
? props->gallery_nv12_.data()
|
||||
: nullptr;
|
||||
} else if (video_frame->size < bytes)
|
||||
data = nullptr;
|
||||
if (data) {
|
||||
const double scale = std::min(
|
||||
{1.0, double(preview_width) / w, double(preview_width) / h});
|
||||
const int dw = std::max(2, int(w * scale) & ~1);
|
||||
const int dh = std::max(2, int(h * scale) & ~1);
|
||||
auto& scaled = props->gallery_scaled_;
|
||||
scaled.resize(static_cast<size_t>(dw) * dh * 3 / 2);
|
||||
if (ScaleNv12ViaI420(data, w, data + w * h, w, w, h, scaled.data(),
|
||||
dw, scaled.data() + dw * dh, dw, dw, dh,
|
||||
libyuv::kFilterBilinear,
|
||||
&props->gallery_scratch_) == 0) {
|
||||
auto rgba = std::make_shared<std::vector<unsigned char>>(
|
||||
static_cast<size_t>(dw) * dh * 4);
|
||||
if (libyuv::NV12ToABGR(scaled.data(), dw, scaled.data() + dw * dh,
|
||||
dw, rgba->data(), dw * 4, dw, dh) == 0) {
|
||||
std::lock_guard lock(props->gallery_frame_mutex_);
|
||||
props->gallery_rgba_ = std::move(rgba);
|
||||
props->gallery_width_ = dw;
|
||||
props->gallery_height_ = dh;
|
||||
props->gallery_frame_at_ = timing_now;
|
||||
props->gallery_frame_timing_ = gallery_timing;
|
||||
++props->gallery_sequence_;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (conversion_lock.owns_lock()) conversion_lock.unlock();
|
||||
// A wall-only session does not need a second full-resolution CPU buffer.
|
||||
if (props->wall_managed_ && !props->tab_opened_) {
|
||||
{
|
||||
std::lock_guard lock(props->video_frame_mutex_);
|
||||
props->video_width_ = video_frame->width;
|
||||
props->video_height_ = video_frame->height;
|
||||
}
|
||||
props->streaming_ = true;
|
||||
return;
|
||||
}
|
||||
const auto timing = props->video_latency_->Frame(
|
||||
video_frame->captured_timestamp, local_now_us, timing_now);
|
||||
bool background_snapshot_only = false;
|
||||
auto* native_renderer = runtime->video_renderer_.get();
|
||||
if (native_renderer && native_renderer->IsActive()) {
|
||||
@@ -205,9 +280,13 @@ void PeerEventHandler::OnReceiveAudioBuffer(
|
||||
return;
|
||||
}
|
||||
|
||||
std::lock_guard delivery_lock(runtime->audio_delivery_mutex_);
|
||||
const auto session =
|
||||
runtime->FindRemoteSession(std::string(user_id, user_id_size));
|
||||
if (!session || session->closing_ || !session->audio_allowed_.load()) return;
|
||||
runtime->audio_buffer_fresh_ = true;
|
||||
|
||||
runtime->devices_.PushAudio(data, size);
|
||||
runtime->devices_.PushAudio(session->remote_id_, data, size);
|
||||
}
|
||||
|
||||
} // namespace crossdesk
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
/*
|
||||
* @Author: DI JUNKUN
|
||||
* @Date: 2026-10-12
|
||||
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
|
||||
*/
|
||||
|
||||
#ifndef _PREVIEW_SUBSCRIPTIONS_H_
|
||||
#define _PREVIEW_SUBSCRIPTIONS_H_
|
||||
|
||||
#include <preview_subscription.h>
|
||||
|
||||
#include <chrono>
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
#include <unordered_map>
|
||||
|
||||
namespace crossdesk {
|
||||
// Accessed by the network callback and screen-capture callback. Legacy
|
||||
// controllers have no entry and continue receiving their normal stream.
|
||||
class PreviewSubscriptions {
|
||||
public:
|
||||
using Clock = std::chrono::steady_clock;
|
||||
std::optional<PreviewSubscription> Get(const std::string &id) {
|
||||
std::lock_guard lock(mutex_);
|
||||
const auto found = states_.find(id);
|
||||
return found == states_.end() ? std::nullopt
|
||||
: std::optional(found->second.request);
|
||||
}
|
||||
bool Set(const std::string &id, const PreviewSubscription &request) {
|
||||
std::lock_guard lock(mutex_);
|
||||
const auto found = states_.find(id);
|
||||
if (found != states_.end() &&
|
||||
!request.IsNewerThan(found->second.request))
|
||||
return false;
|
||||
auto &state = states_[id];
|
||||
if (state.request.fps != request.fps) state.next.clear();
|
||||
state.request = request;
|
||||
return true;
|
||||
}
|
||||
bool ShouldSend(const std::string &id, Clock::time_point now = Clock::now(),
|
||||
const std::string &stream = {}) {
|
||||
std::lock_guard lock(mutex_);
|
||||
const auto found = states_.find(id);
|
||||
if (found == states_.end())
|
||||
return true;
|
||||
auto &state = found->second;
|
||||
// MiniRTC owns pause/resume. This gate only limits preview cadence, so
|
||||
// paused input reaches its cheap send gate without dividing by zero here.
|
||||
if (!state.request.fps)
|
||||
return true;
|
||||
// Interactive streams use the encoder's own 30/60 FPS cadence. A second
|
||||
// time gate here can drop valid frames because capture timing varies.
|
||||
if (state.request.fps == 60)
|
||||
return true;
|
||||
auto &next = state.next[stream];
|
||||
if (state.request.fps == 15) {
|
||||
// Keep a stable 15 FPS deadline across capture jitter. Rebasing every
|
||||
// frame can turn small timing errors into extra dropped capture frames.
|
||||
const auto interval = std::chrono::nanoseconds(1'000'000'000 / 15);
|
||||
if (now + std::chrono::milliseconds(1) < next) return false;
|
||||
next = next == Clock::time_point{} || now - next > interval
|
||||
? now + interval : next + interval;
|
||||
return true;
|
||||
}
|
||||
if (now < next)
|
||||
return false;
|
||||
next = now + std::chrono::milliseconds(1000 / state.request.fps);
|
||||
return true;
|
||||
}
|
||||
void Remove(const std::string &id) {
|
||||
std::lock_guard lock(mutex_);
|
||||
states_.erase(id);
|
||||
}
|
||||
|
||||
private:
|
||||
struct State {
|
||||
PreviewSubscription request;
|
||||
// One display must not consume another display's preview frame budget.
|
||||
std::unordered_map<std::string, Clock::time_point> next;
|
||||
};
|
||||
std::mutex mutex_;
|
||||
std::unordered_map<std::string, State> states_;
|
||||
};
|
||||
} // namespace crossdesk
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,72 @@
|
||||
/*
|
||||
* @Author: DI JUNKUN
|
||||
* @Date: 2026-10-12
|
||||
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
|
||||
*/
|
||||
|
||||
#ifndef _RECEIVED_VIDEO_STATS_H_
|
||||
#define _RECEIVED_VIDEO_STATS_H_
|
||||
|
||||
#include <chrono>
|
||||
#include <cmath>
|
||||
#include <mutex>
|
||||
#include <optional>
|
||||
|
||||
namespace crossdesk::gui_detail {
|
||||
// Measured at decode delivery, including wall-only and background sessions.
|
||||
// Independent of the selected window's presentation cadence and FPS setting.
|
||||
class ReceivedVideoStats {
|
||||
public:
|
||||
using Clock = std::chrono::steady_clock;
|
||||
struct Snapshot {
|
||||
int width = 0;
|
||||
int height = 0;
|
||||
std::optional<int> fps;
|
||||
};
|
||||
|
||||
void Record(int width, int height, Clock::time_point now = Clock::now()) {
|
||||
if (width <= 0 || height <= 0) return;
|
||||
std::lock_guard lock(mutex_);
|
||||
const bool restart = snapshot_.width == 0 ||
|
||||
now - last_frame_ >= std::chrono::seconds(3);
|
||||
snapshot_.width = width;
|
||||
snapshot_.height = height;
|
||||
last_frame_ = now;
|
||||
if (restart) {
|
||||
started_ = now;
|
||||
frames_ = 0;
|
||||
snapshot_.fps.reset();
|
||||
return;
|
||||
}
|
||||
++frames_;
|
||||
const double elapsed = std::chrono::duration<double>(now - started_).count();
|
||||
if (elapsed >= 1.0) {
|
||||
snapshot_.fps = static_cast<int>(std::lround(frames_ / elapsed));
|
||||
frames_ = 0;
|
||||
started_ = now;
|
||||
}
|
||||
}
|
||||
|
||||
Snapshot Get(Clock::time_point now = Clock::now()) const {
|
||||
std::lock_guard lock(mutex_);
|
||||
auto result = snapshot_;
|
||||
if (result.width > 0 && now - last_frame_ >= std::chrono::seconds(1))
|
||||
result.fps = 0;
|
||||
return result;
|
||||
}
|
||||
|
||||
void Reset() {
|
||||
std::lock_guard lock(mutex_);
|
||||
snapshot_ = {};
|
||||
frames_ = 0;
|
||||
started_ = last_frame_ = {};
|
||||
}
|
||||
|
||||
private:
|
||||
mutable std::mutex mutex_;
|
||||
Snapshot snapshot_;
|
||||
unsigned frames_ = 0;
|
||||
Clock::time_point started_{}, last_frame_{};
|
||||
};
|
||||
} // namespace crossdesk::gui_detail
|
||||
#endif
|
||||
@@ -20,12 +20,15 @@
|
||||
#include <vector>
|
||||
|
||||
#include <remote_action.h>
|
||||
#include <preview_subscription.h>
|
||||
#include <stream_names.h>
|
||||
#include "display_info.h"
|
||||
#include "features/gallery/preview_resolution.h"
|
||||
#include "cursor_presentation.h"
|
||||
#include "minirtc.h"
|
||||
#include "rendering/video_latency.h"
|
||||
#include "runtime/net_traffic_stats_cache.h"
|
||||
#include "runtime/received_video_stats.h"
|
||||
#include "runtime/connection_auth_state.h"
|
||||
|
||||
namespace crossdesk { class PeerEventHandler; }
|
||||
@@ -70,6 +73,35 @@ struct FileTransferState {
|
||||
// Runtime state for one connected or connecting remote endpoint. It groups the
|
||||
// connection, media, input, window and transfer data that share one lifetime.
|
||||
struct RemoteSession {
|
||||
// Wall membership, stream-window visibility and input ownership are separate.
|
||||
std::atomic<bool> wall_managed_{false};
|
||||
std::atomic<bool> preview_subscription_supported_{false};
|
||||
std::atomic<bool> preview_resolution_supported_{false};
|
||||
std::atomic<bool> preview_fps15_supported_{false};
|
||||
std::atomic<bool> preview_bandwidth_supported_{false};
|
||||
std::atomic<bool> preview_needs_sync_{true};
|
||||
std::atomic<uint32_t> preview_ack_{0};
|
||||
std::atomic<bool> preview_accepted_{false};
|
||||
uint32_t preview_request_ = 0;
|
||||
PreviewSubscription preview_requested_;
|
||||
gallery::PreviewResolutionDebouncer preview_resolution_;
|
||||
std::chrono::steady_clock::time_point preview_request_at_{};
|
||||
std::atomic<bool> clipboard_allowed_{false};
|
||||
std::atomic<uint64_t> input_generation_{0};
|
||||
std::atomic<bool> audio_allowed_{false};
|
||||
std::atomic<int> gallery_preview_width_{0};
|
||||
std::mutex gallery_frame_mutex_;
|
||||
std::shared_ptr<std::vector<unsigned char>> gallery_rgba_;
|
||||
int gallery_width_ = 0, gallery_height_ = 0;
|
||||
uint64_t gallery_sequence_ = 0;
|
||||
std::chrono::steady_clock::time_point gallery_frame_at_{};
|
||||
VideoLatencyFrame gallery_frame_timing_;
|
||||
// Keep preview submission estimates separate from stream presentation times.
|
||||
std::shared_ptr<VideoLatencyStats> gallery_video_latency_ =
|
||||
std::make_shared<VideoLatencyStats>();
|
||||
// Decoder-thread scratch memory, never shared with the UI.
|
||||
std::mutex gallery_conversion_mutex_;
|
||||
std::vector<unsigned char> gallery_nv12_, gallery_scaled_, gallery_scratch_;
|
||||
Params params_;
|
||||
PeerPtr* peer_ = nullptr;
|
||||
std::shared_ptr<PeerEventHandler> peer_events_;
|
||||
@@ -91,6 +123,8 @@ struct RemoteSession {
|
||||
SignalStatus signal_status_ = SignalStatus::SignalClosed;
|
||||
bool connection_established_ = false;
|
||||
std::atomic<bool> was_connected_{false};
|
||||
// Transport callbacks publish changes; the UI thread persists history.
|
||||
std::atomic<bool> recent_connection_dirty_{false};
|
||||
bool rejoin_ = false;
|
||||
bool net_traffic_stats_button_pressed_ = false;
|
||||
bool enable_mouse_control_ = true;
|
||||
@@ -127,7 +161,7 @@ struct RemoteSession {
|
||||
std::shared_ptr<VideoLatencyStats> video_latency_ =
|
||||
std::make_shared<VideoLatencyStats>();
|
||||
bool tab_selected_ = false;
|
||||
bool tab_opened_ = true;
|
||||
std::atomic<bool> tab_opened_{true};
|
||||
std::string remote_host_name_;
|
||||
// Host metadata and dismissal state belong to this connection and are
|
||||
// protected by remote_version_mutex_.
|
||||
@@ -166,13 +200,14 @@ struct RemoteSession {
|
||||
CursorPresentation cursor_presentation_;
|
||||
// Shared by minirtc callbacks, Slint rendering and SDL audio callbacks.
|
||||
std::atomic<ConnectionStatus> connection_status_ = ConnectionStatus::Closed;
|
||||
TraversalMode traversal_mode_ = TraversalMode::UnknownMode;
|
||||
std::atomic<TraversalMode> traversal_mode_{TraversalMode::UnknownMode};
|
||||
int fps_ = 0;
|
||||
int frame_count_ = 0;
|
||||
std::chrono::steady_clock::time_point last_time_;
|
||||
// UI-thread snapshot refreshed with FPS once per second.
|
||||
std::optional<VideoLatencyStats::Snapshot> video_latency_snapshot_;
|
||||
NetTrafficStatsCache net_traffic_stats_;
|
||||
ReceivedVideoStats received_video_stats_;
|
||||
|
||||
using QueuedFile = FileTransferState::QueuedFile;
|
||||
using FileTransferStatus = FileTransferState::FileTransferStatus;
|
||||
|
||||
@@ -7,6 +7,8 @@
|
||||
#ifndef _RUNTIME_STATE_H_
|
||||
#define _RUNTIME_STATE_H_
|
||||
|
||||
#include <stream_names.h>
|
||||
|
||||
#include <atomic>
|
||||
#include <chrono>
|
||||
#include <cstdint>
|
||||
@@ -19,12 +21,11 @@
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
#include <stream_names.h>
|
||||
|
||||
#include "config_center.h"
|
||||
#include "common/task_queue_lock_free.h"
|
||||
#include "config_center.h"
|
||||
#include "path_manager.h"
|
||||
#include "runtime/device_presence_cache.h"
|
||||
#include "runtime/preview_subscriptions.h"
|
||||
#include "runtime/remote_session.h"
|
||||
#include "runtime/session_lifecycle.h"
|
||||
#include "thumbnail.h"
|
||||
@@ -36,6 +37,8 @@
|
||||
namespace crossdesk::gui_detail {
|
||||
|
||||
struct InfrastructureState {
|
||||
PreviewSubscriptions preview_subscriptions_;
|
||||
std::mutex audio_delivery_mutex_;
|
||||
std::unique_ptr<ConfigCenter> config_center_;
|
||||
ConfigCenter::LANGUAGE localization_language_ =
|
||||
ConfigCenter::LANGUAGE::CHINESE;
|
||||
@@ -54,6 +57,7 @@ struct RecentConnectionsState {
|
||||
std::vector<std::pair<std::string, Thumbnail::RecentConnection>>
|
||||
recent_connections_;
|
||||
std::vector<std::string> recent_connection_ids_;
|
||||
std::vector<std::string> gallery_presence_ids_;
|
||||
int recent_connection_image_width_ = 160;
|
||||
int recent_connection_image_height_ = 90;
|
||||
uint32_t recent_connection_image_save_time_ = 0;
|
||||
@@ -190,6 +194,7 @@ struct ConnectionState {
|
||||
struct PendingReconnect {
|
||||
std::string password;
|
||||
bool remember_password = false;
|
||||
bool background = false;
|
||||
};
|
||||
std::unordered_map<std::string, PendingReconnect> pending_reconnects_;
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { UiRadius } from "ui_radius.slint";
|
||||
// The legacy client uses ImGui::StyleColorsLight(). Keep the structural line
|
||||
// colors in one place so the Slint renderer produces the same visual weight.
|
||||
export global ImGuiLineStyle {
|
||||
@@ -8,7 +9,7 @@ export global ImGuiLineStyle {
|
||||
}
|
||||
|
||||
export global FloatingBarStyle {
|
||||
out property <length> corner-radius: 6px;
|
||||
out property <length> corner-radius: UiRadius.menu;
|
||||
}
|
||||
|
||||
// ImGui loads a 32px font atlas and applies per-window font scales. These
|
||||
@@ -49,6 +50,7 @@ export global FontAwesomeIcons {
|
||||
out property <string> signal: "\u{f012}";
|
||||
out property <string> shield-halved: "\u{f3ed}";
|
||||
out property <string> square-full: "\u{f45c}";
|
||||
out property <string> tags: "\u{f02c}";
|
||||
out property <string> trash-can: "\u{f2ed}";
|
||||
out property <string> user-secret: "\u{f21b}";
|
||||
out property <string> volume-high: "\u{f028}";
|
||||
@@ -59,6 +61,7 @@ export global FontAwesomeIcons {
|
||||
export component IconButton inherits Rectangle {
|
||||
in property <string> icon;
|
||||
in property <string> tooltip;
|
||||
in property <bool> tooltip-above: false;
|
||||
in property <bool> enabled: true;
|
||||
in property <bool> danger: false;
|
||||
in property <length> icon-size: 16px;
|
||||
@@ -74,7 +77,7 @@ export component IconButton inherits Rectangle {
|
||||
background: touch.pressed ? (danger ? #d92d20 : root.pressed-background)
|
||||
: touch.has-hover ? (danger ? #f04438 : root.hover-background)
|
||||
: root.normal-background;
|
||||
border-radius: 4px;
|
||||
border-radius: UiRadius.control;
|
||||
opacity: root.enabled ? 1 : 0.4;
|
||||
|
||||
Text {
|
||||
@@ -103,11 +106,11 @@ export component IconButton inherits Rectangle {
|
||||
|
||||
if touch.has-hover && root.tooltip != "": Rectangle {
|
||||
x: min(root.width - self.width, 0px);
|
||||
y: root.height + 3px;
|
||||
y: root.tooltip-above ? -self.height - 3px : root.height + 3px;
|
||||
width: tip.preferred-width + 12px;
|
||||
height: 24px;
|
||||
background: #22252be8;
|
||||
border-radius: 4px;
|
||||
border-radius: UiRadius.control;
|
||||
z: 20;
|
||||
tip := Text {
|
||||
text: root.tooltip;
|
||||
@@ -134,7 +137,7 @@ export component ImageButton inherits Rectangle {
|
||||
background: touch.pressed ? (danger ? #d92d20 : #d9dde5)
|
||||
: touch.has-hover ? (danger ? #f04438 : #edf0f5)
|
||||
: transparent;
|
||||
border-radius: 4px;
|
||||
border-radius: UiRadius.control;
|
||||
|
||||
Image {
|
||||
x: (parent.width - self.width) / 2;
|
||||
@@ -155,7 +158,7 @@ export component ImageButton inherits Rectangle {
|
||||
width: tip.preferred-width + 12px;
|
||||
height: 24px;
|
||||
background: #22252be8;
|
||||
border-radius: 4px;
|
||||
border-radius: UiRadius.control;
|
||||
z: 20;
|
||||
tip := Text {
|
||||
text: root.tooltip;
|
||||
@@ -171,6 +174,10 @@ export component CompactComboBox inherits Rectangle {
|
||||
in property <[string]> model;
|
||||
in-out property <int> current-index: 0;
|
||||
in property <bool> enabled: true;
|
||||
in property <bool> subtle: false;
|
||||
in property <bool> show-arrow: true;
|
||||
in property <color> normal-border-color: root.subtle ? #dfe6ef : #bfc2c7;
|
||||
in property <color> hover-border-color: root.normal-border-color;
|
||||
property <bool> popup-animation-enabled: false;
|
||||
property <float> popup-progress: 0.0;
|
||||
callback selected(int);
|
||||
@@ -183,29 +190,31 @@ export component CompactComboBox inherits Rectangle {
|
||||
|
||||
width: 73px;
|
||||
height: 24px;
|
||||
background: root.enabled ? white : #f1f1f1;
|
||||
background: root.enabled ? (root.subtle ? #f8fafc : white) : root.subtle ? #f1f3f6 : #f1f1f1;
|
||||
border-width: 1px;
|
||||
border-color: #bfc2c7;
|
||||
border-radius: 3px;
|
||||
border-color: root.enabled && combo-touch.has-hover ? root.hover-border-color : root.normal-border-color;
|
||||
border-radius: UiRadius.control;
|
||||
|
||||
Text {
|
||||
x: 5px;
|
||||
width: parent.width - 24px;
|
||||
x: root.subtle ? 10px : 5px;
|
||||
width: parent.width - (root.show-arrow ? (root.subtle ? 36px : 24px) : (root.subtle ? 20px : 10px));
|
||||
text: root.model[root.current-index];
|
||||
color: root.enabled ? #202124 : #8b8f95;
|
||||
font-size: ImGuiFontStyle.body;
|
||||
font-size: root.subtle ? 12px : ImGuiFontStyle.body;
|
||||
overflow: elide;
|
||||
vertical-alignment: center;
|
||||
}
|
||||
Rectangle {
|
||||
x: parent.width - 21px;
|
||||
if root.show-arrow: Rectangle {
|
||||
x: parent.width - (root.subtle ? 27px : 21px);
|
||||
width: 20px;
|
||||
height: parent.height - 2px;
|
||||
y: 1px;
|
||||
background: root.enabled ? #9bc9f8 : #d7d9dc;
|
||||
background: root.subtle ? transparent : root.enabled ? #9bc9f8 : #d7d9dc;
|
||||
border-top-right-radius: UiRadius.control - 1px;
|
||||
border-bottom-right-radius: UiRadius.control - 1px;
|
||||
Text {
|
||||
text: FontAwesomeIcons.angle-down;
|
||||
color: #202124;
|
||||
color: root.subtle ? #79879a : #202124;
|
||||
font-family: "Font Awesome 6 Free";
|
||||
font-weight: 900;
|
||||
font-size: 10px;
|
||||
@@ -219,8 +228,8 @@ export component CompactComboBox inherits Rectangle {
|
||||
}
|
||||
}
|
||||
}
|
||||
TouchArea {
|
||||
enabled: root.enabled;
|
||||
combo-touch := TouchArea {
|
||||
enabled: root.enabled; mouse-cursor: pointer;
|
||||
clicked => {
|
||||
root.popup-animation-enabled = false;
|
||||
root.popup-progress = 0.0;
|
||||
@@ -259,15 +268,15 @@ export component CompactComboBox inherits Rectangle {
|
||||
opacity: root.popup-progress;
|
||||
background: white;
|
||||
border-width: 1px;
|
||||
border-color: #bfc2c7;
|
||||
border-radius: 3px;
|
||||
border-color: root.normal-border-color;
|
||||
border-radius: UiRadius.menu;
|
||||
clip: true;
|
||||
for choice[index] in root.model: Rectangle {
|
||||
y: index * 23px;
|
||||
width: parent.width;
|
||||
height: 23px;
|
||||
background: option-touch.has-hover ? #dbeafb : index == root.current-index ? #eef5fd : white;
|
||||
Text { x: 5px; width: parent.width - 10px; text: choice; color: #202124; font-size: ImGuiFontStyle.body; overflow: elide; vertical-alignment: center; }
|
||||
Text { x: 5px; width: parent.width - 10px; text: choice; color: #202124; font-size: root.subtle ? 12px : ImGuiFontStyle.body; overflow: elide; vertical-alignment: center; }
|
||||
option-touch := TouchArea {
|
||||
clicked => {
|
||||
popup-open-timer.running = false;
|
||||
@@ -284,8 +293,8 @@ export component CompactComboBox inherits Rectangle {
|
||||
height: parent.height;
|
||||
background: transparent;
|
||||
border-width: 1px;
|
||||
border-color: #bfc2c7;
|
||||
border-radius: 3px;
|
||||
border-color: root.normal-border-color;
|
||||
border-radius: UiRadius.menu;
|
||||
z: 2;
|
||||
}
|
||||
}
|
||||
@@ -345,18 +354,19 @@ export component CompactButton inherits Rectangle {
|
||||
in property <string> text;
|
||||
in property <length> text-size: ImGuiFontStyle.body;
|
||||
in property <bool> primary: false;
|
||||
in property <bool> quiet: false;
|
||||
in property <bool> enabled: true;
|
||||
callback clicked;
|
||||
|
||||
width: 34px;
|
||||
height: 24px;
|
||||
background: !root.enabled ? #eceef0
|
||||
background: !root.enabled ? (root.quiet ? transparent : #eceef0)
|
||||
: button-touch.pressed ? (root.primary ? #82b8f2 : #d8dadd)
|
||||
: button-touch.has-hover ? (root.primary ? #b8d9fb : #f0f1f2)
|
||||
: root.primary ? #a8d0fa : #e2e4e7;
|
||||
: root.primary ? #a8d0fa : root.quiet ? transparent : #e2e4e7;
|
||||
border-width: 1px;
|
||||
border-color: root.primary ? #9bc3ed : #c8cbd0;
|
||||
border-radius: 3px;
|
||||
border-color: root.primary ? #9bc3ed : root.quiet ? transparent : #c8cbd0;
|
||||
border-radius: UiRadius.control;
|
||||
Text {
|
||||
text: root.text;
|
||||
color: root.enabled ? #24272d : #8e9298;
|
||||
@@ -380,7 +390,7 @@ export component PrimaryButton inherits Rectangle {
|
||||
: touch.pressed ? #1849a9
|
||||
: touch.has-hover ? #2970d6
|
||||
: #2463c7;
|
||||
border-radius: 5px;
|
||||
border-radius: UiRadius.control;
|
||||
|
||||
Text {
|
||||
text: root.text;
|
||||
@@ -398,6 +408,7 @@ export component PrimaryButton inherits Rectangle {
|
||||
|
||||
export component SecondaryButton inherits Rectangle {
|
||||
in property <string> text;
|
||||
in property <length> text-size: ImGuiFontStyle.body;
|
||||
in property <bool> enabled: true;
|
||||
callback clicked;
|
||||
|
||||
@@ -408,13 +419,13 @@ export component SecondaryButton inherits Rectangle {
|
||||
: touch.has-hover ? #f3f4f6 : white;
|
||||
border-width: 1px;
|
||||
border-color: #c9ced7;
|
||||
border-radius: 5px;
|
||||
border-radius: UiRadius.control;
|
||||
clip: true;
|
||||
|
||||
Text {
|
||||
text: root.text;
|
||||
color: root.enabled ? #24272d : #949aa5;
|
||||
font-size: ImGuiFontStyle.body;
|
||||
font-size: root.text-size;
|
||||
horizontal-alignment: center;
|
||||
vertical-alignment: center;
|
||||
}
|
||||
@@ -465,5 +476,5 @@ export component DialogSurface inherits Rectangle {
|
||||
background: white;
|
||||
border-width: 1px;
|
||||
border-color: ImGuiLineStyle.border;
|
||||
border-radius: 8px;
|
||||
border-radius: UiRadius.surface;
|
||||
}
|
||||
|
||||
@@ -4,3 +4,8 @@ export { AnnouncementItem, MessageStrings } from "message_panel.slint";
|
||||
export { MainWindow, RecentConnection, UpdateCheckStatus, UiStrings } from "main_window.slint";
|
||||
export { StreamWindow, StreamTab, FileTransferEntry, NetworkStatsRow, StreamStrings } from "stream_window.slint";
|
||||
export { ServerWindow, ServerStatusBar, ServerStrings, ControllerEntry } from "server_window.slint";
|
||||
|
||||
export { GalleryTile, GalleryRecentDevice, GalleryActionDevice } from "gallery_view.slint";
|
||||
export { GalleryStrings } from "device_strings.slint";
|
||||
export { GalleryNetworkDevice } from "gallery_network_dialog.slint";
|
||||
export { DeviceGroupOption } from "device_group_dialog.slint";
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { UiRadius } from "ui_radius.slint";
|
||||
import { IconButton, FontAwesomeIcons } from "common.slint";
|
||||
import { GalleryStrings } from "device_strings.slint";
|
||||
|
||||
export global DeviceCardStyle {
|
||||
out property <length> toolbar-width: 104px;
|
||||
out property <color> offline-preview: #626870;
|
||||
out property <color> offline-background: #e4e6e9;
|
||||
out property <color> offline-border: #c9cdd2;
|
||||
out property <color> offline-text: #68707a;
|
||||
out property <color> offline-placeholder: #eceef1;
|
||||
}
|
||||
|
||||
component CardAction inherits IconButton {
|
||||
width: 26px; height: 23px;
|
||||
border-radius: UiRadius.control;
|
||||
icon-size: 12px; icon-color: white;
|
||||
hover-background: #ffffff2e;
|
||||
pressed-background: #598cf273;
|
||||
tooltip-above: true;
|
||||
}
|
||||
|
||||
// Shared by recent connections and DeskHub; callers supply only behavior.
|
||||
export component DeviceCardActions inherits Rectangle {
|
||||
in property <string> remove-label;
|
||||
in property <bool> connected: false;
|
||||
callback edit-alias();
|
||||
callback edit-groups();
|
||||
callback remove-device();
|
||||
callback connect-device();
|
||||
callback disconnect-device();
|
||||
width: DeviceCardStyle.toolbar-width; height: 23px;
|
||||
background: #4a4e55dc; border-radius: UiRadius.control;
|
||||
HorizontalLayout {
|
||||
spacing: 0px;
|
||||
CardAction {
|
||||
icon: FontAwesomeIcons.pen;
|
||||
tooltip: GalleryStrings.tr("修改名称", "Rename", "Переименовать");
|
||||
clicked => { root.edit-alias(); }
|
||||
}
|
||||
CardAction {
|
||||
icon: FontAwesomeIcons.tags;
|
||||
tooltip: GalleryStrings.tr("修改分组", "Edit groups", "Изменить группы");
|
||||
clicked => { root.edit-groups(); }
|
||||
}
|
||||
CardAction {
|
||||
icon: FontAwesomeIcons.trash-can;
|
||||
tooltip: root.remove-label;
|
||||
clicked => { root.remove-device(); }
|
||||
}
|
||||
CardAction {
|
||||
icon: root.connected ? FontAwesomeIcons.xmark : FontAwesomeIcons.arrow-right-long;
|
||||
icon-size: 13px;
|
||||
tooltip: root.connected ? GalleryStrings.tr("断开", "Disconnect", "Отключить") : GalleryStrings.tr("连接", "Connect", "Подключить");
|
||||
clicked => {
|
||||
if root.connected { root.disconnect-device(); }
|
||||
else { root.connect-device(); }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import { UiRadius } from "ui_radius.slint";
|
||||
import { ScrollView } from "std-widgets.slint";
|
||||
import { RoundedLineEdit } from "rounded_line_edit.slint";
|
||||
import { CompactButton, ImGuiCheckBox } from "common.slint";
|
||||
import { GalleryStrings } from "device_strings.slint";
|
||||
|
||||
export struct DeviceGroupOption {
|
||||
name: string,
|
||||
selected: bool,
|
||||
}
|
||||
|
||||
export component DeviceGroupDialog inherits Rectangle {
|
||||
in property <string> device-name;
|
||||
in property <string> device-id;
|
||||
in property <[DeviceGroupOption]> options;
|
||||
in property <string> error;
|
||||
in-out property <string> new-group;
|
||||
callback toggle-group(string);
|
||||
callback save(string);
|
||||
callback cancel();
|
||||
background: #00000040;
|
||||
TouchArea {}
|
||||
dialog := Rectangle {
|
||||
x: (parent.width - self.width) / 2;
|
||||
y: (parent.height - self.height) / 2;
|
||||
width: min(360px, parent.width - 40px);
|
||||
height: 208px + group-list.height + (root.error == "" ? 0px : 32px);
|
||||
background: white; border-radius: UiRadius.surface;
|
||||
border-width: 1px; border-color: #b2b2b2;
|
||||
TouchArea {}
|
||||
Text {
|
||||
x: 18px; y: 16px; height: 24px;
|
||||
text: GalleryStrings.tr("修改分组", "Edit groups", "Изменить группы");
|
||||
color: #24272d; font-size: 17px; font-weight: 600;
|
||||
}
|
||||
Text {
|
||||
x: 18px; y: 47px; width: parent.width - 36px; height: 18px;
|
||||
text: root.device-name; color: #30343b; font-size: 12px; overflow: elide;
|
||||
}
|
||||
Text {
|
||||
x: 18px; y: 67px; width: parent.width - 36px; height: 16px;
|
||||
text: "ID " + root.device-id; color: #858c97; font-size: 11px;
|
||||
}
|
||||
group-list := Rectangle {
|
||||
x: 18px; y: 95px; width: parent.width - 36px;
|
||||
height: min(5, max(1, root.options.length)) * 32px + 2px;
|
||||
background: #fbfcfd; border-width: 1px; border-color: #e1e5eb; border-radius: UiRadius.surface;
|
||||
clip: true;
|
||||
ScrollView {
|
||||
x: 1px; y: 1px; width: parent.width - 2px; height: parent.height - 2px;
|
||||
viewport-width: self.width;
|
||||
VerticalLayout {
|
||||
spacing: 0px;
|
||||
for option[index] in root.options: Rectangle {
|
||||
height: 32px;
|
||||
background: row-touch.has-hover ? #eef4fc : transparent;
|
||||
ImGuiCheckBox {
|
||||
x: 9px; y: 6px; width: 20px; height: 20px;
|
||||
checked: option.selected && (option.name != "" || root.new-group == "");
|
||||
}
|
||||
Text {
|
||||
x: 39px; width: parent.width - 48px; height: parent.height;
|
||||
text: option.name == "" ? GalleryStrings.tr("未分组", "Ungrouped", "Без группы") : option.name;
|
||||
color: #30343b; font-size: 12px; overflow: elide; vertical-alignment: center;
|
||||
}
|
||||
row-touch := TouchArea { clicked => { root.toggle-group(option.name); } }
|
||||
}
|
||||
}
|
||||
}
|
||||
// Keep row hover backgrounds inside the rounded list on every renderer.
|
||||
Rectangle {
|
||||
x: -UiRadius.surface; y: -UiRadius.surface;
|
||||
width: parent.width + 2 * UiRadius.surface;
|
||||
height: parent.height + 2 * UiRadius.surface;
|
||||
border-width: UiRadius.surface; border-radius: 2 * UiRadius.surface;
|
||||
border-color: white;
|
||||
}
|
||||
Rectangle {
|
||||
width: parent.width; height: parent.height;
|
||||
border-width: 1px; border-radius: UiRadius.surface;
|
||||
border-color: #e1e5eb;
|
||||
}
|
||||
}
|
||||
RoundedLineEdit {
|
||||
x: 18px; y: group-list.y + group-list.height + 12px;
|
||||
width: parent.width - 36px; height: 30px;
|
||||
text <=> root.new-group;
|
||||
placeholder-text: GalleryStrings.tr("新建分组(可选)", "New group (optional)", "Новая группа (необязательно)");
|
||||
}
|
||||
if root.error != "": Text {
|
||||
x: 18px; y: group-list.y + group-list.height + 48px;
|
||||
width: parent.width - 36px; height: 32px;
|
||||
text: root.error; color: #c53e3e; font-size: 11px; wrap: word-wrap;
|
||||
}
|
||||
HorizontalLayout {
|
||||
x: 18px; y: parent.height - 46px; width: parent.width - 36px; height: 28px;
|
||||
spacing: 10px;
|
||||
Rectangle { horizontal-stretch: 1; }
|
||||
CompactButton { width: 72px; height: 28px; text: GalleryStrings.tr("取消", "Cancel", "Отмена"); clicked => { root.cancel(); } }
|
||||
CompactButton { width: 88px; height: 28px; primary: true; text: GalleryStrings.tr("保存", "Save", "Сохранить"); clicked => { root.save(root.new-group); } }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export global GalleryStrings {
|
||||
in-out property <int> language: 0;
|
||||
public pure function tr(zh: string, en: string, ru: string) -> string {
|
||||
return self.language == 0 ? zh : self.language == 2 ? ru : en;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import { UiRadius } from "ui_radius.slint";
|
||||
import { CompactButton, FontAwesomeIcons } from "common.slint";
|
||||
|
||||
export struct GalleryMenuItem { label: string, enabled: bool }
|
||||
|
||||
export component GalleryMenuButton inherits Rectangle {
|
||||
in property <string> text;
|
||||
in property <bool> primary: false;
|
||||
in property <bool> enabled: true;
|
||||
in property <bool> opens-up: false;
|
||||
in property <length> menu-width: 200px;
|
||||
in property <length> horizontal-padding: 12px;
|
||||
out property <length> preferred-button-width: ceil(button-label.preferred-width / 1px) * 1px + root.horizontal-padding * 2 + 18px;
|
||||
in property <[GalleryMenuItem]> items;
|
||||
private property <int> current-index: 0;
|
||||
callback opened;
|
||||
callback selected(int);
|
||||
|
||||
height: 24px;
|
||||
forward-focus: trigger-focus;
|
||||
accessible-role: button;
|
||||
accessible-label: root.text;
|
||||
accessible-enabled: root.enabled;
|
||||
accessible-expandable: true;
|
||||
accessible-expanded: popup.is-open;
|
||||
accessible-action-default => { root.open-menu(); }
|
||||
changed enabled => { if !root.enabled { popup.close(); } }
|
||||
changed visible => { if !root.visible { popup.close(); } }
|
||||
|
||||
public function close-menu() { popup.close(); }
|
||||
function open-menu() {
|
||||
if !root.enabled || root.items.length == 0 { return; }
|
||||
if popup.is-open { popup.close(); return; }
|
||||
root.opened();
|
||||
root.current-index = 0;
|
||||
popup.show();
|
||||
}
|
||||
function choose(index: int) {
|
||||
if !root.enabled || index < 0 || index >= root.items.length || !root.items[index].enabled { return; }
|
||||
popup.close();
|
||||
trigger-focus.focus();
|
||||
root.selected(index);
|
||||
}
|
||||
function handle-key(event: KeyEvent) -> EventResult {
|
||||
if !root.enabled { return reject; }
|
||||
if event.text == Key.Escape && popup.is-open {
|
||||
popup.close(); trigger-focus.focus(); return accept;
|
||||
}
|
||||
if event.text == Key.Return || event.text == " " {
|
||||
if popup.is-open { root.choose(root.current-index); } else { root.open-menu(); }
|
||||
return accept;
|
||||
}
|
||||
if event.text == Key.UpArrow || event.text == Key.DownArrow {
|
||||
if !popup.is-open { root.open-menu(); }
|
||||
else { root.current-index = clamp(root.current-index + (event.text == Key.UpArrow ? -1 : 1), 0, root.items.length - 1); }
|
||||
return accept;
|
||||
}
|
||||
return reject;
|
||||
}
|
||||
trigger-focus := FocusScope {
|
||||
enabled: root.enabled;
|
||||
key-pressed(event) => { return root.handle-key(event); }
|
||||
CompactButton {
|
||||
width: parent.width; height: parent.height;
|
||||
border-radius: UiRadius.control;
|
||||
enabled: root.enabled; primary: root.primary;
|
||||
clicked => { trigger-focus.focus(); root.open-menu(); }
|
||||
}
|
||||
button-label := Text {
|
||||
x: root.horizontal-padding; width: parent.width - root.horizontal-padding * 2 - 18px; height: parent.height;
|
||||
text: root.text; color: root.enabled ? #24272d : #8e9298;
|
||||
font-size: 11px; overflow: elide; horizontal-alignment: center; vertical-alignment: center;
|
||||
}
|
||||
Text {
|
||||
x: parent.width - root.horizontal-padding - 12px; width: 12px; height: parent.height;
|
||||
text: root.opens-up ? FontAwesomeIcons.angle-up : FontAwesomeIcons.angle-down;
|
||||
font-family: "Font Awesome 6 Free"; font-weight: 900; font-size: 9px;
|
||||
color: root.enabled ? #62666d : #8e9298;
|
||||
horizontal-alignment: center; vertical-alignment: center;
|
||||
}
|
||||
}
|
||||
popup := PopupWindow {
|
||||
x: root.width - self.width;
|
||||
y: root.opens-up ? -self.height - 4px : root.height + 4px;
|
||||
width: root.menu-width; height: root.items.length * 30px + 8px;
|
||||
close-policy: close-on-click-outside;
|
||||
forward-focus: popup-focus;
|
||||
popup-focus := FocusScope {
|
||||
key-pressed(event) => { return root.handle-key(event); }
|
||||
Rectangle {
|
||||
background: white; border-width: 1px; border-color: #c9cdd2; border-radius: UiRadius.menu;
|
||||
for item[index] in root.items: Rectangle {
|
||||
x: 4px; y: 4px + index * 30px; width: parent.width - 8px; height: 30px;
|
||||
border-radius: UiRadius.control;
|
||||
background: item.enabled && index == root.current-index ? #edf4fc : transparent;
|
||||
accessible-role: button; accessible-label: item.label; accessible-enabled: item.enabled;
|
||||
accessible-action-default => { root.choose(index); }
|
||||
Text {
|
||||
x: 10px; width: parent.width - 20px; height: parent.height;
|
||||
text: item.label; color: item.enabled ? #30343b : #92979f;
|
||||
font-size: 12px; vertical-alignment: center;
|
||||
}
|
||||
TouchArea {
|
||||
enabled: item.enabled;
|
||||
changed has-hover => { if self.has-hover { root.current-index = index; } }
|
||||
clicked => { root.choose(index); }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
import { UiRadius } from "ui_radius.slint";
|
||||
import { GalleryStrings } from "device_strings.slint";
|
||||
import { CompactButton } from "common.slint";
|
||||
import { ScrollView } from "std-widgets.slint";
|
||||
|
||||
export struct GalleryNetworkDevice {
|
||||
name: string,
|
||||
id: string,
|
||||
mode: string,
|
||||
encryption-known: bool,
|
||||
encryption-enabled: bool,
|
||||
video-latency: string,
|
||||
inbound: string,
|
||||
outbound: string,
|
||||
loss: string,
|
||||
resolution: string,
|
||||
fps: string,
|
||||
}
|
||||
|
||||
component NetworkRow inherits Rectangle {
|
||||
in property <GalleryNetworkDevice> device;
|
||||
in property <bool> heading: false;
|
||||
// Keep the same column geometry in the header and every row. Grouping
|
||||
// related values leaves room for device names even in the 640px window.
|
||||
HorizontalLayout {
|
||||
x: 0px; y: 0px; width: parent.width; height: parent.height;
|
||||
padding-left: 12px; padding-right: 12px; spacing: 10px;
|
||||
identity := Rectangle {
|
||||
horizontal-stretch: 1;
|
||||
device-name := Text {
|
||||
y: root.heading ? 0px : 8px; height: root.heading ? parent.height : 20px;
|
||||
width: parent.width; text: root.device.name;
|
||||
font-size: root.heading ? 11px : 12px; font-weight: 600;
|
||||
color: root.heading ? #6b7785 : #293746;
|
||||
vertical-alignment: center; overflow: elide;
|
||||
}
|
||||
name-hover := TouchArea {
|
||||
y: 8px; height: 20px; width: parent.width;
|
||||
enabled: !root.heading;
|
||||
}
|
||||
if !root.heading: HorizontalLayout {
|
||||
x: 0px; y: 30px; width: parent.width; height: 17px; spacing: 4px;
|
||||
id-text := Text {
|
||||
text: root.device.id; font-size: 10px; color: #7a8490;
|
||||
vertical-alignment: center; overflow: elide;
|
||||
}
|
||||
Rectangle {
|
||||
width: max(0px, min(mode-text.preferred-width + 12px,
|
||||
parent.width - id-text.preferred-width - encryption-badge.width - 12px));
|
||||
background: #edf2f7; border-radius: 3px;
|
||||
mode-text := Text {
|
||||
x: 6px; width: parent.width - 12px; height: parent.height;
|
||||
text: root.device.mode; font-size: 10px; color: #586f87;
|
||||
horizontal-alignment: center; vertical-alignment: center; overflow: elide;
|
||||
}
|
||||
}
|
||||
encryption-badge := Rectangle {
|
||||
width: encryption-text.preferred-width + 12px;
|
||||
background: !root.device.encryption-known ? #f0f2f5
|
||||
: root.device.encryption-enabled ? #e8f5ec : #fff3df;
|
||||
border-radius: 3px;
|
||||
encryption-text := Text {
|
||||
x: 6px; width: parent.width - 12px; height: parent.height;
|
||||
text: !root.device.encryption-known
|
||||
? GalleryStrings.tr("待确认", "Unknown", "Неизвестно")
|
||||
: root.device.encryption-enabled
|
||||
? GalleryStrings.tr("已加密", "Encrypted", "Защищено")
|
||||
: GalleryStrings.tr("未加密", "Unencrypted", "Без шифр.");
|
||||
color: !root.device.encryption-known ? #7a8490
|
||||
: root.device.encryption-enabled ? #18794e : #8a6428;
|
||||
font-size: 10px; horizontal-alignment: center;
|
||||
vertical-alignment: center; overflow: elide;
|
||||
}
|
||||
}
|
||||
Rectangle { horizontal-stretch: 1; }
|
||||
}
|
||||
}
|
||||
Rectangle {
|
||||
width: max(108px, root.width * 0.19);
|
||||
Text {
|
||||
y: root.heading ? 0px : 8px; height: root.heading ? parent.height : 20px;
|
||||
width: parent.width;
|
||||
text: root.heading ? GalleryStrings.tr("画面", "Video", "Видео") : root.device.resolution;
|
||||
font-size: root.heading ? 11px : 12px; font-weight: root.heading ? 600 : 400;
|
||||
color: root.heading ? #6b7785 : #293746;
|
||||
vertical-alignment: center; overflow: elide;
|
||||
}
|
||||
if !root.heading: Text {
|
||||
y: 30px; height: 17px; width: parent.width;
|
||||
text: root.device.fps; font-size: 11px; color: #7a8490;
|
||||
vertical-alignment: center; overflow: elide;
|
||||
}
|
||||
}
|
||||
Rectangle {
|
||||
width: max(100px, root.width * 0.18);
|
||||
if root.heading: Text {
|
||||
x: 0px; width: parent.width;
|
||||
text: GalleryStrings.tr("收发速率", "Transfer rate", "Скорость");
|
||||
font-size: 11px; font-weight: 600; color: #6b7785;
|
||||
vertical-alignment: center; overflow: elide;
|
||||
}
|
||||
if !root.heading: Text {
|
||||
x: 0px; y: 8px; height: 20px; width: 14px;
|
||||
text: "↓"; color: #2676b9; font-size: 12px; vertical-alignment: center;
|
||||
}
|
||||
if !root.heading: Text {
|
||||
x: 17px; y: 8px; height: 20px; width: parent.width - 17px;
|
||||
text: root.device.inbound; color: #293746; font-size: 12px;
|
||||
vertical-alignment: center; overflow: elide;
|
||||
}
|
||||
if !root.heading: Text {
|
||||
x: 0px; y: 30px; height: 17px; width: 14px;
|
||||
text: "↑"; color: #7a8490; font-size: 11px; vertical-alignment: center;
|
||||
}
|
||||
if !root.heading: Text {
|
||||
x: 17px; y: 30px; height: 17px; width: parent.width - 17px;
|
||||
text: root.device.outbound; color: #7a8490; font-size: 11px;
|
||||
vertical-alignment: center; overflow: elide;
|
||||
}
|
||||
}
|
||||
Rectangle {
|
||||
width: max(94px, root.width * 0.16);
|
||||
Text {
|
||||
y: root.heading ? 0px : 8px; height: root.heading ? parent.height : 20px;
|
||||
width: parent.width;
|
||||
text: root.heading ? GalleryStrings.tr("视频延时", "Video latency", "Задержка видео") : root.device.video-latency;
|
||||
font-size: root.heading ? 11px : 12px; font-weight: root.heading ? 600 : 400;
|
||||
color: root.heading ? #6b7785 : #293746;
|
||||
vertical-alignment: center; overflow: elide;
|
||||
}
|
||||
if !root.heading: Text {
|
||||
y: 30px; height: 17px; width: parent.width;
|
||||
text: GalleryStrings.tr("丢包 ", "Loss ", "Потери ") + root.device.loss;
|
||||
font-size: 11px; color: #7a8490;
|
||||
vertical-alignment: center; overflow: elide;
|
||||
}
|
||||
}
|
||||
}
|
||||
if !root.heading && name-hover.has-hover && device-name.preferred-width > identity.width: Rectangle {
|
||||
x: 12px; y: 32px; width: min(name-tip.preferred-width + 20px, root.width - 24px);
|
||||
height: 26px; background: #293746; border-radius: UiRadius.control; z: 10;
|
||||
name-tip := Text {
|
||||
x: 10px; width: parent.width - 20px; height: parent.height;
|
||||
text: root.device.name; font-size: 11px; color: white;
|
||||
vertical-alignment: center; overflow: elide;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
component NetworkEmptyState inherits Rectangle {
|
||||
VerticalLayout {
|
||||
alignment: center; spacing: 8px;
|
||||
Text {
|
||||
text: GalleryStrings.tr("暂无已连接设备", "No connected devices", "Нет подключённых устройств");
|
||||
font-size: 13px; color: #596878; horizontal-alignment: center;
|
||||
}
|
||||
Text {
|
||||
text: GalleryStrings.tr("建立连接后,网络和画面信息将在此显示", "Network and video details appear here once connected", "После подключения здесь появятся данные сети и видео");
|
||||
font-size: 11px; color: #8a95a1; horizontal-alignment: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export component GalleryNetworkDialog inherits Rectangle {
|
||||
in property <[GalleryNetworkDevice]> devices;
|
||||
private property <length> row-height: 56px;
|
||||
private property <length> list-height: max(2, min(root.devices.length, 6)) * root.row-height;
|
||||
callback close();
|
||||
background: #00000042;
|
||||
TouchArea { clicked => { root.close(); } }
|
||||
Rectangle {
|
||||
x: (parent.width - self.width) / 2;
|
||||
y: (parent.height - self.height) / 2;
|
||||
width: min(760px, parent.width - 32px);
|
||||
height: min(120px + root.list-height, parent.height - 32px);
|
||||
background: white; border-radius: UiRadius.surface;
|
||||
border-width: 1px; border-color: #c6cdd5;
|
||||
TouchArea {}
|
||||
FocusScope {
|
||||
init => { self.focus(); }
|
||||
key-pressed(event) => {
|
||||
if event.text == Key.Escape { root.close(); }
|
||||
accept
|
||||
}
|
||||
}
|
||||
Text {
|
||||
x: 20px; y: 17px; width: parent.width - 120px; height: 24px;
|
||||
text: GalleryStrings.tr("设备网络状态", "Device network status", "Сеть устройств");
|
||||
font-size: 17px; font-weight: 600; color: #293746;
|
||||
}
|
||||
CompactButton {
|
||||
border-radius: UiRadius.control;
|
||||
x: parent.width - 80px; y: 17px; width: 60px; height: 26px;
|
||||
text: GalleryStrings.tr("关闭", "Close", "Закрыть"); clicked => { root.close(); }
|
||||
}
|
||||
Text {
|
||||
x: 20px; y: 48px; width: parent.width - 40px; height: 18px;
|
||||
text: GalleryStrings.tr("已连接设备 ", "Connected devices ", "Подключённые устройства ") + root.devices.length
|
||||
+ GalleryStrings.tr(" 台", "", "");
|
||||
font-size: 11px; color: #7a8490; overflow: elide;
|
||||
}
|
||||
NetworkRow {
|
||||
x: 12px; y: 78px; width: parent.width - 38px; height: 30px;
|
||||
heading: true; background: #f3f5f8; border-radius: UiRadius.control;
|
||||
device: {
|
||||
name: GalleryStrings.tr("设备 / ID", "Device / ID", "Устройство / ID"),
|
||||
};
|
||||
}
|
||||
list := ScrollView {
|
||||
x: 12px; y: 108px; width: parent.width - 24px;
|
||||
height: parent.height - self.y - 12px;
|
||||
viewport-width: self.width - 14px;
|
||||
viewport-height: max(self.height, root.devices.length * root.row-height);
|
||||
horizontal-scrollbar-policy: always-off;
|
||||
for device[index] in root.devices: NetworkRow {
|
||||
y: index * root.row-height; width: list.viewport-width; height: root.row-height;
|
||||
device: device; background: mod(index, 2) == 0 ? white : #f8fafc;
|
||||
Rectangle { y: parent.height - 1px; height: 1px; background: #edf0f4; }
|
||||
}
|
||||
}
|
||||
if root.devices.length == 0: NetworkEmptyState {
|
||||
x: list.x; y: list.y; width: list.width; height: list.height;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { UiRadius } from "ui_radius.slint";
|
||||
import { GalleryStrings } from "device_strings.slint";
|
||||
import { SettingsGroup, SettingsRow, SettingsSelect } from "settings_controls.slint";
|
||||
|
||||
export component GallerySettings inherits Rectangle {
|
||||
in-out property <int> resolution-index: 0;
|
||||
in-out property <int> frame-rate-index: 0;
|
||||
in-out property <int> bandwidth-index: 3;
|
||||
in property <string> error;
|
||||
SettingsGroup {
|
||||
y: 0px; width: parent.width;
|
||||
title: GalleryStrings.tr("画面传输", "Video transmission", "Передача видео"); rows: 3;
|
||||
SettingsRow {
|
||||
label: GalleryStrings.tr("分辨率上限", "Resolution limit", "Разрешение");
|
||||
label-reserve: self.width - 112px;
|
||||
SettingsSelect {
|
||||
border-radius: UiRadius.control;
|
||||
x: 136px; y: 4px; width: parent.width - 150px;
|
||||
current-index <=> root.resolution-index;
|
||||
model: [GalleryStrings.tr("自动", "Auto", "Авто"),
|
||||
"320 × 180", "640 × 360", "1280 × 720", "1920 × 1080", "2560 × 1440", "3840 × 2160",
|
||||
GalleryStrings.tr("原始分辨率", "Original resolution", "Исходное разрешение")];
|
||||
}
|
||||
}
|
||||
SettingsRow {
|
||||
label: GalleryStrings.tr("帧率", "Frame rate", "Частота кадров");
|
||||
label-reserve: self.width - 112px;
|
||||
SettingsSelect {
|
||||
border-radius: UiRadius.control;
|
||||
x: 136px; y: 4px; width: parent.width - 150px;
|
||||
current-index <=> root.frame-rate-index;
|
||||
model: [GalleryStrings.tr("跟随设备设置", "Follow device settings", "Настройки устройства"), "15 FPS", "30 FPS", "60 FPS"];
|
||||
}
|
||||
}
|
||||
SettingsRow {
|
||||
label: GalleryStrings.tr("视频总带宽", "Video budget", "Бюджет видео");
|
||||
label-reserve: self.width - 112px; divider: false;
|
||||
SettingsSelect {
|
||||
border-radius: UiRadius.control;
|
||||
x: 136px; y: 4px; width: parent.width - 150px;
|
||||
current-index <=> root.bandwidth-index;
|
||||
model: ["2 Mbps", "5 Mbps", "10 Mbps", "20 Mbps", "50 Mbps", "100 Mbps"];
|
||||
}
|
||||
}
|
||||
}
|
||||
if root.error != "": Text {
|
||||
x: 2px; y: 144px; width: parent.width - 4px; height: 32px;
|
||||
text: root.error; color: #c53e3e; font-size: 11px; wrap: word-wrap;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
import { UiRadius } from "ui_radius.slint";
|
||||
import { ScrollView } from "std-widgets.slint";
|
||||
import { FontAwesomeIcons } from "common.slint";
|
||||
|
||||
// Shared by history and DeskHub; system choices precede the named groups.
|
||||
export component GroupFilter inherits Rectangle {
|
||||
in property <[string]> model;
|
||||
in property <int> system-count: 2;
|
||||
in property <bool> enabled: true;
|
||||
in property <color> normal-border-color: #c9cdd2;
|
||||
in property <color> hover-border-color: root.normal-border-color;
|
||||
in property <color> focus-border-color: #8db5df;
|
||||
in-out property <int> current-index: 0;
|
||||
callback selected(int);
|
||||
private property <bool> has-divider: root.system-count > 0 && root.model.length > root.system-count;
|
||||
private property <length> list-height: root.model.length * 26px + (root.has-divider ? 6px : 0px);
|
||||
private property <length> visible-height: min(root.list-height, 162px);
|
||||
private property <length> scroll-y: 0px;
|
||||
private property <bool> popup-animation-enabled: false;
|
||||
private property <float> popup-progress: 0;
|
||||
|
||||
animate popup-progress {
|
||||
duration: 140ms; easing: ease-out;
|
||||
enabled: root.popup-animation-enabled;
|
||||
}
|
||||
|
||||
width: 155px; height: 28px;
|
||||
background: !root.enabled ? #f3f4f5 : trigger-touch.has-hover ? #f8f9fb : white;
|
||||
border-width: 1px;
|
||||
border-color: popup.is-open || trigger-focus.has-focus ? root.focus-border-color
|
||||
: trigger-touch.has-hover ? root.hover-border-color : root.normal-border-color;
|
||||
border-radius: UiRadius.control;
|
||||
animate background, border-color { duration: 100ms; easing: ease-out; }
|
||||
forward-focus: trigger-focus;
|
||||
accessible-role: combobox;
|
||||
accessible-enabled: root.enabled;
|
||||
accessible-expandable: true;
|
||||
accessible-expanded: popup.is-open;
|
||||
accessible-value: root.model[root.current-index];
|
||||
|
||||
function reveal-selection() {
|
||||
let top = root.current-index * 26px + (root.has-divider && root.current-index >= root.system-count ? 6px : 0px);
|
||||
if top < -root.scroll-y { root.scroll-y = -top; }
|
||||
if top + 26px > -root.scroll-y + root.visible-height {
|
||||
root.scroll-y = root.visible-height - top - 26px;
|
||||
}
|
||||
root.scroll-y = clamp(root.scroll-y, min(0px, root.visible-height - root.list-height), 0px);
|
||||
}
|
||||
function choose(index: int) {
|
||||
if !root.enabled || root.model.length == 0 { return; }
|
||||
root.current-index = clamp(index, 0, root.model.length - 1);
|
||||
root.reveal-selection();
|
||||
root.selected(root.current-index);
|
||||
}
|
||||
function open-menu() {
|
||||
if root.enabled && root.model.length > 0 {
|
||||
popup-open-timer.running = false;
|
||||
root.popup-animation-enabled = false;
|
||||
root.popup-progress = 0;
|
||||
root.reveal-selection();
|
||||
popup.show();
|
||||
popup-open-timer.running = true;
|
||||
}
|
||||
}
|
||||
// Defer until the popup has its initial frame; animate its contents only,
|
||||
// keeping the native popup bounds and scrolling geometry stable.
|
||||
popup-open-timer := Timer {
|
||||
interval: 16ms;
|
||||
running: false;
|
||||
triggered => {
|
||||
self.running = false;
|
||||
if popup.is-open {
|
||||
root.popup-animation-enabled = true;
|
||||
root.popup-progress = 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
function handle-key(event: KeyEvent) -> EventResult {
|
||||
if !root.enabled { return reject; }
|
||||
if event.text == Key.UpArrow { root.choose(root.current-index - 1); return accept; }
|
||||
if event.text == Key.DownArrow { root.choose(root.current-index + 1); return accept; }
|
||||
if event.text == Key.Return || event.text == " " {
|
||||
if popup.is-open { popup.close(); } else { root.open-menu(); }
|
||||
return accept;
|
||||
}
|
||||
if event.text == Key.Escape && popup.is-open { popup.close(); return accept; }
|
||||
return reject;
|
||||
}
|
||||
Text {
|
||||
x: 10px; width: parent.width - 36px; height: parent.height;
|
||||
text: root.model[root.current-index];
|
||||
color: root.enabled ? #30343b : #92979f;
|
||||
font-size: 12px; overflow: elide; vertical-alignment: center;
|
||||
accessible-role: none;
|
||||
}
|
||||
Text {
|
||||
x: parent.width - 25px; width: 16px; height: parent.height;
|
||||
text: FontAwesomeIcons.angle-down; font-family: "Font Awesome 6 Free";
|
||||
font-weight: 900; font-size: 10px; color: #858c96;
|
||||
horizontal-alignment: center; vertical-alignment: center;
|
||||
transform-rotation: popup.is-open ? 180deg : 0deg;
|
||||
animate transform-rotation { duration: 140ms; easing: ease-out; }
|
||||
accessible-role: none;
|
||||
}
|
||||
trigger-focus := FocusScope {
|
||||
enabled: root.enabled;
|
||||
key-pressed(event) => { return root.handle-key(event); }
|
||||
trigger-touch := TouchArea {
|
||||
enabled: root.enabled;
|
||||
clicked => { trigger-focus.focus(); root.open-menu(); }
|
||||
}
|
||||
}
|
||||
popup := PopupWindow {
|
||||
x: 0px; y: root.height + 4px;
|
||||
width: root.width; height: root.visible-height + 8px;
|
||||
close-policy: close-on-click-outside;
|
||||
forward-focus: popup-focus;
|
||||
popup-focus := FocusScope {
|
||||
key-pressed(event) => { return root.handle-key(event); }
|
||||
Rectangle {
|
||||
y: (1 - root.popup-progress) * -4px;
|
||||
width: parent.width; height: parent.height;
|
||||
opacity: root.popup-progress;
|
||||
background: white; border-width: 1px;
|
||||
border-color: root.normal-border-color; border-radius: UiRadius.menu;
|
||||
ScrollView {
|
||||
x: 4px; y: 4px; width: parent.width - 8px; height: parent.height - 8px;
|
||||
viewport-width: self.width;
|
||||
viewport-height: root.list-height;
|
||||
viewport-y <=> root.scroll-y;
|
||||
Rectangle {
|
||||
width: parent.width; height: root.list-height;
|
||||
if root.has-divider: Rectangle {
|
||||
x: 6px; y: root.system-count * 26px + 3px;
|
||||
width: parent.width - 12px; height: 1px; background: #e9ebee;
|
||||
}
|
||||
for choice[index] in root.model: Rectangle {
|
||||
y: index * 26px + (root.has-divider && index >= root.system-count ? 6px : 0px);
|
||||
width: parent.width; height: 26px; border-radius: UiRadius.control;
|
||||
background: index == root.current-index ? #edf4fc : option-touch.has-hover ? #f1f3f5 : transparent;
|
||||
animate background { duration: 100ms; easing: ease-out; }
|
||||
accessible-role: list-item;
|
||||
accessible-label: choice;
|
||||
Text {
|
||||
x: 8px; width: parent.width - 34px; height: parent.height;
|
||||
text: choice; color: #30343b; font-size: 12px;
|
||||
overflow: elide; vertical-alignment: center;
|
||||
accessible-role: none;
|
||||
}
|
||||
if index == root.current-index: Text {
|
||||
x: parent.width - 24px; width: 16px; height: parent.height;
|
||||
text: FontAwesomeIcons.check; font-family: "Font Awesome 6 Free";
|
||||
font-weight: 900; font-size: 10px; color: #4e87c0;
|
||||
horizontal-alignment: center; vertical-alignment: center;
|
||||
accessible-role: none;
|
||||
}
|
||||
option-touch := TouchArea {
|
||||
clicked => { root.choose(index); popup.close(); }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,9 @@
|
||||
import { UiRadius } from "ui_radius.slint";
|
||||
import { ScrollView } from "std-widgets.slint";
|
||||
import { CompactButton, DialogSurface, IconButton, FontAwesomeIcons, ImGuiFontStyle } from "common.slint";
|
||||
import { IconButton, FontAwesomeIcons } from "common.slint";
|
||||
import { SettingsDialog, SettingsButton } from "settings_controls.slint";
|
||||
import { WorkbenchStyle } from "workbench_controls.slint";
|
||||
import "../assets/fonts/message_spacing.ttf";
|
||||
|
||||
export struct AnnouncementItem {
|
||||
id: string,
|
||||
@@ -13,10 +17,11 @@ export global MessageStrings {
|
||||
in-out property <string> messages: "Messages";
|
||||
in-out property <string> unread: "Unread";
|
||||
in-out property <string> read: "Read";
|
||||
in-out property <string> read-all: "Mark all as read";
|
||||
in-out property <string> empty: "No announcements";
|
||||
in-out property <string> select-message: "Select an announcement to read";
|
||||
in-out property <string> loading: "Loading…";
|
||||
in-out property <string> read-save-error: "Unable to save reading status locally. Open the announcement again to retry.";
|
||||
in-out property <string> read-save-error: "Unable to save reading status locally. Please try again.";
|
||||
in-out property <string> close: "Close";
|
||||
in-out property <string> delete-message: "Delete";
|
||||
in-out property <string> delete-title: "Delete announcement";
|
||||
@@ -25,7 +30,7 @@ export global MessageStrings {
|
||||
in-out property <string> cancel: "Cancel";
|
||||
}
|
||||
|
||||
export component MessagePanel inherits DialogSurface {
|
||||
export component MessagePanel inherits SettingsDialog {
|
||||
in property <[AnnouncementItem]> items;
|
||||
in property <int> unread-count;
|
||||
in property <int> total;
|
||||
@@ -36,6 +41,7 @@ export component MessagePanel inherits DialogSurface {
|
||||
in property <bool> loaded;
|
||||
in property <bool> connected;
|
||||
in property <string> selected-id;
|
||||
in property <string> selected-revision;
|
||||
in property <string> selected-title;
|
||||
in property <styled-text> selected-body;
|
||||
in property <string> selected-date;
|
||||
@@ -44,11 +50,9 @@ export component MessagePanel inherits DialogSurface {
|
||||
callback open-link(string);
|
||||
callback delete-message(string, string);
|
||||
callback load-more;
|
||||
callback close;
|
||||
callback read-all;
|
||||
private property <string> notice-text: root.delete-failed ? MessageStrings.delete-error
|
||||
: root.read-save-failed ? MessageStrings.read-save-error : "";
|
||||
private property <length> content-top: title-bar.height + (root.notice-text == "" ? 14px : 50px);
|
||||
private property <color> separator-color: #e6e8eb;
|
||||
private property <string> pending-delete-id;
|
||||
private property <string> pending-delete-revision;
|
||||
function request-delete(id: string, revision: string) {
|
||||
@@ -67,137 +71,151 @@ export component MessagePanel inherits DialogSurface {
|
||||
return reject;
|
||||
}
|
||||
}
|
||||
background: white;
|
||||
border-radius: 7px;
|
||||
TouchArea { }
|
||||
title-bar := Rectangle {
|
||||
x: 0px; y: 0px; width: parent.width; height: 21px;
|
||||
background: #d3d3d3; border-radius: 7px;
|
||||
Rectangle { y: parent.height - 7px; height: 7px; background: #d3d3d3; }
|
||||
message-heading := Text {
|
||||
x: 6px; width: parent.width - 100px; height: parent.height;
|
||||
text: MessageStrings.messages;
|
||||
font-size: ImGuiFontStyle.base; font-weight: 600; color: #202124;
|
||||
vertical-alignment: center; overflow: elide;
|
||||
}
|
||||
if root.unread-count > 0: Rectangle {
|
||||
x: message-heading.x + message-heading.preferred-width + 8px;
|
||||
y: (parent.height - self.height) / 2; height: 16px;
|
||||
width: max(16px, unread-count-label.preferred-width + 8px);
|
||||
border-radius: 8px; background: #eaf2ff;
|
||||
accessible-role: text;
|
||||
accessible-label: MessageStrings.unread + " " + root.unread-count;
|
||||
unread-count-label := Text {
|
||||
text: "" + root.unread-count; color: #2563eb;
|
||||
font-size: ImGuiFontStyle.small; font-weight: 600;
|
||||
horizontal-alignment: center; vertical-alignment: center;
|
||||
accessible-role: none;
|
||||
}
|
||||
}
|
||||
if root.loading: Text {
|
||||
x: parent.width - 150px; width: 110px; height: parent.height;
|
||||
text: MessageStrings.loading; color: #61656c; font-size: ImGuiFontStyle.small;
|
||||
horizontal-alignment: right; vertical-alignment: center;
|
||||
}
|
||||
IconButton {
|
||||
x: parent.width - 25px; y: 2px; width: 19px; height: parent.height - 4px;
|
||||
border-radius: 3px;
|
||||
icon: FontAwesomeIcons.xmark; icon-size: ImGuiFontStyle.base;
|
||||
icon-color: #202124;
|
||||
hover-background: #bfc2c7; pressed-background: #b2b2b2;
|
||||
accessible-role: button;
|
||||
accessible-label: MessageStrings.close;
|
||||
accessible-action-default => { root.close(); }
|
||||
clicked => { root.close(); }
|
||||
title: MessageStrings.messages;
|
||||
close-label: MessageStrings.close;
|
||||
badge-count: root.unread-count;
|
||||
badge-label: MessageStrings.unread + " " + root.unread-count;
|
||||
header-status: root.loading ? MessageStrings.loading : "";
|
||||
action-text: MessageStrings.read-all;
|
||||
action-enabled: root.loaded && !root.loading && root.unread-count > 0;
|
||||
action => { root.read-all(); }
|
||||
notice := Rectangle {
|
||||
x: 24px; y: root.content-top; width: parent.width - 48px;
|
||||
height: root.notice-text == "" ? 0px : notice-label.preferred-height + 20px;
|
||||
visible: root.notice-text != "";
|
||||
border-radius: UiRadius.control; background: #fef2f2;
|
||||
notice-label := Text {
|
||||
x: 10px; y: 10px; width: parent.width - 20px;
|
||||
height: self.preferred-height;
|
||||
text: root.notice-text; font-size: 12px; wrap: word-wrap; color: #b3261e;
|
||||
}
|
||||
}
|
||||
if root.notice-text != "": Rectangle {
|
||||
x: 16px; y: title-bar.height + 10px; width: parent.width - 32px; height: 28px;
|
||||
border-radius: 4px;
|
||||
background: #fef2f2;
|
||||
Text {
|
||||
x: 8px; width: parent.width - 16px; height: parent.height;
|
||||
text: root.notice-text; font-size: ImGuiFontStyle.body; overflow: elide;
|
||||
color: #b3261e;
|
||||
vertical-alignment: center;
|
||||
message-content := Rectangle {
|
||||
x: 24px; y: root.content-top + (root.notice-text == "" ? 0px : notice.height + 12px);
|
||||
width: parent.width - 48px; height: parent.height - self.y - 24px;
|
||||
background: WorkbenchStyle.surface;
|
||||
border-radius: UiRadius.surface; border-width: 1px; border-color: WorkbenchStyle.border;
|
||||
if root.items.length > 0 || root.selected-id != "": Rectangle {
|
||||
x: 1px; y: 1px; width: content-divider.x - 1px; height: parent.height - 2px;
|
||||
background: WorkbenchStyle.field-surface;
|
||||
border-top-left-radius: UiRadius.surface - 1px;
|
||||
border-bottom-left-radius: UiRadius.surface - 1px;
|
||||
}
|
||||
}
|
||||
announcement-list := Rectangle {
|
||||
x: 16px; y: root.content-top; width: 168px; height: parent.height - self.y - 16px;
|
||||
announcements-scroll := ScrollView {
|
||||
width: parent.width; height: parent.height;
|
||||
VerticalLayout {
|
||||
width: announcements-scroll.width; spacing: 0px; padding: 0px;
|
||||
padding-right: 8px;
|
||||
alignment: start;
|
||||
for item[index] in root.items: Rectangle {
|
||||
height: 54px;
|
||||
background: root.selected-id == item.id ? #eaf2ff : entry-touch.has-hover ? #f0f1f2 : transparent;
|
||||
border-radius: 4px;
|
||||
Text { x: 8px; y: 6px; width: parent.width - 32px; height: 18px; text: item.title; overflow: elide; font-size: ImGuiFontStyle.base; font-weight: item.read ? 400 : 600; color: #202124; vertical-alignment: center; }
|
||||
if !item.read: Rectangle {
|
||||
x: parent.width - 17px; y: 13px; width: 5px; height: 5px;
|
||||
border-radius: 2.5px; background: #2563eb;
|
||||
accessible-role: none;
|
||||
}
|
||||
Text { x: 8px; y: 28px; width: parent.width - 36px; height: 16px; text: item.date; color: #61656c; font-size: ImGuiFontStyle.small; overflow: elide; vertical-alignment: center; }
|
||||
accessible-role: button;
|
||||
accessible-label: item.title + " " + (item.read ? MessageStrings.read : MessageStrings.unread);
|
||||
accessible-action-default => { root.open-message(item.id, item.revision); }
|
||||
entry-touch := TouchArea { mouse-cursor: pointer; clicked => { root.open-message(item.id, item.revision); } }
|
||||
IconButton {
|
||||
x: parent.width - 24px; y: 26px; width: 20px; height: 20px;
|
||||
icon: FontAwesomeIcons.xmark; icon-size: ImGuiFontStyle.small;
|
||||
icon-color: #61656c; danger: true;
|
||||
announcement-list := Rectangle {
|
||||
x: 8px; y: 8px; width: 176px; height: parent.height - 16px;
|
||||
announcements-scroll := ScrollView {
|
||||
width: parent.width; height: parent.height;
|
||||
viewport-width: self.width - 10px;
|
||||
viewport-height: announcement-entries.preferred-height;
|
||||
horizontal-scrollbar-policy: always-off;
|
||||
vertical-scrollbar-policy: as-needed;
|
||||
announcement-entries := VerticalLayout {
|
||||
width: announcements-scroll.viewport-width; spacing: 10px; padding: 0px;
|
||||
alignment: start;
|
||||
for item in root.items: Rectangle {
|
||||
height: 64px;
|
||||
background: root.selected-id == item.id ? #e8efff
|
||||
: entry-touch.pressed ? WorkbenchStyle.pressed-surface
|
||||
: entry-touch.has-hover ? WorkbenchStyle.hover-surface : WorkbenchStyle.surface;
|
||||
border-radius: UiRadius.control;
|
||||
border-width: 1px;
|
||||
border-color: root.selected-id == item.id ? #b5c9f5 : WorkbenchStyle.border;
|
||||
Text {
|
||||
x: 10px; y: 9px; width: parent.width - 30px; height: 20px;
|
||||
text: item.title; overflow: elide; font-size: 13px;
|
||||
font-weight: item.read ? 400 : 600;
|
||||
color: root.selected-id == item.id ? WorkbenchStyle.accent : WorkbenchStyle.text;
|
||||
vertical-alignment: center;
|
||||
}
|
||||
if !item.read: Rectangle {
|
||||
x: parent.width - 15px; y: 16px; width: 6px; height: 6px;
|
||||
border-radius: 3px; background: WorkbenchStyle.accent;
|
||||
accessible-role: none;
|
||||
}
|
||||
Text {
|
||||
x: 10px; y: 35px; width: parent.width - 20px; height: 18px;
|
||||
text: item.date; color: WorkbenchStyle.muted; font-size: 11px;
|
||||
overflow: elide; vertical-alignment: center;
|
||||
}
|
||||
accessible-role: button;
|
||||
accessible-label: MessageStrings.delete-message + " " + item.title;
|
||||
accessible-action-default => { root.request-delete(item.id, item.revision); }
|
||||
clicked => { root.request-delete(item.id, item.revision); }
|
||||
}
|
||||
if index < root.items.length - 1: Rectangle {
|
||||
x: 8px; y: parent.height - 1px;
|
||||
width: parent.width - 16px; height: 1px;
|
||||
background: root.separator-color;
|
||||
accessible-role: none;
|
||||
accessible-label: item.title + " " + (item.read ? MessageStrings.read : MessageStrings.unread);
|
||||
accessible-action-default => { root.open-message(item.id, item.revision); }
|
||||
entry-touch := TouchArea {
|
||||
mouse-cursor: pointer;
|
||||
clicked => { root.open-message(item.id, item.revision); }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if root.items.length == 0 && root.total == 0 && root.loaded && root.connected && !root.loading && !root.failed: Text {
|
||||
x: 16px; y: root.content-top; width: parent.width - 32px; height: announcement-list.height;
|
||||
text: MessageStrings.empty; wrap: word-wrap;
|
||||
color: #61656c; font-size: ImGuiFontStyle.body;
|
||||
horizontal-alignment: center; vertical-alignment: center;
|
||||
}
|
||||
content-divider := Rectangle {
|
||||
x: announcement-list.x + announcement-list.width + 12px;
|
||||
y: root.content-top; width: 1px; height: announcement-list.height;
|
||||
background: root.separator-color;
|
||||
visible: root.items.length > 0 || root.selected-id != "";
|
||||
accessible-role: none;
|
||||
}
|
||||
announcement-details := Rectangle {
|
||||
x: content-divider.x + content-divider.width + 12px;
|
||||
y: root.content-top; width: parent.width - self.x - 16px; height: announcement-list.height;
|
||||
if root.selected-id == "" && root.items.length > 0: Text { x: 8px; width: parent.width - 16px; text: MessageStrings.select-message; color: #61656c; font-size: ImGuiFontStyle.body; wrap: word-wrap; horizontal-alignment: center; vertical-alignment: center; }
|
||||
if root.selected-id != "": details-scroll := ScrollView {
|
||||
width: parent.width; height: parent.height;
|
||||
VerticalLayout {
|
||||
width: details-scroll.width; padding: 8px; padding-top: 6px; padding-right: 16px;
|
||||
spacing: 12px; alignment: start;
|
||||
VerticalLayout {
|
||||
vertical-stretch: 0; spacing: 4px;
|
||||
Text { vertical-stretch: 0; min-height: 18px; text: root.selected-title; wrap: word-wrap; font-size: ImGuiFontStyle.base; font-weight: 600; color: #202124; vertical-alignment: center; }
|
||||
Text { vertical-stretch: 0; min-height: 16px; text: root.selected-date; accessible-label: root.selected-date + " · " + (root.selected-read ? MessageStrings.read : MessageStrings.unread); color: #61656c; font-size: ImGuiFontStyle.small; wrap: word-wrap; vertical-alignment: center; }
|
||||
}
|
||||
Rectangle { height: 1px; background: root.separator-color; }
|
||||
StyledText {
|
||||
vertical-stretch: 0;
|
||||
text: root.selected-body;
|
||||
default-color: #202124; default-font-size: ImGuiFontStyle.body;
|
||||
link-color: #2563eb;
|
||||
link-clicked(url) => { root.open-link(url); }
|
||||
if root.items.length == 0 && root.total == 0 && root.loaded && root.connected && !root.loading && !root.failed: Text {
|
||||
x: 24px; width: parent.width - 48px; height: parent.height;
|
||||
text: MessageStrings.empty; wrap: word-wrap;
|
||||
color: WorkbenchStyle.muted; font-size: 13px;
|
||||
horizontal-alignment: center; vertical-alignment: center;
|
||||
}
|
||||
content-divider := Rectangle {
|
||||
x: announcement-list.x + announcement-list.width + 8px;
|
||||
y: 1px; width: 1px; height: parent.height - 2px;
|
||||
background: WorkbenchStyle.separator;
|
||||
visible: root.items.length > 0 || root.selected-id != "";
|
||||
accessible-role: none;
|
||||
}
|
||||
announcement-details := Rectangle {
|
||||
x: content-divider.x + 17px; y: 16px;
|
||||
width: parent.width - self.x - 16px; height: parent.height - 32px;
|
||||
clip: true;
|
||||
if root.selected-id == "" && root.items.length > 0: Text {
|
||||
width: parent.width; height: parent.height;
|
||||
text: MessageStrings.select-message; color: WorkbenchStyle.muted; font-size: 13px;
|
||||
wrap: word-wrap; horizontal-alignment: center; vertical-alignment: center;
|
||||
}
|
||||
if root.selected-id != "": details-scroll := ScrollView {
|
||||
width: parent.width; height: parent.height;
|
||||
viewport-width: self.width - 12px;
|
||||
viewport-height: details-content.preferred-height;
|
||||
horizontal-scrollbar-policy: always-off;
|
||||
vertical-scrollbar-policy: as-needed;
|
||||
details-content := VerticalLayout {
|
||||
width: details-scroll.viewport-width; padding: 0px; padding-bottom: 4px;
|
||||
spacing: 14px; alignment: start;
|
||||
Rectangle {
|
||||
height: max(28px, selected-heading.preferred-height);
|
||||
selected-heading := VerticalLayout {
|
||||
x: 0px; y: 0px;
|
||||
width: parent.width - 40px; height: self.preferred-height;
|
||||
padding: 0px; spacing: 6px;
|
||||
Text {
|
||||
vertical-stretch: 0; text: root.selected-title; wrap: word-wrap;
|
||||
font-size: 16px; font-weight: 600; color: WorkbenchStyle.text;
|
||||
}
|
||||
Text {
|
||||
vertical-stretch: 0; text: root.selected-date;
|
||||
accessible-label: root.selected-date + " · " + (root.selected-read ? MessageStrings.read : MessageStrings.unread);
|
||||
color: WorkbenchStyle.muted; font-size: 11px; wrap: word-wrap;
|
||||
}
|
||||
}
|
||||
IconButton {
|
||||
x: parent.width - self.width; y: 0px; width: 28px; height: 28px;
|
||||
icon: FontAwesomeIcons.trash-can; icon-size: 12px;
|
||||
icon-color: WorkbenchStyle.muted; danger: true;
|
||||
accessible-role: button;
|
||||
accessible-label: MessageStrings.delete-message + " " + root.selected-title;
|
||||
accessible-action-default => { root.request-delete(root.selected-id, root.selected-revision); }
|
||||
clicked => { root.request-delete(root.selected-id, root.selected-revision); }
|
||||
}
|
||||
}
|
||||
Rectangle { height: 1px; background: WorkbenchStyle.separator; }
|
||||
StyledText {
|
||||
vertical-stretch: 0;
|
||||
text: root.selected-body;
|
||||
default-color: WorkbenchStyle.text; default-font-size: 13px;
|
||||
// Metrics-only family provides 1.5em line height; visible
|
||||
// glyphs continue to use Slint's normal font fallback.
|
||||
default-font-family: "CrossDesk Message Spacing";
|
||||
link-color: WorkbenchStyle.accent;
|
||||
link-clicked(url) => { root.open-link(url); }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -211,30 +229,31 @@ export component MessagePanel inherits DialogSurface {
|
||||
triggered => { root.load-more(); }
|
||||
}
|
||||
if root.pending-delete-id != "": Rectangle {
|
||||
background: #00000042; border-radius: root.border-radius; z: 30;
|
||||
background: #15223850; border-radius: root.border-radius; z: 30;
|
||||
TouchArea { clicked => { confirmation-keys.focus(); } }
|
||||
DialogSurface {
|
||||
SettingsDialog {
|
||||
x: (parent.width - self.width) / 2;
|
||||
y: (parent.height - self.height) / 2;
|
||||
width: 230px; height: confirmation-content.preferred-height;
|
||||
border-radius: root.border-radius;
|
||||
TouchArea { clicked => { confirmation-keys.focus(); } }
|
||||
width: min(360px, root.width - 48px);
|
||||
height: self.content-top + confirmation-content.preferred-height + 24px;
|
||||
title: MessageStrings.delete-title;
|
||||
close-label: MessageStrings.cancel;
|
||||
close => { root.pending-delete-id = ""; }
|
||||
confirmation-content := VerticalLayout {
|
||||
padding: 16px;
|
||||
padding-top: 26px;
|
||||
padding-bottom: 26px;
|
||||
spacing: 20px;
|
||||
Text { text: MessageStrings.delete-confirmation; font-size: ImGuiFontStyle.base; color: #202124; wrap: word-wrap; horizontal-alignment: center; }
|
||||
x: 24px; y: parent.content-top; width: parent.width - 48px;
|
||||
height: self.preferred-height;
|
||||
padding: 0px; spacing: 24px;
|
||||
Text { text: MessageStrings.delete-confirmation; font-size: 13px; color: WorkbenchStyle.text; wrap: word-wrap; }
|
||||
HorizontalLayout {
|
||||
alignment: center; spacing: 8px;
|
||||
cancel-delete-button := CompactButton {
|
||||
width: 72px; text: MessageStrings.cancel;
|
||||
alignment: end; spacing: 8px;
|
||||
cancel-delete-button := SettingsButton {
|
||||
width: 88px; text: MessageStrings.cancel;
|
||||
accessible-role: button; accessible-label: MessageStrings.cancel;
|
||||
accessible-action-default => { cancel-delete-button.clicked(); }
|
||||
clicked => { root.pending-delete-id = ""; }
|
||||
}
|
||||
confirm-delete-button := CompactButton {
|
||||
width: 72px; text: MessageStrings.delete-message; primary: true;
|
||||
confirm-delete-button := SettingsButton {
|
||||
width: 88px; text: MessageStrings.delete-message; primary: true;
|
||||
accessible-role: button; accessible-label: MessageStrings.delete-title;
|
||||
accessible-action-default => { confirm-delete-button.clicked(); }
|
||||
clicked => {
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
/*
|
||||
* @Author: DI JUNKUN
|
||||
* @Date: 2026-10-12
|
||||
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
|
||||
*/
|
||||
|
||||
#ifndef _RELEASE_NOTES_TEXT_H_
|
||||
#define _RELEASE_NOTES_TEXT_H_
|
||||
|
||||
#include <slint.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <cctype>
|
||||
#include <string_view>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
namespace crossdesk::release_notes_text {
|
||||
|
||||
struct Block {
|
||||
slint::StyledText content;
|
||||
bool section_gap = false;
|
||||
};
|
||||
|
||||
inline bool IsThematicBreak(std::string_view line) {
|
||||
const auto start = line.find_first_not_of(' ');
|
||||
// Four leading spaces denote a code block, not a thematic break.
|
||||
if (start == std::string_view::npos || start > 3) return false;
|
||||
const char marker = line[start];
|
||||
if (marker != '-' && marker != '*' && marker != '_') return false;
|
||||
size_t count = 0;
|
||||
for (const char ch : line.substr(start)) {
|
||||
if (ch == marker)
|
||||
++count;
|
||||
else if (ch != ' ' && ch != '\t')
|
||||
return false;
|
||||
}
|
||||
return count >= 3;
|
||||
}
|
||||
|
||||
inline std::vector<Block> Parse(std::string_view markdown) {
|
||||
std::vector<Block> blocks;
|
||||
bool section_gap = false;
|
||||
size_t line_start = 0;
|
||||
while (line_start < markdown.size()) {
|
||||
const size_t newline = markdown.find('\n', line_start);
|
||||
const size_t line_end =
|
||||
newline == std::string_view::npos ? markdown.size() : newline;
|
||||
auto line = markdown.substr(line_start, line_end - line_start);
|
||||
line_start = newline == std::string_view::npos ? markdown.size() : newline + 1;
|
||||
if (!line.empty() && line.back() == '\r') line.remove_suffix(1);
|
||||
|
||||
// StyledText does not support thematic breaks. Represent them with the
|
||||
// same spacing as blank lines instead of rendering raw Markdown markers.
|
||||
if (std::all_of(line.begin(), line.end(), [](unsigned char ch) {
|
||||
return std::isspace(ch) != 0;
|
||||
}) || IsThematicBreak(line)) {
|
||||
section_gap = !blocks.empty();
|
||||
continue;
|
||||
}
|
||||
|
||||
size_t heading_end = 0;
|
||||
while (heading_end < line.size() && heading_end < 6 &&
|
||||
line[heading_end] == '#') {
|
||||
++heading_end;
|
||||
}
|
||||
// The dialog already supplies the release title.
|
||||
if (heading_end > 0 && heading_end < line.size() &&
|
||||
std::isspace(static_cast<unsigned char>(line[heading_end])) != 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
Block block;
|
||||
if (const auto parsed = slint::StyledText::from_markdown(line)) {
|
||||
block.content = *parsed;
|
||||
} else {
|
||||
// Unsupported syntax is valid release content, not a parsing failure.
|
||||
// Preserve it literally when it is outside Slint's Markdown subset.
|
||||
block.content = slint::StyledText::from_plain_text(line);
|
||||
}
|
||||
block.section_gap = section_gap;
|
||||
blocks.push_back(std::move(block));
|
||||
section_gap = false;
|
||||
}
|
||||
return blocks;
|
||||
}
|
||||
|
||||
} // namespace crossdesk::release_notes_text
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,34 @@
|
||||
export global RemoteCursor {
|
||||
// Numeric values mirror crossdesk::RemoteCursorShape on the wire.
|
||||
public pure function shape(value: int) -> MouseCursor {
|
||||
return value == 1 ? MouseCursor.none
|
||||
: value == 2 ? MouseCursor.help
|
||||
: value == 3 ? MouseCursor.pointer
|
||||
: value == 4 ? MouseCursor.progress
|
||||
: value == 5 ? MouseCursor.wait
|
||||
: value == 6 ? MouseCursor.crosshair
|
||||
: value == 7 ? MouseCursor.text
|
||||
: value == 8 ? MouseCursor.alias
|
||||
: value == 9 ? MouseCursor.copy
|
||||
: value == 10 ? MouseCursor.move
|
||||
: value == 11 ? MouseCursor.no-drop
|
||||
: value == 12 ? MouseCursor.not-allowed
|
||||
: value == 13 ? MouseCursor.grab
|
||||
: value == 14 ? MouseCursor.grabbing
|
||||
: value == 15 ? MouseCursor.col-resize
|
||||
: value == 16 ? MouseCursor.row-resize
|
||||
: value == 17 ? MouseCursor.n-resize
|
||||
: value == 18 ? MouseCursor.e-resize
|
||||
: value == 19 ? MouseCursor.s-resize
|
||||
: value == 20 ? MouseCursor.w-resize
|
||||
: value == 21 ? MouseCursor.ne-resize
|
||||
: value == 22 ? MouseCursor.nw-resize
|
||||
: value == 23 ? MouseCursor.se-resize
|
||||
: value == 24 ? MouseCursor.sw-resize
|
||||
: value == 25 ? MouseCursor.ew-resize
|
||||
: value == 26 ? MouseCursor.ns-resize
|
||||
: value == 27 ? MouseCursor.nesw-resize
|
||||
: value == 28 ? MouseCursor.nwse-resize
|
||||
: MouseCursor.default;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { LineEdit } from "std-widgets.slint";
|
||||
import { UiRadius } from "ui_radius.slint";
|
||||
|
||||
// Retain the standard editor's selection, clipboard, clear button and input
|
||||
// method support. Only its outer frame is replaced by the shared shape.
|
||||
export component RoundedLineEdit inherits Rectangle {
|
||||
in-out property <string> text;
|
||||
in property <string> placeholder-text;
|
||||
in property <InputType> input-type: InputType.text;
|
||||
in property <bool> enabled: true;
|
||||
in property <length> font-size: 12px;
|
||||
in property <color> normal-border-color: #dfe6ef;
|
||||
in property <brush> surrounding-background: white;
|
||||
callback accepted();
|
||||
callback edited();
|
||||
height: 30px;
|
||||
border-radius: UiRadius.control;
|
||||
background: white;
|
||||
clip: true;
|
||||
forward-focus: editor;
|
||||
|
||||
editor := LineEdit {
|
||||
x: -1px; y: -1px;
|
||||
width: parent.width + 2px; height: parent.height + 2px;
|
||||
text <=> root.text;
|
||||
placeholder-text: root.placeholder-text;
|
||||
input-type: root.input-type;
|
||||
enabled: root.enabled;
|
||||
font-size: root.font-size;
|
||||
accepted => { root.accepted(); }
|
||||
edited => { root.edited(); }
|
||||
}
|
||||
// The software renderer clips children to rectangular bounds. Mask the
|
||||
// standard editor's corners before drawing our rounded border on top.
|
||||
Rectangle {
|
||||
x: -root.border-radius; y: -root.border-radius;
|
||||
width: parent.width + 2 * root.border-radius;
|
||||
height: parent.height + 2 * root.border-radius;
|
||||
border-width: root.border-radius;
|
||||
border-radius: 2 * root.border-radius;
|
||||
border-color: root.surrounding-background;
|
||||
}
|
||||
Rectangle {
|
||||
width: parent.width; height: parent.height;
|
||||
border-radius: root.border-radius;
|
||||
border-width: editor.has-focus ? 2px : 1px;
|
||||
border-color: editor.has-focus ? #3564e3 : root.normal-border-color;
|
||||
background: transparent;
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { UiRadius } from "ui_radius.slint";
|
||||
import { ScrollView } from "std-widgets.slint";
|
||||
import { FontAwesomeIcons, ImGuiLineStyle, FloatingBarStyle } from "common.slint";
|
||||
|
||||
@@ -27,7 +28,7 @@ component SessionButton inherits Rectangle {
|
||||
callback clicked;
|
||||
width: label.preferred-width + 20px;
|
||||
height: 28px;
|
||||
border-radius: 5px;
|
||||
border-radius: UiRadius.control;
|
||||
border-width: 1px;
|
||||
border-color: root.danger ? #d92d20 : #c9ced7;
|
||||
background: root.danger ? (root.pointer-feedback && touch.pressed ? #b42318 : root.pointer-feedback && touch.has-hover ? #f04438 : #d92d20)
|
||||
@@ -73,7 +74,7 @@ component SessionButton inherits Rectangle {
|
||||
}
|
||||
if root.enabled && keyboard.has-focus && root.keyboard-focus: Rectangle {
|
||||
x: 2px; y: 2px; width: parent.width - 4px; height: parent.height - 4px;
|
||||
border-radius: 3px; border-width: 1px; border-color: root.danger ? white : #2463c7;
|
||||
border-radius: UiRadius.control - 2px; border-width: 1px; border-color: root.danger ? white : #2463c7;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -172,7 +173,7 @@ export component ServerWindow inherits Window {
|
||||
Rectangle {
|
||||
width: root.width; height: root.height;
|
||||
background: white; border-width: 1px; border-color: ImGuiLineStyle.border;
|
||||
border-radius: 8px;
|
||||
border-radius: UiRadius.surface;
|
||||
Text {
|
||||
x: 10px; y: 10px; width: parent.width - 20px; height: 24px;
|
||||
text: ServerStrings.heading + (root.controllers.length > 1 ? " · " + root.controllers.length : "");
|
||||
@@ -189,7 +190,7 @@ export component ServerWindow inherits Window {
|
||||
select := Rectangle {
|
||||
x: 0px; y: 0px;
|
||||
width: parent.width - (root.controllers.length > 1 ? disconnect.width + 4px : status.preferred-width + 8px);
|
||||
height: parent.height; border-radius: 4px;
|
||||
height: parent.height; border-radius: UiRadius.control;
|
||||
background: root.controllers.length > 1 && index == root.selected-controller ? #eef5fd : select-touch.has-hover ? #f3f4f6 : transparent;
|
||||
if root.controllers.length > 1: Rectangle {
|
||||
x: 4px; y: (parent.height - 13px) / 2; width: 13px; height: 13px;
|
||||
@@ -274,7 +275,7 @@ export component ServerWindow inherits Window {
|
||||
// Draw outside the scrolling list so its last row can reveal its name.
|
||||
if root.hovered-name != "": Rectangle {
|
||||
x: 10px; y: root.tooltip-y; width: 205px; height: tip.preferred-height + 12px; z: 20;
|
||||
background: #24272d; border-radius: 5px;
|
||||
background: #24272d; border-radius: UiRadius.control;
|
||||
tip := Text { x: 6px; y: 6px; width: parent.width - 12px; text: root.hovered-name; color: white; font-size: 12px; wrap: word-wrap; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
import { UiRadius } from "ui_radius.slint";
|
||||
import { CompactComboBox, ToggleSwitch, DialogSurface, IconButton, FontAwesomeIcons } from "common.slint";
|
||||
import { WorkbenchStyle } from "workbench_controls.slint";
|
||||
|
||||
// Shared visual treatment for the global settings pages.
|
||||
export component SettingsButton inherits Rectangle {
|
||||
in property <string> text;
|
||||
in property <bool> primary: false;
|
||||
in property <bool> quiet: false;
|
||||
in property <bool> align-left: false;
|
||||
in property <bool> enabled: true;
|
||||
callback clicked();
|
||||
width: 88px; height: 32px; border-radius: UiRadius.control;
|
||||
background: !root.enabled ? #f0f3f7
|
||||
: root.primary ? (touch.pressed ? #1e4fa3 : touch.has-hover ? #2866c9 : #3275dc)
|
||||
: touch.pressed ? #e9eef5 : touch.has-hover ? #f0f4fa : root.quiet ? transparent : white;
|
||||
border-width: root.quiet || root.primary ? 0px : 1px;
|
||||
border-color: !root.enabled ? WorkbenchStyle.border
|
||||
: touch.has-hover ? WorkbenchStyle.hover-border : WorkbenchStyle.control-border;
|
||||
accessible-role: button; accessible-label: root.text;
|
||||
accessible-action-default => { if root.enabled { root.clicked(); } }
|
||||
Text {
|
||||
x: 12px; width: parent.width - 24px;
|
||||
text: root.text; font-size: 12px; font-weight: root.primary ? 600 : 400;
|
||||
color: !root.enabled ? #98a2b3 : root.primary ? white : root.quiet ? #65758b : #3c4d63;
|
||||
horizontal-alignment: root.align-left ? left : center; vertical-alignment: center; overflow: elide;
|
||||
}
|
||||
touch := TouchArea { enabled: root.enabled; mouse-cursor: pointer; clicked => { root.clicked(); } }
|
||||
}
|
||||
|
||||
export component SettingsDialog inherits DialogSurface {
|
||||
in property <string> title;
|
||||
in property <string> close-label;
|
||||
in property <int> badge-count: 0;
|
||||
in property <string> badge-label;
|
||||
in property <string> header-status;
|
||||
in property <string> action-text;
|
||||
in property <bool> action-enabled: true;
|
||||
out property <length> content-top: 64px;
|
||||
callback action();
|
||||
callback close();
|
||||
background: #f8fafc;
|
||||
border-color: WorkbenchStyle.separator;
|
||||
drop-shadow-color: #14294424;
|
||||
drop-shadow-blur: 24px;
|
||||
drop-shadow-offset-y: 8px;
|
||||
TouchArea { }
|
||||
HorizontalLayout {
|
||||
x: 24px; y: 18px; width: parent.width - 48px; height: 28px;
|
||||
spacing: 10px;
|
||||
Text {
|
||||
text: root.title; font-size: 20px; font-weight: 600;
|
||||
color: WorkbenchStyle.text; vertical-alignment: center;
|
||||
}
|
||||
if root.badge-count > 0: Rectangle {
|
||||
width: max(24px, count-label.preferred-width + 14px); height: 22px;
|
||||
border-radius: 11px; background: #e8efff;
|
||||
accessible-role: text; accessible-label: root.badge-label;
|
||||
count-label := Text {
|
||||
text: "" + root.badge-count; color: WorkbenchStyle.accent;
|
||||
font-size: 11px; font-weight: 600;
|
||||
horizontal-alignment: center; vertical-alignment: center;
|
||||
accessible-role: none;
|
||||
}
|
||||
}
|
||||
Rectangle { horizontal-stretch: 1; }
|
||||
if root.header-status != "": Text {
|
||||
text: root.header-status; color: WorkbenchStyle.muted;
|
||||
font-size: 11px; vertical-alignment: center;
|
||||
}
|
||||
if root.action-text != "": SettingsButton {
|
||||
width: action-label-metrics.preferred-width + 24px; height: 28px;
|
||||
text: root.action-text; enabled: root.action-enabled;
|
||||
clicked => { root.action(); }
|
||||
action-label-metrics := Text { visible: false; text: root.action-text; font-size: 12px; }
|
||||
}
|
||||
IconButton {
|
||||
width: 28px; height: 28px;
|
||||
icon: FontAwesomeIcons.xmark; icon-size: 14px;
|
||||
icon-color: WorkbenchStyle.muted;
|
||||
hover-background: WorkbenchStyle.hover-surface;
|
||||
pressed-background: WorkbenchStyle.pressed-surface;
|
||||
accessible-role: button; accessible-label: root.close-label;
|
||||
accessible-action-default => { root.close(); }
|
||||
clicked => { root.close(); }
|
||||
}
|
||||
}
|
||||
@children
|
||||
}
|
||||
|
||||
export component SettingsSelect inherits CompactComboBox {
|
||||
subtle: true;
|
||||
show-arrow: false;
|
||||
normal-border-color: WorkbenchStyle.control-border;
|
||||
hover-border-color: WorkbenchStyle.hover-border;
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
export component SettingsGroup inherits Rectangle {
|
||||
in property <string> title;
|
||||
in property <int> rows: 1;
|
||||
height: 24px + root.rows * 36px;
|
||||
Text {
|
||||
x: 2px; y: 0px; width: parent.width - 4px; height: 18px; text: root.title;
|
||||
font-size: 11px; font-weight: 600; color: #718096; vertical-alignment: center;
|
||||
}
|
||||
Rectangle {
|
||||
y: 24px; width: parent.width; height: parent.height - self.y;
|
||||
background: white; border-radius: UiRadius.surface; border-color: WorkbenchStyle.border; border-width: 1px;
|
||||
VerticalLayout { padding: 0px; spacing: 0px; @children }
|
||||
}
|
||||
}
|
||||
|
||||
export component SettingsRow inherits Rectangle {
|
||||
in property <string> label;
|
||||
in property <bool> enabled: true;
|
||||
in property <bool> divider: true;
|
||||
in property <length> label-reserve: 128px;
|
||||
height: 36px;
|
||||
Text {
|
||||
x: 12px; width: parent.width - root.label-reserve; height: parent.height;
|
||||
text: root.label; color: root.enabled ? #34445a : #9ba5b3;
|
||||
font-size: 12px; overflow: elide; vertical-alignment: center;
|
||||
}
|
||||
if root.divider: Rectangle {
|
||||
x: 12px; y: parent.height - 1px; width: parent.width - 24px; height: 1px; background: #d9e1ec;
|
||||
}
|
||||
@children
|
||||
}
|
||||
|
||||
export component SettingsToggleRow inherits SettingsRow {
|
||||
in-out property <bool> checked;
|
||||
label-reserve: 64px;
|
||||
ToggleSwitch {
|
||||
x: parent.width - self.width - 12px; y: (parent.height - self.height) / 2;
|
||||
checked <=> root.checked; enabled: root.enabled;
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
import { UiRadius } from "ui_radius.slint";
|
||||
import { ComboBox, ScrollView } from "std-widgets.slint";
|
||||
import { RemoteCursor } from "remote_cursor.slint";
|
||||
import { FontAwesomeIcons, IconButton, PrimaryButton, DialogSurface, ImGuiLineStyle, ImGuiFontStyle, FloatingBarStyle } from "common.slint";
|
||||
|
||||
export struct StreamTab {
|
||||
@@ -97,7 +99,7 @@ component ControlBarButton inherits Rectangle {
|
||||
background: root.muted ? #eef0f3 : touch.pressed && root.action-enabled ? #0f87fa
|
||||
: touch.has-hover ? #4296fa
|
||||
: root.selected ? #4296fa : #4296fa66;
|
||||
border-radius: 3px;
|
||||
border-radius: UiRadius.control;
|
||||
|
||||
Text {
|
||||
text: root.icon;
|
||||
@@ -158,7 +160,7 @@ component VideoSettingRow inherits VerticalLayout {
|
||||
height: 27px;
|
||||
background: !root.enabled ? #f1f1f1 : root.selected == index ? #d7eaff
|
||||
: option-touch.has-hover ? #eaf3ff : #f5f6f8;
|
||||
border-radius: 4px;
|
||||
border-radius: UiRadius.control;
|
||||
border-width: 1px;
|
||||
border-color: root.enabled && root.selected == index ? #4296fa : #dce0e6;
|
||||
Text { text: option; color: root.enabled ? #30343b : #8b8f95;
|
||||
@@ -714,36 +716,8 @@ export component StreamWindow inherits Window {
|
||||
accept
|
||||
}
|
||||
pointer := TouchArea {
|
||||
mouse-cursor: !root.remote-cursor-active ? MouseCursor.default
|
||||
: root.remote-cursor-shape == 1 ? MouseCursor.none
|
||||
: root.remote-cursor-shape == 2 ? MouseCursor.help
|
||||
: root.remote-cursor-shape == 3 ? MouseCursor.pointer
|
||||
: root.remote-cursor-shape == 4 ? MouseCursor.progress
|
||||
: root.remote-cursor-shape == 5 ? MouseCursor.wait
|
||||
: root.remote-cursor-shape == 6 ? MouseCursor.crosshair
|
||||
: root.remote-cursor-shape == 7 ? MouseCursor.text
|
||||
: root.remote-cursor-shape == 8 ? MouseCursor.alias
|
||||
: root.remote-cursor-shape == 9 ? MouseCursor.copy
|
||||
: root.remote-cursor-shape == 10 ? MouseCursor.move
|
||||
: root.remote-cursor-shape == 11 ? MouseCursor.no-drop
|
||||
: root.remote-cursor-shape == 12 ? MouseCursor.not-allowed
|
||||
: root.remote-cursor-shape == 13 ? MouseCursor.grab
|
||||
: root.remote-cursor-shape == 14 ? MouseCursor.grabbing
|
||||
: root.remote-cursor-shape == 15 ? MouseCursor.col-resize
|
||||
: root.remote-cursor-shape == 16 ? MouseCursor.row-resize
|
||||
: root.remote-cursor-shape == 17 ? MouseCursor.n-resize
|
||||
: root.remote-cursor-shape == 18 ? MouseCursor.e-resize
|
||||
: root.remote-cursor-shape == 19 ? MouseCursor.s-resize
|
||||
: root.remote-cursor-shape == 20 ? MouseCursor.w-resize
|
||||
: root.remote-cursor-shape == 21 ? MouseCursor.ne-resize
|
||||
: root.remote-cursor-shape == 22 ? MouseCursor.nw-resize
|
||||
: root.remote-cursor-shape == 23 ? MouseCursor.se-resize
|
||||
: root.remote-cursor-shape == 24 ? MouseCursor.sw-resize
|
||||
: root.remote-cursor-shape == 25 ? MouseCursor.ew-resize
|
||||
: root.remote-cursor-shape == 26 ? MouseCursor.ns-resize
|
||||
: root.remote-cursor-shape == 27 ? MouseCursor.nesw-resize
|
||||
: root.remote-cursor-shape == 28 ? MouseCursor.nwse-resize
|
||||
: MouseCursor.default;
|
||||
mouse-cursor: root.remote-cursor-active
|
||||
? RemoteCursor.shape(root.remote-cursor-shape) : MouseCursor.default;
|
||||
pointer-event(event) => {
|
||||
let over-control = self.mouse-x >= control.x
|
||||
&& self.mouse-x <= control.x + control.width
|
||||
@@ -994,7 +968,7 @@ export component StreamWindow inherits Window {
|
||||
width: parent.width;
|
||||
height: root.stats-header-height;
|
||||
background: #f3f5f8;
|
||||
border-radius: 4px;
|
||||
border-radius: UiRadius.control;
|
||||
Text {
|
||||
x: stats-table.label-column-width + 4px;
|
||||
width: stats-table.value-column-width - 8px;
|
||||
@@ -1031,7 +1005,7 @@ export component StreamWindow inherits Window {
|
||||
width: parent.width;
|
||||
height: stats-table.row-height;
|
||||
background: index == root.stats-rows.length - 1 ? #edf3fc : transparent;
|
||||
border-radius: 4px;
|
||||
border-radius: UiRadius.control;
|
||||
Text {
|
||||
x: 4px;
|
||||
width: stats-table.label-column-width - 8px;
|
||||
@@ -1160,7 +1134,7 @@ export component StreamWindow inherits Window {
|
||||
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;
|
||||
border-radius: UiRadius.menu; z: 11;
|
||||
TouchArea {
|
||||
pointer-event(event) => { if event.kind == PointerEventKind.down { root.suppress-next-remote-pointer-release = true; } }
|
||||
scroll-event(event) => { accept }
|
||||
@@ -1210,7 +1184,7 @@ export component StreamWindow inherits Window {
|
||||
background: white;
|
||||
border-width: 1px;
|
||||
border-color: ImGuiLineStyle.border;
|
||||
border-radius: 5px;
|
||||
border-radius: UiRadius.menu;
|
||||
clip: true;
|
||||
z: 11;
|
||||
|
||||
@@ -1222,7 +1196,7 @@ export component StreamWindow inherits Window {
|
||||
: index == display-menu.hovered-index ? #d8e6f8
|
||||
: index == root.selected-display ? #e8eef9
|
||||
: transparent;
|
||||
border-radius: 3px;
|
||||
border-radius: UiRadius.control;
|
||||
Text {
|
||||
x: 8px;
|
||||
width: parent.width - 34px;
|
||||
@@ -1273,7 +1247,7 @@ export component StreamWindow inherits Window {
|
||||
|
||||
if root.shortcut-menu-open: Rectangle {
|
||||
x: control.x + shortcut-button.x; y: control.y + 40px; width: 180px; height: 68px;
|
||||
background: white; border-width: 1px; border-color: ImGuiLineStyle.border; border-radius: 5px; z: 11;
|
||||
background: white; border-width: 1px; border-color: ImGuiLineStyle.border; border-radius: UiRadius.menu; z: 11;
|
||||
VerticalLayout {
|
||||
padding: 4px; spacing: 1px;
|
||||
for shortcut in ["Ctrl+Alt+Del", "Win+L"]: Rectangle {
|
||||
@@ -1307,7 +1281,7 @@ export component StreamWindow inherits Window {
|
||||
// Anchor to the button row so expanded stats stay beneath the tooltip.
|
||||
y: control.y + root.control-toolbar-height + self.height + 8px <= video.height
|
||||
? control.y + root.control-toolbar-height + 4px : max(4px, control.y - self.height - 4px);
|
||||
background: #30343bf5; border-radius: 5px; z: 12;
|
||||
background: #30343bf5; border-radius: UiRadius.control; z: 12;
|
||||
control-tip := Text {
|
||||
x: 8px; y: 6px; width: parent.width - 16px;
|
||||
text: root.hovered-control.text;
|
||||
@@ -1331,7 +1305,7 @@ export component StreamWindow inherits Window {
|
||||
width: min(520px, parent.width - 48px);
|
||||
height: max(208px, remote-update-message.y + remote-update-message.height + 92px);
|
||||
border-color: #e5e9f0;
|
||||
border-radius: 14px;
|
||||
border-radius: UiRadius.surface;
|
||||
drop-shadow-color: #07142b30;
|
||||
drop-shadow-blur: 32px;
|
||||
drop-shadow-offset-y: 12px;
|
||||
@@ -1347,7 +1321,7 @@ export component StreamWindow inherits Window {
|
||||
}
|
||||
Rectangle {
|
||||
x: 32px; y: 32px; width: 48px; height: 48px;
|
||||
background: #edf4ff; border-radius: 12px;
|
||||
background: #edf4ff; border-radius: UiRadius.surface;
|
||||
Path {
|
||||
x: 12px; y: 12px; width: 24px; height: 24px;
|
||||
viewbox-width: 24; viewbox-height: 24;
|
||||
@@ -1382,7 +1356,7 @@ export component StreamWindow inherits Window {
|
||||
PrimaryButton {
|
||||
x: parent.width - 136px; y: parent.height - 68px;
|
||||
width: 104px; height: 36px;
|
||||
border-radius: 7px;
|
||||
border-radius: UiRadius.control;
|
||||
text: StreamStrings.remote-update-dismiss;
|
||||
text-size: 14px;
|
||||
clicked => { root.dismiss-remote-update(); }
|
||||
@@ -1392,7 +1366,7 @@ export component StreamWindow inherits Window {
|
||||
// Transfer progress overlay at bottom-left.
|
||||
if root.file-transfer-visible: Rectangle {
|
||||
x: 18px; y: parent.height - min(210px, parent.height * 0.35); width: min(430px, parent.width * 0.6); height: min(190px, parent.height * 0.32);
|
||||
background: #fffffff0; border-width: 1px; border-color: ImGuiLineStyle.border; border-radius: 6px; z: 7;
|
||||
background: #fffffff0; border-width: 1px; border-color: ImGuiLineStyle.border; border-radius: UiRadius.surface; z: 7;
|
||||
Text { x: 12px; y: 8px; text: StreamStrings.file-transfer; color: #2d3138; font-weight: 600; }
|
||||
IconButton { x: parent.width - 32px; y: 1px; icon: FontAwesomeIcons.xmark; icon-size: 13px; clicked => { root.close-file-transfer(); } }
|
||||
ScrollView {
|
||||
|
||||
@@ -40,11 +40,13 @@ inline int ApplyMainWindowStrings(
|
||||
int language_index) {
|
||||
const int language =
|
||||
localization::detail::ClampLanguageIndex(language_index);
|
||||
window->global<ui::GalleryStrings>().set_language(language);
|
||||
auto& strings = window->global<ui::UiStrings>();
|
||||
auto& messages = window->global<ui::MessageStrings>();
|
||||
messages.set_messages(Text(localization::messages[language]));
|
||||
messages.set_unread(Text(localization::message_unread[language]));
|
||||
messages.set_read(Text(localization::message_read[language]));
|
||||
messages.set_read_all(Text(localization::message_read_all[language]));
|
||||
messages.set_empty(Text(localization::message_empty[language]));
|
||||
messages.set_select_message(Text(localization::message_select[language]));
|
||||
messages.set_loading(Text(localization::message_loading[language]));
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
// Logical pixels: compact controls, popup menus, and larger surfaces.
|
||||
// Circular indicators, switches, progress bars, and native window chrome
|
||||
// retain radii derived from their own geometry.
|
||||
export global UiRadius {
|
||||
out property <length> control: 6px;
|
||||
out property <length> menu: 8px;
|
||||
out property <length> surface: 12px;
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
import { UiRadius } from "ui_radius.slint";
|
||||
// Colors and spacing for the connection workbench; shapes are shared app-wide.
|
||||
export global WorkbenchStyle {
|
||||
out property <color> canvas: #f2f5f9;
|
||||
out property <color> surface: #ffffff;
|
||||
out property <color> border: #c6cfdd;
|
||||
out property <color> control-border: #b2bfd1;
|
||||
out property <color> hover-border: #7f97b8;
|
||||
out property <color> field-surface: #f5f7fb;
|
||||
out property <color> hover-surface: #eef3fb;
|
||||
out property <color> pressed-surface: #e4ebf6;
|
||||
out property <color> separator: #e1e7ef;
|
||||
out property <color> text: #1c2940;
|
||||
out property <color> muted: #69788e;
|
||||
out property <color> accent: #3564e3;
|
||||
out property <color> success: #18835a;
|
||||
}
|
||||
|
||||
export component WorkbenchButton inherits Rectangle {
|
||||
in property <string> text;
|
||||
in property <string> icon;
|
||||
in property <string> tooltip;
|
||||
in property <bool> tooltip-suppressed: false;
|
||||
in property <color> icon-color: root.primary ? white : WorkbenchStyle.muted;
|
||||
in property <bool> primary: false;
|
||||
in property <bool> quiet: false;
|
||||
in property <bool> enabled: true;
|
||||
out property <bool> has-focus: button-focus.has-focus;
|
||||
private property <bool> keyboard-focus: false;
|
||||
private property <bool> show-focus: button-focus.has-focus && root.keyboard-focus;
|
||||
private property <bool> tooltip-active: root.enabled && root.tooltip != ""
|
||||
&& !root.tooltip-suppressed && !touch.pressed
|
||||
&& (touch.has-hover || root.show-focus);
|
||||
private property <bool> tooltip-visible: false;
|
||||
changed tooltip-active => { root.tooltip-visible = false; }
|
||||
callback clicked;
|
||||
width: 112px;
|
||||
height: 36px;
|
||||
border-radius: UiRadius.control;
|
||||
border-width: root.show-focus ? 2px : 1px;
|
||||
border-color: root.show-focus ? (root.primary ? #193da0 : WorkbenchStyle.accent)
|
||||
: root.primary ? WorkbenchStyle.accent
|
||||
: touch.has-hover ? WorkbenchStyle.hover-border
|
||||
: root.quiet ? transparent : WorkbenchStyle.control-border;
|
||||
background: root.primary
|
||||
? (touch.pressed ? #254cc0 : touch.has-hover ? #4574f0 : WorkbenchStyle.accent)
|
||||
: (touch.pressed ? WorkbenchStyle.pressed-surface : touch.has-hover ? WorkbenchStyle.hover-surface : root.quiet ? transparent : white);
|
||||
opacity: root.enabled ? 1 : 0.45;
|
||||
animate background, border-color { duration: 120ms; }
|
||||
forward-focus: button-focus;
|
||||
accessible-role: button;
|
||||
accessible-label: root.tooltip != "" ? root.tooltip : root.text;
|
||||
accessible-enabled: root.enabled;
|
||||
accessible-action-default => { if root.enabled { root.clicked(); } }
|
||||
|
||||
HorizontalLayout {
|
||||
padding-left: 10px;
|
||||
padding-right: 10px;
|
||||
spacing: 8px;
|
||||
alignment: center;
|
||||
if root.icon != "": Text {
|
||||
text: root.icon;
|
||||
font-family: "Font Awesome 6 Free";
|
||||
font-weight: 900;
|
||||
font-size: 14px;
|
||||
color: root.icon-color;
|
||||
vertical-alignment: center;
|
||||
accessible-role: none;
|
||||
}
|
||||
if root.text != "": Text {
|
||||
text: root.text;
|
||||
font-size: 13px;
|
||||
font-weight: root.primary ? 600 : 500;
|
||||
color: root.primary ? white : WorkbenchStyle.text;
|
||||
vertical-alignment: center;
|
||||
accessible-role: none;
|
||||
}
|
||||
}
|
||||
button-focus := FocusScope {
|
||||
enabled: root.enabled;
|
||||
focus-gained(reason) => {
|
||||
if reason == FocusReason.tab-navigation {
|
||||
root.keyboard-focus = true;
|
||||
} else if reason == FocusReason.pointer-click {
|
||||
root.keyboard-focus = false;
|
||||
}
|
||||
}
|
||||
key-pressed(event) => {
|
||||
root.keyboard-focus = true;
|
||||
if event.text == Key.Return || event.text == " " {
|
||||
root.clicked();
|
||||
return accept;
|
||||
}
|
||||
return reject;
|
||||
}
|
||||
touch := TouchArea {
|
||||
enabled: root.enabled;
|
||||
mouse-cursor: pointer;
|
||||
pointer-event(event) => {
|
||||
if event.kind == PointerEventKind.down {
|
||||
root.keyboard-focus = false;
|
||||
}
|
||||
}
|
||||
// Keep focus for subsequent keyboard activation without leaving a
|
||||
// keyboard focus ring behind after a pointer click.
|
||||
clicked => { root.keyboard-focus = false; button-focus.focus(); root.clicked(); }
|
||||
}
|
||||
}
|
||||
tooltip-timer := Timer {
|
||||
interval: 450ms;
|
||||
running: root.tooltip-active && !root.tooltip-visible;
|
||||
triggered => { root.tooltip-visible = true; }
|
||||
}
|
||||
if root.tooltip-active && root.tooltip-visible: Rectangle {
|
||||
x: min(0px, root.width - self.width);
|
||||
y: root.height + 6px;
|
||||
width: min(260px, tip.preferred-width + 20px);
|
||||
height: max(28px, tip.preferred-height + 12px);
|
||||
background: #24324a;
|
||||
border-radius: UiRadius.control;
|
||||
z: 20;
|
||||
tip := Text {
|
||||
x: 10px; y: 6px;
|
||||
width: parent.width - 20px;
|
||||
height: parent.height - 12px;
|
||||
text: root.tooltip;
|
||||
font-size: 12px;
|
||||
color: white;
|
||||
wrap: word-wrap;
|
||||
horizontal-alignment: center;
|
||||
vertical-alignment: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A raised surface keeps transient action feedback distinct from the workbench.
|
||||
export component WorkbenchNotice inherits Rectangle {
|
||||
in property <string> text;
|
||||
in property <string> icon;
|
||||
in property <bool> error: false;
|
||||
in property <length> width-limit: 360px;
|
||||
private property <color> foreground: root.error ? #b42318 : #146c49;
|
||||
width: min(root.width-limit, max(280px, message.preferred-width + 64px));
|
||||
height: 64px;
|
||||
background: root.error ? #fff1f0 : #e9f6ef;
|
||||
border-width: 1px;
|
||||
border-color: root.error ? #f0c4bf : #b9dfc9;
|
||||
border-radius: UiRadius.surface;
|
||||
drop-shadow-color: #14294424;
|
||||
drop-shadow-blur: 24px;
|
||||
drop-shadow-offset-y: 6px;
|
||||
accessible-role: AccessibleRole.text;
|
||||
accessible-label: root.text;
|
||||
Text {
|
||||
x: message.x - 28px; width: 18px; height: parent.height;
|
||||
text: root.icon;
|
||||
font-family: "Font Awesome 6 Free";
|
||||
font-weight: 900;
|
||||
font-size: 16px;
|
||||
color: root.foreground;
|
||||
horizontal-alignment: center;
|
||||
vertical-alignment: center;
|
||||
accessible-role: none;
|
||||
}
|
||||
message := Text {
|
||||
x: (parent.width - self.width + 28px) / 2;
|
||||
width: min(self.preferred-width, parent.width - 64px);
|
||||
height: parent.height;
|
||||
text: root.text;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: root.foreground;
|
||||
overflow: elide;
|
||||
vertical-alignment: center;
|
||||
accessible-role: none;
|
||||
}
|
||||
}
|
||||
|
||||
export component WorkbenchCard inherits Rectangle {
|
||||
background: WorkbenchStyle.surface;
|
||||
border-width: 1px;
|
||||
border-color: WorkbenchStyle.border;
|
||||
border-radius: UiRadius.surface;
|
||||
}
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
#import <AppKit/AppKit.h>
|
||||
|
||||
#include <cmath>
|
||||
|
||||
@interface CrossDeskStreamFullscreenTarget : NSObject
|
||||
- (void)toggleStreamFullscreen:(id)sender;
|
||||
@end
|
||||
@@ -17,6 +19,11 @@
|
||||
namespace crossdesk {
|
||||
namespace {
|
||||
|
||||
__weak NSWindow *gallery_window = nil;
|
||||
bool gallery_fullscreen = false;
|
||||
NSRect gallery_restore_frame = NSZeroRect;
|
||||
NSWindowStyleMask gallery_restore_style = NSWindowStyleMaskTitled;
|
||||
NSApplicationPresentationOptions gallery_restore_presentation;
|
||||
__weak NSWindow *stream_window = nil;
|
||||
__weak NSView *stream_slint_view = nil;
|
||||
CrossDeskStreamFullscreenTarget *stream_fullscreen_target = nil;
|
||||
@@ -53,6 +60,14 @@ void SetTitlebarButtonsHidden(NSWindow *window, BOOL hidden) {
|
||||
[window standardWindowButton:NSWindowZoomButton].hidden = hidden;
|
||||
}
|
||||
|
||||
void HideMainWindowZoomButton(NSWindow *window) {
|
||||
NSButton *button = [window standardWindowButton:NSWindowZoomButton];
|
||||
button.target = nil;
|
||||
button.action = nil;
|
||||
button.enabled = NO;
|
||||
button.hidden = YES;
|
||||
}
|
||||
|
||||
void CenterWindowOnScreen(NSWindow *window, NSScreen *screen) {
|
||||
if (window == nil || screen == nil) {
|
||||
return;
|
||||
@@ -80,7 +95,17 @@ bool StartNativeWindowDrag() {
|
||||
}
|
||||
}
|
||||
|
||||
bool ConfigureMainWindowChrome(void *opaque_slint_view) {
|
||||
float MainWindowInitialPositionYOffset() {
|
||||
@autoreleasepool {
|
||||
const NSRect content = NSMakeRect(0, 0, 640, 486);
|
||||
const NSRect frame = [NSWindow frameRectForContentRect:content
|
||||
styleMask:NSWindowStyleMaskTitled | NSWindowStyleMaskClosable |
|
||||
NSWindowStyleMaskMiniaturizable];
|
||||
return float(NSMaxY(frame) - NSMaxY(content));
|
||||
}
|
||||
}
|
||||
|
||||
bool ConfigureMainWindowChrome(void *opaque_slint_view, bool gallery) {
|
||||
@autoreleasepool {
|
||||
NSView *slint_view = (__bridge NSView *)opaque_slint_view;
|
||||
NSWindow *window = slint_view.window;
|
||||
@@ -91,9 +116,18 @@ bool ConfigureMainWindowChrome(void *opaque_slint_view) {
|
||||
|
||||
NSButton *zoom_button =
|
||||
[window standardWindowButton:NSWindowZoomButton];
|
||||
if (zoom_button == nil || zoom_button.enabled) {
|
||||
return false;
|
||||
}
|
||||
if (zoom_button == nil) return false;
|
||||
if (gallery) gallery_window = window;
|
||||
if (gallery && gallery_fullscreen) return true;
|
||||
if (gallery)
|
||||
window.styleMask |= NSWindowStyleMaskResizable;
|
||||
else
|
||||
window.styleMask &= ~NSWindowStyleMaskResizable;
|
||||
window.collectionBehavior =
|
||||
(window.collectionBehavior & ~(NSWindowCollectionBehaviorFullScreenPrimary |
|
||||
NSWindowCollectionBehaviorFullScreenAuxiliary |
|
||||
NSWindowCollectionBehaviorFullScreenAllowsTiling)) |
|
||||
NSWindowCollectionBehaviorFullScreenNone;
|
||||
|
||||
// Winit creates a transparent AppKit backing even when Slint paints an
|
||||
// opaque client area. macOS 27 can expose that backing in the titlebar.
|
||||
@@ -104,7 +138,7 @@ bool ConfigureMainWindowChrome(void *opaque_slint_view) {
|
||||
window.titlebarAppearsTransparent = YES;
|
||||
window.opaque = YES;
|
||||
window.backgroundColor = NSColor.whiteColor;
|
||||
zoom_button.hidden = YES;
|
||||
HideMainWindowZoomButton(window);
|
||||
|
||||
NSView *frame_view = window.contentView.superview;
|
||||
frame_view.needsLayout = YES;
|
||||
@@ -113,6 +147,71 @@ bool ConfigureMainWindowChrome(void *opaque_slint_view) {
|
||||
}
|
||||
}
|
||||
|
||||
bool SetGalleryWindowFullscreen(bool fullscreen) {
|
||||
@autoreleasepool {
|
||||
if (!gallery_window) return false;
|
||||
if (gallery_fullscreen == fullscreen) return true;
|
||||
if (fullscreen) {
|
||||
NSScreen *screen = gallery_window.screen ?: NSScreen.mainScreen;
|
||||
if (!screen) return false;
|
||||
gallery_restore_frame = gallery_window.frame;
|
||||
gallery_restore_style = gallery_window.styleMask;
|
||||
gallery_restore_presentation = NSApp.presentationOptions;
|
||||
gallery_fullscreen = true;
|
||||
gallery_window.styleMask |= NSWindowStyleMaskFullSizeContentView;
|
||||
gallery_window.titleVisibility = NSWindowTitleHidden;
|
||||
gallery_window.movable = NO;
|
||||
SetTitlebarButtonsHidden(gallery_window, YES);
|
||||
NSApp.presentationOptions |=
|
||||
NSApplicationPresentationAutoHideDock | NSApplicationPresentationAutoHideMenuBar;
|
||||
// Let the next event-loop frame paint the updated Slint layout. A
|
||||
// synchronous draw here exposes stale grid geometry during the resize.
|
||||
[gallery_window setFrame:screen.frame display:NO animate:NO];
|
||||
} else {
|
||||
gallery_fullscreen = false;
|
||||
NSApp.presentationOptions = gallery_restore_presentation;
|
||||
gallery_window.styleMask = gallery_restore_style;
|
||||
gallery_window.titleVisibility = NSWindowTitleVisible;
|
||||
gallery_window.movable = YES;
|
||||
[gallery_window standardWindowButton:NSWindowCloseButton].hidden = NO;
|
||||
[gallery_window standardWindowButton:NSWindowMiniaturizeButton].hidden = NO;
|
||||
// DeskHub uses its in-app fullscreen action.
|
||||
HideMainWindowZoomButton(gallery_window);
|
||||
[gallery_window setFrame:gallery_restore_frame display:NO animate:NO];
|
||||
}
|
||||
gallery_window.contentView.needsDisplay = YES;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
bool IsGalleryWindowFullscreen() { return gallery_fullscreen; }
|
||||
bool IsGalleryWindowActive() { return gallery_window && [NSApp isActive] && [gallery_window isKeyWindow]; }
|
||||
|
||||
bool MaximizeGalleryWindowWithoutAnimation() {
|
||||
@autoreleasepool {
|
||||
if (!gallery_window || gallery_fullscreen) return false;
|
||||
NSScreen *screen = gallery_window.screen ?: NSScreen.mainScreen;
|
||||
if (!screen) return false;
|
||||
[gallery_window setFrame:screen.visibleFrame display:YES animate:NO];
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
bool IsGalleryWindowMaximized() {
|
||||
@autoreleasepool {
|
||||
if (!gallery_window || gallery_fullscreen) return false;
|
||||
NSScreen *screen = gallery_window.screen;
|
||||
if (!screen) return false;
|
||||
// isZoomed can report true for a constrained compact window. Only treat
|
||||
// the ordinary window as maximized when it fills the usable screen.
|
||||
const NSRect frame = gallery_window.frame;
|
||||
const NSRect available = screen.visibleFrame;
|
||||
return std::abs(frame.origin.x - available.origin.x) < 1 &&
|
||||
std::abs(frame.origin.y - available.origin.y) < 1 &&
|
||||
std::abs(frame.size.width - available.size.width) < 1 &&
|
||||
std::abs(frame.size.height - available.size.height) < 1;
|
||||
}
|
||||
}
|
||||
|
||||
bool ConfigureStreamWindowLiveResize(void *opaque_slint_view) {
|
||||
@autoreleasepool {
|
||||
NSView *slint_view = (__bridge NSView *)opaque_slint_view;
|
||||
|
||||
@@ -8,10 +8,20 @@ namespace crossdesk {
|
||||
// left-mouse-down event. Returns false when no suitable native event exists.
|
||||
bool StartNativeWindowDrag();
|
||||
|
||||
// Sets a fixed white native titlebar and hides the disabled zoom button on
|
||||
// the fixed-size main window. |slint_view| is its AppKit NSView*. Returns false
|
||||
// while the native window or its fixed-size constraints are not ready.
|
||||
bool ConfigureMainWindowChrome(void* slint_view);
|
||||
// Configures the connection or DeskHub window with a white native titlebar.
|
||||
// Only DeskHub is resizable. |slint_view| is its AppKit NSView*.
|
||||
// Returns false while the native window or its titlebar controls are not ready.
|
||||
bool ConfigureMainWindowChrome(void* slint_view, bool gallery = false);
|
||||
// Winit's initial macOS position is a content origin, while subsequent moves
|
||||
// use the outer frame. Return the logical titlebar inset for initial placement.
|
||||
float MainWindowInitialPositionYOffset();
|
||||
bool SetGalleryWindowFullscreen(bool fullscreen);
|
||||
bool IsGalleryWindowFullscreen();
|
||||
bool IsGalleryWindowActive();
|
||||
// AppKit's animated zoom spins a nested run loop and cannot be called from a
|
||||
// Slint timer. Restore maximization by setting the visible screen frame directly.
|
||||
bool MaximizeGalleryWindowWithoutAnimation();
|
||||
bool IsGalleryWindowMaximized();
|
||||
|
||||
// Configures live resize and replaces the stream window's native Space
|
||||
// fullscreen action with an immediate, single-window fullscreen transition.
|
||||
|
||||
@@ -203,16 +203,15 @@ int Thumbnail::SaveToThumbnail(const char* nv12, int width, int height,
|
||||
const std::string& password,
|
||||
HostPlatform platform) {
|
||||
std::lock_guard lock(mutex_);
|
||||
// Preserve the existing opt-in via Remember password for recent connections.
|
||||
if (password.empty()) return 0;
|
||||
// Successful connections are history even without saved credentials.
|
||||
if (!IsRemoteId(remote_id)) return -1;
|
||||
RecentConnection connection;
|
||||
ReadRecord(std::filesystem::path(save_path_) / (remote_id + ".json"),
|
||||
&connection);
|
||||
connection.remote_id = remote_id;
|
||||
connection.remote_host_name = host_name;
|
||||
if (!host_name.empty()) connection.remote_host_name = host_name;
|
||||
connection.password = password;
|
||||
connection.remember_password = true;
|
||||
connection.remember_password = !password.empty();
|
||||
if (platform != HostPlatform::Unknown) connection.platform = platform;
|
||||
if (!WriteRecord(connection)) return -1;
|
||||
|
||||
|
||||
@@ -41,6 +41,8 @@ class Thumbnail {
|
||||
// Serialized with writes so disabling also clears any in-flight preview.
|
||||
int SetSavePreviews(bool enabled);
|
||||
|
||||
// Also records metadata without an image. Empty password means do not store
|
||||
// credentials (and clears any remembered password from a previous session).
|
||||
int SaveToThumbnail(const char* nv12, int width, int height,
|
||||
const std::string& remote_id,
|
||||
const std::string& host_name,
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
#include "crossdesk_ui.h"
|
||||
#include "fa_solid_900.h"
|
||||
#include "features/gallery/gallery_window_state.h"
|
||||
#include "ui/release_notes_text.h"
|
||||
#include "ui/ui_localization.h"
|
||||
|
||||
#include <algorithm>
|
||||
@@ -125,9 +127,16 @@ bool ConfigureDocumentationDemo(
|
||||
{"987654321", "Dev Workstation", "fixtures/source.png"},
|
||||
{"246813579", "Office PC", "fixtures/website.png"},
|
||||
{"135792468", "Home Laptop", "web-client.png"},
|
||||
{"864209753", "Studio Mac", "fixtures/source.png"},
|
||||
{"753198642", "Build Server", "fixtures/website.png"},
|
||||
{"642087531", "Test Device", "web-client.png"},
|
||||
};
|
||||
std::vector<crossdesk::ui::RecentConnection> connections;
|
||||
for (const auto &device : devices) {
|
||||
if (connections.size() == 3 && options.page != "main-recent-overflow" &&
|
||||
options.page != "main-recent-scrolled") {
|
||||
break;
|
||||
}
|
||||
const std::string path = options.demo_assets_path + "/" + device.thumbnail;
|
||||
const auto thumbnail =
|
||||
slint::Image::load_from_path(slint::SharedString(path.c_str()));
|
||||
@@ -219,8 +228,23 @@ void ConfigureMainCapturePage(
|
||||
window->set_wayland_titlebar(false);
|
||||
#endif
|
||||
|
||||
if (options.page == "settings") {
|
||||
if (options.page == "settings" || options.page == "settings-scroll-hover" ||
|
||||
options.page == "settings-scrolled" || options.page == "settings-scroll-drag") {
|
||||
window->set_settings_open(true);
|
||||
} else if (options.page == "main-menu-badges") {
|
||||
window->set_announcement_unread(4);
|
||||
window->set_update_available(true);
|
||||
window->set_update_open(false);
|
||||
} else if (options.page == "settings-deskhub" ||
|
||||
options.page == "settings-deskhub-error") {
|
||||
window->set_settings_open(true);
|
||||
window->set_settings_tab(1);
|
||||
if (options.page == "settings-deskhub-error") {
|
||||
window->set_gallery_settings_error(
|
||||
window->global<crossdesk::ui::GalleryStrings>().invoke_tr(
|
||||
"无法保存设置,请重试。", "Unable to save settings. Please try again.",
|
||||
"Не удалось сохранить настройки. Повторите попытку."));
|
||||
}
|
||||
} else if (options.page == "self-hosted") {
|
||||
window->set_settings_open(true);
|
||||
window->set_self_host_settings_open(true);
|
||||
@@ -238,11 +262,52 @@ void ConfigureMainCapturePage(
|
||||
? crossdesk::ui::UpdateCheckStatus::Failed
|
||||
: crossdesk::ui::UpdateCheckStatus::Available);
|
||||
}
|
||||
} else if (options.page == "update") {
|
||||
} else if (options.page == "update" || options.page == "update-scrolled" ||
|
||||
options.page == "update-short") {
|
||||
window->set_update_available(true);
|
||||
window->set_latest_version("v0.0.1");
|
||||
window->set_latest_version("v1.6.1-20261010");
|
||||
window->set_release_name("CrossDesk Preview");
|
||||
window->set_release_date("2026-08-25");
|
||||
window->set_release_date("2026-10-10");
|
||||
// Illustrative notes exercise the production Markdown parser and wrapping,
|
||||
// including enough content to inspect both ends of the scrolling viewport.
|
||||
const char *notes[] = {
|
||||
"**新增功能与改进**\n\n"
|
||||
"1. 增强信令快速重连与连接恢复能力;桌面端配置修改无需重新连接信令服务。\n"
|
||||
"2. Windows 安装版新增登录前远程控制支持,被控端可随系统启动,在尚未登录时接受远程连接。\n"
|
||||
"3. Linux 提供普通版与 Xfce 虚拟桌面版独立安装包,普通版不再强制安装 Xfce 和 Xvfb。\n"
|
||||
"4. 被控端会话窗口改为紧凑的屏幕边缘状态栏。\n"
|
||||
"5. 完善 iOS / Android 原生控制端,优化远程画面、触控与外接键鼠体验。\n\n"
|
||||
"**问题修复**\n\n"
|
||||
"6. 修复连接中断后的状态显示,改善高分辨率屏幕上的文字与按钮边界。\n"
|
||||
"7. 统一窗口、输入框与按钮的圆角,优化更新提示与长文本滚动体验。",
|
||||
"**Features and improvements**\n\n"
|
||||
"1. Improve signaling reconnection and session recovery. Desktop configuration changes no longer require reconnecting to the signaling service.\n"
|
||||
"2. Add remote control before Windows sign-in, allowing the installed client to accept connections when the system starts.\n"
|
||||
"3. Provide separate Linux packages for standard installations and Xfce virtual desktops.\n"
|
||||
"4. Replace the controlled session window with a compact status bar at the edge of the screen.\n"
|
||||
"5. Improve the native iOS and Android clients, including video, touch, and external keyboard and mouse support.\n\n"
|
||||
"**Fixes**\n\n"
|
||||
"6. Correct connection status after interruptions and improve text and control borders on high resolution displays.\n"
|
||||
"7. Unify corner radii and improve update notifications and scrolling for long release notes.",
|
||||
"**Новые возможности и улучшения**\n\n"
|
||||
"1. Улучшено восстановление соединения. Изменение настроек больше не требует повторного подключения к серверу сигнализации.\n"
|
||||
"2. Добавлено удалённое управление Windows до входа пользователя в систему.\n"
|
||||
"3. Подготовлены отдельные пакеты Linux для стандартной установки и виртуальных рабочих столов Xfce.\n"
|
||||
"4. Окно управляемого сеанса заменено компактной строкой состояния у края экрана.\n"
|
||||
"5. Улучшены клиенты iOS и Android, работа с сенсорным экраном, внешней клавиатурой и мышью.\n\n"
|
||||
"**Исправления**\n\n"
|
||||
"6. Исправлено отображение состояния после обрыва соединения, улучшена читаемость на экранах высокого разрешения.\n"
|
||||
"7. Унифицированы скругления элементов и улучшена прокрутка длинных описаний обновлений."};
|
||||
std::vector<crossdesk::ui::ReleaseNoteBlock> blocks;
|
||||
for (const auto &parsed : crossdesk::release_notes_text::Parse(notes[language])) {
|
||||
crossdesk::ui::ReleaseNoteBlock block{};
|
||||
block.content = parsed.content;
|
||||
block.section_gap = parsed.section_gap;
|
||||
blocks.push_back(std::move(block));
|
||||
if (options.page == "update-short" && blocks.size() == 2) break;
|
||||
}
|
||||
window->set_release_note_blocks(
|
||||
std::make_shared<slint::VectorModel<crossdesk::ui::ReleaseNoteBlock>>(blocks));
|
||||
window->set_update_open(true);
|
||||
} else if (options.page == "reset-password") {
|
||||
window->set_reset_password_open(true);
|
||||
@@ -363,10 +428,72 @@ int RunCaptureMode(
|
||||
slint::ComponentHandle<crossdesk::ui::MainWindow> &window,
|
||||
slint::ComponentHandle<crossdesk::ui::StreamWindow> &stream,
|
||||
slint::ComponentHandle<crossdesk::ui::ServerWindow> &server) {
|
||||
window->on_format_remote_id([](slint::SharedString id) { return id; });
|
||||
ConfigureMainCapturePage(window, options);
|
||||
if (!ConfigureDocumentationDemo(window, options)) {
|
||||
return 8;
|
||||
}
|
||||
// Preview DeskHub with the same demo images, without opening any sessions.
|
||||
if (options.page.starts_with("gallery")) {
|
||||
window->set_gallery_mode(true);
|
||||
std::vector<crossdesk::ui::GalleryTile> tiles;
|
||||
const auto recent = window->get_recent_connections();
|
||||
for (size_t index = 0; index < recent->row_count(); ++index) {
|
||||
const auto device = recent->row_data(index).value();
|
||||
crossdesk::ui::GalleryTile tile{};
|
||||
tile.id = device.remote_id;
|
||||
tile.name = device.display_name;
|
||||
tile.online = tile.presence_known = tile.connected = true;
|
||||
tile.has_frame = device.has_thumbnail;
|
||||
tile.frame = device.thumbnail;
|
||||
tile.selected = options.page == "gallery-batch";
|
||||
tiles.push_back(tile);
|
||||
}
|
||||
window->set_gallery_tiles(
|
||||
std::make_shared<slint::VectorModel<crossdesk::ui::GalleryTile>>(tiles));
|
||||
window->set_gallery_count(static_cast<int>(tiles.size()));
|
||||
window->set_gallery_connected(static_cast<int>(tiles.size()));
|
||||
window->set_gallery_groups(
|
||||
std::make_shared<slint::VectorModel<slint::SharedString>>(
|
||||
std::vector{window->global<crossdesk::ui::GalleryStrings>().invoke_tr(
|
||||
"全部设备", "All devices", "Все устройства")}));
|
||||
window->set_gallery_batch(options.page == "gallery-batch");
|
||||
window->set_gallery_selected(options.page == "gallery-batch"
|
||||
? static_cast<int>(tiles.size()) : 0);
|
||||
window->set_gallery_adding(options.page == "gallery-add");
|
||||
if (options.page == "gallery-focus" && !tiles.empty())
|
||||
window->set_gallery_focused(tiles.front());
|
||||
// A 960 x 720 preview includes the native 22px macOS titlebar.
|
||||
const float client_height =
|
||||
#if defined(__APPLE__)
|
||||
698.0f;
|
||||
#else
|
||||
720.0f;
|
||||
#endif
|
||||
window->window().set_size(
|
||||
slint::LogicalSize(slint::Size<float>{960.0f, client_height}));
|
||||
}
|
||||
// Workbench states use the same rendered components and demo-only data.
|
||||
if (options.page == "main-empty" || options.page == "main-empty-group") {
|
||||
window->set_recent_connections(
|
||||
std::make_shared<slint::VectorModel<crossdesk::ui::RecentConnection>>());
|
||||
window->set_recent_group_index(options.page == "main-empty-group" ? 1 : 0);
|
||||
} else if (options.page == "main-error") {
|
||||
window->set_remote_id_input("123");
|
||||
window->set_connection_input_error(
|
||||
window->global<crossdesk::ui::GalleryStrings>().invoke_tr(
|
||||
"请输入完整的 9 位设备 ID", "Enter a complete 9-digit device ID",
|
||||
"Введите полный ID устройства из 9 цифр"));
|
||||
} else if (options.page == "main-offline") {
|
||||
window->set_signal_connected(false);
|
||||
window->set_password_visible(false);
|
||||
const auto connections = window->get_recent_connections();
|
||||
for (size_t index = 0; index < connections->row_count(); ++index) {
|
||||
auto device = connections->row_data(index).value();
|
||||
device.online = false;
|
||||
connections->set_row_data(index, device);
|
||||
}
|
||||
}
|
||||
stream->hide();
|
||||
server->hide();
|
||||
|
||||
@@ -422,11 +549,113 @@ int RunCaptureMode(
|
||||
// taking the snapshot; capturing immediately after show() can yield 1x.
|
||||
bool snapshot_written = false;
|
||||
slint::Timer snapshot_timer;
|
||||
slint::Timer capture_settle_timer;
|
||||
slint::Timer pointer_move_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();
|
||||
if (options.page == "main-device-tooltip" ||
|
||||
options.page == "main-device-tooltip-moved" ||
|
||||
options.page == "main-device-tooltip-edge" ||
|
||||
options.page == "main-device-tooltip-exit") {
|
||||
const float titlebar = window->get_minimum_window_height() - 472.0f;
|
||||
window->window().dispatch_pointer_move_event(
|
||||
slint::LogicalPosition(slint::Point<float>{90.0f, titlebar + 330.0f}));
|
||||
if (options.page != "main-device-tooltip") {
|
||||
// A second event in the same card verifies hover movement without
|
||||
// pressing the mouse or relying on another enter/leave event.
|
||||
pointer_move_timer.start(slint::TimerMode::SingleShot,
|
||||
std::chrono::milliseconds(50), [&, titlebar] {
|
||||
const bool edge = options.page == "main-device-tooltip-edge";
|
||||
const bool leave = options.page == "main-device-tooltip-exit";
|
||||
window->window().dispatch_pointer_move_event(slint::LogicalPosition(
|
||||
slint::Point<float>{leave ? 2.0f : edge ? window->get_minimum_window_width() - 40.0f : 170.0f,
|
||||
leave ? 2.0f : titlebar + 340.0f}));
|
||||
});
|
||||
}
|
||||
} else if (options.page == "settings-scroll-hover" ||
|
||||
options.page == "settings-scrolled" ||
|
||||
options.page == "settings-scroll-drag") {
|
||||
const float dialog_x = (window->get_minimum_window_width() - 400.0f) / 2;
|
||||
const float dialog_y = (window->get_minimum_window_height() - 446.0f) / 2;
|
||||
const auto scrollbar_position = slint::LogicalPosition(
|
||||
slint::Point<float>{dialog_x + 389.0f, dialog_y + 150.0f});
|
||||
if (options.page == "settings-scrolled") {
|
||||
window->window().dispatch_pointer_scroll_event(
|
||||
slint::LogicalPosition(slint::Point<float>{320.0f, 260.0f}),
|
||||
0.0f, -2000.0f);
|
||||
}
|
||||
window->window().dispatch_pointer_move_event(scrollbar_position);
|
||||
if (options.page == "settings-scroll-drag") {
|
||||
window->window().dispatch_pointer_press_event(
|
||||
scrollbar_position, slint::PointerEventButton::Left);
|
||||
const auto drag_end = slint::LogicalPosition(
|
||||
slint::Point<float>{dialog_x + 389.0f, dialog_y + 340.0f});
|
||||
window->window().dispatch_pointer_move_event(drag_end);
|
||||
window->window().dispatch_pointer_release_event(
|
||||
drag_end, slint::PointerEventButton::Left);
|
||||
}
|
||||
} else if (options.page == "update-scrolled") {
|
||||
window->window().dispatch_pointer_scroll_event(
|
||||
slint::LogicalPosition(slint::Point<float>{320.0f, 260.0f}),
|
||||
0.0f, -2000.0f);
|
||||
window->window().dispatch_pointer_move_event(
|
||||
slint::LogicalPosition(slint::Point<float>{2.0f, 2.0f}));
|
||||
} else if (options.page == "main-recent-scrolled") {
|
||||
window->window().dispatch_pointer_scroll_event(
|
||||
slint::LogicalPosition(slint::Point<float>{
|
||||
400.0f, window->get_minimum_window_height() - 472.0f + 360.0f}),
|
||||
-400.0f, 0.0f);
|
||||
window->window().dispatch_pointer_move_event(
|
||||
slint::LogicalPosition(slint::Point<float>{2.0f, 2.0f}));
|
||||
} else if (options.page == "main-menu" ||
|
||||
options.page == "main-menu-badges") {
|
||||
const auto menu_position = slint::LogicalPosition(
|
||||
slint::Point<float>{window->get_minimum_window_width() - 38.0f,
|
||||
window->get_minimum_window_height() - 472.0f + 20.0f});
|
||||
window->window().dispatch_pointer_press_event(
|
||||
menu_position, slint::PointerEventButton::Left);
|
||||
window->window().dispatch_pointer_release_event(
|
||||
menu_position, slint::PointerEventButton::Left);
|
||||
window->window().dispatch_pointer_move_event(
|
||||
slint::LogicalPosition(slint::Point<float>{2.0f, 2.0f}));
|
||||
} else if (options.page == "main-copy-tooltip" ||
|
||||
options.page == "main-password-tooltip") {
|
||||
const bool copy = options.page == "main-copy-tooltip";
|
||||
window->window().dispatch_pointer_move_event(slint::LogicalPosition(
|
||||
slint::Point<float>{window->get_minimum_window_width() / 2 - (copy ? 48.0f : 42.0f),
|
||||
window->get_minimum_window_height() - 472.0f +
|
||||
(copy ? 150.0f : 216.0f)}));
|
||||
} else if (options.page == "main-copy-released" ||
|
||||
options.page == "main-copy-released-outside" ||
|
||||
options.page == "main-keyboard-tab") {
|
||||
const auto copy_position = slint::LogicalPosition(slint::Point<float>{
|
||||
window->get_minimum_window_width() / 2 - 48.0f,
|
||||
window->get_minimum_window_height() - 472.0f + 150.0f});
|
||||
window->window().dispatch_pointer_move_event(copy_position);
|
||||
window->window().dispatch_pointer_press_event(
|
||||
copy_position, slint::PointerEventButton::Left);
|
||||
window->window().dispatch_pointer_release_event(
|
||||
copy_position, slint::PointerEventButton::Left);
|
||||
if (options.page != "main-copy-released") {
|
||||
window->window().dispatch_pointer_move_event(
|
||||
slint::LogicalPosition(slint::Point<float>{2.0f, 2.0f}));
|
||||
}
|
||||
if (options.page == "main-keyboard-tab") {
|
||||
// Traverse the remote ID input and focus the Connect button.
|
||||
for (int index = 0; index < 2; ++index) {
|
||||
window->window().dispatch_key_press_event("\t");
|
||||
window->window().dispatch_key_release_event("\t");
|
||||
}
|
||||
}
|
||||
}
|
||||
// Let settings scroll animations settle as well as hover/focus feedback.
|
||||
capture_settle_timer.start(slint::TimerMode::SingleShot,
|
||||
std::chrono::milliseconds(
|
||||
options.page.rfind("settings", 0) == 0 ? 350 : 150), [&] {
|
||||
snapshot_written =
|
||||
WriteWindowSnapshot(*capture_window, options.snapshot_path.c_str());
|
||||
slint::quit_event_loop();
|
||||
});
|
||||
});
|
||||
slint::run_event_loop();
|
||||
return snapshot_written ? 0 : 7;
|
||||
@@ -440,10 +669,194 @@ int RunCaptureMode(
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Sample the native window while the same restoration used by the application
|
||||
// runs. A component-only screenshot used to miss the conflicting restore width.
|
||||
bool VerifyMainWindowGeometry() {
|
||||
for (bool restore : {false, true}) {
|
||||
auto component = crossdesk::ui::MainWindow::create();
|
||||
auto &window = component->window();
|
||||
crossdesk::gallery::WindowState placement;
|
||||
const float width = component->get_minimum_window_width();
|
||||
const float height = component->get_minimum_window_height();
|
||||
if (width != 640.0f || height != 472.0f) return false;
|
||||
if (restore) {
|
||||
placement.BeginRestore();
|
||||
placement.PrepareForShow(
|
||||
window, false,
|
||||
placement.RestoreSize(false, window.scale_factor(), height, width),
|
||||
std::nullopt);
|
||||
}
|
||||
window.show();
|
||||
bool stable = true;
|
||||
int samples = 0;
|
||||
slint::Timer timer;
|
||||
timer.start(slint::TimerMode::Repeated, std::chrono::milliseconds(16), [&] {
|
||||
const auto actual = window.size();
|
||||
const auto expected = placement.RestoreSize(
|
||||
false, window.scale_factor(), height, width);
|
||||
if (actual.width != expected.width || actual.height != expected.height) {
|
||||
std::cerr << "Startup geometry changed: restore=" << restore
|
||||
<< " sample=" << samples << " actual=" << actual.width
|
||||
<< 'x' << actual.height << " expected=" << expected.width
|
||||
<< 'x' << expected.height << '\n';
|
||||
stable = false;
|
||||
}
|
||||
if (placement.RestoringNormal()) {
|
||||
if (!placement.NormalSizeRestored(actual, expected))
|
||||
window.set_size(expected);
|
||||
} else if (placement.Restoring()) {
|
||||
// Match the application's fixed connection-window role. AppKit can
|
||||
// report a fixed-size window as zoomed even at its normal dimensions.
|
||||
placement.FinishRestore(false, actual, false, false);
|
||||
}
|
||||
if (++samples == 20) slint::quit_event_loop();
|
||||
});
|
||||
slint::run_event_loop();
|
||||
timer.stop();
|
||||
window.hide();
|
||||
if (!stable || placement.Restoring()) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Exercise hit targets after the workbench layout change, rather than calling
|
||||
// callbacks directly: overlapping surfaces must not swallow these actions.
|
||||
bool VerifyWorkbenchActions() {
|
||||
auto window = crossdesk::ui::MainWindow::create();
|
||||
const float width = window->get_minimum_window_width();
|
||||
window->window().set_size(
|
||||
slint::LogicalSize(slint::Size<float>{width, window->get_minimum_window_height()}));
|
||||
auto click = [&](float x, float y) {
|
||||
const auto position = slint::LogicalPosition(slint::Point<float>{x, y});
|
||||
window->window().dispatch_pointer_press_event(
|
||||
position, slint::PointerEventButton::Left);
|
||||
window->window().dispatch_pointer_release_event(
|
||||
position, slint::PointerEventButton::Left);
|
||||
};
|
||||
int copies = 0, visibility_changes = 0, refreshes = 0, connections = 0;
|
||||
bool gallery_opened = false;
|
||||
window->on_copy_local_id([&] { ++copies; return true; });
|
||||
window->on_toggle_password_visibility([&] { ++visibility_changes; });
|
||||
window->on_refresh_password([&] { ++refreshes; });
|
||||
window->on_open_gallery([&] { gallery_opened = true; });
|
||||
window->set_remote_id_input("987 654 321");
|
||||
window->on_connect_requested([&](slint::SharedString id) {
|
||||
if (id == "987 654 321") ++connections;
|
||||
});
|
||||
click(width / 2 - 48, 150);
|
||||
click(width / 2 - 118, 216);
|
||||
click(width / 2 - 80, 216);
|
||||
click(width / 2 - 42, 216);
|
||||
if (copies != 1 || visibility_changes != 1 || refreshes != 1 ||
|
||||
!window->get_reset_password_open()) return false;
|
||||
window->set_reset_password_open(false);
|
||||
window->set_password_change_pending(true);
|
||||
click(width / 2 - 80, 216);
|
||||
click(width / 2 - 42, 216);
|
||||
if (refreshes != 1 || window->get_reset_password_open()) return false;
|
||||
click(width * 0.75f - 6, 216);
|
||||
// Space activates the focused primary button; pending state blocks repeats.
|
||||
window->window().dispatch_key_press_event(" ");
|
||||
window->window().dispatch_key_release_event(" ");
|
||||
if (connections != 2) return false;
|
||||
window->set_connection_pending(true);
|
||||
click(width * 0.75f - 6, 216);
|
||||
if (connections != 2) return false;
|
||||
window->set_connection_pending(false);
|
||||
click(width - 38, 20);
|
||||
click(width - 78, 56);
|
||||
if (!window->get_settings_open()) return false;
|
||||
// Both tabs must remain at the same position, including with a save error.
|
||||
click(width / 2 + 87, 89);
|
||||
if (window->get_settings_tab() != 1) return false;
|
||||
click(width / 2 - 87, 89);
|
||||
if (window->get_settings_tab() != 0) return false;
|
||||
click(width / 2 + 87, 89);
|
||||
window->set_gallery_settings_error("Unable to save settings. Please try again.");
|
||||
click(width / 2 - 87, 89);
|
||||
if (window->get_settings_tab() != 0) return false;
|
||||
window->set_gallery_settings_error("");
|
||||
window->set_settings_open(false);
|
||||
click(60, 20);
|
||||
if (!gallery_opened) return false;
|
||||
crossdesk::ui::RecentConnection device{};
|
||||
device.remote_id = "987654321";
|
||||
device.display_name = "Workstation";
|
||||
device.online = true;
|
||||
window->set_recent_connections(
|
||||
std::make_shared<slint::VectorModel<crossdesk::ui::RecentConnection>>(
|
||||
std::vector{device}));
|
||||
bool recent_connected = false;
|
||||
window->on_recent_connect([&](slint::SharedString id) {
|
||||
recent_connected = id == "987654321";
|
||||
});
|
||||
click(100, 344);
|
||||
if (!recent_connected) return false;
|
||||
bool password_submitted = false, cancelled = false;
|
||||
window->on_connection_submit_password([&](slint::SharedString value, bool) {
|
||||
password_submitted = value == "654321";
|
||||
});
|
||||
window->on_connection_cancel([&] { cancelled = true; });
|
||||
window->set_connection_password("654321");
|
||||
window->set_connection_password_required(true);
|
||||
window->set_connection_dialog_open(true);
|
||||
click(width / 2 + 62, 312);
|
||||
click(width / 2 - 62, 312);
|
||||
if (!password_submitted || !cancelled) return false;
|
||||
window->set_connection_dialog_open(false);
|
||||
|
||||
// Rounded frames must preserve editor focus, text bindings and Enter actions.
|
||||
auto type = [&](std::string_view text) {
|
||||
for (char character : text) {
|
||||
const slint::SharedString key(std::string(1, character).c_str());
|
||||
window->window().dispatch_key_press_event(key);
|
||||
window->window().dispatch_key_release_event(key);
|
||||
}
|
||||
};
|
||||
bool renamed = false;
|
||||
window->on_recent_edit_alias([&](slint::SharedString, slint::SharedString name) {
|
||||
renamed = name == "Office";
|
||||
});
|
||||
window->set_alias_input("");
|
||||
window->set_alias_open(true);
|
||||
click(width / 2, 238);
|
||||
type("Office\n");
|
||||
if (!renamed || window->get_alias_open()) {
|
||||
std::cerr << "Alias editor did not accept the typed name; text="
|
||||
<< std::string(window->get_alias_input()) << '\n';
|
||||
return false;
|
||||
}
|
||||
|
||||
bool added = false;
|
||||
window->on_gallery_add_device([&](slint::SharedString id,
|
||||
slint::SharedString password, slint::SharedString group, bool) {
|
||||
added = id == "123456789" && password == "123456" && group == "Lab";
|
||||
return added;
|
||||
});
|
||||
window->set_gallery_mode(true);
|
||||
window->window().set_size(slint::LogicalSize(slint::Size<float>{width, 448.0f}));
|
||||
window->set_gallery_adding(true);
|
||||
click(width / 2, 142);
|
||||
type("123456789\n123456\nLab\n");
|
||||
if (!added || window->get_gallery_adding()) {
|
||||
std::cerr << "Add-device editors did not retain Enter focus traversal\n";
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
int main() {
|
||||
const CaptureOptions capture_options = LoadCaptureOptions();
|
||||
if (!VerifyMainWindowGeometry()) {
|
||||
std::cerr << "Main window dimensions must stay stable through startup\n";
|
||||
return 11;
|
||||
}
|
||||
if (!VerifyWorkbenchActions()) {
|
||||
std::cerr << "Workbench controls must retain their pointer and keyboard actions\n";
|
||||
return 10;
|
||||
}
|
||||
auto window = crossdesk::ui::MainWindow::create();
|
||||
auto stream = crossdesk::ui::StreamWindow::create();
|
||||
auto server = crossdesk::ui::ServerWindow::create();
|
||||
|
||||
@@ -510,7 +510,7 @@ function setup_targets()
|
||||
"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/features/settings/*.cpp", "apps/desktop/src/gui/features/gallery/*.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",
|
||||
|
||||
@@ -4,6 +4,10 @@ This target is a native MiniRTC controller. It uses the same WebSocket
|
||||
signaling, libnice ICE, SRTP/RTP and data-stream protocol as the desktop app.
|
||||
There is no `WKWebView` or browser runtime.
|
||||
|
||||
UPnP discovery and router port mapping are disabled in iOS builds, matching
|
||||
Android. Local connections, ICE/STUN hole punching, and TURN UDP/TCP relay
|
||||
remain available. The native archive excludes GUPnP and its HTTP/XML dependencies.
|
||||
|
||||
## Background connection and Picture in Picture
|
||||
|
||||
An established remote desktop session supports native Picture in Picture (PiP).
|
||||
@@ -265,11 +269,10 @@ in the source-notices document as well.
|
||||
`licenses/sources.json` pins the reviewed source archives (SHA-256), notice-review
|
||||
Git revisions, selected legal files and source-notice extraction rules.
|
||||
`CrossDeskMobile/Resources/ThirdPartyLicenses.json` is the generated resource
|
||||
bundled by Xcode. The catalog covers CrossDesk and 33 dependency entries for the default iOS build,
|
||||
bundled by Xcode. The catalog covers CrossDesk and 25 dependency entries for the default iOS build,
|
||||
including header-only dependencies, GLib's proxy-libintl, spdlog's bundled fmt,
|
||||
WebRTC-derived code, inih, and the applicable patent notices. Host build tools,
|
||||
OpenH264, desktop-only libraries, and the disabled libpsl built-in suffix list
|
||||
are excluded.
|
||||
OpenH264, desktop-only libraries, and the disabled UPnP dependency stack are excluded.
|
||||
|
||||
Regenerate from the pinned source distributions with Python 3.9 or newer:
|
||||
|
||||
|
||||
@@ -157,87 +157,6 @@
|
||||
"subprojects/gvdb/"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "gssdp",
|
||||
"name": "GSSDP",
|
||||
"version": "1.6.4",
|
||||
"license": "LGPL-2.1-or-later",
|
||||
"source": {
|
||||
"kind": "archive",
|
||||
"url": "https://download.gnome.org/sources/gssdp/1.6/gssdp-1.6.4.tar.xz",
|
||||
"sha256": "ff97fdfb7f561d3e6813b4f6a2145259e7c2eff43cc0e63f3fd031d0b6266032"
|
||||
},
|
||||
"documents": [
|
||||
{
|
||||
"path": "COPYING",
|
||||
"sha256": "dc626520dcd53a22f727af3ee42c770e56c97a64fe3adb063799d8ab032fe551"
|
||||
},
|
||||
{
|
||||
"path": "AUTHORS",
|
||||
"sha256": "66b5509fcdd21acaab5d7a3856e699bada9e9ebf0485312abbe987711bc7bd27"
|
||||
}
|
||||
],
|
||||
"recipe_sha256": "52667099a445f7004b9200df4ed771dd74e73745a5c6dc7fe728168e9f8b3e7a",
|
||||
"recipe_path": "deps/submodules/minirtc/thirdparty/gupnp/xmake.lua",
|
||||
"package": "gssdp",
|
||||
"notice_prefixes": [
|
||||
"libgssdp/"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "gupnp",
|
||||
"name": "GUPnP",
|
||||
"version": "1.6.9",
|
||||
"license": "LGPL-2.1-or-later",
|
||||
"source": {
|
||||
"kind": "archive",
|
||||
"url": "https://download.gnome.org/sources/gupnp/1.6/gupnp-1.6.9.tar.xz",
|
||||
"sha256": "2edb6ee3613558e62f538735368aee27151b7e09d4e2e2c51606833da801869b"
|
||||
},
|
||||
"documents": [
|
||||
{
|
||||
"path": "COPYING",
|
||||
"sha256": "dc626520dcd53a22f727af3ee42c770e56c97a64fe3adb063799d8ab032fe551"
|
||||
},
|
||||
{
|
||||
"path": "AUTHORS",
|
||||
"sha256": "fadd2ab40439bc89d2e9540c02aef1f23edea96983af9cceaef345a9423d3c70"
|
||||
}
|
||||
],
|
||||
"recipe_sha256": "52667099a445f7004b9200df4ed771dd74e73745a5c6dc7fe728168e9f8b3e7a",
|
||||
"recipe_path": "deps/submodules/minirtc/thirdparty/gupnp/xmake.lua",
|
||||
"package": "gupnp",
|
||||
"notice_prefixes": [
|
||||
"libgupnp/"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "gupnp-igd",
|
||||
"name": "GUPnP IGD",
|
||||
"version": "1.6.0",
|
||||
"license": "LGPL-2.1-or-later",
|
||||
"source": {
|
||||
"kind": "archive",
|
||||
"url": "https://download.gnome.org/sources/gupnp-igd/1.6/gupnp-igd-1.6.0.tar.xz",
|
||||
"sha256": "4099978339ab22126d4968f2a332b6d094fc44c78797860781f1fc2f11771b74"
|
||||
},
|
||||
"documents": [
|
||||
{
|
||||
"path": "COPYING",
|
||||
"sha256": "6095e9ffa777dd22839f7801aa845b31c9ed07f3d6bf8a26dc5d2dec8ccc0ef3"
|
||||
},
|
||||
{
|
||||
"path": "AUTHORS",
|
||||
"sha256": "77a943ea347578cfcffc032616707d43e5f5e03a12f864c0d421c3d632fc1582"
|
||||
}
|
||||
],
|
||||
"recipe_sha256": "52667099a445f7004b9200df4ed771dd74e73745a5c6dc7fe728168e9f8b3e7a",
|
||||
"recipe_path": "deps/submodules/minirtc/thirdparty/gupnp/xmake.lua",
|
||||
"package": "gupnp-igd",
|
||||
"notice_prefixes": [
|
||||
"libgupnp-igd/"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "inih",
|
||||
"name": "inih / INIReader",
|
||||
@@ -378,7 +297,7 @@
|
||||
"sha256": "d72eb6fa9d5e93abb11d13b6c6f04fda2ba183d0705e703887cda243f6f1eead"
|
||||
}
|
||||
],
|
||||
"recipe_sha256": "bfb5f125dc57768fcfb7edadb4be69783d66f31e00e0576ea038e30ba593a811",
|
||||
"recipe_sha256": "9c18a7313113ddfd260f345a49ebc0cdc3aeeb6c6d5679c9d3ed50365b51e271",
|
||||
"recipe_path": "deps/submodules/minirtc/thirdparty/libnice/xmake.lua",
|
||||
"package": "libnice",
|
||||
"notice_prefixes": [
|
||||
@@ -389,61 +308,6 @@
|
||||
"stun/"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "upnp-libpsl",
|
||||
"name": "libpsl",
|
||||
"version": "0.21.5",
|
||||
"license": "MIT / BSD-3-Clause",
|
||||
"source": {
|
||||
"kind": "archive",
|
||||
"url": "https://github.com/rockdaboot/libpsl/releases/download/0.21.5/libpsl-0.21.5.tar.gz",
|
||||
"sha256": "1dcc9ceae8b128f3c0b3f654decd0e1e891afc6ff81098f227ef260449dae208"
|
||||
},
|
||||
"documents": [
|
||||
{
|
||||
"path": "LICENSE",
|
||||
"sha256": "322392e6001b2b3b0fca705dc9f11f26e12441188ecde50eb1d22909980b2925"
|
||||
},
|
||||
{
|
||||
"path": "src/LICENSE.chromium",
|
||||
"sha256": "1d7f52747a9169751cdf2641a8299c0098e9ecdf9429296ffd55bdb14c9ed5b3"
|
||||
}
|
||||
],
|
||||
"recipe_sha256": "52667099a445f7004b9200df4ed771dd74e73745a5c6dc7fe728168e9f8b3e7a",
|
||||
"recipe_path": "deps/submodules/minirtc/thirdparty/gupnp/xmake.lua",
|
||||
"package": "upnp-libpsl",
|
||||
"notice_prefixes": [
|
||||
"src/",
|
||||
"include/"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "libsoup",
|
||||
"name": "libsoup",
|
||||
"version": "3.6.5",
|
||||
"license": "LGPL-2.0-or-later",
|
||||
"source": {
|
||||
"kind": "archive",
|
||||
"url": "https://download.gnome.org/sources/libsoup/3.6/libsoup-3.6.5.tar.xz",
|
||||
"sha256": "6891765aac3e949017945c3eaebd8cc8216df772456dc9f460976fbdb7ada234"
|
||||
},
|
||||
"documents": [
|
||||
{
|
||||
"path": "COPYING",
|
||||
"sha256": "b7993225104d90ddd8024fd838faf300bea5e83d91203eab98e29512acebd69c"
|
||||
},
|
||||
{
|
||||
"path": "AUTHORS",
|
||||
"sha256": "f8776285b4556368878a1b903a1f0e6d6cee342284961562a7716a2dacc28321"
|
||||
}
|
||||
],
|
||||
"recipe_sha256": "52667099a445f7004b9200df4ed771dd74e73745a5c6dc7fe728168e9f8b3e7a",
|
||||
"recipe_path": "deps/submodules/minirtc/thirdparty/gupnp/xmake.lua",
|
||||
"package": "libsoup",
|
||||
"notice_prefixes": [
|
||||
"libsoup/"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "libsrtp",
|
||||
"name": "libsrtp",
|
||||
@@ -469,30 +333,6 @@
|
||||
"include/"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "upnp-libxml2",
|
||||
"name": "libxml2",
|
||||
"version": "2.14.6",
|
||||
"license": "MIT and embedded notices",
|
||||
"source": {
|
||||
"kind": "archive",
|
||||
"url": "https://download.gnome.org/sources/libxml2/2.14/libxml2-2.14.6.tar.xz",
|
||||
"sha256": "7ce458a0affeb83f0b55f1f4f9e0e55735dbfc1a9de124ee86fb4a66b597203a"
|
||||
},
|
||||
"documents": [
|
||||
{
|
||||
"path": "Copyright",
|
||||
"sha256": "5d4873884a890122a4b9b20ad56ac6f7da1d796a5bfcf04a427970ac96217626"
|
||||
}
|
||||
],
|
||||
"recipe_sha256": "52667099a445f7004b9200df4ed771dd74e73745a5c6dc7fe728168e9f8b3e7a",
|
||||
"recipe_path": "deps/submodules/minirtc/thirdparty/gupnp/xmake.lua",
|
||||
"package": "upnp-libxml2",
|
||||
"notice_prefixes": [
|
||||
"include/"
|
||||
],
|
||||
"notice_root_files": true
|
||||
},
|
||||
{
|
||||
"id": "libyuv",
|
||||
"name": "libyuv",
|
||||
@@ -541,29 +381,6 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "nghttp2",
|
||||
"name": "nghttp2",
|
||||
"version": "1.68.0",
|
||||
"license": "MIT",
|
||||
"source": {
|
||||
"kind": "archive",
|
||||
"url": "https://github.com/nghttp2/nghttp2/releases/download/v1.68.0/nghttp2-1.68.0.tar.gz",
|
||||
"sha256": "2c16ffc588ad3f9e2613c3fad72db48ecb5ce15bc362fcc85b342e48daf51013"
|
||||
},
|
||||
"documents": [
|
||||
{
|
||||
"path": "COPYING",
|
||||
"sha256": "6b94f3abc1aabd0c72a7c7d92a77f79dda7c8a0cb3df839a97890b4116a2de2a"
|
||||
}
|
||||
],
|
||||
"recipe_sha256": "eec10214a906c663f74e3e6bf8952cddec39fec3cac710c10d8b9ea669c9ab79",
|
||||
"recipe_path": "xmake-repo/packages/n/nghttp2/xmake.lua",
|
||||
"package": "nghttp2",
|
||||
"notice_prefixes": [
|
||||
"lib/"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "openfec",
|
||||
"name": "OpenFEC",
|
||||
@@ -749,24 +566,6 @@
|
||||
"include/"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "sqlite3",
|
||||
"name": "SQLite",
|
||||
"version": "3.50.0+400",
|
||||
"license": "Public domain",
|
||||
"source": {
|
||||
"kind": "archive",
|
||||
"url": "https://sqlite.org/2025/sqlite-autoconf-3500400.tar.gz",
|
||||
"sha256": "a3db587a1b92ee5ddac2f66b3edb41b26f9c867275782d46c3a088977d6a5b18"
|
||||
},
|
||||
"documents": [],
|
||||
"recipe_sha256": "a752522259dbd722ea79beaa01e5287429ccb37643ccffbecf5fa30ec4df30fd",
|
||||
"recipe_path": "xmake-repo/packages/s/sqlite3/xmake.lua",
|
||||
"package": "sqlite3",
|
||||
"notice_prefixes": [
|
||||
"sqlite3.h"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "svt-av1",
|
||||
"name": "SVT-AV1",
|
||||
@@ -898,7 +697,7 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"bundle_sha256": "f54e5e2168bf6e91d81a413931a052d29f59e0c221df5e1de3f51bd27c662c3a",
|
||||
"bundle_sha256": "a27c89beaaee2397a306417ff133d7f48c74fe6db9041e145fc08f18ec69a080",
|
||||
"toolchain": {
|
||||
"xmake_min_version": "3.1.1",
|
||||
"xmake_repository_revision": "121109352da272cd1a3d4ed52f2dcda34f839955"
|
||||
|
||||
@@ -172,7 +172,6 @@ print -r -- "${CLEAN_INFO}" > "${OUTPUT_DIR}/minirtc-target-info.txt"
|
||||
|
||||
REQUIRED_LINKS=(
|
||||
nice glib-2.0 gobject-2.0 gmodule-2.0 gio-2.0 gthread-2.0 intl
|
||||
gupnp-igd-1.6 gupnp-1.6 gssdp-1.6 soup-3.0 nghttp2 sqlite3 psl xml2
|
||||
ffi pcre2-8 pcre2-posix z ssl crypto srtp2 openfec opus yuv kcp
|
||||
datachannel usrsctp dav1d SvtAv1Enc
|
||||
)
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
# 桌面墙(DeskHub)
|
||||
|
||||
主窗口左上角的“桌面墙”按钮打开独立的桌面墙窗口,与右上角菜单共用顶部工具栏,下方是“本桌面”和“远程桌面”卡片,连接窗口保持紧凑尺寸。重复点击按钮会唤起已有桌面墙窗口,最小化时恢复显示,不创建重复窗口。每次启动软件只显示连接窗口;桌面墙首次打开时恢复保存的位置、尺寸及全屏状态;没有已保存尺寸时默认采用 960 × 720 逻辑像素(含标题栏,当前 macOS 的内容区为 960 × 688),两个窗口分别记忆位置。关闭桌面墙时释放远程操控并断开全部远程设备连接,不受当前分组、分页和选中状态限制,共用连接的独立串流窗口也随之关闭;同时取消排队连接、密码重试和待重连请求。设备列表、分组与窗口布局保留,再次点击“桌面墙”可打开并手动重新连接设备。最小化窗口不会断开连接。已断开的显示器所对应的位置会修正到可用屏幕内。
|
||||
|
||||
桌面墙内容区不再重复显示“桌面墙”标题,顶部仅保留一行工具栏,距窗口内容区顶部 12 逻辑像素;分组、设备数量、“添加”、“选择/完成”、纯文字“精简模式”按钮和全屏图标共用这一行,不显示菜单及消息、更新角标。“添加”、“选择”和“精简模式”默认统一使用浅灰背景、灰色边框及相同的悬停、按下样式;进入选择或精简模式后,对应按钮以蓝色高亮表示当前状态。按钮按当前语言的文字宽度加统一左右内边距自适应,普通文字按钮宽度至少为 48 逻辑像素;内边距默认为 12 逻辑像素,窗口内容区宽度小于 720 时统一收紧为 8,保证最窄窗口下的多语言操作和已选数量可见。下拉按钮额外预留箭头及间距;“选择/完成”和“精简模式/退出精简”按两种状态中较宽的文字预留空间,切换时保持宽度稳定。“选择/完成”固定在视图控制按钮左侧,设备列表紧接工具栏下方,释放原有标题行占用的空间。桌面墙可以单独调整尺寸及全屏,不改变连接窗口的尺寸和位置。全局设置从主窗口菜单进入;设备操作和桌面墙连接的密码弹窗显示在桌面墙窗口内。
|
||||
|
||||
全屏按钮的提示绘制在设备网格和放大视图上方,靠右对齐并保持在窗口内。全屏切换期间持续显示设备列表和已有预览画面,不再淡出内容区或等待淡入;窗口恢复完成后,在下一次 UI 更新中立即发起全屏切换,窗口尺寸变化会及时更新网格。尺寸稳定前暂时屏蔽设备操作与重复全屏点击,并暂停保存过渡尺寸。进入和退出全屏保留当前接管设备及键盘焦点,仅在切换前释放已按下的键鼠,切换期间不发送新的远程输入;尺寸稳定后直接继续操控,无需再次点击接管。真正切换到其他窗口、关闭桌面墙或设备断开时仍会释放操控。切换复用现有 UI 更新循环,不启动嵌套事件循环;失败或超时会解除交互屏蔽。
|
||||
|
||||
可见的普通窗口若低于连接页最小尺寸,桌面墙恢复上次有效大小,连接页恢复固定大小;最小化、隐藏和全屏窗口不会触发这一恢复。窗口恢复以实际尺寸确认完成,期间不记录过渡尺寸;macOS 的最大化恢复使用无动画原生窗口调整,避免系统缩放动画嵌套事件循环。
|
||||
|
||||
## 使用
|
||||
|
||||
- 在桌面墙中点“添加 → 通过设备 ID 添加”,填写设备 ID、密码和可选的分组名称,再点“添加并连接”。密码仅用于本次连接,勾选“记住密码”才会通过原有凭据机制保存;不会写入桌面墙配置。
|
||||
- “添加 → 从近期连接添加”支持多选、全选和统一指定分组。已加入桌面墙的设备显示为灰色、保持勾选并标注“已添加”,不可操作,不受当前分组筛选影响;全选和待添加数量只计算尚未加入的设备,全部已添加时禁用添加按钮。导入后只加入桌面墙,使用“选择 → 连接”再发起连接。
|
||||
- 连接成功后立即记录在连接页的“最近连接”中,无需等到断开,也不要求记住密码。失败的连接不会新增最近记录。
|
||||
- “全部设备”显示保存的成员及当前活动会话。分组成员与连接相互独立:断开后成员仍在,重启后不会自动连接。
|
||||
- 卡片名称栏只显示设备名,长名称省略,不再显示状态圆点。未在线的设备(离线或在线状态未知)整张卡片使用灰色,包括预览背景、边框和名称;在线但尚未连接的设备保持正常样式。选中边框、复选框和操作入口仍可用。悬停画面可查看设备名称、ID 和在线状态,精简模式也支持。在线状态订阅包含全部分组中的设备;尚未收到结果、结果过期或服务器断连时显示未知,已有活动连接的设备仍显示在线。
|
||||
- 鼠标悬停时,名称栏右侧显示与近期连接一致的改名、修改分组、移除、连接/断开操作条,并显示按钮提示。两处的连接按钮均随实际会话状态切换:未连接时显示连接箭头,已连接时显示叉号,点击断开该设备;点卡片仍可查看设备。名称与近期连接共用别名;移除会先确认,只移除当前分组或桌面墙中的成员,不删除近期记录或断开会话。批量选择时隐藏操作条。
|
||||
- 两处的“修改分组”共用操作条组件、分组弹窗和保存逻辑。可以选择多个已有分组、填写新分组,或选“未分组”清除分组归属。分组与桌面墙成员独立保存:近期连接修改分组不会自动加入桌面墙,也不会自动连接;通过“添加”菜单导入或按设备 ID 添加,才保存为桌面墙成员,未指定新分组时保留已有分组。从“全部设备”移除成员后仍保留其分组信息;在特定分组中移除则取消对应分组归属,移除最后一个分组后仍保留在“全部设备”中。修改分组保留墙内设备的顺序,保存失败时保留弹窗和输入。
|
||||
- 近期连接卡片使用与桌面墙相同的离线灰色,不再在缩略图上叠加在线/离线标签。不在线时减淡历史缩略图并将名称栏置灰;没有缩略图时,平台占位也使用相同灰色。在线或已连接的设备正常显示,悬停仍可查看在线状态和操作设备。
|
||||
- 近期连接的设备提示浮窗显示所属分组;多个分组并列显示并自动换行,没有分组时显示“未分组”。分组变更后自动刷新。
|
||||
- “近期连接”标题行右侧可按“全部设备”、未分组或已有分组筛选;一个设备可出现在它所属的多个分组中。筛选与桌面墙当前分组独立,不改变历史记录、在线状态订阅或桌面墙成员。分组修改后自动更新结果;当前分组被清空删除时回到全部设备。
|
||||
- 网格中每台设备的预览区域统一固定为 16:9,普通模式的设备名称栏位于画面下方;其他比例的远端桌面留边完整显示。设备信息提示显示在该设备预览区域内部左下角,宽高受画面范围限制;过长的名称省略显示,保留 ID 和在线状态,不遮挡相邻设备或底部操作栏,精简模式及放大视图的缩略设备同样适用。设备直接放在连续表格的单元格中,单元格之间没有卡片间距,共用贯通整行、整列的单条分隔线;空单元格保留表格线,不再为每台设备绘制独立边框和右下角拖拽标记。拖动任意横向、竖向分隔线或交点,可同步放大、缩小全部单元格,预览区域始终保持 16:9;分隔线两侧的拖动方向一致。普通和精简模式共用单元格宽度,表格按实际尺寸自动换行、调整分页容量;窗口较小时限制格子大小,避免超出可用区域,窗口恢复后继续使用原尺寸。宽度至少为 184 逻辑像素、至多为 960 逻辑像素,并受当前可用区域限制;松开鼠标后保存尺寸,重启后恢复。拖动分隔线不会触发展开、选择或设备操作。翻页不关闭连接,分组内顺序不会按在线状态自动重排。
|
||||
- 按住单元格内的设备画面或名称拖动,可与另一台设备交换位置;目标单元格高亮,鼠标旁显示设备缩略图和松开提示。跨页时保持按住鼠标,将设备拖到窗口内容区左、右边缘内侧 32 逻辑像素范围,或上一页、下一页按钮,悬停 700 毫秒后翻页;边缘显示方向提示并在触发区域高亮,继续停留可连续翻页,到首尾页自动停止。翻页期间保留拖动源,末页设备不足一整页时也可继续拖动,移到目标设备后松开完成交换;离开翻页区域会重新计算悬停时间,在可翻页的边缘区域或按钮上松开只取消拖动,不会误与边缘下方的设备换位;窗口外不触发翻页。普通、精简及批量选择模式均支持,短距离点击仍执行原来的查看或选择操作。拖到自身、空单元格、分隔线或表格外不会换位;Esc、窗口失焦、切换分组或调整窗口大小会取消拖动。交换后的顺序保存到本地,并应用于所有分组视图,不修改分组、批量选择、连接或静音状态;保存失败时保持原顺序并提示重试。仅活动会话也可换位,排序不会自动将它加入保存的桌面墙成员。
|
||||
- 顶部“精简模式”切换为仅画面的设备卡片,隐藏名称、在线状态、状态文字和悬停操作条,同时收起设备管理工具栏;画面完整等比显示,不裁切。顶部保持当前语言下宽度稳定的文字按钮高亮显示“退出精简”,不带图标;全屏入口仍可见。底部保留连接计数、网络详情入口和分页,不显示服务器状态栏。点击图片仍可展开查看和接管操控,返回后保持精简模式;模式切换会释放操控、退出批量选择并返回网格,不改变连接、分组和设备顺序。
|
||||
- 主窗口菜单中的全局“设置”顶部并排提供“常规”和“桌面墙”两个选项卡;“桌面墙”可统一配置桌面墙设备的传输分辨率上限和帧率,桌面墙处于普通、精简、展开或全屏视图时,也可通过主窗口菜单打开设置。切换选项卡保留未保存修改,底部统一保存或取消。分辨率支持自动、320 × 180、640 × 360、1280 × 720、1920 × 1080、2560 × 1440、3840 × 2160和原始分辨率;固定上限适用于缩略图和展开画面,保持比例且不放大源画面。帧率支持跟随设备设置、15、30、60 FPS。保存后立即更新当前桌面墙会话,也用于后续桌面墙连接,不影响普通连接,跨分组和分页生效;取消不应用修改,恢复默认需保存后生效。配置保存在本地,重启后保留;保存失败时保留面板和输入。打开设置会释放远程操控。
|
||||
- 服务器连接状态、指示灯和重连入口仅在主窗口显示,桌面墙的普通、精简、展开、批量选择和全屏视图均不显示服务器状态栏。桌面墙底部操作栏左侧以普通文字显示已连接设备数量,旁边是紧凑的“网络状态”按钮;点击按钮可打开当前已建立连接的设备列表,空白区域不响应点击,也不显示整条悬停提示。弹窗采用紧凑宽度,高度随设备数量调整,最多同时显示 6 行,更多设备通过滚动查看。网络列表不受桌面墙分组和分页限制;离线、未连接和连接中的设备不列入,断开后自动移除。列表显示设备名称、ID、直连/中继、实际协商的 SRTP 加密状态、控制端接收与发送速率、视频丢包率、视频延时、实际接收分辨率与帧率,打开期间每秒刷新。加密状态紧随连接方式显示为“已加密/未加密”,尚无统计或报告超过 3 秒未更新时显示“待确认”。分辨率与帧率在解码交付时统计,墙内预览和后台会话均可用;尚未收到画面时显示“—”,画面停止更新超过 1 秒时帧率显示 0 FPS。网络数值尚无统计或报告超过 3 秒未更新时显示“—”。每台设备采用两行布局:画面列显示分辨率和帧率,视频延时列显示延时和丢包率,不再显示 RTT。视频延时直接显示毫秒数,取最近 1 秒内新提交预览帧的平均值;从被控端采集时间戳计时,包含编码、传输、解码、预览转换和 UI 等待,按采集到画面提交估算,不包含提交后到屏幕实际显示的等待。当前无墙内预览样本时,可使用该设备独立串流窗口的延时统计。没有新显示帧、时间戳不可用或无有效样本时显示“—”,不把 RTT 当作视频延时。可通过关闭按钮、Esc 或点击列表外关闭;打开列表会释放远程操控。
|
||||
- 点卡片进入只读放大视图,点“接管操控”才开始发送键鼠。点“释放操控”或按 Ctrl/⌘ + Shift + Esc 可以释放。切换设备、翻页、打开本地操作面板、切换模式及窗口失焦也会释放。
|
||||
- 接管期间,主画面内的鼠标形态跟随当前主选设备,支持文本输入、链接、忙碌、窗口缩放及隐藏等状态;群控时也只采用主选设备的光标状态,其他设备的反馈不会覆盖它。释放操控或主选设备断开后恢复默认指针,工具栏与缩略图保留本地鼠标形态。
|
||||
- 放大视图中点击“开启群控”,以当前设备为主控,同时接管操控;鼠标移动、点击、拖拽、滚轮、键盘和组合键同步到所有其他已连接设备,包含其他分组、分页及未显示在墙内的连接。所有设备使用主控设备映射后的相同坐标和按键,不针对屏幕比例另做适配;新建立的连接也会接收后续输入,未连接和正在断开的设备跳过。点击“关闭群控”先释放各设备已按下的键鼠,再恢复当前设备的单独操控;释放操控、切换设备、窗口失焦或主控设备断开时自动结束群控。群控状态不保存,重新接管需要手动开启。
|
||||
- 桌面墙连接默认静音,包含新建连接、重连、复用已有连接、添加或导入已有会话,以及切入桌面墙时已有的连接。展开、全屏或接管操控不会自动开启声音;需要声音时,可通过批量“取消静音”或在独立串流窗口主动开启。两个入口共用同一静音状态,主动开启的声音在桌面墙可见时播放,翻页、展开及释放操控保留声音设置;每台设备使用独立音频流同时播放,静音一台不会清空其他设备的声音。新版本对端按会话关闭音频订阅;旧版本发送的音频由控制端丢弃,不通过停止对端的全局声音采集影响其他控制端。
|
||||
- 放大视图使用一行设备操作栏,显示返回、设备名称、状态和接管/释放操控按钮,右侧保留精简模式及全屏入口;选中的桌面在下方放大显示,同页其他桌面的缩略图统一在上方横向排列,超出宽度时可横向滚动。点击上方缩略图可切换下方桌面,当前桌面不在缩略图中重复显示;同页只有一台设备时隐藏缩略图栏。普通模式缩略图下方以一行显示设备名,长名称省略,不显示状态圆点;不在线设备的缩略图和下方放大预览统一使用灰色,精简模式同样生效;悬停可查看设备名称、ID 和在线状态,精简模式也支持悬停查看。缺少画面或画面停滞时保留状态占位;滚动条单独留出空间。普通和精简模式的缩略图预览区域均固定为 16:9,普通模式的设备名称单独显示在画面下方。普通、精简、宽窗口及全屏均保持上下布局,上下区域之间提供带抓手的分隔条:向上拖动扩大下方主画面,向下拖动同步放大上方缩略图;选中分隔条后也可用上下方向键微调。缩略图始终保持 16:9,并保留主画面的最小可用高度;窗口缩小时临时限制尺寸,恢复窗口后沿用原先尺寸。普通和精简模式共用此尺寸,切换设备后保持,松开鼠标后保存并在重启时恢复;与网格的单元格尺寸独立。同页只有一台设备时同时隐藏缩略图栏和分隔条。拖动分隔条不会切换设备或发送远程键鼠,Esc、窗口失焦及窗口大小变化会结束拖动。调整尺寸后仍保留当前设备所在页。主预览和缩略图均完整等比显示,鼠标坐标按实际预览区域映射。
|
||||
- 在“近期连接”中点击已连接设备的卡片画面,可打开原有独立串流窗口,使用声音、显示器切换、隐私屏、文件传输等完整单会话工具。关闭墙内设备的独立窗口不会断开墙中的会话。
|
||||
- 点击“选择”进入批量模式,“完成”在同一位置退出;顶部设备管理栏原位切换为批量操作栏,从左到右显示已选数量(包含跨页数量)、“全选/取消全选”、“连接”、“断开”、“更多”和“完成”,无需往返底栏。批量模式暂时收起分组筛选和“添加”入口,退出后恢复;工具栏高度不变,设备卡片的位置、尺寸及分页容量保持不变。已选数量占用剩余空间,批量操作按钮同样按文字宽度和统一内边距自适应;“全选/取消全选”使用与“断开”、“更多”一致的浅灰背景和边框,并预留两种文字中较宽的一种,切换选中数量或按钮文字时,后续按钮保持原位。“连接”保留主操作的蓝色强调,“完成”以蓝色表示当前选择模式。点击“全选”会选中当前筛选下的所有设备,跨页生效;全部选中后显示“取消全选”。翻页保留选择,切换分组清空选择;底栏始终保留已连接数量、网络状态入口和分页;静音和文件分发收进顶部向下展开的“更多”菜单。声音菜单项根据已选且已连接设备的状态切换:全部静音时显示“取消静音”,有设备开启声音时显示“静音”,无已连接设备时禁用。点击后统一设置所有已选的已连接设备,包含跨页选择,跳过未连接设备,保留当前选择。断开和文件分发会先列出完整目标;提交后的操作目标不会跟随之后的选择变化。
|
||||
|
||||
## 预览与兼容性
|
||||
|
||||
自动分辨率模式下,桌面墙发起或接管的会话在能力协商后默认请求最长边不超过 320 像素的画面。点卡片展开后,根据视频显示区域的实际物理像素(包含 HiDPI 缩放)请求分辨率;全屏、退出全屏和调整窗口大小后会重新计算,尺寸稳定 200 毫秒后更新订阅。返回网格或切换到其他设备后恢复缩略图尺寸。不可见设备暂停视频;如果同一设备仍在可见的独立串流窗口显示,则继续传输。同一会话同时在独立串流窗口显示时,按两个可见视频区域中较大的尺寸请求,避免小预览降低大窗口的清晰度。选择固定或原始分辨率后使用对应上限;恢复自动后重新跟随视图。普通连接页发起且未加入桌面墙管理的会话保留原有分辨率行为。
|
||||
|
||||
被控端按连接分别在编码前缩放,完整保留桌面比例,不放大超过源画面的尺寸,也不修改被控端屏幕的系统分辨率。请求的宽高是上限,画质和网络自适应仍可降低实际发送尺寸。默认跟随每个会话原有的 30/60 帧设置;指定帧率仅覆盖桌面墙订阅,不修改会话的画质或原有帧率偏好。声音仍需明确开启。
|
||||
|
||||
15 帧订阅使用编码前发送节流和稳定的逐显示流时间预算,并采用保持分辨率策略,避免低帧率触发交互视频的画质降级。30/60 帧选项使用编码器自身的交互帧率控制。仅向声明 `supports_deskhub_fps15` 的对端发送 `fps=15`;缺少该能力的 v1/v2 对端使用原有 `fps=60, frame_rate=30` 格式回退到 30 FPS,未声明预览能力的更旧版本保持原行为。协议仍接受旧控制端的 1/8 帧请求,但设置界面不再提供这两个档位。
|
||||
|
||||
预览在解码回调中随收到的画面生成有界缩略图,可见桌面墙随 UI 刷新显示最新帧。UI 不处理全分辨率转换,只保留当前可见设备的最新预览,超过 5 秒未更新会标记停帧。画面保持比例,以完整矩形显示;表格单元格与画面之间保留内边距,不裁切桌面内容或画面边角。实际显示帧率受设备性能与网络状况影响。
|
||||
|
||||
连接旧版被控端时,若仅声明 `supports_deskhub_preview`,控制端发送原有 v1 订阅;若未声明预览能力,则不发送该订阅,沿用其视频策略。旧控制端连接新版被控端时继续使用原分辨率;v1 订阅也会清除此前的尺寸限制。只有同时声明 `supports_deskhub_preview` 与 `supports_deskhub_resolution` 的被控端才会收到 v2 尺寸请求,旧版本不承诺发送端节省资源。
|
||||
|
||||
### 视频带宽分配
|
||||
|
||||
“设置 → 桌面墙 → 视频总带宽”配置所有支持带宽协商的桌面墙会话共享的视频预算,默认 20 Mbps,可选 2、5、10、20、50、100 Mbps。保存立即应用,取消不生效,重启后保留;恢复默认后需要保存。它是视频的持续目标预算,包含发送端为视频纠错、重传及 RTP 开销预留的份额;音频、文件传输、普通连接及不支持带宽协商的旧版设备不计入,关键帧和切换期间的瞬时速率可能超过预算。
|
||||
|
||||
控制端根据请求的分辨率和帧率估算每台设备的需求上限。先为每台可见设备保留至多 100 Kbps 的基础份额(总预算不足时等分),再按权重分配剩余预算:展开画面或可见的独立串流窗口权重为缩略图的 4 倍。相同规格、相同优先级的设备基本均分;达到需求上限的设备让出剩余额度,不强制用满总预算。默认缩略图 320 × 320 上限、30 FPS 的需求预算为 500 Kbps。分配随连接、分页、分组、窗口可见性以及分辨率/帧率变化更新,独立串流窗口和墙内画面使用同一份设备预算。需求和总预算未变化时复用分配结果,不随 UI 刷新重复计算。
|
||||
|
||||
翻页、切换分组、隐藏或最小化桌面墙后,没有可见画面的墙内会话发送 `fps=0` 暂停视频,保留连接和主动开启的音频。重新可见后恢复当前需求并请求关键帧。单纯重新分配带宽不会触发所有设备同时发送关键帧,也不会重置 15 FPS 的发送节奏。
|
||||
|
||||
预算不代表测速结果。每条连接原有的拥塞控制继续工作,发送端使用网络可用带宽与设备预算中的较小值,并在设备的多个活动显示流之间再分配。视频探测速率受同一设备额度约束,以便网络恢复或画面展开后继续升速;额度下降会取消旧探测,暂停视频时关闭探测和填充包。只有声明 `supports_deskhub_bandwidth` 的对端会收到带宽字段;支持预览但不支持带宽协商的旧版仍能暂停不可见视频,更旧版本保持原行为。
|
||||
|
||||
### 协议与接口
|
||||
|
||||
可靠控制通道的 `deskhub_preview` v2 保留 v1 的 `request_id`、`fps`、`quality`、`frame_rate`、`preference`、`audio` 字段,新增必填 `width`、`height` 整数,单位为物理像素。两个值均为 0 表示恢复源尺寸上限,否则两个值均须在 2~16384 之间;非法或缺失的值不应用。`deskhub_preview_status` 回传请求的协议版本、请求 ID 与 `accepted`。请求 ID 跳过 0,支持 32 位回绕,重复或过期请求不会覆盖新设置。断开会话清除订阅和 15 FPS 协商能力,再连接会重新发送当前需求。15 FPS 扩展通过可选能力 `supports_deskhub_fps15` 协商,沿用 v1/v2 的字段结构,使用 `fps=15, frame_rate=30`,在编码前将实际输入限制为 15 FPS。
|
||||
|
||||
带宽扩展沿用 v1/v2,增加可选整数 `max_bitrate_bps`(0~100000000,单位 bps),仅向声明 `supports_deskhub_bandwidth` 的对端发送。0 或缺省表示解除额外的视频预算限制;暂停视频仍使用 `fps=0`。非法字段拒绝整个订阅,断开后清除能力,重连重新协商。MiniRTC 的 `UpdateVideoBitrateLimit(peer, remote_id, remote_id_size, max_bitrate_bps)` 为指定连接的所有视频流设置合计预算,不改变音频与数据发送;返回 0 表示已进入拥塞控制队列,-1 表示参数、连接或传输不支持。
|
||||
|
||||
MiniRTC 新增 `UpdateVideoResolution(peer, remote_id, remote_id_size, width, height)` 接口,返回 0 表示已接受,-1 表示参数、连接或传输类型不支持。它为指定控制端的所有显示流设置独立尺寸上限,切换时丢弃旧设置尚未发出的编码结果,并请求新关键帧;底层自适应也以这个上限作为有效源尺寸。
|
||||
|
||||
`SetVideoSendPaused(peer, remote_id, remote_id_size, paused)` 与 `SetAudioSendPaused(peer, remote_id, remote_id_size, paused)` 分别控制指定连接的所有视频/音频发送通道,`true` 暂停、`false` 恢复。重复设置不产生额外操作;返回 0 表示接受,-1 表示参数、连接或传输类型不支持。视频暂停阻止新帧进入编码,废弃旧编码结果,并在发送队列上清除待发视频/重传/纠错包,停止探测和填充;恢复请求新关键帧。音频暂停阻止 PCM 编码并清理待发音频,恢复时仅发送新音频。已交给网络的包无法撤回。API 保留连接、采集源、码率与画质设置以及其他媒体/数据通道,也不改变其他控制端的发送状态。
|
||||
|
||||
桌面墙将 `fps=0` 和 `audio=false` 分别映射到上述 API;应用层仅保留低帧率节流,不再使用 `1 bps` 代替视频暂停或在采集回调中过滤静音音频。接收端仍保留静音门控及播放队列清理,以立即停止播放网络中已在途的音频。旧版 `audio_capture` 命令在支持新 API 的传输中也按连接静音,已有订阅的音频状态优先;不支持 API 的旧/Web 传输沿用采集开关回退。
|
||||
|
||||
## 批量操作
|
||||
|
||||
连接和文件分发最多并行启动 4 个操作。尚未开始连接的设备卡片显示“排队中”,轮到后自动切换为实际连接状态。断开立即执行,同时取消该设备排队中的操作及重连请求,不等待文件传输完成。单台等待密码不会阻塞其他设备,可以从对应设备的放大视图填写密码。连接状态显示在设备卡片上,文件传输详情可在该设备的独立串流窗口中查看。
|
||||
|
||||
批量操作仅在本次运行中执行,不提供任务列表、操作历史或重启后的恢复。成员资料与窗口状态(含共享格子宽度)保存在应用缓存目录的 `deskhub.json` 和 `deskhub.json.window` 中,不含设备密码和文件内容。
|
||||
|
||||
`deskhub.json` 第 2 版分别保存分组记录和桌面墙设备 ID,通过可选的 `device_order` 保存手动排列顺序,并通过可选的 `stream_settings` 保存分辨率预设及帧率。没有手动顺序的旧配置继续使用原有排列。缺少或无效的设置使用自动分辨率和跟随设备帧率,不影响设备成员。已保存的 1/8 FPS 自动迁移为 15 FPS,保留原分辨率预设;原有 30/60 FPS 设置保持不变。第 1 版配置仍可读取,原有桌面墙成员、分组和顺序会保留;旧版数据未记录加入来源,因此不会自动移除已有成员。
|
||||
|
||||
批量远程命令、重启尚未提供。实际大规模并发容量需要用真实设备测量。
|
||||
|
||||
## 验证
|
||||
|
||||
```sh
|
||||
xmake build crossdesk
|
||||
xmake run slint_ui_smoke_test
|
||||
xmake run crossdesk_wire_test
|
||||
xmake run keyboard_state_wire_test
|
||||
```
|
||||
@@ -4,49 +4,51 @@
|
||||
|
||||
## 来源 / Sources
|
||||
|
||||
- 桌面与 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-25 在 [web.crossdesk.cn](https://web.crossdesk.cn/) 重新截取的连接首页,页面显示版本 `2026.09.24.2`,视口为 1280 × 720。ID 与密码输入框为空,没有发起远程连接;使用该图片作为设备缩略图的桌面截图也已重新生成。
|
||||
- 桌面截图更新于 **2026-10-14**,UI 源代码为 `e76d2bf9`,使用 Slint 1.17.1、macOS arm64 和软件渲染器。图片直接渲染仓库中的 Slint 组件。
|
||||
- 主窗口、常规设置、桌面墙设置、自托管、关于与会话截图来自现有 [`slint_ui_smoke_test`](../../apps/desktop/tests/slint_ui_smoke_test.cpp) 的捕获模式。主窗口展示左上角桌面墙入口、双卡片连接区和近期连接分组筛选;设置展示“常规 / 桌面墙”选项卡与新的分组布局。
|
||||
- 桌面墙网格与放大图复用同一捕获代码,在**工程外的临时目录**中复制源文件,仅调整演示设备数量、单元格宽度和网格窗口高度。Slint 组件没有改动,工程内没有新增或修改测试文件。网格展示六台演示设备;放大图展示只读预览、群控和接管入口,没有建立真实连接。
|
||||
- 本机 ID `123 456 789`、密码 `123456`、设备名称、在线 / 连接状态及记录均为演示数据。主窗口展示三台设备,桌面墙展示六台;它们不是真实设备凭据或真实远程会话。示例配置不代表所有运行时默认值。
|
||||
- “关于”中的 `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/),两者采集于 2026-09-07。它们只用于展示预览效果,不代表这些页面正在对应设备上运行,页面中的版本信息也不代表最新发布。
|
||||
- `web-client.png` 保留 **2026-09-25** 从 [Web 客户端](https://web.crossdesk.cn/) 采集的 1280 × 720 连接首页,页面版本为 `2026.09.24.2`,ID 与密码为空。本次未重新采集 Web 页面;桌面设备预览继续复用这张图片。
|
||||
|
||||
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.
|
||||
Desktop images were refreshed on **2026-10-14** from UI source `e76d2bf9`, using Slint 1.17.1 on macOS arm64. They render the actual components with demonstration data. DeskHub images use an external temporary copy of the existing capture program with six sample devices, 300 logical-pixel cells, and a shorter grid window; no UI components or repository tests were changed. IDs, credentials, connection states, versions, and update results are illustrative. No remote sessions were opened. Public-page fixtures remain from 2026-09-07, and the Web screenshot remains from 2026-09-25. This refresh does not include native mobile screenshots.
|
||||
|
||||
## 文件 / Files
|
||||
|
||||
| 文件 | 页面 | 语言 |
|
||||
| 文件(`zh` / `en`) | 页面 | PNG 尺寸 |
|
||||
| --- | --- | --- |
|
||||
| `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 连接首页 | 中文 |
|
||||
| `desktop-main-*.png` | 主窗口 / Main window | 1280 × 944 |
|
||||
| `desktop-deskhub-*.png`(不含 `focus`) | 六设备桌面墙 / Six-device DeskHub grid | 1920 × 1000 |
|
||||
| `desktop-deskhub-focus-*.png` | 桌面墙放大视图 / Enlarged DeskHub view | 1920 × 1396 |
|
||||
| `desktop-settings-*.png`(不含 `deskhub`) | 常规设置 / General settings | 1280 × 944 |
|
||||
| `desktop-settings-deskhub-*.png` | 桌面墙传输设置 / DeskHub video settings | 1280 × 944 |
|
||||
| `desktop-self-hosted-*.png` | 自托管设置 / Self-hosting | 1280 × 944 |
|
||||
| `desktop-stream-video-settings-*.png` | 会话工具栏与画面设置 / Session video settings | 1920 × 1200 |
|
||||
| `desktop-about-*.png` | 关于与检查更新 / About and update check | 1280 × 944 |
|
||||
| `web-client.png`(中文) | Web 连接首页 / Web connection page | 1280 × 720 |
|
||||
|
||||
主窗口及其弹窗图片为 1280 × 900 PNG,会话图片为 1920 × 1200 PNG,均使用 2× 原生渲染缩放保持文字清晰,未放大旧图。Markdown 只设置展示宽度,保持原始比例。设置列表可滚动,截图仅展示首屏。演示配置显示硬件编解码器可用;实际可用性由平台、设备与构建决定。Windows 专属的采集方式与便携版服务入口不会出现在这些 macOS 截图中。
|
||||
桌面图片使用 2× 原生渲染缩放,捕获窗口内容区,不包含 macOS 原生标题栏。未放大旧 UI 截图,也未重绘控件;演示远端画面由实际组件按比例显示。Markdown 只设置展示宽度。常规设置可滚动,截图仅展示首屏;Windows 专属采集方式与便携版服务入口不会出现在这些 macOS 图片中。
|
||||
|
||||
## 重新生成 / Regenerate
|
||||
|
||||
更新 Web 图片时,在浏览器中打开 [web.crossdesk.cn](https://web.crossdesk.cn/),等待连接表单和底部版本号加载完成,保持 ID 与密码为空,截取页面视口。当前图片使用 1280 × 720 视口;浏览器导出的 JPEG 仅转换为 PNG,未修改页面布局或内容。替换 `web-client.png` 后,重新生成下方桌面截图以同步设备卡片缩略图,并更新本页的日期与页面版本号。
|
||||
以下命令在仓库根目录、已配置依赖的 macOS 开发环境执行。所有临时源码、PPM 和额外捕获程序均写入系统临时目录,最终 PNG 才写入 `docs/images`。`sips` 仅做 PPM 到 PNG 的无损格式转换,其他平台可使用支持 PPM 的转换工具。
|
||||
|
||||
以下命令在仓库根目录、已完成依赖配置的 macOS 开发环境运行。`sips` 仅将 Slint 导出的 PPM 无损转换为 PNG,不修改 UI。其他平台可以使用支持 PPM 的图片格式转换工具。
|
||||
先检查 `/tmp/crossdesk-ui-capture-page` 和 `/tmp/crossdesk-ui-capture-language`:旧覆盖文件存在且非空时会优先于环境变量,应确认并移开后再捕获。`CROSSDESK_UI_CAPTURE_DEMO_ASSETS` 必须包含上述公开页面图片,缺失图片会导致捕获失败。工具等待窗口尺寸与缩放初始化后再截图。
|
||||
|
||||
### 主窗口、设置与会话
|
||||
|
||||
```bash
|
||||
set -e
|
||||
xmake b -y slint_ui_smoke_test
|
||||
mkdir -p build/readme-capture docs/images
|
||||
capture_dir=$(mktemp -d "${TMPDIR:-/tmp}/crossdesk-readme-capture.XXXXXX")
|
||||
export capture_dir
|
||||
|
||||
for locale in zh en; do
|
||||
language=0
|
||||
if [ "$locale" = en ]; then language=1; fi
|
||||
for page in main settings self-hosted about stream-video-settings; do
|
||||
snapshot="$PWD/build/readme-capture/desktop-$page-$locale.ppm"
|
||||
for page in main settings settings-deskhub self-hosted about stream-video-settings; do
|
||||
snapshot="$capture_dir/desktop-$page-$locale.ppm"
|
||||
SLINT_BACKEND=winit-software SLINT_SCALE_FACTOR=2 \
|
||||
CROSSDESK_UI_CAPTURE=1 \
|
||||
CROSSDESK_UI_CAPTURE_DEMO_ASSETS="$PWD/docs/images" \
|
||||
@@ -60,6 +62,67 @@ for locale in zh en; do
|
||||
done
|
||||
```
|
||||
|
||||
`CROSSDESK_UI_CAPTURE_DEMO_ASSETS` 必须指向包含上述图片的目录。缺失图片会让捕获命令失败,避免再次导出空白缩略图;不设置该变量时,仍保留原有测试状态。捕获工具会先运行窗口事件循环,等待尺寸与缩放初始化;直接在 `show()` 后截图可能得到 1× 图片。
|
||||
### 六设备桌面墙
|
||||
|
||||
捕获模式兼容 `/tmp/crossdesk-ui-capture-page` 和 `/tmp/crossdesk-ui-capture-language` 两个旧覆盖文件;如存在,它们会优先于环境变量,运行前请检查。更新后逐张检查文字、布局、敏感信息和文档引用,并同步修改本页日期与源码版本。
|
||||
现有捕获模式的 `gallery` / `gallery-focus` 默认使用三台设备。要复现本页的六设备图,在上一步同一 shell 中创建临时副本,只修改演示场景:
|
||||
|
||||
```bash
|
||||
python3 - <<'PY'
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
source = Path('apps/desktop/tests/slint_ui_smoke_test.cpp').read_text()
|
||||
changes = [
|
||||
('options.page != "main-recent-scrolled") {',
|
||||
'options.page != "main-recent-scrolled" &&\n'
|
||||
' !options.page.starts_with("gallery")) {'),
|
||||
('window->set_gallery_mode(true);\n std::vector',
|
||||
'window->set_gallery_mode(true);\n'
|
||||
' window->set_gallery_tile_width(300);\n std::vector'),
|
||||
('698.0f;\n#else\n 720.0f;',
|
||||
'options.page == "gallery" ? 500.0f : 698.0f;\n#else\n'
|
||||
' options.page == "gallery" ? 500.0f : 720.0f;'),
|
||||
]
|
||||
for old, new in changes:
|
||||
assert source.count(old) == 1, 'Capture source changed; review the fixture adjustment.'
|
||||
source = source.replace(old, new, 1)
|
||||
Path(os.environ['capture_dir'], 'readme_capture.cpp').write_text(source)
|
||||
PY
|
||||
```
|
||||
|
||||
以下编译路径对应本次的 `macosx / arm64 / debug` 配置。其他配置先用 `xmake show -t slint_ui_smoke_test` 查看当前生成头文件和库目录,再替换路径;不要在工程内添加临时构建入口。
|
||||
|
||||
```bash
|
||||
slint_prefix=$(xmake lua -c 'import("core.project.config"); config.load(); import("core.project.project"); print(project.target("slint_ui_smoke_test"):pkg("slint"):installdir())')
|
||||
xcrun clang++ -std=c++20 \
|
||||
-Iapps/desktop/src/gui \
|
||||
-Iapps/desktop/src/gui/assets/fonts \
|
||||
-Iapps/desktop/src/gui/assets/localization \
|
||||
-Ibuild/.gens/crossdesk_ui/macosx/arm64/debug/rules/slint \
|
||||
-isystem "$slint_prefix/include/slint" \
|
||||
"$capture_dir/readme_capture.cpp" \
|
||||
build/macosx/arm64/debug/libcrossdesk_ui.a \
|
||||
-L"$slint_prefix/lib" -lslint_cpp -Wl,-rpath,"$slint_prefix/lib" \
|
||||
-o "$capture_dir/readme_capture"
|
||||
|
||||
for locale in zh en; do
|
||||
language=0
|
||||
if [ "$locale" = en ]; then language=1; fi
|
||||
for page in gallery gallery-focus; do
|
||||
name=deskhub
|
||||
if [ "$page" = gallery-focus ]; then name=deskhub-focus; fi
|
||||
snapshot="$capture_dir/desktop-$name-$locale.ppm"
|
||||
SLINT_BACKEND=winit-software SLINT_SCALE_FACTOR=2 \
|
||||
CROSSDESK_UI_CAPTURE=1 \
|
||||
CROSSDESK_UI_CAPTURE_DEMO_ASSETS="$PWD/docs/images" \
|
||||
CROSSDESK_UI_CAPTURE_PAGE="$page" \
|
||||
CROSSDESK_UI_CAPTURE_LANGUAGE="$language" \
|
||||
CROSSDESK_UI_CAPTURE_SNAPSHOT="$snapshot" \
|
||||
"$capture_dir/readme_capture"
|
||||
sips -s format png "$snapshot" \
|
||||
--out "docs/images/desktop-$name-$locale.png"
|
||||
done
|
||||
done
|
||||
```
|
||||
|
||||
更新后逐张检查文字、布局、演示数据和文档引用,并同步日期、源码版本与图片尺寸。若更新 Web 图片,应打开 Web 客户端,等待表单与底部版本号加载完成,保持 ID 与密码为空,再截取页面视口;替换后也需重新生成桌面图以同步缩略图。
|
||||
|
||||
|
Before Width: | Height: | Size: 143 KiB After Width: | Height: | Size: 138 KiB |
|
Before Width: | Height: | Size: 142 KiB After Width: | Height: | Size: 136 KiB |
|
After Width: | Height: | Size: 462 KiB |
|
After Width: | Height: | Size: 593 KiB |
|
After Width: | Height: | Size: 599 KiB |
|
After Width: | Height: | Size: 466 KiB |
|
Before Width: | Height: | Size: 141 KiB After Width: | Height: | Size: 178 KiB |
|
Before Width: | Height: | Size: 140 KiB After Width: | Height: | Size: 183 KiB |
|
Before Width: | Height: | Size: 145 KiB After Width: | Height: | Size: 136 KiB |
|
Before Width: | Height: | Size: 145 KiB After Width: | Height: | Size: 134 KiB |
|
After Width: | Height: | Size: 134 KiB |
|
After Width: | Height: | Size: 131 KiB |
|
Before Width: | Height: | Size: 149 KiB After Width: | Height: | Size: 148 KiB |
|
Before Width: | Height: | Size: 152 KiB After Width: | Height: | Size: 146 KiB |
|
Before Width: | Height: | Size: 361 KiB After Width: | Height: | Size: 364 KiB |
|
Before Width: | Height: | Size: 359 KiB After Width: | Height: | Size: 362 KiB |
@@ -0,0 +1,130 @@
|
||||
/*
|
||||
* @Author: DI JUNKUN
|
||||
* @Date: 2026-10-12
|
||||
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
|
||||
*/
|
||||
|
||||
#ifndef _PREVIEW_SUBSCRIPTION_H_
|
||||
#define _PREVIEW_SUBSCRIPTION_H_
|
||||
|
||||
#include <cstdint>
|
||||
#include <nlohmann/json.hpp>
|
||||
#include <optional>
|
||||
|
||||
namespace crossdesk {
|
||||
// Versioned independently from the interactive 30/60 FPS video settings.
|
||||
struct PreviewSubscription {
|
||||
uint32_t request_id = 0;
|
||||
int fps =
|
||||
0; // 0 pauses; 1/8 legacy previews; 15 negotiated preview; 60 interactive.
|
||||
int quality = 0;
|
||||
int frame_rate = 30;
|
||||
int preference = 0;
|
||||
bool audio = false;
|
||||
// v2 bounds are physical pixels. 0/0 restores the legacy source limit.
|
||||
int width = 0;
|
||||
int height = 0;
|
||||
int version = 1;
|
||||
// Optional negotiated extension: total video budget for this peer, in bps.
|
||||
// 0 restores normal congestion control; absent preserves legacy wire format.
|
||||
std::optional<int> max_bitrate_bps;
|
||||
|
||||
static bool ValidResolution(int width, int height) {
|
||||
return (width == 0 && height == 0) ||
|
||||
(width >= 2 && height >= 2 && width <= 16384 && height <= 16384);
|
||||
}
|
||||
|
||||
static PreviewSubscription Interactive(int quality, int frame_rate,
|
||||
int preference, bool audio = false) {
|
||||
return {0, 60, quality, frame_rate, preference, audio};
|
||||
}
|
||||
|
||||
bool IsNewerThan(const PreviewSubscription &other) const {
|
||||
return static_cast<int32_t>(request_id - other.request_id) > 0;
|
||||
}
|
||||
|
||||
bool SameSettings(const PreviewSubscription &other) const {
|
||||
return fps == other.fps && quality == other.quality &&
|
||||
frame_rate == other.frame_rate &&
|
||||
EncoderPreference() == other.EncoderPreference() &&
|
||||
audio == other.audio && width == other.width &&
|
||||
height == other.height && version == other.version &&
|
||||
max_bitrate_bps == other.max_bitrate_bps;
|
||||
}
|
||||
|
||||
int EncoderPreference() const {
|
||||
// Sparse preview input is intentional. Preserve spatial detail instead of
|
||||
// applying the interactive frame-rate/keyframe latency budget to it.
|
||||
return fps == 1 || fps == 8 || fps == 15 ? 1 : preference;
|
||||
}
|
||||
|
||||
nlohmann::json ToJson() const {
|
||||
nlohmann::json json = {{"type", "deskhub_preview"}, {"version", version},
|
||||
{"request_id", request_id}, {"fps", fps},
|
||||
{"quality", quality}, {"frame_rate", frame_rate},
|
||||
{"preference", EncoderPreference()}, {"audio", audio}};
|
||||
if (version == 2) {
|
||||
json["width"] = width;
|
||||
json["height"] = height;
|
||||
}
|
||||
if (max_bitrate_bps) json["max_bitrate_bps"] = *max_bitrate_bps;
|
||||
return json;
|
||||
}
|
||||
static std::optional<PreviewSubscription> Parse(const nlohmann::json &json) {
|
||||
try {
|
||||
if (json.at("type") != "deskhub_preview" ||
|
||||
!json.at("version").is_number_integer() ||
|
||||
(json.at("version") != 1 && json.at("version") != 2))
|
||||
return {};
|
||||
for (const char *key :
|
||||
{"request_id", "fps", "quality", "frame_rate", "preference"})
|
||||
if (!json.at(key).is_number_integer())
|
||||
return {};
|
||||
const auto id = json.at("request_id").get<int64_t>();
|
||||
if (id <= 0 || id > UINT32_MAX)
|
||||
return {};
|
||||
for (const char *key : {"fps", "quality", "frame_rate", "preference"}) {
|
||||
const auto value = json.at(key).get<int64_t>();
|
||||
if (value < 0 || value > 60)
|
||||
return {};
|
||||
}
|
||||
PreviewSubscription result{static_cast<uint32_t>(id), json.at("fps"),
|
||||
json.at("quality"), json.at("frame_rate"),
|
||||
json.at("preference")};
|
||||
if ((result.fps != 0 && result.fps != 1 && result.fps != 8 &&
|
||||
result.fps != 15 && result.fps != 60) ||
|
||||
result.quality < 0 || result.quality > 2 ||
|
||||
(result.frame_rate != 30 && result.frame_rate != 60) ||
|
||||
result.preference < 0 || result.preference > 2)
|
||||
return {};
|
||||
if (json.contains("audio")) {
|
||||
if (!json.at("audio").is_boolean())
|
||||
return {};
|
||||
result.audio = json.at("audio").get<bool>();
|
||||
}
|
||||
result.version = json.at("version").get<int>();
|
||||
if (result.version == 2) {
|
||||
for (const char *key : {"width", "height"}) {
|
||||
if (!json.at(key).is_number_integer()) return {};
|
||||
const auto value = json.at(key).get<int64_t>();
|
||||
if (value < 0 || value > 16384) return {};
|
||||
}
|
||||
result.width = json.at("width").get<int>();
|
||||
result.height = json.at("height").get<int>();
|
||||
if (!ValidResolution(result.width, result.height)) return {};
|
||||
}
|
||||
if (json.contains("max_bitrate_bps")) {
|
||||
if (!json.at("max_bitrate_bps").is_number_integer()) return {};
|
||||
const auto bitrate = json.at("max_bitrate_bps").get<int64_t>();
|
||||
if (bitrate < 0 || bitrate > 100'000'000) return {};
|
||||
result.max_bitrate_bps = int(bitrate);
|
||||
}
|
||||
return result;
|
||||
} catch (...) {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
};
|
||||
} // namespace crossdesk
|
||||
|
||||
#endif
|
||||
@@ -137,6 +137,10 @@ struct HostInfo {
|
||||
// Optional capability; absent in legacy desktop and web host information.
|
||||
bool supports_privacy_screen;
|
||||
bool supports_video_settings;
|
||||
bool supports_deskhub_preview;
|
||||
bool supports_deskhub_resolution;
|
||||
bool supports_deskhub_fps15;
|
||||
bool supports_deskhub_bandwidth;
|
||||
};
|
||||
|
||||
struct ServiceStatus {
|
||||
|
||||
@@ -26,6 +26,10 @@ void ResetHostInfo(HostInfo& info) {
|
||||
info.bottom = nullptr;
|
||||
info.supports_privacy_screen = false;
|
||||
info.supports_video_settings = false;
|
||||
info.supports_deskhub_preview = false;
|
||||
info.supports_deskhub_resolution = false;
|
||||
info.supports_deskhub_fps15 = false;
|
||||
info.supports_deskhub_bandwidth = false;
|
||||
}
|
||||
|
||||
bool AllocateHostDisplays(HostInfo& info, std::size_t count) {
|
||||
@@ -219,7 +223,11 @@ std::string RemoteAction::ToJson(const RemoteAction& action) {
|
||||
{"display_num", action.i.display_num},
|
||||
{"displays", displays},
|
||||
{"supports_privacy_screen", action.i.supports_privacy_screen},
|
||||
{"supports_video_settings", action.i.supports_video_settings}};
|
||||
{"supports_video_settings", action.i.supports_video_settings},
|
||||
{"supports_deskhub_preview", action.i.supports_deskhub_preview},
|
||||
{"supports_deskhub_resolution", action.i.supports_deskhub_resolution},
|
||||
{"supports_deskhub_fps15", action.i.supports_deskhub_fps15},
|
||||
{"supports_deskhub_bandwidth", action.i.supports_deskhub_bandwidth}};
|
||||
break;
|
||||
}
|
||||
case ControlType::invalid:
|
||||
@@ -412,6 +420,14 @@ bool RemoteAction::FromJson(const std::string& json_string,
|
||||
privacy_support->is_boolean() && privacy_support->get<bool>();
|
||||
output.i.supports_video_settings =
|
||||
host_info_object.value("supports_video_settings", false);
|
||||
output.i.supports_deskhub_preview =
|
||||
host_info_object.value("supports_deskhub_preview", false);
|
||||
output.i.supports_deskhub_resolution =
|
||||
host_info_object.value("supports_deskhub_resolution", false);
|
||||
output.i.supports_deskhub_fps15 =
|
||||
host_info_object.value("supports_deskhub_fps15", false);
|
||||
output.i.supports_deskhub_bandwidth =
|
||||
host_info_object.value("supports_deskhub_bandwidth", 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(),
|
||||
|
||||