Compare commits

...

35 Commits

Author SHA1 Message Date
dijunkun
ee08b231db [fix] fix height when server window is restored from collapsed state 2026-01-20 23:58:43 +08:00
dijunkun
619e54dc0e [feat] add controller info and file transfer in server window 2026-01-20 21:22:20 +08:00
dijunkun
9b69596af1 [fix] fix stream window size recalculation 2026-01-20 01:33:27 +08:00
dijunkun
f6e169c013 [feat] add support for server window resizing and dragging 2026-01-20 01:22:14 +08:00
dijunkun
fd242d50c1 [feat] show server window in the bottom-right corner of the screen 2026-01-19 17:42:22 +08:00
dijunkun
d6d8ecd6c5 [feat] add server window 2026-01-19 00:47:34 +08:00
dijunkun
669fac7f50 [feat] support drag-and-drop file sending, refs #63 2026-01-14 18:13:22 +08:00
dijunkun
92d670916e [fix] fix incorrect data send function used for control data 2026-01-13 18:13:54 +08:00
dijunkun
0155413c12 [feat] use reliable transmission to send control info 2026-01-12 17:28:19 +08:00
dijunkun
8468be6532 [fix] update MiniRTC 2026-01-12 17:27:49 +08:00
dijunkun
78c82f778a [chore] remove SetOnFileComplete() in class FileReceiver 2025-12-29 12:48:14 +08:00
dijunkun
ab89a3d41a [fix] fix clipboard copy from remote device to controller 2025-12-29 12:46:19 +08:00
dijunkun
5f320af6e6 [fix] fix macOS clipboard monitoring compilation error using changeCount polling 2025-12-29 01:01:08 +08:00
dijunkun
17b7ba6b72 [feat] support clipboard sharing, refs #35 2025-12-29 00:53:14 +08:00
dijunkun
c70ebdfe15 [fix] correct file send rate calculation by using data channel feedback 2025-12-29 00:53:14 +08:00
dijunkun
a3e564f160 [feat] support multi-file transfer, refs #45 2025-12-29 00:52:58 +08:00
dijunkun
eea37424c9 [feat] use receiver ack to calculate file transfer progress 2025-12-25 18:13:18 +08:00
dijunkun
b322181853 [feat] add file transfer window to show file sending info 2025-12-24 18:12:50 +08:00
dijunkun
3ad66f5e0b [fix] fix file transfer 2025-12-24 01:45:59 +08:00
dijunkun
4035e0dd13 [feat] upgrade MiniRTC and expose source ID in data callbacks 2025-12-23 00:53:47 +08:00
dijunkun
832b820096 [fix] update MiniRTC to latest version and add logging for sent file size 2025-12-22 18:33:53 +08:00
dijunkun
d337971de0 [feat] update MiniRTC api calling 2025-12-19 18:22:34 +08:00
dijunkun
a967dc72d7 [feat] use SendReliableDataFrame() instead of SendDataFrame() 2025-12-19 01:46:16 +08:00
dijunkun
5066fcda48 [feat] implement file transfer module 2025-12-18 18:30:51 +08:00
dijunkun
e7bdf42694 [feat] add file transfer button to control bar 2025-12-18 01:54:03 +08:00
dijunkun
875fea88ee [chore] adjust main window title font size 2025-12-18 01:32:38 +08:00
dijunkun
b2654ea9db [feat] use fingerprint-based verification for both default and self-hosted servers 2025-12-10 03:46:03 +08:00
dijunkun
8f8e415262 [chore] update README 2025-12-10 03:38:05 +08:00
dijunkun
5ff624f7b2 [feat] use fingerprint-based verification for TLS connection 2025-12-10 03:28:28 +08:00
dijunkun
e09243f1ec [chore] update README 2025-12-10 00:23:04 +08:00
dijunkun
f5941c7eda [chore] update README 2025-12-10 00:20:26 +08:00
dijunkun
3c2ebe602e [fix] do not save password info into logs 2025-12-10 00:20:10 +08:00
dijunkun
2f64172ead [feat] use new client id and password if server switched 2025-12-10 00:09:49 +08:00
Junkun Di
a83206a346 [ci] update issue templates 2025-12-09 17:50:57 +08:00
dijunkun
46e769976f [chore] update README 2025-12-09 00:36:00 +08:00
28 changed files with 5832 additions and 2539 deletions

View File

@@ -1,6 +1,6 @@
---
name: 问题反馈
about: Create a report to help us improve
about: 请在此提交问题报告,以便持续优化产品。
title: ''
labels: bug
assignees: kunkundi

28
.github/ISSUE_TEMPLATE/需求建议.md vendored Normal file
View File

@@ -0,0 +1,28 @@
---
name: 需求建议
about: 请在此提交功能需求或改进建议,以便后续迭代参考。
title: ''
labels: enhancement
assignees: kunkundi
---
**功能/改进建议描述**
清晰简洁地描述希望新增的功能或改进的内容。
**使用场景 / 背景**
说明该功能或改进的使用场景,以及解决后带来的价值。
**预期效果**
描述你认为最理想的功能表现或改进效果。
**参考示例(可选)**
提供类似功能截图、参考链接或其他说明,帮助更好理解需求。
**优先级(可选)**
- [ ]
- [ ]
- [ ]
**补充信息(可选)**
其他相关信息或特殊要求。

View File

@@ -169,7 +169,7 @@ xmake r -d crossdesk
## 自托管服务器
推荐使用Docker部署CrossDesk Server。
```
```bash
sudo docker run -d \
--name crossdesk_server \
--network host \
@@ -181,7 +181,7 @@ sudo docker run -d \
-e MAX_PORT=xxxxx \
-v /var/lib/crossdesk:/var/lib/crossdesk \
-v /var/log/crossdesk:/var/log/crossdesk \
crossdesk/crossdesk-server:v1.1.2
crossdesk/crossdesk-server:v1.1.3
```
上述命令中,用户需注意的参数如下:
@@ -194,9 +194,25 @@ sudo docker run -d \
- MIN_PORT/MAX_PORTCOTURN 服务使用的端口范围例如MIN_PORT=50000, MAX_PORT=60000范围可根据客户端数量调整。
- `-v /var/lib/crossdesk:/var/lib/crossdesk`:持久化数据库和证书文件到宿主机
- `-v /var/log/crossdesk:/var/log/crossdesk`:持久化日志文件到宿主机
-
**示例**
```bash
sudo docker run -d \
--name crossdesk_server \
--network host \
-e EXTERNAL_IP=114.114.114.114 \
-e INTERNAL_IP=10.0.0.1 \
-e CROSSDESK_SERVER_PORT=9099 \
-e COTURN_PORT=3478 \
-e MIN_PORT=50000 \
-e MAX_PORT=60000 \
-v /var/lib/crossdesk:/var/lib/crossdesk \
-v /var/log/crossdesk:/var/log/crossdesk \
crossdesk/crossdesk-server:v1.1.3
```
**注意**
- **服务器需开放端口:3478/udp3478/tcpMIN_PORT-MAX_PORT/udpCROSSDESK_SERVER_PORT/tcp。**
- **服务器需开放端口:COTURN_PORT/udpCOTURN_PORT/tcpMIN_PORT-MAX_PORT/udpCROSSDESK_SERVER_PORT/tcp。**
- 如果不挂载 volume容器删除后数据会丢失
- 证书文件会在首次启动时自动生成并持久化到宿主机的 `/var/lib/crossdesk/certs` 路径下
- 数据库文件会自动创建并持久化到宿主机的 `/var/lib/crossdesk/db/crossdesk-server.db` 路径下
@@ -213,21 +229,19 @@ sudo mkdir -p /var/lib/crossdesk /var/log/crossdesk
sudo chown -R $(id -u):$(id -g) /var/lib/crossdesk /var/log/crossdesk
```
## 证书文件
在宿主机的 `/var/lib/crossdesk/certs` 路径下可找到证书文件 `crossdesk.cn_root.crt`,下载到你的客户端主机,并在客户端的**自托管服务器设置**中选择相应的**证书文件路径**。
### 客户端
1. 点击右上角设置进入设置页面。<br>
1. 点击右上角设置进入设置页面。<br><br>
<img width="600" height="210" alt="image" src="https://github.com/user-attachments/assets/6431131d-b32a-4726-8783-6788f47baa3b" /><br><br>
3. 点击点击**自托管服务器配置**。<br><br>
<img width="600" height="160" alt="image" src="https://github.com/user-attachments/assets/24c761a3-1985-4d7e-84be-787383c2afb8" /><br><br>
2. 点击点击`自托管服务器配置`按钮。<br><br>
<img width="600" height="140" alt="image" src="https://github.com/user-attachments/assets/24c761a3-1985-4d7e-84be-787383c2afb8" /><br><br>
5. 在**证书文件路径**选择框中找到 **crossdesk.cn_root.crt** 的存放路径,选中 **crossdesk.cn_root.crt**,点击确认。<br><br>
<img width="600" height="220" alt="image" src="https://github.com/user-attachments/assets/4af7cd3a-c72e-44fb-b032-30e050019c2a" /><br><br>
3. 输入`服务器地址`(**EXTERNAL_IP**)、`信令服务端口`(**CROSSDESK_SERVER_PORT**)、`中继服务端口`(**COTURN_PORT**)。<br><br>
<img width="600" height="200" alt="image" src="https://github.com/user-attachments/assets/9a32ddd5-37f8-4bee-9a51-eae295820f9a" /><br><br>
7. 勾选使用**自托管服务器配置**,点击确认配置生效。<br><br>
<img width="600" height="160" alt="image" src="https://github.com/user-attachments/assets/1e455dc3-4087-4f37-a544-1ff9f8789383" /><br><br>
4. 后续如果自托管服务器被重置或因其他原因导致证书更换,可以点击`重置证书指纹`按钮重置客户端保存的证书指纹。<br><br>
<img width="600" height="200" alt="image" src="https://github.com/user-attachments/assets/d9e423ab-0c2b-4fab-b132-4dc27462d704" /><br><br>
### Web 客户端
详情见项目 [CrossDesk Web Client](https://github.com/kunkundi/crossdesk-web-client)。

View File

@@ -189,7 +189,7 @@ sudo docker run -d \
-e MAX_PORT=xxxxx \
-v /var/lib/crossdesk:/var/lib/crossdesk \
-v /var/log/crossdesk:/var/log/crossdesk \
crossdesk/crossdesk-server:v1.1.2
crossdesk/crossdesk-server:v1.1.3
```
The parameters you need to pay attention to are as follows:
@@ -203,8 +203,24 @@ The parameters you need to pay attention to are as follows:
- `-v /var/lib/crossdesk:/var/lib/crossdesk`: Persists database and certificate files on the host machine.
- `-v /var/log/crossdesk:/var/log/crossdesk`: Persists log files on the host machine.
**Example**:
```bash
sudo docker run -d \
--name crossdesk_server \
--network host \
-e EXTERNAL_IP=114.114.114.114 \
-e INTERNAL_IP=10.0.0.1 \
-e CROSSDESK_SERVER_PORT=9099 \
-e COTURN_PORT=3478 \
-e MIN_PORT=50000 \
-e MAX_PORT=60000 \
-v /var/lib/crossdesk:/var/lib/crossdesk \
-v /var/log/crossdesk:/var/log/crossdesk \
crossdesk/crossdesk-server:v1.1.3
```
**Notes**
- **The server must open the following ports: 3478/udp, 3478/tcp, MIN_PORTMAX_PORT/udp, and CROSSDESK_SERVER_PORT/tcp.**
- **The server must open the following ports: COTURN_PORT/udp, COTURN_PORT/tcp, MIN_PORTMAX_PORT/udp, and CROSSDESK_SERVER_PORT/tcp.**
- If you dont mount volumes, all data will be lost when the container is removed.
- Certificate files will be automatically generated on first startup and persisted to the host at `/var/lib/crossdesk/certs`.
- The database file will be automatically created and stored at `/var/lib/crossdesk/db/crossdesk-server.db`.
@@ -222,25 +238,21 @@ sudo mkdir -p /var/lib/crossdesk /var/log/crossdesk
sudo chown -R $(id -u):$(id -g) /var/lib/crossdesk /var/log/crossdesk
```
### Certificate Files
You can find the certificate file `crossdesk.cn_root.crt` at `/var/lib/crossdesk/certs` on the host machine.
Download it to your client device and select it in the **Certificate File Path** field under the CrossDesk clients **Self-Hosted Server Settings**.
### Server Side
Place **crossdesk.cn.key** and **crossdesk.cn_bundle.crt** into the **/path/to/your/certs** directory.
### Client Side
1. Click the settings icon in the top-right corner to enter the settings page.<br>
1. Click the settings icon in the top-right corner to enter the settings page.<br><br>
<img width="600" height="210" alt="image" src="https://github.com/user-attachments/assets/6431131d-b32a-4726-8783-6788f47baa3b" /><br><br>
2. Click **Self-Hosted Server Configuration**.<br><br>
2. Click `Self-Hosted Server Configuration` button.<br><br>
<img width="600" height="160" alt="image" src="https://github.com/user-attachments/assets/24c761a3-1985-4d7e-84be-787383c2afb8" /><br><br>
3. In the **Certificate File Path** selection, locate and select the **crossdesk.cn_root.crt** file.<br><br>
<img width="600" height="220" alt="image" src="https://github.com/user-attachments/assets/4af7cd3a-c72e-44fb-b032-30e050019c2a" /><br><br>
3. Enter the `Server Address` (**EXTERNAL_IP**), `Signaling Service Port` (**CROSSDESK_SERVER_PORT**), and `Relay Service Port` (**COTURN_PORT**).<br><br>
<img width="600" height="200" alt="image" src="https://github.com/user-attachments/assets/9a32ddd5-37f8-4bee-9a51-eae295820f9a" /><br><br>
4. Check the option to use **Self-Hosted Server Configuration**.<br><br>
<img width="600" height="160" alt="image" src="https://github.com/user-attachments/assets/1e455dc3-4087-4f37-a544-1ff9f8789383" /><br><br>
4. If the self-hosted server is later reset or the certificate is replaced for any reason, you can click the `Reset Certificate Fingerprint` button to clear the certificate fingerprint saved on the client.<br><br>
<img width="600" height="200" alt="image" src="https://github.com/user-attachments/assets/d9e423ab-0c2b-4fab-b132-4dc27462d704" /><br><br>
### Web Client
See [CrossDesk Web Client](https://github.com/kunkundi/crossdesk-web-client)。

View File

@@ -0,0 +1,25 @@
/*
* @Author: DI JUNKUN
* @Date: 2026-01-20
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
*/
#ifndef _WINDOW_UTIL_MAC_H_
#define _WINDOW_UTIL_MAC_H_
struct SDL_Window;
namespace crossdesk {
// Best-effort: keep an SDL window above normal windows on macOS.
// No-op on non-macOS builds.
void MacSetWindowAlwaysOnTop(::SDL_Window* window, bool always_on_top);
// Best-effort: exclude an SDL window from the Window menu and window cycling.
// Note: Cmd-Tab switches apps (not individual windows), so this primarily
// affects the Window menu and Cmd-` window cycling.
void MacSetWindowExcludedFromWindowMenu(::SDL_Window* window, bool excluded);
} // namespace crossdesk
#endif

View File

@@ -0,0 +1,64 @@
#include "window_util_mac.h"
#if defined(__APPLE__)
#include <SDL3/SDL.h>
#import <Cocoa/Cocoa.h>
namespace crossdesk {
static NSWindow* GetNSWindowFromSDL(::SDL_Window* window) {
if (!window) {
return nil;
}
#if !defined(SDL_PROP_WINDOW_COCOA_WINDOW_POINTER)
return nil;
#else
SDL_PropertiesID props = SDL_GetWindowProperties(window);
void* cocoa_window_ptr =
SDL_GetPointerProperty(props, SDL_PROP_WINDOW_COCOA_WINDOW_POINTER, NULL);
if (!cocoa_window_ptr) {
return nil;
}
return (__bridge NSWindow*)cocoa_window_ptr;
#endif
}
void MacSetWindowAlwaysOnTop(::SDL_Window* window, bool always_on_top) {
NSWindow* ns_window = GetNSWindowFromSDL(window);
if (!ns_window) {
(void)always_on_top;
return;
}
// Keep above normal windows.
const NSInteger level = always_on_top ? NSFloatingWindowLevel : NSNormalWindowLevel;
[ns_window setLevel:level];
// Optional: keep visible across Spaces/fullscreen. Safe as best-effort.
NSWindowCollectionBehavior behavior = [ns_window collectionBehavior];
behavior |= NSWindowCollectionBehaviorCanJoinAllSpaces;
behavior |= NSWindowCollectionBehaviorFullScreenAuxiliary;
[ns_window setCollectionBehavior:behavior];
}
void MacSetWindowExcludedFromWindowMenu(::SDL_Window* window, bool excluded) {
NSWindow* ns_window = GetNSWindowFromSDL(window);
if (!ns_window) {
(void)excluded;
return;
}
[ns_window setExcludedFromWindowsMenu:excluded];
NSWindowCollectionBehavior behavior = [ns_window collectionBehavior];
behavior |= NSWindowCollectionBehaviorIgnoresCycle;
behavior |= NSWindowCollectionBehaviorTransient;
[ns_window setCollectionBehavior:behavior];
}
} // namespace crossdesk
#endif // __APPLE__

View File

@@ -77,6 +77,64 @@ int ConfigCenter::Load() {
} else {
cert_file_path_ = "";
}
const char* cert_fingerprint_value =
ini_.GetValue(section_, "cert_fingerprint", nullptr);
if (cert_fingerprint_value != nullptr && strlen(cert_fingerprint_value) > 0) {
cert_fingerprint_ = cert_fingerprint_value;
} else {
cert_fingerprint_ = "";
}
const char* cert_fingerprint_server_host_value =
ini_.GetValue(section_, "cert_fingerprint_server_host", nullptr);
if (cert_fingerprint_server_host_value != nullptr &&
strlen(cert_fingerprint_server_host_value) > 0) {
cert_fingerprint_server_host_ = cert_fingerprint_server_host_value;
} else {
cert_fingerprint_server_host_ = "";
}
const char* default_cert_fingerprint_value =
ini_.GetValue(section_, "default_cert_fingerprint", nullptr);
if (default_cert_fingerprint_value != nullptr &&
strlen(default_cert_fingerprint_value) > 0) {
default_cert_fingerprint_ = default_cert_fingerprint_value;
} else {
default_cert_fingerprint_ = "";
}
const char* default_cert_fingerprint_server_host_value =
ini_.GetValue(section_, "default_cert_fingerprint_server_host", nullptr);
if (default_cert_fingerprint_server_host_value != nullptr &&
strlen(default_cert_fingerprint_server_host_value) > 0) {
default_cert_fingerprint_server_host_ =
default_cert_fingerprint_server_host_value;
} else {
default_cert_fingerprint_server_host_ = "";
}
if (enable_self_hosted_ && !cert_fingerprint_.empty() &&
!cert_fingerprint_server_host_.empty() &&
signal_server_host_ != cert_fingerprint_server_host_) {
LOG_INFO("Server IP changed from {} to {}, clearing old fingerprint",
cert_fingerprint_server_host_, signal_server_host_);
cert_fingerprint_.clear();
cert_fingerprint_server_host_.clear();
ini_.Delete(section_, "cert_fingerprint", false);
ini_.Delete(section_, "cert_fingerprint_server_host", false);
ini_.SaveFile(config_path_.c_str());
}
if (!enable_self_hosted_ && !default_cert_fingerprint_.empty() &&
!default_cert_fingerprint_server_host_.empty() &&
signal_server_host_default_ != default_cert_fingerprint_server_host_) {
LOG_INFO(
"Default server IP changed from {} to {}, clearing old fingerprint",
default_cert_fingerprint_server_host_, signal_server_host_default_);
default_cert_fingerprint_.clear();
default_cert_fingerprint_server_host_.clear();
ini_.Delete(section_, "default_cert_fingerprint", false);
ini_.Delete(section_, "default_cert_fingerprint_server_host", false);
ini_.SaveFile(config_path_.c_str());
}
enable_autostart_ =
ini_.GetBoolValue(section_, "enable_autostart", enable_autostart_);
@@ -108,6 +166,18 @@ int ConfigCenter::Save() {
ini_.SetLongValue(section_, "coturn_server_port",
static_cast<long>(coturn_server_port_));
ini_.SetValue(section_, "cert_file_path", cert_file_path_.c_str());
if (!cert_fingerprint_.empty()) {
ini_.SetValue(section_, "cert_fingerprint", cert_fingerprint_.c_str());
ini_.SetValue(section_, "cert_fingerprint_server_host",
cert_fingerprint_server_host_.c_str());
}
}
if (!default_cert_fingerprint_.empty()) {
ini_.SetValue(section_, "default_cert_fingerprint",
default_cert_fingerprint_.c_str());
ini_.SetValue(section_, "default_cert_fingerprint_server_host",
default_cert_fingerprint_server_host_.c_str());
}
ini_.SetBoolValue(section_, "enable_autostart", enable_autostart_);
@@ -200,6 +270,15 @@ int ConfigCenter::SetSrtp(bool enable_srtp) {
}
int ConfigCenter::SetServerHost(const std::string& signal_server_host) {
if (enable_self_hosted_ && !cert_fingerprint_.empty() &&
signal_server_host != signal_server_host_) {
LOG_INFO("Server IP changed from {} to {}, clearing old fingerprint",
signal_server_host_, signal_server_host);
cert_fingerprint_.clear();
cert_fingerprint_server_host_.clear();
ini_.Delete(section_, "cert_fingerprint", false);
ini_.Delete(section_, "cert_fingerprint_server_host", false);
}
signal_server_host_ = signal_server_host;
ini_.SetValue(section_, "signal_server_host", signal_server_host_.c_str());
SI_Error rc = ini_.SaveFile(config_path_.c_str());
@@ -241,6 +320,57 @@ int ConfigCenter::SetCertFilePath(const std::string& cert_file_path) {
return 0;
}
int ConfigCenter::SetCertFingerprint(const std::string& fingerprint) {
cert_fingerprint_ = fingerprint;
cert_fingerprint_server_host_ = signal_server_host_;
ini_.SetValue(section_, "cert_fingerprint", cert_fingerprint_.c_str());
ini_.SetValue(section_, "cert_fingerprint_server_host",
cert_fingerprint_server_host_.c_str());
SI_Error rc = ini_.SaveFile(config_path_.c_str());
if (rc < 0) {
return -1;
}
return 0;
}
int ConfigCenter::SetDefaultCertFingerprint(const std::string& fingerprint) {
default_cert_fingerprint_ = fingerprint;
default_cert_fingerprint_server_host_ = signal_server_host_default_;
ini_.SetValue(section_, "default_cert_fingerprint",
default_cert_fingerprint_.c_str());
ini_.SetValue(section_, "default_cert_fingerprint_server_host",
default_cert_fingerprint_server_host_.c_str());
SI_Error rc = ini_.SaveFile(config_path_.c_str());
if (rc < 0) {
return -1;
}
return 0;
}
int ConfigCenter::ClearCertFingerprint() {
cert_fingerprint_.clear();
cert_fingerprint_server_host_.clear();
ini_.Delete(section_, "cert_fingerprint", false);
ini_.Delete(section_, "cert_fingerprint_server_host", false);
SI_Error rc = ini_.SaveFile(config_path_.c_str());
if (rc < 0) {
return -1;
}
return 0;
}
int ConfigCenter::ClearDefaultCertFingerprint() {
default_cert_fingerprint_.clear();
default_cert_fingerprint_server_host_.clear();
ini_.Delete(section_, "default_cert_fingerprint", false);
ini_.Delete(section_, "default_cert_fingerprint_server_host", false);
SI_Error rc = ini_.SaveFile(config_path_.c_str());
if (rc < 0) {
return -1;
}
return 0;
}
int ConfigCenter::SetSelfHosted(bool enable_self_hosted) {
enable_self_hosted_ = enable_self_hosted;
ini_.SetBoolValue(section_, "enable_self_hosted", enable_self_hosted_);
@@ -272,6 +402,28 @@ int ConfigCenter::SetSelfHosted(bool enable_self_hosted) {
if (cert_file_path_value != nullptr && strlen(cert_file_path_value) > 0) {
cert_file_path_ = cert_file_path_value;
}
const char* cert_fingerprint_value =
ini_.GetValue(section_, "cert_fingerprint", nullptr);
if (cert_fingerprint_value != nullptr &&
strlen(cert_fingerprint_value) > 0) {
cert_fingerprint_ = cert_fingerprint_value;
}
const char* cert_fingerprint_server_host_value =
ini_.GetValue(section_, "cert_fingerprint_server_host", nullptr);
if (cert_fingerprint_server_host_value != nullptr &&
strlen(cert_fingerprint_server_host_value) > 0) {
cert_fingerprint_server_host_ = cert_fingerprint_server_host_value;
}
if (!cert_fingerprint_.empty() && !cert_fingerprint_server_host_.empty() &&
signal_server_host_ != cert_fingerprint_server_host_) {
LOG_INFO("Server IP changed from {} to {}, clearing old fingerprint",
cert_fingerprint_server_host_, signal_server_host_);
cert_fingerprint_.clear();
cert_fingerprint_server_host_.clear();
ini_.Delete(section_, "cert_fingerprint", false);
ini_.Delete(section_, "cert_fingerprint_server_host", false);
}
ini_.SetValue(section_, "signal_server_host", signal_server_host_.c_str());
ini_.SetLongValue(section_, "signal_server_port",
@@ -279,6 +431,11 @@ int ConfigCenter::SetSelfHosted(bool enable_self_hosted) {
ini_.SetLongValue(section_, "coturn_server_port",
static_cast<long>(coturn_server_port_));
ini_.SetValue(section_, "cert_file_path", cert_file_path_.c_str());
if (!cert_fingerprint_.empty()) {
ini_.SetValue(section_, "cert_fingerprint", cert_fingerprint_.c_str());
ini_.SetValue(section_, "cert_fingerprint_server_host",
cert_fingerprint_server_host_.c_str());
}
}
SI_Error rc = ini_.SaveFile(config_path_.c_str());
@@ -368,6 +525,14 @@ int ConfigCenter::GetCoturnServerPort() const { return coturn_server_port_; }
std::string ConfigCenter::GetCertFilePath() const { return cert_file_path_; }
std::string ConfigCenter::GetCertFingerprint() const {
return cert_fingerprint_;
}
std::string ConfigCenter::GetDefaultCertFingerprint() const {
return default_cert_fingerprint_;
}
std::string ConfigCenter::GetDefaultServerHost() const {
return signal_server_host_default_;
}

View File

@@ -38,6 +38,10 @@ class ConfigCenter {
int SetServerPort(int signal_server_port);
int SetCoturnServerPort(int coturn_server_port);
int SetCertFilePath(const std::string& cert_file_path);
int SetCertFingerprint(const std::string& fingerprint);
int SetDefaultCertFingerprint(const std::string& fingerprint);
int ClearCertFingerprint();
int ClearDefaultCertFingerprint();
int SetSelfHosted(bool enable_self_hosted);
int SetMinimizeToTray(bool enable_minimize_to_tray);
int SetAutostart(bool enable_autostart);
@@ -56,6 +60,8 @@ class ConfigCenter {
int GetSignalServerPort() const;
int GetCoturnServerPort() const;
std::string GetCertFilePath() const;
std::string GetCertFingerprint() const;
std::string GetDefaultCertFingerprint() const;
std::string GetDefaultServerHost() const;
int GetDefaultSignalServerPort() const;
int GetDefaultCoturnServerPort() const;
@@ -88,6 +94,10 @@ class ConfigCenter {
int coturn_server_port_default_ = 3478;
std::string cert_file_path_ = "";
std::string cert_file_path_default_ = "";
std::string cert_fingerprint_ = "";
std::string cert_fingerprint_server_host_ = "";
std::string default_cert_fingerprint_ = "";
std::string default_cert_fingerprint_server_host_ = "";
bool enable_self_hosted_ = false;
bool enable_minimize_to_tray_ = false;
bool enable_autostart_ = false;

File diff suppressed because it is too large Load Diff

View File

@@ -116,6 +116,9 @@ static std::vector<std::string> self_hosted_server_certificate_path = {
reinterpret_cast<const char*>(u8"证书文件路径:"), "Certificate File Path:"};
static std::vector<std::string> select_a_file = {
reinterpret_cast<const char*>(u8"请选择文件"), "Please select a file"};
static std::vector<std::string> reset_cert_fingerprint = {
reinterpret_cast<const char*>(u8"重置证书指纹"),
"Reset Certificate Fingerprint"};
static std::vector<std::string> ok = {reinterpret_cast<const char*>(u8"确认"),
"OK"};
static std::vector<std::string> cancel = {
@@ -182,6 +185,23 @@ static std::vector<std::string> enable_daemon = {
static std::vector<std::string> takes_effect_after_restart = {
reinterpret_cast<const char*>(u8"重启后生效"),
"Takes effect after restart"};
static std::vector<std::string> select_file = {
reinterpret_cast<const char*>(u8"选择文件"), "Select File"};
static std::vector<std::string> file_transfer_progress = {
reinterpret_cast<const char*>(u8"文件传输进度"), "File Transfer Progress"};
static std::vector<std::string> queued = {
reinterpret_cast<const char*>(u8"队列中"), "Queued"};
static std::vector<std::string> sending = {
reinterpret_cast<const char*>(u8"正在传输"), "Sending"};
static std::vector<std::string> completed = {
reinterpret_cast<const char*>(u8"已完成"), "Completed"};
static std::vector<std::string> failed = {
reinterpret_cast<const char*>(u8"失败"), "Failed"};
static std::vector<std::string> controller = {
reinterpret_cast<const char*>(u8"控制端:"), "Controller:"};
static std::vector<std::string> file_transfer = {
reinterpret_cast<const char*>(u8"文件传输:"), "File Transfer:"};
#if _WIN32
static std::vector<std::string> minimize_to_tray = {
reinterpret_cast<const char*>(u8"退出时最小化到系统托盘:"),

View File

@@ -30,6 +30,7 @@ int Render::LocalWindow() {
ImGui::SetCursorPos(
ImVec2(io.DisplaySize.x * 0.045f, io.DisplaySize.y * 0.02f));
ImGui::SetWindowFontScale(0.9f);
ImGui::TextColored(
ImVec4(0.0f, 0.0f, 0.0f, 0.5f), "%s",
localization::local_desktop[localization_language_index_].c_str());
@@ -233,15 +234,41 @@ int Render::LocalWindow() {
sizeof(password_saved_) - 1);
password_saved_[sizeof(password_saved_) - 1] = '\0';
std::string client_id_with_password =
std::string(client_id_) + "@" + password_saved_;
strncpy(client_id_with_password_, client_id_with_password.c_str(),
sizeof(client_id_with_password_) - 1);
client_id_with_password_[sizeof(client_id_with_password_) - 1] =
'\0';
// if self hosted
if (config_center_->IsSelfHosted()) {
std::string self_hosted_id_str;
if (strlen(self_hosted_id_) > 0) {
const char* at_pos = strchr(self_hosted_id_, '@');
if (at_pos != nullptr) {
self_hosted_id_str =
std::string(self_hosted_id_, at_pos - self_hosted_id_);
} else {
self_hosted_id_str = self_hosted_id_;
}
} else {
self_hosted_id_str = client_id_;
}
std::string new_self_hosted_id =
self_hosted_id_str + "@" + password_saved_;
memset(&self_hosted_id_, 0, sizeof(self_hosted_id_));
strncpy(self_hosted_id_, new_self_hosted_id.c_str(),
sizeof(self_hosted_id_) - 1);
self_hosted_id_[sizeof(self_hosted_id_) - 1] = '\0';
} else {
std::string client_id_with_password =
std::string(client_id_) + "@" + password_saved_;
strncpy(client_id_with_password_, client_id_with_password.c_str(),
sizeof(client_id_with_password_) - 1);
client_id_with_password_[sizeof(client_id_with_password_) - 1] =
'\0';
}
SaveSettingsIntoCacheFile();
memset(new_password_, 0, sizeof(new_password_));
LeaveConnection(peer_, client_id_);
DestroyPeer(&peer_);
focus_on_input_widget_ = true;

View File

@@ -26,6 +26,7 @@ int Render::RecentConnectionsWindow() {
ImGui::SetCursorPos(
ImVec2(io.DisplaySize.x * 0.045f, io.DisplaySize.y * 0.02f));
ImGui::SetWindowFontScale(0.9f);
ImGui::TextColored(
ImVec4(0.0f, 0.0f, 0.0f, 0.5f), "%s",
localization::recent_connections[localization_language_index_].c_str());

View File

@@ -31,6 +31,7 @@ int Render::RemoteWindow() {
ImGui::SetCursorPos(
ImVec2(io.DisplaySize.x * 0.057f, io.DisplaySize.y * 0.02f));
ImGui::SetWindowFontScale(0.9f);
ImGui::TextColored(
ImVec4(0.0f, 0.0f, 0.0f, 0.5f), "%s",
localization::remote_desktop[localization_language_index_].c_str());
@@ -189,11 +190,11 @@ int Render::ConnectTo(const std::string& remote_id, const char* password,
props->params_.user_id = props->local_id_.c_str();
props->peer_ = CreatePeer(&props->params_);
props->control_window_width_ = title_bar_height_ * 8.0f;
props->control_window_width_ = title_bar_height_ * 9.0f;
props->control_window_height_ = title_bar_height_ * 1.3f;
props->control_window_min_width_ = title_bar_height_ * 0.65f;
props->control_window_min_height_ = title_bar_height_ * 1.3f;
props->control_window_max_width_ = title_bar_height_ * 8.0f;
props->control_window_max_width_ = title_bar_height_ * 9.0f;
props->control_window_max_height_ = title_bar_height_ * 6.0f;
if (!props->peer_) {
@@ -205,7 +206,11 @@ int Render::ConnectTo(const std::string& remote_id, const char* password,
AddVideoStream(props->peer_, display_info.name.c_str());
}
AddAudioStream(props->peer_, props->audio_label_.c_str());
AddDataStream(props->peer_, props->data_label_.c_str());
AddDataStream(props->peer_, props->data_label_.c_str(), false);
AddDataStream(props->peer_, props->control_data_label_.c_str(), true);
AddDataStream(props->peer_, props->file_label_.c_str(), true);
AddDataStream(props->peer_, props->file_feedback_label_.c_str(), true);
AddDataStream(props->peer_, props->clipboard_label_.c_str(), true);
props->connection_status_ = ConnectionStatus::Connecting;

File diff suppressed because it is too large Load Diff

View File

@@ -11,10 +11,12 @@
#include <atomic>
#include <chrono>
#include <filesystem>
#include <fstream>
#include <mutex>
#include <nlohmann/json.hpp>
#include <optional>
#include <queue>
#include <shared_mutex>
#include <string>
#include <unordered_map>
@@ -44,7 +46,11 @@ class Render {
Params params_;
PeerPtr* peer_ = nullptr;
std::string audio_label_ = "control_audio";
std::string data_label_ = "control_data";
std::string data_label_ = "data";
std::string file_label_ = "file";
std::string control_data_label_ = "control_data";
std::string file_feedback_label_ = "file_feedback";
std::string clipboard_label_ = "clipboard";
std::string local_id_ = "";
std::string remote_id_ = "";
bool exit_ = false;
@@ -122,6 +128,39 @@ class Render {
int frame_count_ = 0;
std::chrono::steady_clock::time_point last_time_;
XNetTrafficStats net_traffic_stats_;
// File transfer progress
std::atomic<bool> file_sending_ = false;
std::atomic<uint64_t> file_sent_bytes_ = 0;
std::atomic<uint64_t> file_total_bytes_ = 0;
std::atomic<uint32_t> file_send_rate_bps_ = 0;
std::mutex file_transfer_mutex_;
std::chrono::steady_clock::time_point file_send_start_time_;
std::chrono::steady_clock::time_point file_send_last_update_time_;
uint64_t file_send_last_bytes_ = 0;
bool file_transfer_window_visible_ = false;
std::atomic<uint32_t> current_file_id_{0};
struct QueuedFile {
std::filesystem::path file_path;
std::string file_label;
};
std::queue<QueuedFile> file_send_queue_;
std::mutex file_queue_mutex_;
enum class FileTransferStatus { Queued, Sending, Completed, Failed };
struct FileTransferInfo {
std::string file_name;
std::filesystem::path file_path;
uint64_t file_size = 0;
FileTransferStatus status = FileTransferStatus::Queued;
uint64_t sent_bytes = 0;
uint32_t file_id = 0;
uint32_t rate_bps = 0;
};
std::vector<FileTransferInfo> file_transfer_list_;
std::mutex file_transfer_list_mutex_;
};
public:
@@ -142,6 +181,7 @@ class Render {
void UpdateInteractions();
void HandleRecentConnections();
void HandleStreamWindow();
void HandleServerWindow();
void Cleanup();
void CleanupFactories();
void CleanupPeer(std::shared_ptr<SubStreamWindowProperties> props);
@@ -151,12 +191,20 @@ class Render {
void UpdateRenderRect();
void ProcessSdlEvent(const SDL_Event& event);
void ProcessFileDropEvent(const SDL_Event& event);
void ProcessSelectedFile(const std::string& path,
std::shared_ptr<SubStreamWindowProperties>& props,
const std::string& file_label);
private:
int CreateStreamRenderWindow();
int TitleBar(bool main_window);
int MainWindow();
int UpdateNotificationWindow();
int StreamWindow();
int ServerWindow();
int RemoteClientInfoWindow();
int LocalWindow();
int RemoteWindow();
int RecentConnectionsWindow();
@@ -172,6 +220,8 @@ class Render {
int ShowRecentConnections();
void Hyperlink(const std::string& label, const std::string& url,
const float window_width);
int FileTransferWindow(std::shared_ptr<SubStreamWindowProperties>& props);
std::string OpenFileDialog(std::string title);
private:
int ConnectTo(const std::string& remote_id, const char* password,
@@ -180,11 +230,15 @@ class Render {
int DestroyMainWindow();
int CreateStreamWindow();
int DestroyStreamWindow();
int SetupFontAndStyle(bool main_window);
int CreateServerWindow();
int DestroyServerWindow();
int SetupFontAndStyle(ImFont** system_chinese_font_out);
int DestroyMainWindowContext();
int DestroyStreamWindowContext();
int DestroyServerWindowContext();
int DrawMainWindow();
int DrawStreamWindow();
int DrawServerWindow();
int ConfirmDeleteConnection();
int NetTrafficStats(std::shared_ptr<SubStreamWindowProperties>& props);
void DrawConnectionStatusText(
@@ -201,14 +255,17 @@ class Render {
public:
static void OnReceiveVideoBufferCb(const XVideoFrame* video_frame,
const char* user_id, size_t user_id_size,
const char* src_id, size_t src_id_size,
void* user_data);
static void OnReceiveAudioBufferCb(const char* data, size_t size,
const char* user_id, size_t user_id_size,
const char* src_id, size_t src_id_size,
void* user_data);
static void OnReceiveDataBufferCb(const char* data, size_t size,
const char* user_id, size_t user_id_size,
const char* src_id, size_t src_id_size,
void* user_data);
static void OnSignalStatusCb(SignalStatus status, const char* user_id,
@@ -259,6 +316,12 @@ class Render {
int CreateConnectionPeer();
// File transfer helper functions
void StartFileTransfer(std::shared_ptr<SubStreamWindowProperties> props,
const std::filesystem::path& file_path,
const std::string& file_label);
void ProcessFileQueue(std::shared_ptr<SubStreamWindowProperties> props);
int AudioDeviceInit();
int AudioDeviceDestroy();
@@ -277,8 +340,25 @@ class Render {
unsigned char iv[16];
};
struct CDCacheV2 {
char client_id_with_password[17];
int language;
int video_quality;
int video_frame_rate;
int video_encode_format;
bool enable_hardware_video_codec;
bool enable_turn;
bool enable_srtp;
unsigned char key[16];
unsigned char iv[16];
char self_hosted_id[17];
};
private:
CDCache cd_cache_;
CDCacheV2 cd_cache_v2_;
std::mutex cd_cache_mutex_;
std::unique_ptr<ConfigCenter> config_center_;
ConfigCenter::LANGUAGE localization_language_ =
@@ -317,6 +397,7 @@ class Render {
ImGuiContext* main_ctx_ = nullptr;
ImFont* main_windows_system_chinese_font_ = nullptr;
ImFont* stream_windows_system_chinese_font_ = nullptr;
ImFont* server_windows_system_chinese_font_ = nullptr;
bool exit_ = false;
const int sdl_refresh_ms_ = 16; // ~60 FPS
#if _WIN32
@@ -389,6 +470,7 @@ class Render {
std::string controlled_remote_id_ = "";
std::string focused_remote_id_ = "";
bool need_to_send_host_info_ = false;
std::string remote_client_id_ = "";
SDL_Event last_mouse_event;
SDL_AudioStream* output_stream_;
uint32_t STREAM_REFRESH_EVENT = 0;
@@ -415,6 +497,42 @@ class Render {
float stream_window_dpi_scaling_w_ = 1.0f;
float stream_window_dpi_scaling_h_ = 1.0f;
// server window render
SDL_Window* server_window_ = nullptr;
SDL_Renderer* server_renderer_ = nullptr;
ImGuiContext* server_ctx_ = nullptr;
// server window properties
bool need_to_create_server_window_ = false;
bool need_to_destroy_server_window_ = false;
bool server_window_created_ = false;
bool server_window_inited_ = false;
int server_window_width_default_ = 250;
int server_window_height_default_ = 150;
float server_window_width_ = 250;
float server_window_height_ = 150;
float server_window_title_bar_height_ = 30.0f;
SDL_PixelFormat server_pixformat_ = SDL_PIXELFORMAT_NV12;
int server_window_normal_width_ = 250;
int server_window_normal_height_ = 150;
float server_window_dpi_scaling_w_ = 1.0f;
float server_window_dpi_scaling_h_ = 1.0f;
// server window collapsed mode
bool server_window_collapsed_ = false;
bool server_window_collapsed_dragging_ = false;
float server_window_collapsed_drag_start_mouse_x_ = 0.0f;
float server_window_collapsed_drag_start_mouse_y_ = 0.0f;
int server_window_collapsed_drag_start_win_x_ = 0;
int server_window_collapsed_drag_start_win_y_ = 0;
// server window drag normal mode
bool server_window_dragging_ = false;
float server_window_drag_start_mouse_x_ = 0.0f;
float server_window_drag_start_mouse_y_ = 0.0f;
int server_window_drag_start_win_x_ = 0;
int server_window_drag_start_win_y_ = 0;
bool label_inited_ = false;
bool connect_button_pressed_ = false;
bool password_validating_ = false;
@@ -431,6 +549,7 @@ class Render {
bool fullscreen_button_pressed_ = false;
bool focus_on_input_widget_ = true;
bool is_client_mode_ = false;
bool is_server_mode_ = false;
bool reload_recent_connections_ = true;
bool show_confirm_delete_connection_ = false;
bool delete_connection_ = false;
@@ -447,7 +566,16 @@ class Render {
std::string video_secondary_label_ = "secondary_display";
std::string audio_label_ = "audio";
std::string data_label_ = "data";
std::string info_label_ = "info";
std::string control_data_label_ = "control_data";
std::string file_label_ = "file";
std::string file_feedback_label_ = "file_feedback";
std::string clipboard_label_ = "clipboard";
Params params_;
// Map file_id to props for tracking file transfer progress via ACK
std::unordered_map<uint32_t, std::weak_ptr<SubStreamWindowProperties>>
file_id_to_props_;
std::shared_mutex file_id_to_props_mutex_;
SDL_AudioDeviceID input_dev_;
SDL_AudioDeviceID output_dev_;
ScreenCapturerFactory* screen_capturer_factory_ = nullptr;
@@ -470,6 +598,8 @@ class Render {
char client_id_display_[12] = "";
char client_id_with_password_[17] = "";
char password_saved_[7] = "";
char self_hosted_id_[17] = "";
char self_hosted_user_id_[17] = "";
int language_button_value_ = 0;
int video_quality_button_value_ = 0;
int video_frame_rate_button_value_ = 1;

View File

@@ -1,7 +1,16 @@
#include <chrono>
#include <cmath>
#include <cstdlib>
#include <cstring>
#include <filesystem>
#include <fstream>
#include <unordered_map>
#include "clipboard.h"
#include "device_controller.h"
#include "file_transfer.h"
#include "localization.h"
#include "minirtc.h"
#include "platform.h"
#include "rd_log.h"
#include "render.h"
@@ -211,6 +220,7 @@ void Render::SdlCaptureAudioOut([[maybe_unused]] void* userdata,
void Render::OnReceiveVideoBufferCb(const XVideoFrame* video_frame,
const char* user_id, size_t user_id_size,
const char* src_id, size_t src_id_size,
void* user_data) {
Render* render = (Render*)user_data;
if (!render) {
@@ -278,6 +288,7 @@ void Render::OnReceiveVideoBufferCb(const XVideoFrame* video_frame,
void Render::OnReceiveAudioBufferCb(const char* data, size_t size,
const char* user_id, size_t user_id_size,
const char* src_id, size_t src_id_size,
void* user_data) {
Render* render = (Render*)user_data;
if (!render) {
@@ -297,12 +308,142 @@ void Render::OnReceiveAudioBufferCb(const char* data, size_t size,
void Render::OnReceiveDataBufferCb(const char* data, size_t size,
const char* user_id, size_t user_id_size,
const char* src_id, size_t src_id_size,
void* user_data) {
Render* render = (Render*)user_data;
if (!render) {
return;
}
std::string source_id = std::string(src_id, src_id_size);
if (source_id == render->file_label_) {
std::string remote_user_id = std::string(user_id, user_id_size);
static FileReceiver receiver;
receiver.SetOnSendAck([render](const FileTransferAck& ack) -> int {
return SendReliableDataFrame(
render->peer_, reinterpret_cast<const char*>(&ack),
sizeof(FileTransferAck), render->file_feedback_label_.c_str());
});
receiver.OnData(data, size);
return;
} else if (source_id == render->clipboard_label_) {
if (size > 0) {
std::string clipboard_text(data, size);
if (!Clipboard::SetText(clipboard_text)) {
LOG_ERROR("Failed to set clipboard content from remote");
}
}
return;
} else if (source_id == render->file_feedback_label_) {
if (size < sizeof(FileTransferAck)) {
LOG_ERROR("FileTransferAck: buffer too small, size={}", size);
return;
}
FileTransferAck ack{};
memcpy(&ack, data, sizeof(FileTransferAck));
if (ack.magic != kFileAckMagic) {
LOG_ERROR(
"FileTransferAck: invalid magic, got 0x{:08X}, expected 0x{:08X}",
ack.magic, kFileAckMagic);
return;
}
std::shared_ptr<SubStreamWindowProperties> props = nullptr;
{
std::shared_lock lock(render->file_id_to_props_mutex_);
auto it = render->file_id_to_props_.find(ack.file_id);
if (it != render->file_id_to_props_.end()) {
props = it->second.lock();
}
}
if (!props) {
LOG_WARN("FileTransferAck: no props found for file_id={}", ack.file_id);
return;
}
// Update progress based on ACK
props->file_sent_bytes_ = ack.acked_offset;
props->file_total_bytes_ = ack.total_size;
uint32_t rate_bps = 0;
{
uint32_t data_channel_bitrate =
props->net_traffic_stats_.data_outbound_stats.bitrate;
if (data_channel_bitrate > 0 && props->file_sending_.load()) {
rate_bps = static_cast<uint32_t>(data_channel_bitrate * 0.99f);
uint32_t current_rate = props->file_send_rate_bps_.load();
if (current_rate > 0) {
// 70% old + 30% new for smoother display
rate_bps = static_cast<uint32_t>(current_rate * 0.7 + rate_bps * 0.3);
}
} else {
rate_bps = props->file_send_rate_bps_.load();
}
props->file_send_rate_bps_ = rate_bps;
props->file_send_last_bytes_ = ack.acked_offset;
auto now = std::chrono::steady_clock::now();
props->file_send_last_update_time_ = now;
}
// Update file transfer list: update progress and rate
{
std::lock_guard<std::mutex> lock(props->file_transfer_list_mutex_);
for (auto& info : props->file_transfer_list_) {
if (info.file_id == ack.file_id) {
info.sent_bytes = ack.acked_offset;
info.file_size = ack.total_size;
info.rate_bps = rate_bps;
break;
}
}
}
// Check if transfer is completed
if ((ack.flags & 0x01) != 0) {
// Transfer completed - receiver has finished receiving the file
// Reopen window if it was closed by user
props->file_transfer_window_visible_ = true;
props->file_sending_ = false; // Mark sending as finished
LOG_INFO(
"File transfer completed via ACK, file_id={}, total_size={}, "
"acked_offset={}",
ack.file_id, ack.total_size, ack.acked_offset);
// Update file transfer list: mark as completed
{
std::lock_guard<std::mutex> lock(props->file_transfer_list_mutex_);
for (auto& info : props->file_transfer_list_) {
if (info.file_id == ack.file_id) {
info.status =
SubStreamWindowProperties::FileTransferStatus::Completed;
info.sent_bytes = ack.total_size;
break;
}
}
}
// Unregister file_id mapping after completion
{
std::lock_guard<std::shared_mutex> lock(
render->file_id_to_props_mutex_);
render->file_id_to_props_.erase(ack.file_id);
}
// Process next file in queue
render->ProcessFileQueue(props);
}
return;
}
std::string json_str(data, size);
RemoteAction remote_action;
@@ -427,6 +568,7 @@ void Render::OnConnectionStatusCb(ConnectionStatus status, const char* user_id,
switch (status) {
case ConnectionStatus::Connected: {
render->need_to_send_host_info_ = true;
if (!render->need_to_create_stream_window_ &&
!render->client_properties_.empty()) {
render->need_to_create_stream_window_ = true;
@@ -481,9 +623,12 @@ void Render::OnConnectionStatusCb(ConnectionStatus status, const char* user_id,
switch (status) {
case ConnectionStatus::Connected: {
render->need_to_create_server_window_ = true;
render->need_to_send_host_info_ = true;
render->is_server_mode_ = true;
render->start_screen_capturer_ = true;
render->start_speaker_capturer_ = true;
render->remote_client_id_ = remote_id;
#ifdef CROSSDESK_DEBUG
render->start_mouse_controller_ = false;
render->start_keyboard_capturer_ = false;
@@ -506,11 +651,14 @@ void Render::OnConnectionStatusCb(ConnectionStatus status, const char* user_id,
kv.second == ConnectionStatus::Failed ||
kv.second == ConnectionStatus::Disconnected;
})) {
render->need_to_destroy_server_window_ = true;
render->is_server_mode_ = false;
render->start_screen_capturer_ = false;
render->start_speaker_capturer_ = false;
render->start_mouse_controller_ = false;
render->start_keyboard_capturer_ = false;
render->need_to_send_host_info_ = false;
render->remote_client_id_ = "";
if (props) props->connection_established_ = false;
if (render->audio_capture_) {
render->StopSpeakerCapturer();
@@ -556,24 +704,93 @@ void Render::NetStatusReport(const char* client_id, size_t client_id_size,
password = at_pos + 1;
}
memset(&render->client_id_, 0, sizeof(render->client_id_));
strncpy(render->client_id_, id.c_str(), sizeof(render->client_id_) - 1);
render->client_id_[sizeof(render->client_id_) - 1] = '\0';
bool is_self_hosted = render->config_center_->IsSelfHosted();
memset(&render->password_saved_, 0, sizeof(render->password_saved_));
strncpy(render->password_saved_, password.c_str(),
sizeof(render->password_saved_) - 1);
render->password_saved_[sizeof(render->password_saved_) - 1] = '\0';
if (is_self_hosted) {
memset(&render->client_id_, 0, sizeof(render->client_id_));
strncpy(render->client_id_, id.c_str(), sizeof(render->client_id_) - 1);
render->client_id_[sizeof(render->client_id_) - 1] = '\0';
memset(&render->client_id_with_password_, 0,
sizeof(render->client_id_with_password_));
strncpy(render->client_id_with_password_, client_id,
sizeof(render->client_id_with_password_) - 1);
render->client_id_with_password_[sizeof(render->client_id_with_password_) -
memset(&render->password_saved_, 0, sizeof(render->password_saved_));
strncpy(render->password_saved_, password.c_str(),
sizeof(render->password_saved_) - 1);
render->password_saved_[sizeof(render->password_saved_) - 1] = '\0';
memset(&render->self_hosted_id_, 0, sizeof(render->self_hosted_id_));
strncpy(render->self_hosted_id_, client_id,
sizeof(render->self_hosted_id_) - 1);
render->self_hosted_id_[sizeof(render->self_hosted_id_) - 1] = '\0';
LOG_INFO("Use self-hosted client id [{}] and save to cache file", id);
render->cd_cache_mutex_.lock();
std::ifstream v2_file_read(render->cache_path_ + "/secure_cache_v2.enc",
std::ios::binary);
if (v2_file_read.good()) {
v2_file_read.read(reinterpret_cast<char*>(&render->cd_cache_v2_),
sizeof(CDCacheV2));
v2_file_read.close();
} else {
memset(&render->cd_cache_v2_, 0, sizeof(CDCacheV2));
memset(&render->cd_cache_v2_.client_id_with_password, 0,
sizeof(render->cd_cache_v2_.client_id_with_password));
strncpy(render->cd_cache_v2_.client_id_with_password,
render->client_id_with_password_,
sizeof(render->cd_cache_v2_.client_id_with_password));
memcpy(&render->cd_cache_v2_.key, &render->aes128_key_,
sizeof(render->cd_cache_v2_.key));
memcpy(&render->cd_cache_v2_.iv, &render->aes128_iv_,
sizeof(render->cd_cache_v2_.iv));
}
memset(&render->cd_cache_v2_.self_hosted_id, 0,
sizeof(render->cd_cache_v2_.self_hosted_id));
strncpy(render->cd_cache_v2_.self_hosted_id, client_id,
sizeof(render->cd_cache_v2_.self_hosted_id) - 1);
render->cd_cache_v2_
.self_hosted_id[sizeof(render->cd_cache_v2_.self_hosted_id) - 1] =
'\0';
memset(&render->cd_cache_v2_.client_id_with_password, 0,
sizeof(render->cd_cache_v2_.client_id_with_password));
strncpy(render->cd_cache_v2_.client_id_with_password,
render->client_id_with_password_,
sizeof(render->cd_cache_v2_.client_id_with_password));
memcpy(&render->cd_cache_v2_.key, &render->aes128_key_,
sizeof(render->cd_cache_v2_.key));
memcpy(&render->cd_cache_v2_.iv, &render->aes128_iv_,
sizeof(render->cd_cache_v2_.iv));
std::ofstream cd_cache_v2_file(
render->cache_path_ + "/secure_cache_v2.enc", std::ios::binary);
if (cd_cache_v2_file) {
cd_cache_v2_file.write(reinterpret_cast<char*>(&render->cd_cache_v2_),
sizeof(CDCacheV2));
cd_cache_v2_file.close();
}
render->cd_cache_mutex_.unlock();
} else {
memset(&render->client_id_, 0, sizeof(render->client_id_));
strncpy(render->client_id_, id.c_str(), sizeof(render->client_id_) - 1);
render->client_id_[sizeof(render->client_id_) - 1] = '\0';
memset(&render->password_saved_, 0, sizeof(render->password_saved_));
strncpy(render->password_saved_, password.c_str(),
sizeof(render->password_saved_) - 1);
render->password_saved_[sizeof(render->password_saved_) - 1] = '\0';
memset(&render->client_id_with_password_, 0,
sizeof(render->client_id_with_password_));
strncpy(render->client_id_with_password_, client_id,
sizeof(render->client_id_with_password_) - 1);
render
->client_id_with_password_[sizeof(render->client_id_with_password_) -
1] = '\0';
LOG_INFO("Use client id [{}] and save id into cache file", id);
render->SaveSettingsIntoCacheFile();
LOG_INFO("Use client id [{}] and save id into cache file", id);
render->SaveSettingsIntoCacheFile();
}
}
std::string remote_id(user_id, user_id_size);

View File

@@ -1,7 +1,17 @@
#include <algorithm>
#include <atomic>
#include <chrono>
#include <filesystem>
#include <fstream>
#include <thread>
#include <vector>
#include "file_transfer.h"
#include "layout.h"
#include "localization.h"
#include "rd_log.h"
#include "render.h"
#include "tinyfiledialogs.h"
namespace crossdesk {
@@ -31,6 +41,89 @@ int LossRateDisplay(float loss_rate) {
return 0;
}
std::string Render::OpenFileDialog(std::string title) {
const char* path = tinyfd_openFileDialog(title.c_str(),
"", // default path
0, // number of filters
nullptr, // filters
nullptr, // filter description
0 // no multiple selection
);
return path ? path : "";
}
void Render::ProcessSelectedFile(
const std::string& path, std::shared_ptr<SubStreamWindowProperties>& props,
const std::string& file_label) {
if (path.empty()) {
return;
}
LOG_INFO("Selected file: {}", path.c_str());
std::filesystem::path file_path = std::filesystem::u8path(path);
// Get file size
std::error_code ec;
uint64_t file_size = std::filesystem::file_size(file_path, ec);
if (ec) {
LOG_ERROR("Failed to get file size: {}", ec.message().c_str());
file_size = 0;
}
// Add file to transfer list
{
std::lock_guard<std::mutex> lock(props->file_transfer_list_mutex_);
SubStreamWindowProperties::FileTransferInfo info;
info.file_name = file_path.filename().u8string();
info.file_path = file_path; // Store full path for precise matching
info.file_size = file_size;
info.status = SubStreamWindowProperties::FileTransferStatus::Queued;
info.sent_bytes = 0;
info.file_id = 0;
info.rate_bps = 0;
props->file_transfer_list_.push_back(info);
}
props->file_transfer_window_visible_ = true;
if (props->file_sending_.load()) {
// Add to queue
size_t queue_size = 0;
{
std::lock_guard<std::mutex> lock(props->file_queue_mutex_);
SubStreamWindowProperties::QueuedFile queued_file;
queued_file.file_path = file_path;
queued_file.file_label = file_label;
props->file_send_queue_.push(queued_file);
queue_size = props->file_send_queue_.size();
}
LOG_INFO("File added to queue: {} ({} files in queue)",
file_path.filename().string().c_str(), queue_size);
} else {
StartFileTransfer(props, file_path, file_label);
if (props->file_sending_.load()) {
} else {
// Failed to start (race condition: another file started between
// check and call) Add to queue
size_t queue_size = 0;
{
std::lock_guard<std::mutex> lock(props->file_queue_mutex_);
SubStreamWindowProperties::QueuedFile queued_file;
queued_file.file_path = file_path;
queued_file.file_label = file_label;
props->file_send_queue_.push(queued_file);
queue_size = props->file_send_queue_.size();
}
LOG_INFO(
"File added to queue after race condition: {} ({} files in "
"queue)",
file_path.filename().string().c_str(), queue_size);
}
}
}
int Render::ControlBar(std::shared_ptr<SubStreamWindowProperties>& props) {
float button_width = title_bar_height_ * 0.8f;
float button_height = title_bar_height_ * 0.8f;
@@ -41,14 +134,14 @@ int Render::ControlBar(std::shared_ptr<SubStreamWindowProperties>& props) {
if (props->control_bar_expand_) {
ImGui::SetCursorPosX(props->is_control_bar_in_left_
? props->control_window_width_ * 1.03f
: props->control_window_width_ * 0.2f);
: props->control_window_width_ * 0.17f);
ImDrawList* draw_list = ImGui::GetWindowDrawList();
if (!props->is_control_bar_in_left_) {
draw_list->AddLine(
ImVec2(ImGui::GetCursorScreenPos().x - button_height * 0.56f,
ImVec2(ImGui::GetCursorScreenPos().x - button_height * 0.5f,
ImGui::GetCursorScreenPos().y + button_height * 0.2f),
ImVec2(ImGui::GetCursorScreenPos().x - button_height * 0.56f,
ImVec2(ImGui::GetCursorScreenPos().x - button_height * 0.5f,
ImGui::GetCursorScreenPos().y + button_height * 0.8f),
IM_COL32(178, 178, 178, 255), 2.0f);
}
@@ -73,8 +166,8 @@ int Render::ControlBar(std::shared_ptr<SubStreamWindowProperties>& props) {
remote_action.d = i;
if (props->connection_status_ == ConnectionStatus::Connected) {
std::string msg = remote_action.to_json();
SendDataFrame(props->peer_, msg.c_str(), msg.size(),
props->data_label_.c_str());
SendReliableDataFrame(props->peer_, msg.c_str(), msg.size(),
props->control_data_label_.c_str());
}
}
props->display_selectable_hovered_ = ImGui::IsWindowHovered();
@@ -154,8 +247,8 @@ int Render::ControlBar(std::shared_ptr<SubStreamWindowProperties>& props) {
remote_action.type = ControlType::audio_capture;
remote_action.a = props->audio_capture_button_pressed_;
std::string msg = remote_action.to_json();
SendDataFrame(props->peer_, msg.c_str(), msg.size(),
props->data_label_.c_str());
SendReliableDataFrame(props->peer_, msg.c_str(), msg.size(),
props->control_data_label_.c_str());
}
}
@@ -175,6 +268,16 @@ int Render::ControlBar(std::shared_ptr<SubStreamWindowProperties>& props) {
line_thickness);
}
ImGui::SameLine();
std::string open_folder = ICON_FA_FOLDER_OPEN;
if (ImGui::Button(open_folder.c_str(),
ImVec2(button_width, button_height))) {
std::string title =
localization::select_file[localization_language_index_];
std::string path = OpenFileDialog(title);
this->ProcessSelectedFile(path, props, file_label_);
}
ImGui::SameLine();
// net traffic stats button
bool button_color_style_pushed = false;
@@ -238,9 +341,9 @@ int Render::ControlBar(std::shared_ptr<SubStreamWindowProperties>& props) {
if (props->is_control_bar_in_left_) {
draw_list->AddLine(
ImVec2(ImGui::GetCursorScreenPos().x + button_height * 0.2f,
ImVec2(ImGui::GetCursorScreenPos().x + button_height * 0.13f,
ImGui::GetCursorScreenPos().y + button_height * 0.2f),
ImVec2(ImGui::GetCursorScreenPos().x + button_height * 0.2f,
ImVec2(ImGui::GetCursorScreenPos().x + button_height * 0.13f,
ImGui::GetCursorScreenPos().y + button_height * 0.8f),
IM_COL32(178, 178, 178, 255), 2.0f);
}
@@ -250,7 +353,7 @@ int Render::ControlBar(std::shared_ptr<SubStreamWindowProperties>& props) {
float expand_button_pos_x =
props->control_bar_expand_ ? (props->is_control_bar_in_left_
? props->control_window_width_ * 1.91f
? props->control_window_width_ * 1.917f
: props->control_window_width_ * 0.03f)
: (props->is_control_bar_in_left_
? props->control_window_width_ * 1.02f

View File

@@ -0,0 +1,242 @@
#include <algorithm>
#include <cmath>
#include "IconsFontAwesome6.h"
#include "layout.h"
#include "localization.h"
#include "rd_log.h"
#include "render.h"
namespace crossdesk {
namespace {
int CountDigits(int number) {
if (number == 0) return 1;
return (int)std::floor(std::log10(std::abs(number))) + 1;
}
int BitrateDisplay(int bitrate) {
int num_of_digits = CountDigits(bitrate);
if (num_of_digits <= 3) {
ImGui::Text("%d bps", bitrate);
} else if (num_of_digits > 3 && num_of_digits <= 6) {
ImGui::Text("%d kbps", bitrate / 1000);
} else {
ImGui::Text("%.1f mbps", bitrate / 1000000.0f);
}
return 0;
}
} // namespace
int Render::FileTransferWindow(
std::shared_ptr<SubStreamWindowProperties>& props) {
// Only show window if there are files in transfer list or currently
// transferring
std::vector<SubStreamWindowProperties::FileTransferInfo> file_list;
{
std::lock_guard<std::mutex> lock(props->file_transfer_list_mutex_);
file_list = props->file_transfer_list_;
}
// Sort file list: Sending first, then Completed, then Queued, then Failed
std::sort(
file_list.begin(), file_list.end(),
[](const SubStreamWindowProperties::FileTransferInfo& a,
const SubStreamWindowProperties::FileTransferInfo& b) {
// Priority: Sending > Completed > Queued > Failed
auto get_priority =
[](SubStreamWindowProperties::FileTransferStatus status) {
switch (status) {
case SubStreamWindowProperties::FileTransferStatus::Sending:
return 0;
case SubStreamWindowProperties::FileTransferStatus::Completed:
return 1;
case SubStreamWindowProperties::FileTransferStatus::Queued:
return 2;
case SubStreamWindowProperties::FileTransferStatus::Failed:
return 3;
}
return 3;
};
return get_priority(a.status) < get_priority(b.status);
});
// Only show window if file_transfer_window_visible_ is true
// Window can be closed by user even during transfer
// It will be reopened automatically when:
// 1. A file transfer completes (in render_callback.cpp)
// 2. A new file starts sending from queue (in render.cpp)
if (!props->file_transfer_window_visible_) {
return 0;
}
ImGuiIO& io = ImGui::GetIO();
// Position window at bottom-left of stream window
// Adjust window size based on number of files
float file_transfer_window_width = main_window_width_ * 0.6f;
float file_transfer_window_height =
main_window_height_ * 0.3f; // Dynamic height
float pos_x = file_transfer_window_width * 0.05f;
float pos_y = stream_window_height_ - file_transfer_window_height -
file_transfer_window_width * 0.05;
float same_line_width = file_transfer_window_width * 0.1f;
ImGui::SetNextWindowPos(ImVec2(pos_x, pos_y), ImGuiCond_Always);
ImGui::SetNextWindowSize(
ImVec2(file_transfer_window_width, file_transfer_window_height),
ImGuiCond_Always);
// Set Chinese font for proper display
if (stream_windows_system_chinese_font_) {
ImGui::PushFont(stream_windows_system_chinese_font_);
}
ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 3.0f);
ImGui::PushStyleVar(ImGuiStyleVar_WindowBorderSize, 1.0f);
ImGui::PushStyleColor(ImGuiCol_WindowBg, ImVec4(1.0f, 1.0f, 1.0f, 0.9f));
ImGui::PushStyleColor(ImGuiCol_Border, ImVec4(1.0f, 1.0f, 1.0f, 1.0f));
ImGui::PushStyleColor(ImGuiCol_TitleBg, ImVec4(1.0f, 1.0f, 1.0f, 1.0f));
ImGui::PushStyleColor(ImGuiCol_TitleBgActive, ImVec4(1.0f, 1.0f, 1.0f, 1.0f));
ImGui::SetWindowFontScale(0.5f);
bool window_opened = true;
if (ImGui::Begin(
localization::file_transfer_progress[localization_language_index_]
.c_str(),
&window_opened,
ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoResize |
ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoSavedSettings |
ImGuiWindowFlags_NoScrollbar)) {
ImGui::SetWindowFontScale(1.0f);
ImGui::SetWindowFontScale(0.5f);
ImGui::PopStyleColor(4);
ImGui::PopStyleVar(2);
// Close button handling
if (!window_opened) {
props->file_transfer_window_visible_ = false;
ImGui::End();
return 0;
}
// Display file list
if (file_list.empty()) {
ImGui::Text("No files in transfer queue");
} else {
// Use a scrollable child window for the file list
ImGui::SetWindowFontScale(0.5f);
ImGui::BeginChild(
"FileList", ImVec2(0, file_transfer_window_height * 0.75f),
ImGuiChildFlags_Border, ImGuiWindowFlags_HorizontalScrollbar);
ImGui::SetWindowFontScale(1.0f);
ImGui::SetWindowFontScale(0.5f);
for (size_t i = 0; i < file_list.size(); ++i) {
const auto& info = file_list[i];
ImGui::PushID(static_cast<int>(i));
// Status icon and file name
const char* status_icon = "";
ImVec4 status_color(0.5f, 0.5f, 0.5f, 1.0f);
const char* status_text = "";
switch (info.status) {
case SubStreamWindowProperties::FileTransferStatus::Queued:
status_icon = ICON_FA_CLOCK;
status_color =
ImVec4(0.5f, 0.6f, 0.7f, 1.0f); // Common blue-gray for queued
status_text =
localization::queued[localization_language_index_].c_str();
break;
case SubStreamWindowProperties::FileTransferStatus::Sending:
status_icon = ICON_FA_ARROW_UP;
status_color = ImVec4(0.2f, 0.6f, 1.0f, 1.0f);
status_text =
localization::sending[localization_language_index_].c_str();
break;
case SubStreamWindowProperties::FileTransferStatus::Completed:
status_icon = ICON_FA_CHECK;
status_color = ImVec4(0.0f, 0.8f, 0.0f, 1.0f);
status_text =
localization::completed[localization_language_index_].c_str();
break;
case SubStreamWindowProperties::FileTransferStatus::Failed:
status_icon = ICON_FA_XMARK;
status_color = ImVec4(1.0f, 0.2f, 0.2f, 1.0f);
status_text =
localization::failed[localization_language_index_].c_str();
break;
}
ImGui::TextColored(status_color, "%s", status_icon);
ImGui::SameLine();
ImGui::Text("%s", info.file_name.c_str());
ImGui::SameLine();
ImGui::TextColored(status_color, "%s", status_text);
// Progress bar for sending files
if (info.status ==
SubStreamWindowProperties::FileTransferStatus::Sending &&
info.file_size > 0) {
float progress = static_cast<float>(info.sent_bytes) /
static_cast<float>(info.file_size);
progress = (std::max)(0.0f, (std::min)(1.0f, progress));
float text_height = ImGui::GetTextLineHeight();
ImGui::ProgressBar(
progress, ImVec2(file_transfer_window_width * 0.5f, text_height),
"");
ImGui::SameLine();
ImGui::Text("%.1f%%", progress * 100.0f);
ImGui::SameLine();
float speed_x_pos = file_transfer_window_width * 0.65f;
ImGui::SetCursorPosX(speed_x_pos);
BitrateDisplay(static_cast<int>(info.rate_bps));
} else if (info.status ==
SubStreamWindowProperties::FileTransferStatus::Completed) {
// Show completed size
char size_str[64];
if (info.file_size < 1024) {
snprintf(size_str, sizeof(size_str), "%llu B",
(unsigned long long)info.file_size);
} else if (info.file_size < 1024 * 1024) {
snprintf(size_str, sizeof(size_str), "%.2f KB",
info.file_size / 1024.0f);
} else {
snprintf(size_str, sizeof(size_str), "%.2f MB",
info.file_size / (1024.0f * 1024.0f));
}
ImGui::Text("Size: %s", size_str);
}
ImGui::PopID();
ImGui::Spacing();
}
ImGui::SetWindowFontScale(1.0f);
ImGui::SetWindowFontScale(0.5f);
ImGui::EndChild();
ImGui::SetWindowFontScale(1.0f);
}
ImGui::SetWindowFontScale(1.0f);
ImGui::SetWindowFontScale(0.5f);
ImGui::End();
ImGui::SetWindowFontScale(1.0f);
// Pop Chinese font if it was pushed
if (stream_windows_system_chinese_font_) {
ImGui::PopFont();
}
} else {
ImGui::PopStyleColor(4);
ImGui::PopStyleVar(2);
}
ImGui::SetWindowFontScale(1.0f);
return 0;
}
} // namespace crossdesk

View File

@@ -214,20 +214,31 @@ int Render::SelfHostedServerWindow() {
ImGui::Separator();
// {
// ImGui::AlignTextToFramePadding();
// ImGui::Text(
// "%s",
// localization::reset_cert_fingerprint[localization_language_index_]
// .c_str());
// ImGui::SameLine();
// if (ConfigCenter::LANGUAGE::CHINESE == localization_language_) {
// ImGui::SetCursorPosX(title_bar_button_width_ * 2.5f);
// } else {
// ImGui::SetCursorPosX(title_bar_button_width_ * 3.43f);
// }
// ImGui::SetNextItemWidth(title_bar_button_width_ * 3.8f);
// ShowSimpleFileBrowser();
// }
{
ImGui::AlignTextToFramePadding();
ImGui::Text("%s", localization::self_hosted_server_certificate_path
if (ImGui::Button(localization::reset_cert_fingerprint
[localization_language_index_]
.c_str());
ImGui::SameLine();
if (ConfigCenter::LANGUAGE::CHINESE == localization_language_) {
ImGui::SetCursorPosX(title_bar_button_width_ * 2.5f);
} else {
ImGui::SetCursorPosX(title_bar_button_width_ * 3.43f);
.c_str())) {
config_center_->ClearCertFingerprint();
LOG_INFO("Certificate fingerprint cleared by user");
}
ImGui::SetNextItemWidth(title_bar_button_width_ * 3.8f);
ShowSimpleFileBrowser();
}
if (stream_window_inited_) {

View File

@@ -0,0 +1,161 @@
#include "layout_relative.h"
#include "localization.h"
#include "rd_log.h"
#include "render.h"
namespace crossdesk {
namespace {} // namespace
int Render::ServerWindow() {
ImGui::SetNextWindowSize(ImVec2(server_window_width_, server_window_height_),
ImGuiCond_Always);
ImGui::SetNextWindowPos(ImVec2(0, 0), ImGuiCond_Always);
ImGui::Begin("##server_window", nullptr,
ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoResize |
ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoCollapse |
ImGuiWindowFlags_NoScrollbar |
ImGuiWindowFlags_NoScrollWithMouse);
server_window_title_bar_height_ = title_bar_height_;
ImGui::PushStyleColor(ImGuiCol_ChildBg, ImVec4(1.0f, 1.0f, 1.0f, 1.0f));
ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(0, 0));
ImGui::SetNextWindowPos(ImVec2(0, 0), ImGuiCond_Always);
ImGui::BeginChild(
"ServerTitleBar",
ImVec2(server_window_width_, server_window_title_bar_height_),
ImGuiChildFlags_Border,
ImGuiWindowFlags_NoDecoration | ImGuiWindowFlags_NoMove |
ImGuiWindowFlags_NoBringToFrontOnFocus);
float server_title_bar_button_width = server_window_title_bar_height_;
float server_title_bar_button_height = server_window_title_bar_height_;
// Collapse/expand toggle button (FontAwesome icon).
{
ImGui::SetCursorPos(ImVec2(0.0f, 0.0f));
ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0, 0, 0, 0));
ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(0, 0, 0, 0.1f));
ImGui::PushStyleColor(ImGuiCol_ButtonActive,
ImVec4(1.0f, 1.0f, 1.0f, 1.0f));
ImGui::SetWindowFontScale(0.5f);
const char* icon =
server_window_collapsed_ ? ICON_FA_ANGLE_DOWN : ICON_FA_ANGLE_UP;
std::string toggle_label = std::string(icon) + "##server_toggle";
if (ImGui::Button(toggle_label.c_str(),
ImVec2(server_title_bar_button_width,
server_title_bar_button_height))) {
if (server_window_) {
int w = 0;
int h = 0;
int x = 0;
int y = 0;
SDL_GetWindowSize(server_window_, &w, &h);
SDL_GetWindowPosition(server_window_, &x, &y);
if (server_window_collapsed_) {
const int normal_h = server_window_normal_height_;
SDL_SetWindowSize(server_window_, w, normal_h);
SDL_SetWindowPosition(server_window_, x, y);
server_window_collapsed_ = false;
} else {
const int collapsed_h = (int)server_window_title_bar_height_;
// Collapse upward: keep top edge stable.
SDL_SetWindowSize(server_window_, w, collapsed_h);
SDL_SetWindowPosition(server_window_, x, y);
server_window_collapsed_ = true;
}
}
}
ImGui::SetWindowFontScale(1.0f);
ImGui::PopStyleColor(3);
}
ImGui::EndChild();
ImGui::PopStyleVar();
ImGui::PopStyleColor();
RemoteClientInfoWindow();
ImGui::End();
return 0;
}
int Render::RemoteClientInfoWindow() {
float remote_client_info_window_width = server_window_width_ * 0.75f;
float remote_client_info_window_height =
(server_window_height_ - server_window_title_bar_height_) * 0.3f;
ImGui::PushStyleColor(ImGuiCol_ChildBg, ImVec4(1.0f, 1.0f, 1.0f, 1.0f));
ImGui::PushStyleVar(ImGuiStyleVar_ChildRounding, 5.0f);
ImGui::BeginChild(
"RemoteClientInfoWindow",
ImVec2(remote_client_info_window_width, remote_client_info_window_height),
ImGuiChildFlags_Border,
ImGuiWindowFlags_NoDecoration | ImGuiWindowFlags_NoMove |
ImGuiWindowFlags_NoBringToFrontOnFocus);
ImGui::PopStyleVar();
ImGui::PopStyleColor();
ImGui::SetWindowFontScale(0.7f);
ImGui::Text("%s",
localization::controller[localization_language_index_].c_str());
ImGui::SameLine();
ImGui::Text("%s", remote_client_id_.c_str());
ImGui::SetWindowFontScale(1.0f);
ImGui::EndChild();
ImGui::SameLine();
float close_connection_button_width = server_window_width_ * 0.15f;
float close_connection_button_height = remote_client_info_window_height;
ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(1.0f, 0.0f, 0.0f, 1.0f));
ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(1.0f, 0.3f, 0.3f, 1.0f));
ImGui::PushStyleColor(ImGuiCol_ButtonActive, ImVec4(1.0f, 0.5f, 0.5f, 1.0f));
ImGui::PushStyleVar(ImGuiStyleVar_FrameRounding, 5.0f);
ImGui::SetWindowFontScale(0.7f);
if (ImGui::Button(ICON_FA_XMARK, ImVec2(close_connection_button_width,
close_connection_button_height))) {
LeaveConnection(peer_, self_hosted_id_);
}
ImGui::SetWindowFontScale(1.0f);
ImGui::PopStyleColor(3);
ImGui::PopStyleVar();
ImGui::PushStyleColor(ImGuiCol_Border, ImVec4(1.0f, 1.0f, 1.0f, 0.0f));
ImGui::BeginChild(
"RemoteClientInfoFileTransferWindow",
ImVec2(remote_client_info_window_width, remote_client_info_window_height),
ImGuiChildFlags_Border,
ImGuiWindowFlags_NoDecoration | ImGuiWindowFlags_NoMove |
ImGuiWindowFlags_NoBringToFrontOnFocus);
ImGui::PopStyleColor();
ImGui::SetWindowFontScale(0.7f);
ImGui::AlignTextToFramePadding();
ImGui::Text(
"%s", localization::file_transfer[localization_language_index_].c_str());
ImGui::SameLine();
if (ImGui::Button(
localization::select_file[localization_language_index_].c_str())) {
std::string title = localization::select_file[localization_language_index_];
std::string path = OpenFileDialog(title);
LOG_INFO("Selected file path: {}", path.c_str());
}
ImGui::SetWindowFontScale(1.0f);
ImGui::EndChild();
return 0;
}
} // namespace crossdesk

View File

@@ -138,6 +138,9 @@ int Render::StreamWindow() {
UpdateRenderRect();
ControlWindow(props);
// Show file transfer window if needed
FileTransferWindow(props);
focused_remote_id_ = props->remote_id_;
@@ -233,6 +236,10 @@ int Render::StreamWindow() {
UpdateRenderRect();
ControlWindow(props);
// Show file transfer window if needed
FileTransferWindow(props);
ImGui::End();
if (!props->peer_) {

534
src/tools/clipboard.cpp Normal file
View File

@@ -0,0 +1,534 @@
#include "clipboard.h"
#include <atomic>
#include <chrono>
#include <mutex>
#include <string>
#include <thread>
#include "rd_log.h"
#ifdef _WIN32
#include <windows.h>
#elif __linux__
#include <X11/Xatom.h>
#include <X11/Xlib.h>
#include <X11/extensions/Xfixes.h>
#include <cstring>
#include <memory>
#endif
namespace crossdesk {
std::atomic<bool> g_monitoring{false};
std::thread g_monitor_thread;
std::mutex g_monitor_mutex;
std::string g_last_clipboard_text;
int g_check_interval_ms = 100;
Clipboard::OnClipboardChanged g_on_clipboard_changed;
#ifdef _WIN32
HWND g_clipboard_wnd = nullptr;
const char* g_clipboard_class_name = "CrossDeskClipboardMonitor";
#endif
#ifdef __linux__
Display* g_x11_display = nullptr;
Atom g_clipboard_atom = None;
Atom g_xfixes_selection_notify = None;
#endif
} // namespace crossdesk
namespace crossdesk {
#ifdef _WIN32
std::string Clipboard::GetText() {
if (!OpenClipboard(nullptr)) {
LOG_ERROR("Clipboard::GetText: failed to open clipboard");
return "";
}
std::string result;
HANDLE hData = GetClipboardData(CF_UNICODETEXT);
if (hData != nullptr) {
wchar_t* pszText = static_cast<wchar_t*>(GlobalLock(hData));
if (pszText != nullptr) {
int size_needed = WideCharToMultiByte(CP_UTF8, 0, pszText, -1, nullptr, 0,
nullptr, nullptr);
if (size_needed > 0) {
// -1 because WideCharToMultiByte contains '\0'
result.resize(size_needed - 1);
WideCharToMultiByte(CP_UTF8, 0, pszText, -1, &result[0], size_needed,
nullptr, nullptr);
}
GlobalUnlock(hData);
}
}
CloseClipboard();
return result;
}
bool Clipboard::SetText(const std::string& text) {
if (!OpenClipboard(nullptr)) {
LOG_ERROR("Clipboard::SetText: failed to open clipboard");
return false;
}
if (!EmptyClipboard()) {
LOG_ERROR("Clipboard::SetText: failed to empty clipboard");
CloseClipboard();
return false;
}
// Convert UTF-8 string to wide char
int size_needed =
MultiByteToWideChar(CP_UTF8, 0, text.c_str(), -1, nullptr, 0);
if (size_needed <= 0) {
LOG_ERROR("Clipboard::SetText: failed to convert to wide char");
CloseClipboard();
return false;
}
HGLOBAL hMem = GlobalAlloc(GMEM_MOVEABLE, size_needed * sizeof(wchar_t));
if (hMem == nullptr) {
LOG_ERROR("Clipboard::SetText: failed to allocate memory");
CloseClipboard();
return false;
}
wchar_t* pszText = static_cast<wchar_t*>(GlobalLock(hMem));
if (pszText == nullptr) {
LOG_ERROR("Clipboard::SetText: failed to lock memory");
GlobalFree(hMem);
CloseClipboard();
return false;
}
MultiByteToWideChar(CP_UTF8, 0, text.c_str(), -1, pszText, size_needed);
GlobalUnlock(hMem);
if (SetClipboardData(CF_UNICODETEXT, hMem) == nullptr) {
LOG_ERROR("Clipboard::SetText: failed to set clipboard data");
GlobalFree(hMem);
CloseClipboard();
return false;
}
CloseClipboard();
return true;
}
bool Clipboard::HasText() {
if (!OpenClipboard(nullptr)) {
return false;
}
bool has_text = IsClipboardFormatAvailable(CF_UNICODETEXT) ||
IsClipboardFormatAvailable(CF_TEXT);
CloseClipboard();
return has_text;
}
#elif __APPLE__
// macOS implementation is in clipboard_mac.mm
#elif __linux__
std::string Clipboard::GetText() {
Display* display = XOpenDisplay(nullptr);
if (display == nullptr) {
LOG_ERROR("Clipboard::GetText: failed to open X display");
return "";
}
std::string result;
Window owner = XGetSelectionOwner(display, XA_PRIMARY);
if (owner == None) {
// Try using CLIPBOARD
owner =
XGetSelectionOwner(display, XInternAtom(display, "CLIPBOARD", False));
if (owner == None) {
XCloseDisplay(display);
return "";
}
}
Atom selection = XA_PRIMARY;
Atom target = XInternAtom(display, "UTF8_STRING", False);
if (target == None) {
target = XA_STRING;
}
XEvent event;
Window window = XCreateSimpleWindow(display, DefaultRootWindow(display), 0, 0,
1, 1, 0, 0, 0);
XSelectInput(display, window, PropertyChangeMask);
XConvertSelection(display, selection, target, XA_PRIMARY, window,
CurrentTime);
// Wait for selection conversion to complete
bool done = false;
while (!done) {
XNextEvent(display, &event);
if (event.type == SelectionNotify) {
if (event.xselection.property == None) {
// Try using CLIPBOARD
if (selection == XA_PRIMARY) {
selection = XInternAtom(display, "CLIPBOARD", False);
XConvertSelection(display, selection, target, XA_PRIMARY, window,
CurrentTime);
continue;
}
break;
}
Atom actual_type;
int actual_format;
unsigned long nitems;
unsigned long bytes_after;
unsigned char* data = nullptr;
if (XGetWindowProperty(display, window, XA_PRIMARY, 0, LONG_MAX / 4,
False, AnyPropertyType, &actual_type,
&actual_format, &nitems, &bytes_after,
&data) == Success) {
if (data != nullptr) {
result = std::string(reinterpret_cast<char*>(data), nitems);
XFree(data);
}
done = true;
}
}
}
XDestroyWindow(display, window);
XCloseDisplay(display);
return result;
}
bool Clipboard::SetText(const std::string& text) {
Display* display = XOpenDisplay(nullptr);
if (display == nullptr) {
LOG_ERROR("Clipboard::SetText: failed to open X display");
return false;
}
Window window = XCreateSimpleWindow(display, DefaultRootWindow(display), 0, 0,
1, 1, 0, 0, 0);
Atom clipboard = XInternAtom(display, "CLIPBOARD", False);
Atom utf8_string = XInternAtom(display, "UTF8_STRING", False);
Atom targets = XInternAtom(display, "TARGETS", False);
Atom xa_string = XA_STRING;
XSetSelectionOwner(display, clipboard, window, CurrentTime);
if (XGetSelectionOwner(display, clipboard) != window) {
LOG_ERROR("Clipboard::SetText: failed to set selection owner");
XDestroyWindow(display, window);
XCloseDisplay(display);
return false;
}
XChangeProperty(display, window, XA_PRIMARY, utf8_string, 8, PropModeReplace,
reinterpret_cast<const unsigned char*>(text.c_str()),
static_cast<int>(text.length()));
XEvent event;
while (true) {
XNextEvent(display, &event);
if (event.type == SelectionRequest) {
XSelectionRequestEvent* req = &event.xselectionrequest;
XSelectionEvent se;
se.type = SelectionNotify;
se.display = req->display;
se.requestor = req->requestor;
se.selection = req->selection;
se.time = req->time;
se.target = req->target;
se.property = req->property;
if (req->target == targets) {
// Return supported formats
Atom supported[] = {utf8_string, xa_string, targets};
XChangeProperty(display, req->requestor, req->property, XA_ATOM, 32,
PropModeReplace,
reinterpret_cast<unsigned char*>(supported), 3);
se.property = req->property;
} else if (req->target == utf8_string || req->target == xa_string) {
// Return text data
XChangeProperty(display, req->requestor, req->property, req->target, 8,
PropModeReplace,
reinterpret_cast<const unsigned char*>(text.c_str()),
static_cast<int>(text.length()));
se.property = req->property;
} else {
se.property = None;
}
XSendEvent(display, req->requestor, False, 0,
reinterpret_cast<XEvent*>(&se));
XSync(display, False);
} else if (event.type == SelectionClear) {
break;
}
}
XDestroyWindow(display, window);
XCloseDisplay(display);
return true;
}
bool Clipboard::HasText() {
Display* display = XOpenDisplay(nullptr);
if (display == nullptr) {
return false;
}
Atom clipboard = XInternAtom(display, "CLIPBOARD", False);
Window owner = XGetSelectionOwner(display, clipboard);
if (owner == None) {
owner = XGetSelectionOwner(display, XA_PRIMARY);
}
XCloseDisplay(display);
return owner != None;
}
#else
std::string Clipboard::GetText() {
LOG_ERROR("Clipboard::GetText: unsupported platform");
return "";
}
bool Clipboard::SetText(const std::string& text) {
LOG_ERROR("Clipboard::SetText: unsupported platform");
return false;
}
bool Clipboard::HasText() {
LOG_ERROR("Clipboard::HasText: unsupported platform");
return false;
}
#endif
void HandleClipboardChange() {
if (!Clipboard::HasText()) {
std::lock_guard<std::mutex> lock(g_monitor_mutex);
if (!g_last_clipboard_text.empty()) {
g_last_clipboard_text.clear();
LOG_INFO("Clipboard content cleared");
}
return;
}
std::string current_text = Clipboard::GetText();
// Check if the content has changed
{
std::lock_guard<std::mutex> lock(g_monitor_mutex);
if (current_text != g_last_clipboard_text) {
g_last_clipboard_text = current_text;
if (!current_text.empty()) {
if (g_on_clipboard_changed) {
int ret = g_on_clipboard_changed(current_text.c_str(),
current_text.length());
if (ret != 0) {
LOG_WARN("Clipboard callback returned error: {}", ret);
}
}
}
}
}
}
#ifdef _WIN32
LRESULT CALLBACK ClipboardWndProc(HWND hwnd, UINT uMsg, WPARAM wParam,
LPARAM lParam) {
if (uMsg == WM_CLIPBOARDUPDATE) {
HandleClipboardChange();
return 0;
}
return DefWindowProc(hwnd, uMsg, wParam, lParam);
}
static void MonitorThreadFunc() {
// Create a hidden window to receive clipboard messages
WNDCLASSA wc = {0};
wc.lpfnWndProc = ClipboardWndProc;
wc.hInstance = GetModuleHandle(nullptr);
wc.lpszClassName = g_clipboard_class_name;
RegisterClassA(&wc);
g_clipboard_wnd = CreateWindowA(g_clipboard_class_name, nullptr, 0, 0, 0, 0,
0, HWND_MESSAGE, nullptr, nullptr, nullptr);
if (!g_clipboard_wnd) {
LOG_ERROR("Failed to create clipboard monitor window");
g_monitoring.store(false);
return;
}
// Register clipboard format listener
if (!AddClipboardFormatListener(g_clipboard_wnd)) {
LOG_ERROR("Failed to add clipboard format listener");
DestroyWindow(g_clipboard_wnd);
g_clipboard_wnd = nullptr;
g_monitoring.store(false);
return;
}
LOG_INFO("Clipboard event monitoring started (Windows)");
MSG msg;
while (g_monitoring.load()) {
BOOL ret = GetMessage(&msg, nullptr, 0, 0);
if (ret == 0 || ret == -1) {
break;
}
TranslateMessage(&msg);
DispatchMessage(&msg);
}
RemoveClipboardFormatListener(g_clipboard_wnd);
if (g_clipboard_wnd) {
DestroyWindow(g_clipboard_wnd);
g_clipboard_wnd = nullptr;
}
UnregisterClassA(g_clipboard_class_name, GetModuleHandle(nullptr));
}
#elif __APPLE__
// macOS use notification mechanism, need external functions
extern void StartMacOSClipboardMonitoring();
extern void StopMacOSClipboardMonitoring();
static void MonitorThreadFunc() { StartMacOSClipboardMonitoring(); }
#elif __linux__
static void MonitorThreadFunc() {
g_x11_display = XOpenDisplay(nullptr);
if (!g_x11_display) {
LOG_ERROR("Failed to open X display for clipboard monitoring");
g_monitoring.store(false);
return;
}
// Check if XFixes extension is available
int event_base, error_base;
if (!XFixesQueryExtension(g_x11_display, &event_base, &error_base)) {
LOG_WARN("XFixes extension not available, falling back to polling");
XCloseDisplay(g_x11_display);
g_x11_display = nullptr;
// fallback to polling mode
while (g_monitoring.load()) {
std::this_thread::sleep_for(
std::chrono::milliseconds(g_check_interval_ms));
if (!g_monitoring.load()) {
break;
}
HandleClipboardChange();
}
return;
}
g_clipboard_atom = XInternAtom(g_x11_display, "CLIPBOARD", False);
g_xfixes_selection_notify =
XInternAtom(g_x11_display, "XFIXES_SELECTION_NOTIFY", False);
// Create event window
Window root = DefaultRootWindow(g_x11_display);
Window event_window =
XCreateSimpleWindow(g_x11_display, root, 0, 0, 1, 1, 0, 0, 0);
// Select events to monitor
XFixesSelectSelectionInput(g_x11_display, event_window, g_clipboard_atom,
XFixesSetSelectionOwnerNotifyMask |
XFixesSelectionWindowDestroyNotifyMask |
XFixesSelectionClientCloseNotifyMask);
LOG_INFO("Clipboard event monitoring started (Linux XFixes)");
XEvent event;
while (g_monitoring.load()) {
XNextEvent(g_x11_display, &event);
if (event.type == event_base + XFixesSelectionNotify) {
HandleClipboardChange();
}
}
XFixesSelectSelectionInput(g_x11_display, event_window, g_clipboard_atom, 0);
XDestroyWindow(g_x11_display, event_window);
XCloseDisplay(g_x11_display);
g_x11_display = nullptr;
}
#else
// Fallback to polling mode for other platforms
static void MonitorThreadFunc() {
while (g_monitoring.load()) {
std::this_thread::sleep_for(std::chrono::milliseconds(g_check_interval_ms));
if (!g_monitoring.load()) {
break;
}
HandleClipboardChange();
}
}
#endif
void Clipboard::StartMonitoring(int check_interval_ms,
OnClipboardChanged on_changed) {
if (g_monitoring.load()) {
LOG_WARN("Clipboard monitoring is already running");
return;
}
g_check_interval_ms = check_interval_ms > 0 ? check_interval_ms : 100;
{
std::lock_guard<std::mutex> lock(g_monitor_mutex);
g_on_clipboard_changed = on_changed;
if (HasText()) {
g_last_clipboard_text = GetText();
} else {
g_last_clipboard_text.clear();
}
}
g_monitoring.store(true);
g_monitor_thread = std::thread(MonitorThreadFunc);
LOG_INFO("Clipboard event monitoring started");
}
void Clipboard::StopMonitoring() {
if (!g_monitoring.load()) {
return;
}
g_monitoring.store(false);
#ifdef _WIN32
if (g_clipboard_wnd) {
PostMessage(g_clipboard_wnd, WM_QUIT, 0, 0);
}
#elif __APPLE__
StopMacOSClipboardMonitoring();
#endif
if (g_monitor_thread.joinable()) {
g_monitor_thread.join();
}
{
std::lock_guard<std::mutex> lock(g_monitor_mutex);
g_last_clipboard_text.clear();
g_on_clipboard_changed = nullptr;
}
LOG_INFO("Clipboard monitoring stopped");
}
bool Clipboard::IsMonitoring() { return g_monitoring.load(); }
} // namespace crossdesk

38
src/tools/clipboard.h Normal file
View File

@@ -0,0 +1,38 @@
/*
* @Author: DI JUNKUN
* @Date: 2025-12-28
* Copyright (c) 2025 by DI JUNKUN, All Rights Reserved.
*/
#ifndef _CLIPBOARD_H_
#define _CLIPBOARD_H_
#include <functional>
#include <string>
namespace crossdesk {
class Clipboard {
public:
using OnClipboardChanged = std::function<int(const char* data, size_t size)>;
Clipboard() = default;
~Clipboard() = default;
static std::string GetText();
static bool SetText(const std::string& text);
static bool HasText();
static void StartMonitoring(int check_interval_ms = 100,
OnClipboardChanged on_changed = nullptr);
static void StopMonitoring();
static bool IsMonitoring();
};
} // namespace crossdesk
#endif

120
src/tools/clipboard_mac.mm Normal file
View File

@@ -0,0 +1,120 @@
/*
* @Author: DI JUNKUN
* @Date: 2025-12-18
* Copyright (c) 2025 by DI JUNKUN, All Rights Reserved.
*/
#include "clipboard.h"
#include <AppKit/AppKit.h>
#include <CoreFoundation/CoreFoundation.h>
#include <atomic>
#include <mutex>
#include <string>
#include <thread>
#include "rd_log.h"
namespace crossdesk {
extern std::atomic<bool> g_monitoring;
extern std::mutex g_monitor_mutex;
extern std::string g_last_clipboard_text;
extern Clipboard::OnClipboardChanged g_on_clipboard_changed;
static CFRunLoopRef g_monitor_runloop = nullptr;
std::string Clipboard::GetText() {
@autoreleasepool {
NSPasteboard* pasteboard = [NSPasteboard generalPasteboard];
NSString* string = [pasteboard stringForType:NSPasteboardTypeString];
if (string == nil) {
return "";
}
return std::string([string UTF8String]);
}
}
bool Clipboard::SetText(const std::string& text) {
@autoreleasepool {
NSPasteboard* pasteboard = [NSPasteboard generalPasteboard];
[pasteboard clearContents];
NSString* string = [NSString stringWithUTF8String:text.c_str()];
if (string == nil) {
LOG_ERROR("Clipboard::SetText: failed to create NSString");
return false;
}
BOOL success = [pasteboard setString:string forType:NSPasteboardTypeString];
return success == YES;
}
}
bool Clipboard::HasText() {
@autoreleasepool {
NSPasteboard* pasteboard = [NSPasteboard generalPasteboard];
NSArray* types = [pasteboard types];
return [types containsObject:NSPasteboardTypeString];
}
}
extern void HandleClipboardChange();
void StartMacOSClipboardMonitoring() {
@autoreleasepool {
NSPasteboard* pasteboard = [NSPasteboard generalPasteboard];
// Store RunLoop reference for waking up
NSRunLoop* runLoop = [NSRunLoop currentRunLoop];
g_monitor_runloop = [runLoop getCFRunLoop];
if (g_monitor_runloop) {
CFRetain(g_monitor_runloop);
}
// Track changeCount to detect clipboard changes
// Use __block to allow modification inside the block
__block NSInteger lastChangeCount = [pasteboard changeCount];
LOG_INFO("Clipboard event monitoring started (macOS)");
// Use a timer to periodically check changeCount
// This is more reliable than NSPasteboardDidChangeNotification which may not be available
NSTimer* timer =
[NSTimer scheduledTimerWithTimeInterval:0.1
repeats:YES
block:^(NSTimer* timer) {
if (!g_monitoring.load()) {
[timer invalidate];
return;
}
NSInteger currentChangeCount = [pasteboard changeCount];
if (currentChangeCount != lastChangeCount) {
lastChangeCount = currentChangeCount;
HandleClipboardChange();
}
}];
while (g_monitoring.load()) {
@autoreleasepool {
NSDate* date = [NSDate dateWithTimeIntervalSinceNow:0.1];
[runLoop runMode:NSDefaultRunLoopMode beforeDate:date];
}
}
// Cleanup
[timer invalidate];
if (g_monitor_runloop) {
CFRelease(g_monitor_runloop);
g_monitor_runloop = nullptr;
}
}
}
void StopMacOSClipboardMonitoring() {
// Wake up the RunLoop immediately so it can check g_monitoring and exit
// This ensures the RunLoop exits promptly instead of waiting up to 0.1 seconds
if (g_monitor_runloop) {
CFRunLoopWakeUp(g_monitor_runloop);
}
}
} // namespace crossdesk

307
src/tools/file_transfer.cpp Normal file
View File

@@ -0,0 +1,307 @@
#include "file_transfer.h"
#include <atomic>
#include <chrono>
#include "rd_log.h"
namespace crossdesk {
namespace {
std::atomic<uint32_t> g_next_file_id{1};
} // namespace
uint32_t FileSender::NextFileId() { return g_next_file_id.fetch_add(1); }
int FileSender::SendFile(const std::filesystem::path& path,
const std::string& label, const SendFunc& send,
std::size_t chunk_size, uint32_t file_id) {
if (!send) {
LOG_ERROR("FileSender::SendFile: send function is empty");
return -1;
}
std::error_code ec;
if (!std::filesystem::exists(path, ec) ||
!std::filesystem::is_regular_file(path, ec)) {
LOG_ERROR("FileSender::SendFile: file [{}] not found or not regular",
path.string().c_str());
return -1;
}
uint64_t total_size = std::filesystem::file_size(path, ec);
if (ec) {
LOG_ERROR("FileSender::SendFile: failed to get size of [{}]: {}",
path.string().c_str(), ec.message().c_str());
return -1;
}
std::ifstream ifs(path, std::ios::binary);
if (!ifs.is_open()) {
LOG_ERROR("FileSender::SendFile: failed to open [{}]",
path.string().c_str());
return -1;
}
LOG_INFO("FileSender send file {}, total size {}, file_id={}",
path.string().c_str(), total_size, file_id);
if (file_id == 0) {
file_id = NextFileId();
}
const uint32_t final_file_id = file_id;
uint64_t offset = 0;
bool is_first = true;
std::string file_name = label.empty() ? path.filename().string() : label;
std::vector<char> buffer;
buffer.resize(chunk_size);
while (ifs && offset < total_size) {
uint64_t remaining = total_size - offset;
uint32_t to_read =
static_cast<uint32_t>(std::min<uint64_t>(remaining, chunk_size));
ifs.read(buffer.data(), static_cast<std::streamsize>(to_read));
std::streamsize bytes_read = ifs.gcount();
if (bytes_read <= 0) {
break;
}
bool is_last = (offset + static_cast<uint64_t>(bytes_read) >= total_size);
const std::string* name_ptr = is_first ? &file_name : nullptr;
std::vector<char> chunk = BuildChunk(
final_file_id, offset, total_size, buffer.data(),
static_cast<uint32_t>(bytes_read), name_ptr, is_first, is_last);
int ret = send(chunk.data(), chunk.size());
if (ret != 0) {
LOG_ERROR("FileSender::SendFile: send failed for [{}], ret={}",
path.string().c_str(), ret);
return ret;
}
offset += static_cast<uint64_t>(bytes_read);
is_first = false;
}
return 0;
}
std::vector<char> FileSender::BuildChunk(uint32_t file_id, uint64_t offset,
uint64_t total_size, const char* data,
uint32_t data_size,
const std::string* file_name,
bool is_first, bool is_last) {
FileChunkHeader header{};
header.magic = kFileChunkMagic;
header.file_id = file_id;
header.offset = offset;
header.total_size = total_size;
header.chunk_size = data_size;
header.name_len =
(file_name && is_first) ? static_cast<uint16_t>(file_name->size()) : 0;
header.flags = 0;
if (is_first) header.flags |= 0x01;
if (is_last) header.flags |= 0x02;
std::size_t total_size_bytes =
sizeof(FileChunkHeader) + header.name_len + header.chunk_size;
std::vector<char> buffer;
buffer.resize(total_size_bytes);
std::size_t offset_bytes = 0;
memcpy(buffer.data() + offset_bytes, &header, sizeof(FileChunkHeader));
offset_bytes += sizeof(FileChunkHeader);
if (header.name_len > 0 && file_name) {
memcpy(buffer.data() + offset_bytes, file_name->data(), header.name_len);
offset_bytes += header.name_len;
}
if (header.chunk_size > 0 && data) {
memcpy(buffer.data() + offset_bytes, data, header.chunk_size);
}
return buffer;
}
// ---------- FileReceiver ----------
FileReceiver::FileReceiver() : output_dir_(GetDefaultDesktopPath()) {}
FileReceiver::FileReceiver(const std::filesystem::path& output_dir)
: output_dir_(output_dir) {
std::error_code ec;
if (!output_dir_.empty()) {
std::filesystem::create_directories(output_dir_, ec);
if (ec) {
LOG_ERROR("FileReceiver: failed to create output dir [{}]: {}",
output_dir_.string().c_str(), ec.message().c_str());
}
}
}
std::filesystem::path FileReceiver::GetDefaultDesktopPath() {
#ifdef _WIN32
const char* home_env = std::getenv("USERPROFILE");
#else
const char* home_env = std::getenv("HOME");
#endif
if (!home_env) {
return std::filesystem::path{};
}
std::filesystem::path desktop_path =
std::filesystem::path(home_env) / "Desktop";
std::error_code ec;
std::filesystem::create_directories(desktop_path, ec);
if (ec) {
LOG_ERROR("FileReceiver: failed to create desktop directory [{}]: {}",
desktop_path.string().c_str(), ec.message().c_str());
}
return desktop_path;
}
bool FileReceiver::OnData(const char* data, size_t size) {
if (!data || size < sizeof(FileChunkHeader)) {
LOG_ERROR("FileReceiver::OnData: invalid buffer");
return false;
}
FileChunkHeader header{};
memcpy(&header, data, sizeof(FileChunkHeader));
if (header.magic != kFileChunkMagic) {
return false;
}
std::size_t header_and_name =
sizeof(FileChunkHeader) + static_cast<std::size_t>(header.name_len);
if (size < header_and_name ||
size < header_and_name + static_cast<std::size_t>(header.chunk_size)) {
LOG_ERROR("FileReceiver::OnData: buffer too small for header + payload");
return false;
}
const char* name_ptr = data + sizeof(FileChunkHeader);
std::string file_name;
const std::string* file_name_ptr = nullptr;
if (header.name_len > 0) {
file_name.assign(name_ptr,
name_ptr + static_cast<std::size_t>(header.name_len));
file_name_ptr = &file_name;
}
const char* payload = data + header_and_name;
std::size_t payload_size =
static_cast<std::size_t>(header.chunk_size); // may be 0
return HandleChunk(header, payload, payload_size, file_name_ptr);
}
bool FileReceiver::HandleChunk(const FileChunkHeader& header,
const char* payload, size_t payload_size,
const std::string* file_name) {
auto it = contexts_.find(header.file_id);
if (it == contexts_.end()) {
// new file context must start with first chunk.
if ((header.flags & 0x01) == 0) {
LOG_ERROR("FileReceiver: received non-first chunk for unknown file_id={}",
header.file_id);
return false;
}
FileContext ctx;
ctx.total_size = header.total_size;
std::string filename;
if (file_name && !file_name->empty()) {
filename = *file_name;
} else {
filename = "received_" + std::to_string(header.file_id);
}
ctx.file_name = filename;
std::filesystem::path save_path = output_dir_.empty()
? std::filesystem::path(filename)
: output_dir_ / filename;
// if file exists, append timestamp.
std::error_code ec;
if (std::filesystem::exists(save_path, ec)) {
auto now = std::chrono::system_clock::now();
auto ts = std::chrono::duration_cast<std::chrono::milliseconds>(
now.time_since_epoch())
.count();
save_path = save_path.parent_path() /
(save_path.stem().string() + "_" + std::to_string(ts) +
save_path.extension().string());
}
ctx.ofs.open(save_path, std::ios::binary | std::ios::trunc);
if (!ctx.ofs.is_open()) {
LOG_ERROR("FileReceiver: failed to open [{}] for writing",
save_path.string().c_str());
return false;
}
contexts_.emplace(header.file_id, std::move(ctx));
it = contexts_.find(header.file_id);
}
FileContext& ctx = it->second;
if (payload_size > 0 && payload) {
ctx.ofs.seekp(static_cast<std::streamoff>(header.offset), std::ios::beg);
ctx.ofs.write(payload, static_cast<std::streamsize>(payload_size));
if (!ctx.ofs.good()) {
LOG_ERROR("FileReceiver: write failed for file_id={}", header.file_id);
return false;
}
ctx.received += static_cast<uint64_t>(payload_size);
}
// Send ACK after processing chunk
if (on_send_ack_) {
FileTransferAck ack{};
ack.magic = kFileAckMagic;
ack.file_id = header.file_id;
ack.acked_offset = header.offset + static_cast<uint64_t>(payload_size);
ack.total_size = header.total_size;
ack.flags = 0;
bool is_last = (header.flags & 0x02) != 0;
if (is_last || ctx.received >= ctx.total_size) {
ack.flags |= 0x01; // completed
}
int ret = on_send_ack_(ack);
if (ret != 0) {
LOG_ERROR("FileReceiver: failed to send ACK for file_id={}, ret={}",
header.file_id, ret);
}
}
bool is_last = (header.flags & 0x02) != 0;
if (is_last || ctx.received >= ctx.total_size) {
ctx.ofs.close();
std::filesystem::path saved_path =
output_dir_.empty() ? std::filesystem::path(ctx.file_name)
: output_dir_ / ctx.file_name;
LOG_INFO("FileReceiver: file received complete, file_id={}, size={}",
header.file_id, ctx.received);
contexts_.erase(header.file_id);
}
return true;
}
} // namespace crossdesk

114
src/tools/file_transfer.h Normal file
View File

@@ -0,0 +1,114 @@
/*
* @Author: DI JUNKUN
* @Date: 2025-12-18
* Copyright (c) 2025 by DI JUNKUN, All Rights Reserved.
*/
#ifndef _FILE_TRANSFER_H_
#define _FILE_TRANSFER_H_
#include <cstdint>
#include <filesystem>
#include <fstream>
#include <functional>
#include <string>
#include <unordered_map>
#include <vector>
namespace crossdesk {
// Magic constants for file transfer protocol
constexpr uint32_t kFileChunkMagic = 0x4A4E544D; // 'JNTM'
constexpr uint32_t kFileAckMagic = 0x4A4E5443; // 'JNTC'
#pragma pack(push, 1)
struct FileChunkHeader {
uint32_t magic; // magic to identify file-transfer chunks
uint32_t file_id; // unique id per file transfer
uint64_t offset; // offset in file
uint64_t total_size; // total file size
uint32_t chunk_size; // payload size in this chunk
uint16_t name_len; // filename length (bytes), only set on first chunk
uint8_t flags; // bit0: is_first, bit1: is_last, others reserved
};
struct FileTransferAck {
uint32_t magic; // magic to identify file-transfer ack
uint32_t file_id; // must match FileChunkHeader.file_id
uint64_t acked_offset; // received offset
uint64_t total_size; // total file size
uint32_t flags; // bit0: completed, bit1: error
};
#pragma pack(pop)
class FileSender {
public:
using SendFunc = std::function<int(const char* data, size_t size)>;
public:
FileSender() = default;
// generate a new file id
static uint32_t NextFileId();
// synchronously send a file using the provided send function.
// `path` : full path to the local file.
// `label` : logical filename to send (usually path.filename()).
// `send` : callback that pushes one encoded chunk into the data channel.
// `file_id` : file id to use (0 means auto-generate).
// Return 0 on success, <0 on error.
int SendFile(const std::filesystem::path& path, const std::string& label,
const SendFunc& send, std::size_t chunk_size = 64 * 1024,
uint32_t file_id = 0);
// build a single encoded chunk buffer according to FileChunkHeader protocol.
static std::vector<char> BuildChunk(uint32_t file_id, uint64_t offset,
uint64_t total_size, const char* data,
uint32_t data_size,
const std::string* file_name,
bool is_first, bool is_last);
};
class FileReceiver {
public:
struct FileContext {
std::string file_name;
uint64_t total_size = 0;
uint64_t received = 0;
std::ofstream ofs;
};
using OnFileComplete =
std::function<void(const std::filesystem::path& saved_path)>;
using OnSendAck = std::function<int(const FileTransferAck& ack)>;
public:
// save to default desktop directory.
FileReceiver();
// save to a specified directory.
explicit FileReceiver(const std::filesystem::path& output_dir);
// process one received data buffer (one chunk).
// return true if parsed and processed successfully, false otherwise.
bool OnData(const char* data, size_t size);
void SetOnSendAck(OnSendAck cb) { on_send_ack_ = cb; }
const std::filesystem::path& OutputDir() const { return output_dir_; }
private:
static std::filesystem::path GetDefaultDesktopPath();
bool HandleChunk(const FileChunkHeader& header, const char* payload,
size_t payload_size, const std::string* file_name);
private:
std::filesystem::path output_dir_;
std::unordered_map<uint32_t, FileContext> contexts_;
OnSendAck on_send_ack_ = nullptr;
};
} // namespace crossdesk
#endif

View File

@@ -33,6 +33,7 @@ add_requires("imgui v1.92.1-docking", {configs = {sdl3 = true, sdl3_renderer = t
add_requires("openssl3 3.3.2", {system = false})
add_requires("nlohmann_json 3.11.3")
add_requires("cpp-httplib v0.26.0", {configs = {ssl = true}})
add_requires("tinyfiledialogs 3.15.1")
if is_os("windows") then
add_requires("libyuv", "miniaudio 0.11.21")
@@ -69,6 +70,9 @@ target("common")
set_kind("object")
add_deps("rd_log")
add_files("src/common/*.cpp")
if is_os("macosx") then
add_files("src/common/*.mm")
end
add_includedirs("src/common", {public = true})
target("path_manager")
@@ -168,13 +172,22 @@ target("version_checker")
add_files("src/version_checker/*.cpp")
add_includedirs("src/version_checker", {public = true})
target("tools")
set_kind("object")
add_deps("rd_log")
add_files("src/tools/*.cpp")
if is_os("macosx") then
add_files("src/tools/*.mm")
end
add_includedirs("src/tools", {public = true})
target("gui")
set_kind("object")
add_packages("libyuv")
add_packages("libyuv", "tinyfiledialogs")
add_defines("CROSSDESK_VERSION=\"" .. (get_config("CROSSDESK_VERSION") or "Unknown") .. "\"")
add_deps("rd_log", "common", "assets", "config_center", "minirtc",
"path_manager", "screen_capturer", "speaker_capturer",
"device_controller", "thumbnail", "version_checker")
"device_controller", "thumbnail", "version_checker", "tools")
add_files("src/gui/*.cpp", "src/gui/panels/*.cpp", "src/gui/toolbars/*.cpp",
"src/gui/windows/*.cpp")
add_includedirs("src/gui", "src/gui/panels", "src/gui/toolbars",