Compare commits
16
Commits
v1.4.0
..
8d4c118ae6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8d4c118ae6 | ||
|
|
d477cbfee9 | ||
|
|
870a5832ea | ||
|
|
8e0f1283c6 | ||
|
|
2de21b2b6b | ||
|
|
53b7151fb7 | ||
|
|
d2149851b2 | ||
|
|
ca6f76565c | ||
|
|
d15cd77a28 | ||
|
|
1a9312d2d1 | ||
|
|
f3a8406050 | ||
|
|
ff6b290580 | ||
|
|
da0ec983ba | ||
|
|
6220335f63 | ||
|
|
c7f7b7b29b | ||
|
|
59fead7b5f |
No files matched your search
@@ -390,7 +390,7 @@ nohup ./server_linux_amd64 --port 6543 --http-port 9001 > yama.log 2>&1 &
|
||||
- **主机管理**:迁移 CMachineManager(进程 / 窗口 / 网络 / 软件 / IE 历史 / 收藏夹 / 服务 / 任务 / hosts 枚举 + 进程注入),COMMAND_MACHINE_MANAGE 162
|
||||
- **授权失败触发器**:新增 TRIGGER_AUTH_FAILED 触发类型(与主机上线触发独立),授权失败时下发插件 DLL,受 ShouldLogAuth 去重约束
|
||||
- **iPhone 远程音频**:WebAudio + 自托管 opus-decoder WASM 解码,修复 iOS 无 MSE 静音
|
||||
- **画质与传输控制**:远程桌面 / 屏幕预览质量等级菜单(各 6 档可配置)+ 文件传输速度对话框(7 档 + 无限档,CMD_FILE_SPEED 252)
|
||||
- **画质与传输控制**:远程桌面 / 屏幕预览质量等级菜单(各 6 档可配置)+ 文件传输速度对话框(7 档 + 无限档,经 MasterSettings 下发)
|
||||
- **Shine/HELL 协议选择**:分享 / 传输主机时可选协议,尾随冒号字段透传,旧客户端兼容
|
||||
- **Go Web UI 子路径部署**:YAMA_WEB_BASE_PATH 挂载整个 Web UI 到 URL 前缀
|
||||
- **国际化**:德语(de_DE)第 4 种语言、语言切换工具栏按钮、剩余字符串翻译补齐;远程桌面剪贴板按钮(全屏可达)
|
||||
|
||||
+1
-1
@@ -390,7 +390,7 @@ Valid : 2026-02-01 to 2028-02-01
|
||||
- **Machine management**: CMachineManager migrated (process / window / network / software / IE history / favorites / service / task / hosts enumeration plus process injection), COMMAND_MACHINE_MANAGE 162
|
||||
- **Auth-failed trigger**: new TRIGGER_AUTH_FAILED type (independent of the host-online trigger) pushes plugin DLLs on authorization failure, gated by ShouldLogAuth dedup
|
||||
- **iPhone remote audio**: Web Audio API + self-hosted opus-decoder WASM decoding fixes silent audio on iOS without MSE
|
||||
- **Quality & transfer controls**: configurable remote-desktop / screen-preview quality-level menus (six presets each) plus a file-transfer speed dialog (seven presets + unlimited, CMD_FILE_SPEED 252)
|
||||
- **Quality & transfer controls**: configurable remote-desktop / screen-preview quality-level menus (six presets each) plus a file-transfer speed dialog (seven presets + unlimited, delivered via MasterSettings)
|
||||
- **Shine/HELL protocol selection**: choose the protocol when sharing / transferring a host, passed as a trailing colon field, backward compatible
|
||||
- **Go web UI subpath deployment**: YAMA_WEB_BASE_PATH mounts the whole web UI under a URL prefix
|
||||
- **i18n**: German (de_DE) as the fourth language, a language-switch toolbar button, remaining string translations; remote-desktop clipboard buttons (reachable in fullscreen)
|
||||
|
||||
+1
-1
@@ -390,7 +390,7 @@ nohup ./server_linux_amd64 --port 6543 --http-port 9001 > yama.log 2>&1 &
|
||||
- **主機管理**:遷移 CMachineManager(處理程序 / 視窗 / 網路 / 軟體 / IE 歷史 / 收藏夾 / 服務 / 排程工作 / hosts 列舉 + 處理程序注入),COMMAND_MACHINE_MANAGE 162
|
||||
- **授權失敗觸發器**:新增 TRIGGER_AUTH_FAILED 觸發類型(與主機上線觸發獨立),授權失敗時下發外掛程式 DLL,受 ShouldLogAuth 去重約束
|
||||
- **iPhone 遠端音訊**:WebAudio + 自託管 opus-decoder WASM 解碼,修復 iOS 無 MSE 靜音
|
||||
- **品質與傳輸控制**:遠端桌面 / 螢幕預覽品質等級選單(各 6 檔可設定)+ 檔案傳輸速度對話框(7 檔 + 無限檔,CMD_FILE_SPEED 252)
|
||||
- **品質與傳輸控制**:遠端桌面 / 螢幕預覽品質等級選單(各 6 檔可設定)+ 檔案傳輸速度對話框(7 檔 + 無限檔,經 MasterSettings 下發)
|
||||
- **Shine/HELL 協定選擇**:分享 / 傳輸主機時可選協定,尾隨冒號欄位透傳,舊用戶端相容
|
||||
- **Go Web UI 子路徑部署**:YAMA_WEB_BASE_PATH 掛載整個 Web UI 到 URL 前綴
|
||||
- **國際化**:德語(de_DE)第 4 種語言、語言切換工具列按鈕、剩餘字串翻譯補齊;遠端桌面剪貼簿按鈕(全螢幕可達)
|
||||
|
||||
@@ -46,13 +46,15 @@ CCaptureVideo::~CCaptureVideo()
|
||||
HRESULT CCaptureVideo::Open(int iDeviceID,int iPress)
|
||||
{
|
||||
Mprintf("CCaptureVideo call Open\n");
|
||||
HRESULT hResult = S_OK;
|
||||
HRESULT hResult = S_FALSE;
|
||||
do {
|
||||
hResult = InitCaptureGraphBuilder();
|
||||
if (FAILED(hResult))
|
||||
break;
|
||||
if(!BindVideoFilter(iDeviceID, &m_pBF))
|
||||
if (!BindVideoFilter(iDeviceID, &m_pBF)) {
|
||||
hResult = S_FALSE;
|
||||
break;
|
||||
}
|
||||
|
||||
hResult = m_pGB->AddFilter(m_pBF, L"Capture Filter");
|
||||
|
||||
|
||||
@@ -245,14 +245,17 @@ INT_PTR CALLBACK CChatManager::ChatDialogProc(HWND hDlg, UINT uMsg, WPARAM wPara
|
||||
return TRUE;
|
||||
}
|
||||
if (LOWORD(wParam) == IDC_BUTTON_END) {
|
||||
// 被控端主动结束交谈:关闭本地聊天窗口
|
||||
SendMessage(hDlg, WM_USER + 1, NULL, NULL);
|
||||
// 被控端关闭聊天窗口:仅隐藏对话框,会话保持。关闭交谈始终由服务端发起,
|
||||
// 服务端继续发消息时 OnReceive 会用 SW_RESTORE 重新弹出本对话框。
|
||||
ShowWindow(hDlg, SW_HIDE);
|
||||
return TRUE;
|
||||
}
|
||||
break;
|
||||
|
||||
case WM_CLOSE:
|
||||
return 0;
|
||||
// X 关闭按钮:仅隐藏对话框(与「结束交谈」一致),关闭交谈始终由服务端发起
|
||||
ShowWindow(hDlg, SW_HIDE);
|
||||
return TRUE;
|
||||
|
||||
case WM_USER + 1:
|
||||
pThis->m_hWnd = NULL;
|
||||
|
||||
@@ -1113,6 +1113,13 @@ VOID CKernelManager::OnReceive(PBYTE szBuffer, ULONG ulLength)
|
||||
mgr->EnableOfflineRecord(TRUE);
|
||||
}
|
||||
Logger::getInstance().usingLog(m_settings.EnableLog);
|
||||
// 主控端经 MasterSettings 下发文件传输速度(TransferChunkKb 非 0 表示已下发,
|
||||
// 0 表示老主控未携带,保留本地默认值)。写入 [transfer] 节,供各 InitFileUpload
|
||||
// 调用点经 GetTransferSpeed 读取。
|
||||
if (m_settings.TransferChunkKb) {
|
||||
m_cfg->SetInt("transfer", "ChunkKb", m_settings.TransferChunkKb);
|
||||
m_cfg->SetInt("transfer", "DurationMs", m_settings.TransferDurationMs);
|
||||
}
|
||||
}
|
||||
if (IsAuthKernel() &&
|
||||
(m_settings.FeedbackUrl[0] || m_settings.HelpUrl[0] || m_settings.RequestAuthUrl[0] || m_settings.GetPluginUrl[0])) {
|
||||
@@ -1481,7 +1488,41 @@ VOID CKernelManager::OnReceive(PBYTE szBuffer, ULONG ulLength)
|
||||
s_v2RecvInited = true;
|
||||
}
|
||||
// C2C/V2 文件接收(RecvFileChunkV2 内部会打印进度)
|
||||
int n = RecvFileChunkV2((char*)szBuffer, ulLength, m_conn,
|
||||
// 展开文件名中的环境变量(如 %USERPROFILE%),与 V1 的 COMMAND_FILE_SIZE 保持一致。
|
||||
// 快捷图标定位远程目录时,主控端下发的 targetDir 仍含未展开的 %USERPROFILE%
|
||||
// (server/2015Remote/FileManagerDlg.cpp OnRemoteDesktop/Downloads/Home),
|
||||
// 若不在接收端展开,RecvFileChunkV2 会按字面量建目录(如 CWD\%%USERPROFILE%%\...)。
|
||||
char* recvBuf = (char*)szBuffer;
|
||||
size_t recvLen = ulLength;
|
||||
std::vector<uint8_t> rebuilt;
|
||||
if (ulLength >= sizeof(FileChunkPacketV2) && szBuffer[0] == COMMAND_SEND_FILE_V2) {
|
||||
const FileChunkPacketV2* pkt = (const FileChunkPacketV2*)szBuffer;
|
||||
if (ulLength >= sizeof(FileChunkPacketV2) + pkt->nameLength + pkt->dataLength) {
|
||||
// 文件名未必以 '\0' 结尾,先构造出以 '\0' 结尾的副本
|
||||
std::string name((const char*)szBuffer + sizeof(FileChunkPacketV2), (size_t)pkt->nameLength);
|
||||
char expanded[MAX_PATH * 2] = { 0 };
|
||||
DWORD n = ExpandEnvironmentStringsA(name.c_str(), expanded, (DWORD)sizeof(expanded));
|
||||
if (n > 0 && n < (DWORD)sizeof(expanded)) {
|
||||
std::string newName(expanded, n - 1); // n 含末尾 '\0'
|
||||
if (newName.size() != pkt->nameLength) {
|
||||
// 展开后长度变化,重建包:[header][展开后的 name][data]
|
||||
size_t newLen = sizeof(FileChunkPacketV2) + newName.size() + pkt->dataLength;
|
||||
rebuilt.resize(newLen);
|
||||
memcpy(rebuilt.data(), szBuffer, sizeof(FileChunkPacketV2));
|
||||
FileChunkPacketV2* newPkt = (FileChunkPacketV2*)rebuilt.data();
|
||||
newPkt->nameLength = newName.size();
|
||||
memcpy(rebuilt.data() + sizeof(FileChunkPacketV2), newName.data(), newName.size());
|
||||
memcpy(rebuilt.data() + sizeof(FileChunkPacketV2) + newName.size(),
|
||||
(const char*)szBuffer + sizeof(FileChunkPacketV2) + pkt->nameLength,
|
||||
(size_t)pkt->dataLength);
|
||||
recvBuf = (char*)rebuilt.data();
|
||||
recvLen = newLen;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
int n = RecvFileChunkV2(recvBuf, recvLen, m_conn,
|
||||
nullptr, m_hash, m_hmac, m_MyClientID);
|
||||
if (n) {
|
||||
Mprintf("[C2C] RecvFileChunkV2 failed: %d\n", n);
|
||||
|
||||
+22
-6
@@ -378,13 +378,29 @@ LOGIN_INFOR GetLoginInfo(DWORD dwSpeed, CONNECT_ADDRESS& conn, const std::string
|
||||
std::string pubIP = cfg.GetStr("settings", "public_ip", "");
|
||||
auto ip_time = cfg.GetInt("settings", "ip_time");
|
||||
bool timeout = ip_time <= 0 || (time(0) - ip_time > 7 * 86400);
|
||||
// 升级后强制重新查询一次:旧版缓存的 location 是 GBK/CP1252,或已把 ö/ä/ß 等替换成 '?';
|
||||
// 新版 GetGeoLocation 改为 UTF-8 上报(见 common/location.h),需重新拉取才能修正。
|
||||
// 版本号即编译日期 __DATE__(见 common/commands.h 的 DLL_VERSION):编译期宏,内存 DLL
|
||||
// 也能取到,且每次编译都会变,天然标识“升级”,无需手动递增,也不依赖 exe 版本资源。
|
||||
bool cacheVerChanged = (cfg.GetStr("settings", "loc_cache_ver", "") != __DATE__);
|
||||
IPConverter cvt;
|
||||
if (loc.empty() || pubIP.empty() || timeout) {
|
||||
pubIP = cvt.getPublicIP();
|
||||
loc = cvt.GetGeoLocation(pubIP);
|
||||
cfg.SetStr("settings", "location", loc);
|
||||
cfg.SetStr("settings", "public_ip", pubIP);
|
||||
cfg.SetInt("settings", "ip_time", time(0));
|
||||
if (loc.empty() || pubIP.empty() || timeout || cacheVerChanged) {
|
||||
std::string newPubIP = pubIP;
|
||||
// 升级重查 location 时复用已缓存的公网 IP;仅当 IP 缺失或 7 天过期才重新探测,
|
||||
// 否则离线/无外网机器每次重连都会轮询 5 个公网 IP 源、空等数秒到十几秒。
|
||||
if (pubIP.empty() || timeout) {
|
||||
newPubIP = cvt.getPublicIP();
|
||||
}
|
||||
std::string newLoc = cvt.GetGeoLocation(newPubIP);
|
||||
// 仅查询成功才覆盖缓存并推进版本号;失败保留旧值,下次登录仍会重试。
|
||||
if (!newLoc.empty() && !newPubIP.empty()) {
|
||||
pubIP = newPubIP;
|
||||
loc = newLoc;
|
||||
cfg.SetStr("settings", "location", loc);
|
||||
cfg.SetStr("settings", "public_ip", pubIP);
|
||||
cfg.SetInt("settings", "ip_time", time(0));
|
||||
cfg.SetStr("settings", "loc_cache_ver", __DATE__);
|
||||
}
|
||||
}
|
||||
LoginInfor.AddReserved(loc.c_str());
|
||||
LoginInfor.AddReserved(pubIP.c_str());
|
||||
|
||||
@@ -21,7 +21,7 @@ CVideoManager::CVideoManager(IOCPClient* ClientObject, int n, void* user) : CMan
|
||||
m_pVideoCodec = NULL;
|
||||
m_fccHandler = 1129730893;
|
||||
|
||||
m_CapVideo.Open(0,0); // 开启
|
||||
m_bVideoOpened = m_CapVideo.Open(0,0) == S_OK; // 开启
|
||||
lpBuffer = NULL;
|
||||
|
||||
m_hWorkThread = __CreateThread(NULL, 0, WorkThread, this, 0, NULL);
|
||||
@@ -31,6 +31,11 @@ CVideoManager::CVideoManager(IOCPClient* ClientObject, int n, void* user) : CMan
|
||||
DWORD CVideoManager::WorkThread(LPVOID lParam)
|
||||
{
|
||||
CVideoManager *This = (CVideoManager *)lParam;
|
||||
if (!This->m_bVideoOpened) {
|
||||
This->Destroy();
|
||||
return 0x20261013;
|
||||
}
|
||||
|
||||
static ULONGLONG dwLastScreen = GetTickCount64();
|
||||
|
||||
if (This->Initialize()) { //转到Initialize
|
||||
|
||||
@@ -25,6 +25,7 @@ public:
|
||||
|
||||
BOOL m_bIsWorking;
|
||||
HANDLE m_hWorkThread;
|
||||
BOOL m_bVideoOpened;
|
||||
|
||||
void SendBitMapInfor();
|
||||
BOOL SendNextScreen();
|
||||
|
||||
+3
-1
@@ -1213,7 +1213,9 @@ typedef struct MasterSettings {
|
||||
char GetPluginUrl[80]; // Since 2026-04-08
|
||||
char Authorized; // Since 2026-05-15
|
||||
char IsTrail; // Since 2026-05-15
|
||||
char Reserved[106]; // Since 2025-11-27
|
||||
uint16_t TransferChunkKb; // Since 2026-10-07 文件传输速度 chunkKb(随登录配置下发)
|
||||
uint16_t TransferDurationMs; // Since 2026-10-07 文件传输速度 durationMs(0=不限速)
|
||||
char Reserved[102]; // Since 2025-11-27
|
||||
} MasterSettings;
|
||||
#pragma pack(pop)
|
||||
|
||||
|
||||
+4
-2
@@ -328,8 +328,10 @@ private:
|
||||
}
|
||||
}
|
||||
|
||||
std::string city = Utf8ToAnsi(json[cityField].asString());
|
||||
std::string country = Utf8ToAnsi(json[countryField].asString());
|
||||
// 地理位置 API 返回 UTF-8;不再转 CP_ACP——那会把 ö/ä/ß 等变音符替换成 '?'。
|
||||
// GetGeoLocation 现统一返回 UTF-8,消费方按需用 NormalizeLocationToUtf8 / SafeUtf8ToAnsi 转。
|
||||
std::string city = json[cityField].asString();
|
||||
std::string country = json[countryField].asString();
|
||||
|
||||
if (!city.empty() && !country.empty()) return city + ", " + country;
|
||||
if (!city.empty()) return city;
|
||||
|
||||
@@ -67,7 +67,7 @@ static int send_duration = 50; // 默认 50ms
|
||||
| 方向 | 发送方 | `InitFileUpload` 调用点 | 生效方式 |
|
||||
|---|---|---|---|
|
||||
| 上传(主控端 → 被控端) | 主控端 | `server/2015Remote/2015RemoteDlg.cpp:2223`(启动一次) | 读 `THIS_CFG`,重启主控端生效 |
|
||||
| 下载(被控端 → 主控端) | 被控端 | `client/FileManager.cpp:40`(CFileManager 构造,每会话一次) | 读客户端配置,下次打开文件管理会话生效 |
|
||||
| 下载(被控端 → 主控端) | 被控端 | `client/FileManager.cpp:40`(CFileManager 构造,每会话一次) | 读客户端配置(`MasterSettings` 登录下发写入),下次打开文件管理会话生效 |
|
||||
|
||||
### 4.1 上传方向(主控端本地,无协议改动)
|
||||
|
||||
@@ -79,17 +79,22 @@ int durMs = THIS_CFG.GetInt("transfer", "DurationMs", 50);
|
||||
InitFileUpload({}, {}, GetHMAC(), chunkKb, durMs, Logf, GetUpperHash());
|
||||
```
|
||||
|
||||
### 4.2 下载方向(需下发到被控端)
|
||||
### 4.2 下载方向(随 `MasterSettings` 登录下发到被控端)
|
||||
|
||||
新增命令 `CMD_FILE_SPEED`,照 `CMD_QUALITY_LEVEL(78)` 的“服务端下发 → 客户端持久化 → 本地生效”套路:
|
||||
下载速度**不再用独立命令下发**,而是折叠进 `MasterSettings` 结构体,随**客户端每次登录成功**第一时间下发(主连接建立即 `SendMasterSettings`),客户端持久化到 `[transfer]` 节后本地生效:
|
||||
|
||||
1. **`common/commands.h`**:新增 `CMD_FILE_SPEED = 252`(命令号单字节,252 < 255 安全)。
|
||||
2. **命令格式**:`[cmd:1][chunkKb:uint16 LE][durationMs:uint16 LE]`,共 5 字节。
|
||||
- 传原始 `chunkKb`/`durationMs` 而非档位索引,**避免客户端复制映射表**,映射表只存在于主控端对话框。
|
||||
3. **主控端下发**:在 `OnOnlineFileManager()`(`server/2015Remote/2015RemoteDlg.cpp:4737`,发 `COMMAND_LIST_DRIVE` 开文件管理会话)发 `COMMAND_LIST_DRIVE` **之前**,先经主连接把当前档位 `CMD_FILE_SPEED`(5 字节)发给被控端;`OnOnlineDesktopManager()`(`2015RemoteDlg.cpp:4723`,发 `COMMAND_SCREEN_SPY`)同理也发一次,覆盖“先开远程桌面再开文件管理”的引用计数时序。两处都在 `SendFileSpeedCommand()`(`:9310`)统一读取并下发。
|
||||
4. **被控端接收**:在客户端主连接分发 `client/KernelManager.cpp:814` 的 `switch`(`COMMAND_LIST_DRIVE` 所在,`case` 加在 `1280` 附近)加 `case CMD_FILE_SPEED`,把两数写入 `iniFile cfg(CLIENT_PATH)` 的 `[transfer]` 节。
|
||||
5. **被控端调用点读配置**:`client/FileManager.cpp:40`(CFileManager 构造)改为读 `cfg.GetInt("transfer","ChunkKb",64)` / `DurationMs`,替换硬编码 `64, 50`。CFileManager 每会话重建(构造 Init、析构 Uninit),故下次打开文件管理即生效。
|
||||
1. **`common/commands.h`**:在 `MasterSettings` 结构体 `Reserved` 之前新增两个字段(`sizeof` 仍为 1000,`Signature` 偏移不变,向下兼容老客户端):
|
||||
```c
|
||||
uint16_t TransferChunkKb; // 文件传输速度 chunkKb(随登录配置下发)
|
||||
uint16_t TransferDurationMs; // 文件传输速度 durationMs(0=不限速)
|
||||
```
|
||||
2. **主控端登录即下发**:`OnInitDialog()` 读 `THIS_CFG` 的 `[transfer]`(`GetTransferSpeed`,默认 64/50),填入 `m_settings.TransferChunkKb/TransferDurationMs`。登录链路本就把 `m_settings` 经 `SendMasterSettings` 下发给每个新连接客户端,**无需任何额外命令**。
|
||||
3. **主控端改速即时广播**:`OnFileTransferSpeed()` 保存后同步更新 `m_settings` 两个字段并 `SendMasterSettings(nullptr, m_settings)`,在线客户端即时收到新速度;新连接主机也自动获取。
|
||||
4. **被控端接收**:客户端 `CMD_MASTERSETTING` 处理器(`client/KernelManager.cpp:1116` 附近)中,`TransferChunkKb` 非 0(老主控未携带时为 0,保留本地默认值)则把两数写入 `iniFile(CLIENT_PATH)` 的 `[transfer]` 节。
|
||||
5. **被控端调用点读配置**:`client/FileManager.cpp:40`(CFileManager 构造)等 4 个 `InitFileUpload` 调用点均读 `cfg.GetInt("transfer", ...)`,替换硬编码 `64, 50`。CFileManager 每会话重建(构造 Init、析构 Uninit),故下次打开文件管理即生效。
|
||||
|
||||
> **为何不用独立命令**:v1.4.0 曾用独立 `CMD_FILE_SPEED(252)` 在 `OnOnlineFileManager()`/`OnOnlineDesktopManager()` 发 `COMMAND_LIST_DRIVE`/`COMMAND_SCREEN_SPY` **之前**追加下发。主控端 IOCP 对两次背靠背 `Send2Client` 会合并为两次整包 `WSASend`(`OutCompressedBuffer` 累积),导致客户端收到重复的 `[FILE_SPEED][SCREEN_SPY]` 命令流——这正是“点击远程桌面弹出两个对话框”的根因(commit 76decec)。改为随 `MasterSettings` 登录下发后,下载方向不再有任何背靠背发送。
|
||||
> **向后兼容**:`CMD_FILE_SPEED = 252` 命令号与客户端 `case CMD_FILE_SPEED` 处理器仍保留,供老主控(仍按旧机制下发)与新客户端互通;新主控不再发送该命令。
|
||||
> **关键**:库用进程级引用计数,速度只在**首次** `InitFileUpload` 时设定(`g_threadCount > 1` 时提前返回,不重设)。客户端共 4 个 `InitFileUpload` 调用点(`FileManager.cpp:40`、`ScreenManager.cpp:109`、`ScreenManager.cpp:873`、`KernelManager.cpp:1399`),谁先初始化谁锁定速度。故**这 4 处必须全部改为读同一配置**,否则下载速度会被先初始化的管理器(如远程桌面会话)锁成旧值。
|
||||
|
||||
---
|
||||
@@ -97,7 +102,7 @@ InitFileUpload({}, {}, GetHMAC(), chunkKb, durMs, Logf, GetUpperHash());
|
||||
## 5. 配置存储
|
||||
|
||||
- 主控端:`THIS_CFG`(ini)`[transfer]` 节 `ChunkKb` / `DurationMs`(int)。
|
||||
- 被控端:`iniFile(CLIENT_PATH)` 的 `[transfer]` 节 `ChunkKb` / `DurationMs`(int),由 `CMD_FILE_SPEED` 写入。
|
||||
- 被控端:`iniFile(CLIENT_PATH)` 的 `[transfer]` 节 `ChunkKb` / `DurationMs`(int),由 `MasterSettings` 登录下发写入。
|
||||
|
||||
存 `ChunkKb`/`DurationMs` 两个 int 而非档位索引:自解释、直接对应 `InitFileUpload` 入参、不易错位。
|
||||
|
||||
@@ -111,9 +116,9 @@ InitFileUpload({}, {}, GetHMAC(), chunkKb, durMs, Logf, GetUpperHash());
|
||||
| `server/2015Remote/2015Remote.rc` | 「高级」菜单加一项;新增对话框资源(滑块+预估标签+按钮)。**文件为 UTF-16LE 编码,须按 Unicode 读写** |
|
||||
| `server/2015Remote/CFileSpeedDlg.h` | 新建:档位表 + `OnHScroll` 实时预估 + 恢复默认(照 `CPreviewLevelsDlg.h` 内联实现) |
|
||||
| `server/2015Remote/2015RemoteDlg.h` | 加 `OnFileTransferSpeed()` 声明 |
|
||||
| `server/2015Remote/2015RemoteDlg.cpp` | 消息映射 `ON_COMMAND(ID_TOOL_FILE_SPEED, ...)`;`2223` 读配置(上传);`OnFileTransferSpeed()` 弹出对话框并写回 THIS_CFG;`OnOnlineFileManager()`/`OnOnlineDesktopManager()` 发 `CMD_FILE_SPEED` |
|
||||
| `common/commands.h` | 加 `CMD_FILE_SPEED = 252` |
|
||||
| `client/KernelManager.cpp` | `case CMD_FILE_SPEED` 写客户端配置(主连接 switch,`814`) |
|
||||
| `server/2015Remote/2015RemoteDlg.cpp` | 消息映射 `ON_COMMAND(ID_TOOL_FILE_SPEED, ...)`;`2223` 读配置(上传);`OnInitDialog()` 把 `[transfer]` 速度填入 `m_settings`;`OnFileTransferSpeed()` 保存后更新 `m_settings` 并经 `SendMasterSettings(nullptr, ...)` 广播 |
|
||||
| `common/commands.h` | `MasterSettings` 新增 `TransferChunkKb`/`TransferDurationMs`(`sizeof` 不变,`CMD_FILE_SPEED=252` 保留向后兼容) |
|
||||
| `client/KernelManager.cpp` | `CMD_MASTERSETTING` 处理器写 `[transfer]`(`TransferChunkKb` 非 0 才写) |
|
||||
| `client/FileManager.cpp`、`client/ScreenManager.cpp`、`client/KernelManager.cpp` | 4 个 `InitFileUpload` 调用点读配置替换硬编码 `64, 50`(引用计数要求全部一致) |
|
||||
| `server/2015Remote/lang/en_US.ini`、`zh_TW.ini`、`de_DE.ini` | 追加「文件传输速度」相关 13 条中文原文→译文(GBK 编码,`RCDATA "lang\*.ini"` 由 rc.exe 嵌入) |
|
||||
|
||||
@@ -122,8 +127,8 @@ InitFileUpload({}, {}, GetHMAC(), chunkKb, durMs, Logf, GetUpperHash());
|
||||
## 7. 生效时机与提示文案
|
||||
|
||||
- 上传速度:重启主控端生效。
|
||||
- 下载速度:下次打开该主机的文件管理会话生效。
|
||||
- 对话框保存后弹提示:`文件传输速度已保存。上传速度重启主控端后生效;下载速度下次打开文件管理或远程桌面后生效。`
|
||||
- 下载速度:已推送到在线客户端即时生效;新连接的主机自动获取。
|
||||
- 对话框保存后弹提示:`文件传输速度已保存。上传速度重启主控端后生效;下载速度已推送到在线客户端,新连接的主机也会自动获取。`
|
||||
|
||||
---
|
||||
|
||||
@@ -131,16 +136,16 @@ InitFileUpload({}, {}, GetHMAC(), chunkKb, durMs, Logf, GetUpperHash());
|
||||
|
||||
1. **`Sleep` 精度**:Windows 计时器粒度约 15.6ms,`durationMs` 过小则失真(见 §3)。
|
||||
2. **实际速度 < 预估**:预估为纯 `chunk/delay` 理论值,未计入 `f()` 网络发送耗时;对话框文案用“约/大概”表述。
|
||||
3. **命令号冲突**:`CMD_FILE_SPEED=252` 需确认 `commands.h` 无重号;252 < 255(单字节 `uint8_t cmd` 上限)。
|
||||
3. **结构体字段布局**:`TransferChunkKb`/`TransferDurationMs` 加在 `Reserved` 之前(`sizeof(MasterSettings)` 仍为 1000,`Signature` 偏移不变),保证与 Go 侧/老客户端二进制兼容;`TransferChunkKb == 0` 作为“未下发”哨兵(老主控未填充 → 客户端保留本地默认值)。
|
||||
4. **`.rc` 编码**:`2015Remote.rc` 为 UTF-16LE(带 BOM),编辑必须按 Unicode 处理,避免破坏资源脚本。
|
||||
5. **跨平台**:Linux/macOS 客户端下载路径暂不改(`CMD_FILE_SPEED` 会被老/其他平台客户端静默忽略,向后兼容)。
|
||||
5. **跨平台**:Linux/macOS 客户端下载路径暂不改;`MasterSettings` 新增字段位于原 `Reserved` 区(老/其他平台客户端不识别即忽略),向后兼容。
|
||||
6. **多语言 key 精确匹配**:语言文件为 GBK 编码、key 不 trim(`CIniParser` 保留原始空格);译文 key 必须与源码逐字一致——源码为 UTF-8、经 `/execution-charset:.936` 编译为 GBK,`·`(U+00B7)、全角括号()等需一致,否则查不到翻译。
|
||||
|
||||
---
|
||||
|
||||
## 9. 实施顺序
|
||||
|
||||
1. `common/commands.h` 加命令号 → `resource.h` 加 ID → `.rc` 加菜单项与对话框资源。
|
||||
1. `common/commands.h` 扩 `MasterSettings` → `resource.h` 加 ID → `.rc` 加菜单项与对话框资源。
|
||||
2. 新建 `CFileSpeedDlg.h` → `2015RemoteDlg` 接线(上传方向,可先验证)。
|
||||
3. 主控端下发 `CMD_FILE_SPEED` → 客户端接收持久化 → 客户端调用点读配置(下载方向)。
|
||||
3. 主控端经 `MasterSettings` 下发速度 → 客户端 `CMD_MASTERSETTING` 持久化 → 客户端调用点读配置(下载方向)。
|
||||
4. 编译验证(MSBuild 须传 `-p:SolutionDir`,见项目记忆)。
|
||||
Binary file not shown.
@@ -239,6 +239,69 @@ UINT GetClientEncoding(context* ctx) {
|
||||
return 936;
|
||||
}
|
||||
|
||||
// 客户端「进程名/路径」编码:Windows 用 A 接口生成,恒为客户端 ANSI(简中=GBK/936),
|
||||
// 不随 CLIENT_CAP_UTF8 转 UTF-8;LNX/MAC 的 /proc 天然 UTF-8。
|
||||
// 不能用 GetClientEncoding——它按能力位返回 CP_UTF8,会误解 Windows 客户端的 GBK 进程名。
|
||||
// 与 McpServer::BuildListProcesses 的判定保持一致。
|
||||
UINT GetProcessListEncoding(context* ctx) {
|
||||
if (!ctx) return 936;
|
||||
CString clientType = ctx->GetAdditionalData(RES_CLIENT_TYPE);
|
||||
if (clientType == "LNX" || clientType == "MAC") return CP_UTF8;
|
||||
// 子连接情形:自身 RES_CLIENT_TYPE 为空 -> 找主连接取 clientType
|
||||
if (g_2015RemoteDlg) {
|
||||
context* mainCtx = g_2015RemoteDlg->FindHostByClientID(ctx->GetClientID());
|
||||
if (!mainCtx) {
|
||||
mainCtx = g_2015RemoteDlg->FindHostByIP(ctx->GetPeerName());
|
||||
}
|
||||
if (mainCtx) {
|
||||
CString mainType = mainCtx->GetAdditionalData(RES_CLIENT_TYPE);
|
||||
if (mainType == "LNX" || mainType == "MAC") return CP_UTF8;
|
||||
}
|
||||
}
|
||||
return 936;
|
||||
}
|
||||
|
||||
// 判断字符串是否为合法 UTF-8(MB_ERR_INVALID_CHARS 严格校验,避免把 GBK 误判成 UTF-8)。
|
||||
static bool IsValidUtf8Str(const char* s)
|
||||
{
|
||||
if (!s || !s[0]) return false;
|
||||
return MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, s, -1, NULL, 0) > 0;
|
||||
}
|
||||
|
||||
// 把按 cp 编码的字符串转成 UTF-8(cp == CP_UTF8 时原样返回)。
|
||||
static CString LocationToUtf8(const CString& str, UINT cp)
|
||||
{
|
||||
if (str.IsEmpty() || cp == CP_UTF8) return str;
|
||||
int wlen = MultiByteToWideChar(cp, 0, str, -1, NULL, 0);
|
||||
if (wlen <= 1) return str;
|
||||
std::wstring w(wlen - 1, L'\0');
|
||||
MultiByteToWideChar(cp, 0, str, -1, &w[0], wlen);
|
||||
int u8len = WideCharToMultiByte(CP_UTF8, 0, w.c_str(), -1, NULL, 0, NULL, NULL);
|
||||
if (u8len <= 1) return str;
|
||||
std::string u8(u8len - 1, '\0');
|
||||
WideCharToMultiByte(CP_UTF8, 0, w.c_str(), -1, &u8[0], u8len, NULL, NULL);
|
||||
return CString(u8.c_str());
|
||||
}
|
||||
|
||||
// 兜底规整地理位置为 UTF-8:合法 UTF-8(LNX/MAC 自报、迁移后的缓存)原样保留,
|
||||
// 否则视为 GBK/936(Windows 自报、本地库 QQWry/Ip2Region、老缓存)转 UTF-8。
|
||||
static CString NormalizeLocationToUtf8(const CString& str)
|
||||
{
|
||||
if (str.IsEmpty() || IsValidUtf8Str(str.GetString())) return str;
|
||||
return LocationToUtf8(str, 936);
|
||||
}
|
||||
|
||||
// 把按 cp 编码的字符串转成宽字符串(cp == CP_UTF8 用于地理位置,CP_ACP 用于 GBK/ASCII 部分)。
|
||||
static CStringW AnsiToWide(const char* s, UINT cp = CP_ACP)
|
||||
{
|
||||
if (!s || !s[0]) return L"";
|
||||
int wlen = MultiByteToWideChar(cp, 0, s, -1, NULL, 0);
|
||||
if (wlen <= 1) return L"";
|
||||
std::wstring w(wlen - 1, L'\0');
|
||||
MultiByteToWideChar(cp, 0, s, -1, &w[0], wlen);
|
||||
return CStringW(w.c_str());
|
||||
}
|
||||
|
||||
// 授权日志频率控制:首次必须记录,状态变化必须记录,相同状态每小时记录一次
|
||||
static bool ShouldLogAuth(const std::string& sn, int success) {
|
||||
struct AuthLogState {
|
||||
@@ -1427,7 +1490,17 @@ VOID CMy2015RemoteDlg::CreateToolBar()
|
||||
m_ToolBar.SetButtonText(11, _TR("生成服务端"));
|
||||
m_ToolBar.SetButtonText(12, _TR("搜索"));
|
||||
m_ToolBar.SetButtonText(13, _TR("帮助"));
|
||||
m_ToolBar.SetButtonText(14, _T("Lang/语言"));
|
||||
// 按钮 14 "Lang/语言" 含 GBK 中文:comctl32 的 ANSI 消息按系统 ACP 解码,非中文系统会乱码,
|
||||
// 故改用 Unicode 消息 + 显式 GBK(936)→Unicode 转换设置(与 TranslateListHeader 同法)。
|
||||
{
|
||||
WCHAR wLabel[64] = { 0 };
|
||||
MultiByteToWideChar(936, 0, _T("Lang/语言"), -1, wLabel, 64);
|
||||
TBBUTTONINFOW tbi = { 0 };
|
||||
tbi.cbSize = sizeof(tbi);
|
||||
tbi.dwMask = TBIF_TEXT | TBIF_BYINDEX; // wParam 为按钮下标(与 SetButtonText(14,...) 同义)
|
||||
tbi.pszText = wLabel;
|
||||
::SendMessageW(m_ToolBar.GetSafeHwnd(), TB_SETBUTTONINFOW, 14, (LPARAM)&tbi);
|
||||
}
|
||||
m_ToolBar.SetButtonText(15, _T("MCP"));
|
||||
m_ToolBar.SetButtonText(16, _TR("连接管理"));
|
||||
m_ToolBar.SetButtonText(17, _TR("远程交谈"));
|
||||
@@ -1606,7 +1679,7 @@ VOID CMy2015RemoteDlg::AddList(CString strIP, CString strAddr, CString strPCName
|
||||
v[RES_CLIENT_ID] = std::to_string(id);
|
||||
}
|
||||
|
||||
bool modify = false, needConvert = true;
|
||||
bool modify = false;
|
||||
|
||||
// 新客户端 V2 ID 算法首次上线时,把老 ID 下的元数据迁过来。
|
||||
if (TryMigrateClientMetadata(id, strPCName, path)) {
|
||||
@@ -1615,20 +1688,21 @@ VOID CMy2015RemoteDlg::AddList(CString strIP, CString strAddr, CString strPCName
|
||||
// 优先级:① 客户端刚发来的有效地理位置(非空非"?")
|
||||
// ② 服务端缓存(排除历史遗留的"?"无效值)
|
||||
// ③ 服务端按连接 IP 自行查询
|
||||
// 存储统一为 UTF-8:列表用宽字符显示、HostJson/邮件导出直接透传,
|
||||
// 不再 SafeUtf8ToAnsi 转 GBK(GBK 无法编码 ö/ä/ß 等变音符,会替换成 '?')。
|
||||
CString clientLoc = v[RES_CLIENT_LOC].c_str();
|
||||
CString cachedLoc = m_ClientMap->GetClientMapData(id, MAP_LOCATION);
|
||||
CString loc;
|
||||
if (!clientLoc.IsEmpty() && clientLoc != "?") {
|
||||
loc = clientLoc;
|
||||
// 客户端自报:GetGeoLocation 已改为返回 UTF-8(见 common/location.h),
|
||||
// 新客户端各类型都上报 UTF-8;老版本/旧缓存的客户端可能仍是 GBK/936。统一按 UTF-8 规整。
|
||||
loc = NormalizeLocationToUtf8(clientLoc);
|
||||
} else if (!cachedLoc.IsEmpty() && cachedLoc != "?") {
|
||||
loc = cachedLoc;
|
||||
loc = NormalizeLocationToUtf8(cachedLoc);
|
||||
} else {
|
||||
loc = m_IPConverter->GetGeoLocation(data[ONLINELIST_IP].GetString()).c_str();
|
||||
needConvert = !m_HasLocDB;
|
||||
std::string geo = m_IPConverter->GetGeoLocation(data[ONLINELIST_IP].GetString());
|
||||
loc = NormalizeLocationToUtf8(CString(geo.c_str()));
|
||||
}
|
||||
// TODO: Remove SafeUtf8ToAnsi after migrating to UTF-8
|
||||
if (needConvert)
|
||||
loc = SafeUtf8ToAnsi(loc.GetString()).c_str();
|
||||
bool flag = strIP == "127.0.0.1" && !v[RES_CLIENT_PUBIP].empty();
|
||||
data[ONLINELIST_IP] = flag ? v[RES_CLIENT_PUBIP].c_str() : strIP;
|
||||
data[ONLINELIST_LOCATION] = loc;
|
||||
@@ -1702,7 +1776,13 @@ VOID CMy2015RemoteDlg::AddList(CString strIP, CString strAddr, CString strPCName
|
||||
}
|
||||
|
||||
std::string tip = flag ? " (" + v[RES_CLIENT_PUBIP] + ") " : "";
|
||||
ShowMessage(_TR("操作成功"), strIP + tip.c_str() + " " + _L(_T("主机上线")) + "[" + loc + "][" + groupName.c_str() + "]");
|
||||
// 地理位置 loc 是 UTF-8,其余是 GBK/ASCII;拼成宽字符串走 ShowMessageW,
|
||||
// 否则 UTF-8 会在 ANSI 日志列表里被按 GBK 显示成乱码(Böblingen → B枚blingen)。
|
||||
CString type = _TR("操作成功");
|
||||
CString prefix = strIP + tip.c_str() + " " + _L(_T("主机上线")) + "[";
|
||||
CString suffix = "][" + CString(groupName.c_str()) + "]";
|
||||
ShowMessageW(AnsiToWide(type.GetString()),
|
||||
AnsiToWide(prefix.GetString(), CP_ACP) + AnsiToWide(loc.GetString(), CP_UTF8) + AnsiToWide(suffix.GetString(), CP_ACP));
|
||||
|
||||
CharMsg *title = new CharMsg(_TR("主机上线"));
|
||||
CharMsg *text = new CharMsg(strIP + CString(tip.c_str()) + _T(" ") + _L(_T("主机上线")) + _T(" [") + loc + _T("]"));
|
||||
@@ -1849,6 +1929,61 @@ VOID CMy2015RemoteDlg::ShowMessage(CString strType, CString strMsg)
|
||||
m_StatusBar.SetPaneText(0,strStatusMsg); //在状态条上显示文字
|
||||
}
|
||||
|
||||
// 宽字符版 ShowMessage:消息里嵌入了 UTF-8 地理位置时,SetItemText 会把 UTF-8 按 GBK
|
||||
// 显示成乱码(Böblingen → B枚blingen)。这里把消息列经 LVM_SETITEMTEXTW 直接写宽字符串。
|
||||
// 类型/时间列与线程安全镜像仍保持 GBK,与其它条目及 McpServer get_audit_log 的 ToUtf8(msg,936) 一致。
|
||||
VOID CMy2015RemoteDlg::ShowMessageW(const CStringW& strTypeW, const CStringW& strMsgW)
|
||||
{
|
||||
AUTO_TICK(200, "");
|
||||
CTime Timer = CTime::GetCurrentTime();
|
||||
CString strTime = Timer.Format("%Y-%m-%d %H:%M:%S");
|
||||
|
||||
int count = m_CList_Message.GetItemCount();
|
||||
if (count >= MAX_MESSAGE_COUNT) {
|
||||
m_CList_Message.DeleteItem(count - 1);
|
||||
}
|
||||
|
||||
// 宽 → GBK 仅用于 InsertItem(类型列)、颜色判定与线程安全镜像;ö 等变音符会退化为 '?',
|
||||
// 但 UI 消息列用的是下面的宽字符写,不受影响。
|
||||
CString strType(strTypeW);
|
||||
CString strMsg(strMsgW);
|
||||
|
||||
m_CList_Message.InsertItem(0, strType);
|
||||
m_CList_Message.SetItemText(0, 1, strTime);
|
||||
|
||||
LVITEMW lv = {};
|
||||
lv.mask = LVIF_TEXT;
|
||||
lv.iItem = 0;
|
||||
lv.iSubItem = 2;
|
||||
lv.pszText = const_cast<LPWSTR>(strMsgW.GetString());
|
||||
::SendMessageW(m_CList_Message.GetSafeHwnd(), LVM_SETITEMTEXTW, 0, (LPARAM)&lv);
|
||||
|
||||
m_CList_Message.SetItemData(0, (DWORD_PTR)MsgColorLevel(strType, strMsg));
|
||||
|
||||
// 同步线程安全镜像(GBK,与其它条目一致)
|
||||
{
|
||||
EnterCriticalSection(&m_cs);
|
||||
LogMessage entry;
|
||||
entry.type = strType.GetString();
|
||||
entry.time = strTime.GetString();
|
||||
entry.msg = strMsg.GetString();
|
||||
m_MessageLog.insert(m_MessageLog.begin(), entry);
|
||||
if ((int)m_MessageLog.size() > MAX_MESSAGE_COUNT)
|
||||
m_MessageLog.pop_back();
|
||||
LeaveCriticalSection(&m_cs);
|
||||
}
|
||||
|
||||
CString strStatusMsg;
|
||||
EnterCriticalSection(&m_cs);
|
||||
int m_iCount = m_CList_Online.GetItemCount();
|
||||
int totalCount = m_HostList.size();
|
||||
LeaveCriticalSection(&m_cs);
|
||||
strStatusMsg.FormatL("有%d个主机在线", m_iCount);
|
||||
strStatusMsg += CString("[Total: ") + std::to_string(totalCount).c_str() + "]";
|
||||
if (m_StatusBar.GetSafeHwnd())
|
||||
m_StatusBar.SetPaneText(0, strStatusMsg);
|
||||
}
|
||||
|
||||
// 试用版 IP 段触发:OnAccept 发现入站连接对端是公网 IP(已透过 Proxy Protocol v2 解出真实 IP)。
|
||||
// 与 OnTrialRttAbuse 共用 IOCPServer::s_TrialAbuseWarned latch,本函数每进程最多调一次。
|
||||
LRESULT CMy2015RemoteDlg::OnTrialWanIpAbuse(WPARAM wParam, LPARAM lParam)
|
||||
@@ -2425,6 +2560,12 @@ BOOL CMy2015RemoteDlg::OnInitDialog()
|
||||
strcpy(m_settings.GetPluginUrl, THIS_CFG.GetStr("settings", "GetPluginUrl", BRAND_URL_GET_PLUGIN).c_str());
|
||||
m_settings.Authorized = !pwd.empty() && !THIS_CFG.GetStr("settings", "PwdHmac").empty();
|
||||
m_settings.IsTrail = IsTrail(pwd);
|
||||
{
|
||||
int chunkKb, durMs;
|
||||
GetTransferSpeed(THIS_CFG, chunkKb, durMs);
|
||||
m_settings.TransferChunkKb = (uint16_t)chunkKb;
|
||||
m_settings.TransferDurationMs = (uint16_t)durMs;
|
||||
}
|
||||
m_bEnableFileV2 = THIS_CFG.GetInt("settings", "EnableFileV2", 0) != 0;
|
||||
|
||||
// 缩略图配置:从 [thumbnail] 节读取(独立于 MasterSettings,纯主控端 UI 偏好)。
|
||||
@@ -4221,6 +4362,10 @@ void CMy2015RemoteDlg::OnGetDispInfo(NMHDR* pNMHDR, LRESULT* pResult)
|
||||
text = ctx->GetClientData(slot);
|
||||
if (text.IsEmpty()) text = "?";
|
||||
}
|
||||
// 地理位置列已统一按 UTF-8 存储(见 AddList);A 版回调只能填 CP_ACP 文本,转回 CP_ACP。
|
||||
if (slot == ONLINELIST_LOCATION) {
|
||||
text = Utf8ToAnsi(std::string(text.GetString())).c_str();
|
||||
}
|
||||
lstrcpyn(pItem->pszText, text, pItem->cchTextMax);
|
||||
}
|
||||
}
|
||||
@@ -4287,7 +4432,8 @@ void CMy2015RemoteDlg::OnGetDispInfoW(NMHDR* pNMHDR, LRESULT* pResult)
|
||||
}
|
||||
if (wtext.empty()) wtext = L"?";
|
||||
} else {
|
||||
// 其它列:CString 仍是 ANSI(CP_ACP),按 CP_ACP 转宽供控件显示
|
||||
// 其它列:CString 仍是 ANSI(CP_ACP),按 CP_ACP 转宽供控件显示;
|
||||
// 地理位置列已统一按 UTF-8 存储(见 AddList),按 UTF-8 转宽,避免把 UTF-8 字节当 GBK。
|
||||
CString text;
|
||||
if (nCol == ONLINELIST_COMPUTER_NAME) {
|
||||
CString note = m_ClientMap->GetClientMapData(ctx->GetClientID(), MAP_NOTE);
|
||||
@@ -4296,10 +4442,11 @@ void CMy2015RemoteDlg::OnGetDispInfoW(NMHDR* pNMHDR, LRESULT* pResult)
|
||||
text = ctx->GetClientData(nCol);
|
||||
if (text.IsEmpty()) text = "?";
|
||||
}
|
||||
int wlen = MultiByteToWideChar(CP_ACP, 0, text, -1, NULL, 0);
|
||||
UINT cp = (nCol == ONLINELIST_LOCATION) ? CP_UTF8 : CP_ACP;
|
||||
int wlen = MultiByteToWideChar(cp, 0, text, -1, NULL, 0);
|
||||
if (wlen > 0) {
|
||||
wtext.resize(wlen - 1);
|
||||
MultiByteToWideChar(CP_ACP, 0, text, -1, &wtext[0], wlen);
|
||||
MultiByteToWideChar(cp, 0, text, -1, &wtext[0], wlen);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4512,6 +4659,10 @@ void CMy2015RemoteDlg::OnOnlineMessage()
|
||||
|
||||
void CMy2015RemoteDlg::OnChat()
|
||||
{
|
||||
// 二次确认:避免误点工具栏“远程交谈”就立刻向被控端发起双向聊天
|
||||
if (IDYES != MessageBoxL(_T("确定开始远程交谈吗?"), _T("提示"), MB_ICONQUESTION | MB_YESNO))
|
||||
return;
|
||||
|
||||
BYTE bToken = COMMAND_CHAT; //向被控端发送开始远程交谈(双向聊天)
|
||||
SendSelectedCommand(&bToken, sizeof(BYTE));
|
||||
}
|
||||
@@ -4770,9 +4921,6 @@ VOID CMy2015RemoteDlg::OnOnlineDesktopManager()
|
||||
{
|
||||
if (!ShouldRemoteControl())
|
||||
return;
|
||||
// 先下发当前文件传输速度档位,再开远程桌面:避免被控端 ScreenManager 先初始化
|
||||
// 文件上传模块(进程级引用计数)把速度锁成旧值。
|
||||
SendFileSpeedCommand();
|
||||
int n = THIS_CFG.GetInt("settings", "DXGI");
|
||||
BOOL all = THIS_CFG.GetInt("settings", "MultiScreen", TRUE);
|
||||
CString algo = THIS_CFG.GetStr("settings", "ScreenCompress", ALGORITHM_NULL).c_str();
|
||||
@@ -4782,9 +4930,6 @@ VOID CMy2015RemoteDlg::OnOnlineDesktopManager()
|
||||
|
||||
VOID CMy2015RemoteDlg::OnOnlineFileManager()
|
||||
{
|
||||
// 先下发文件传输速度档位,再发 COMMAND_LIST_DRIVE 开文件管理:保证被控端
|
||||
// CFileManager 构造时读到的 [transfer] 配置已是最新值。
|
||||
SendFileSpeedCommand();
|
||||
BYTE bToken = COMMAND_LIST_DRIVE;
|
||||
SendSelectedCommand(&bToken, sizeof(BYTE));
|
||||
}
|
||||
@@ -5477,7 +5622,7 @@ int CMy2015RemoteDlg::AuthorizeClient(context* ctx, const std::string& sn, const
|
||||
if (outExpired) *outExpired = true; // 签名有效但已过期
|
||||
if (ctx != nullptr) {
|
||||
std::string ip = ctx->GetClientData(ONLINELIST_IP);
|
||||
std::string location = m_IPConverter ? m_IPConverter->GetGeoLocation(ip) : "";
|
||||
std::string location = m_IPConverter ? SafeUtf8ToAnsi(m_IPConverter->GetGeoLocation(ip)) : "";
|
||||
std::string machineName = ctx->GetClientData(ONLINELIST_COMPUTER_NAME);
|
||||
UpdateLicenseActivity(sn, passcode, hmacStr, ip, location, machineName);
|
||||
} else {
|
||||
@@ -5495,7 +5640,7 @@ int CMy2015RemoteDlg::AuthorizeClient(context* ctx, const std::string& sn, const
|
||||
// 授权成功时更新 license 活跃信息
|
||||
if (ctx != nullptr) {
|
||||
std::string ip = ctx->GetClientData(ONLINELIST_IP);
|
||||
std::string location = m_IPConverter ? m_IPConverter->GetGeoLocation(ip) : "";
|
||||
std::string location = m_IPConverter ? SafeUtf8ToAnsi(m_IPConverter->GetGeoLocation(ip)) : "";
|
||||
std::string machineName = ctx->GetClientData(ONLINELIST_COMPUTER_NAME);
|
||||
UpdateLicenseActivity(sn, passcode, hmacStr, ip, location, machineName);
|
||||
} else {
|
||||
@@ -5544,7 +5689,7 @@ int CMy2015RemoteDlg::AuthorizeClientV2(context* ctx, const std::string& sn, con
|
||||
if (outExpired) *outExpired = true; // 签名有效但已过期
|
||||
if (ctx != nullptr) {
|
||||
std::string ip = ctx->GetClientData(ONLINELIST_IP);
|
||||
std::string location = m_IPConverter ? m_IPConverter->GetGeoLocation(ip) : "";
|
||||
std::string location = m_IPConverter ? SafeUtf8ToAnsi(m_IPConverter->GetGeoLocation(ip)) : "";
|
||||
std::string machineName = ctx->GetClientData(ONLINELIST_COMPUTER_NAME);
|
||||
UpdateLicenseActivity(sn, passcode, hmacV2, ip, location, machineName);
|
||||
} else {
|
||||
@@ -5562,7 +5707,7 @@ int CMy2015RemoteDlg::AuthorizeClientV2(context* ctx, const std::string& sn, con
|
||||
// 授权成功时更新 license 活跃信息
|
||||
if (ctx != nullptr) {
|
||||
std::string ip = ctx->GetClientData(ONLINELIST_IP);
|
||||
std::string location = m_IPConverter ? m_IPConverter->GetGeoLocation(ip) : "";
|
||||
std::string location = m_IPConverter ? SafeUtf8ToAnsi(m_IPConverter->GetGeoLocation(ip)) : "";
|
||||
std::string machineName = ctx->GetClientData(ONLINELIST_COMPUTER_NAME);
|
||||
UpdateLicenseActivity(sn, passcode, hmacV2, ip, location, machineName);
|
||||
} else {
|
||||
@@ -8747,22 +8892,21 @@ void CMy2015RemoteDlg::OnListClick(NMHDR* pNMHDR, LRESULT* pResult)
|
||||
CString startTime = ctx->GetClientData(ONLINELIST_STARTTIME);
|
||||
ctx->GetAdditionalData(res);
|
||||
// 客户端 RES_* 字符串编码取决于客户端能力位:UTF-8 客户端(Linux/macOS/新 Win)
|
||||
// 发的是 UTF-8 字节,老客户端是 CP_ACP。这里统一规整到 CP_ACP,让下游 FormatL
|
||||
// 与既有 ANSI 字符串拼接以及最终 CP_ACP→wide 的浮窗渲染都能正确识别。
|
||||
// 若服务端运行系统的 ANSI 代码页不能容纳客户端字符(如德语服务端遇到中文路径),
|
||||
// 不可表示的字符会变 '?' —— 与项目其它路径的既有限制一致,不在本次修复范围。
|
||||
// 发的是 UTF-8 字节,老客户端是 GBK。这里统一规整到 GBK(936) —— 工程内部 ANSI
|
||||
// 约定即 GBK(/execution-charset:.936 + setlocale(".936")),不随系统 ACP 变。
|
||||
// 下游 FormatL 拼接、以及最终 936→wide 的浮窗渲染都能据此正确识别中文。
|
||||
UINT cp = GetClientEncoding(ctx);
|
||||
if (cp != CP_ACP) {
|
||||
if (cp != 936) {
|
||||
for (int i = 0; i < RES_MAX; i++) {
|
||||
if (res[i].IsEmpty()) continue;
|
||||
int wlen = MultiByteToWideChar(cp, 0, res[i].GetString(), -1, NULL, 0);
|
||||
if (wlen <= 1) continue;
|
||||
std::wstring wbuf(wlen - 1, L'\0');
|
||||
MultiByteToWideChar(cp, 0, res[i].GetString(), -1, &wbuf[0], wlen);
|
||||
int alen = WideCharToMultiByte(CP_ACP, 0, wbuf.c_str(), -1, NULL, 0, NULL, NULL);
|
||||
int alen = WideCharToMultiByte(936, 0, wbuf.c_str(), -1, NULL, 0, NULL, NULL);
|
||||
if (alen <= 1) continue;
|
||||
CString out;
|
||||
WideCharToMultiByte(CP_ACP, 0, wbuf.c_str(), -1,
|
||||
WideCharToMultiByte(936, 0, wbuf.c_str(), -1,
|
||||
out.GetBufferSetLength(alen - 1), alen, NULL, NULL);
|
||||
out.ReleaseBuffer(alen - 1);
|
||||
res[i] = out;
|
||||
@@ -8817,13 +8961,14 @@ void CMy2015RemoteDlg::OnListClick(NMHDR* pNMHDR, LRESULT* pResult)
|
||||
}
|
||||
|
||||
auto* tip = new CPreviewTipWnd();
|
||||
// 项目是 MBCS(CP_ACP),浮窗内部用宽字符渲染(避免跨语言系统乱码)。
|
||||
// 这里把 strText (CStringA) 按 CP_ACP 转 wide。
|
||||
// 浮窗内部用宽字符渲染。strText 里的中文(标签来自 GBK 源字面量,路径/用户名
|
||||
// 已在上方规整为 GBK)统一是 GBK(936),按 936 转 wide 而非系统 CP_ACP ——
|
||||
// 否则非中文 ACP(德语/英语等)服务端会把中文解成乱码。
|
||||
CStringW textW;
|
||||
{
|
||||
int wlen = MultiByteToWideChar(CP_ACP, 0, strText.GetString(), -1, NULL, 0);
|
||||
int wlen = MultiByteToWideChar(936, 0, strText.GetString(), -1, NULL, 0);
|
||||
if (wlen > 1) {
|
||||
MultiByteToWideChar(CP_ACP, 0, strText.GetString(), -1, textW.GetBufferSetLength(wlen - 1), wlen);
|
||||
MultiByteToWideChar(936, 0, strText.GetString(), -1, textW.GetBufferSetLength(wlen - 1), wlen);
|
||||
textW.ReleaseBuffer(wlen - 1);
|
||||
}
|
||||
}
|
||||
@@ -9363,26 +9508,14 @@ void CMy2015RemoteDlg::OnFileTransferSpeed()
|
||||
if (dlg.DoModal() == IDOK) {
|
||||
THIS_CFG.SetInt("transfer", "ChunkKb", dlg.GetChunkKb());
|
||||
THIS_CFG.SetInt("transfer", "DurationMs", dlg.GetDurMs());
|
||||
MessageBoxL("文件传输速度已保存。上传速度重启主控端后生效;下载速度下次打开文件管理或远程桌面后生效。",
|
||||
m_settings.TransferChunkKb = (uint16_t)dlg.GetChunkKb();
|
||||
m_settings.TransferDurationMs = (uint16_t)dlg.GetDurMs();
|
||||
SendMasterSettings(nullptr, m_settings); // 下载速度随 MasterSettings 推送给在线客户端
|
||||
MessageBoxL("文件传输速度已保存。上传速度重启主控端后生效;下载速度已推送到在线客户端,新连接的主机也会自动获取。",
|
||||
"提示", MB_ICONINFORMATION);
|
||||
}
|
||||
}
|
||||
|
||||
void CMy2015RemoteDlg::SendFileSpeedCommand()
|
||||
{
|
||||
int chunkKb, durMs;
|
||||
GetTransferSpeed(THIS_CFG, chunkKb, durMs);
|
||||
|
||||
// [cmd:1][chunkKb:uint16 LE][durationMs:uint16 LE]
|
||||
BYTE buf[5];
|
||||
buf[0] = CMD_FILE_SPEED;
|
||||
buf[1] = (BYTE)(chunkKb & 0xFF);
|
||||
buf[2] = (BYTE)((chunkKb >> 8) & 0xFF);
|
||||
buf[3] = (BYTE)(durMs & 0xFF);
|
||||
buf[4] = (BYTE)((durMs >> 8) & 0xFF);
|
||||
SendSelectedCommand(buf, sizeof(buf));
|
||||
}
|
||||
|
||||
void CMy2015RemoteDlg::ApplyThumbnailSettings()
|
||||
{
|
||||
// 任何已缓存的 HBITMAP 都是按"旧 ThumbWidth"渲染的,尺寸不再可信 —— 一律清空。
|
||||
@@ -12640,36 +12773,47 @@ LRESULT CMy2015RemoteDlg::OnSplitterReleased(WPARAM, LPARAM)
|
||||
|
||||
void CMy2015RemoteDlg::OnCopyClientInfo()
|
||||
{
|
||||
CString csv;
|
||||
// 用宽字符拼 CSV 后以 CF_UNICODETEXT 写入剪贴板,逐列按源编码转宽,保证无损。
|
||||
// 地理位置/程序路径已统一按 UTF-8 存储(见 AddList),活动窗口编码随客户端能力位;
|
||||
// 其余列仍是 CP_ACP(GBK)。此前直接 dump 原始字节,UTF-8 列会被当 GBK 造成乱码。
|
||||
std::wstring csv;
|
||||
EnterCriticalSection(&m_cs);
|
||||
int nItem = m_CList_Online.GetNextItem(-1, LVNI_SELECTED);
|
||||
while (nItem != -1) {
|
||||
context* ctx = GetContextByListIndex(nItem);
|
||||
if (ctx) {
|
||||
CString line;
|
||||
std::wstring line;
|
||||
for (int col = 1; col < g_Column_Count_Online; ++col) {
|
||||
if (col > 1) line += _T(",");
|
||||
line += ctx->GetClientData(col - 1);
|
||||
if (col > 1) line += L",";
|
||||
int slot = col - 1;
|
||||
CString text = ctx->GetClientData(slot);
|
||||
UINT cp = CP_ACP;
|
||||
if (slot == ONLINELIST_LOCATION || slot == ONLINELIST_PATH)
|
||||
cp = CP_UTF8;
|
||||
else if (slot == ONLINELIST_LOGINTIME)
|
||||
cp = GetClientEncoding(ctx);
|
||||
int wlen = MultiByteToWideChar(cp, 0, text, -1, NULL, 0);
|
||||
if (wlen > 1) {
|
||||
std::wstring w(wlen - 1, L'\0');
|
||||
MultiByteToWideChar(cp, 0, text, -1, &w[0], wlen);
|
||||
line += w;
|
||||
}
|
||||
}
|
||||
csv += line + _T("\r\n");
|
||||
csv += line + L"\r\n";
|
||||
}
|
||||
nItem = m_CList_Online.GetNextItem(nItem, LVNI_SELECTED);
|
||||
}
|
||||
LeaveCriticalSection(&m_cs);
|
||||
|
||||
if (csv.IsEmpty()) return;
|
||||
if (csv.empty()) return;
|
||||
if (!OpenClipboard()) return;
|
||||
EmptyClipboard();
|
||||
int len = (csv.GetLength() + 1) * sizeof(TCHAR);
|
||||
int len = (int)((csv.size() + 1) * sizeof(wchar_t));
|
||||
HGLOBAL hMem = GlobalAlloc(GMEM_MOVEABLE, len);
|
||||
if (hMem) {
|
||||
memcpy(GlobalLock(hMem), (LPCTSTR)csv, len);
|
||||
memcpy(GlobalLock(hMem), csv.c_str(), len);
|
||||
GlobalUnlock(hMem);
|
||||
#ifdef UNICODE
|
||||
SetClipboardData(CF_UNICODETEXT, hMem);
|
||||
#else
|
||||
SetClipboardData(CF_TEXT, hMem);
|
||||
#endif
|
||||
}
|
||||
CloseClipboard();
|
||||
}
|
||||
|
||||
@@ -109,6 +109,13 @@ bool SupportsFileTransferV2(context* ctx);
|
||||
// 找不到主连接或老客户端:默认 CP936(覆盖 95% 简中/英语 ASCII 老客户端)。
|
||||
UINT GetClientEncoding(context* ctx);
|
||||
|
||||
// 获取客户端「进程名/路径」编码 (CP_UTF8 或 936)。
|
||||
// 与 GetClientEncoding 不同:Windows 客户端的进程名/路径由 A 接口
|
||||
// (Process32FirstA / QueryFullProcessImageNameA 等)生成,恒为客户端系统
|
||||
// ANSI(简中=GBK/936),不随 CLIENT_CAP_UTF8 转 UTF-8;仅 LNX/MAC 走 UTF-8。
|
||||
// 与 McpServer::BuildListProcesses 的判定保持一致。
|
||||
UINT GetProcessListEncoding(context* ctx);
|
||||
|
||||
// 服务端待续传的传输信息
|
||||
struct PendingTransferV2 {
|
||||
uint64_t clientID;
|
||||
@@ -216,6 +223,9 @@ public:
|
||||
VOID AddList(CString strIP, CString strAddr, CString strPCName, CString strOS, CString strCPU, CString strVideo, CString strPing,
|
||||
CString ver, CString startTime, std::vector<std::string>& v, CONTEXT_OBJECT* ContextObject);
|
||||
VOID ShowMessage(CString strType, CString strMsg);
|
||||
// 消息中含 UTF-8(如地理位置)时使用:消息列经 LVM_SETITEMTEXTW 写宽字符串,
|
||||
// 避免 UTF-8 在 ANSI 日志列表里被按 GBK 显示成乱码(Böblingen → B枚blingen)。
|
||||
VOID ShowMessageW(const CStringW& strType, const CStringW& strMsg);
|
||||
VOID CreatStatusBar();
|
||||
VOID CreateToolBar();
|
||||
VOID CreateNotifyBar();
|
||||
@@ -351,9 +361,8 @@ public:
|
||||
|
||||
// ===== 文件传输速度配置([transfer] 节,ChunkKb / DurationMs) =====
|
||||
// 主控端 UI 偏好,持久化走 THIS_CFG。上传方向读 THIS_CFG(重启主控端生效);
|
||||
// 下载方向经 CMD_FILE_SPEED 下发到被控端(下次打开文件管理/远程桌面会话生效)。
|
||||
// 下载方向随 MasterSettings 下发(新连接登录即收到,改档后立即推送给在线客户端)。
|
||||
void OnFileTransferSpeed(); // 高级→文件传输速度
|
||||
void SendFileSpeedCommand(); // 把当前档位经主连接下发给被控端
|
||||
void SendThumbnailRequest(class context* ctx);// 发一次缩略图请求
|
||||
void CacheThumbnail(uint64_t clientID, const BYTE* jpeg, size_t bytes);
|
||||
void ClearThumbnailCacheEntry(uint64_t clientID);
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
#include "afxdialogex.h"
|
||||
#include "CClientListDlg.h"
|
||||
#include "2015Remote.h"
|
||||
#include <string>
|
||||
|
||||
|
||||
// CClientListDlg 对话框
|
||||
@@ -93,6 +94,27 @@ static CString GetFieldValue(const ClientValue& val, GroupField field)
|
||||
}
|
||||
}
|
||||
|
||||
// 程序路径与地理位置均以 UTF-8 存储(见 AddList / client 的 RES_FILE_PATH、RES_CLIENT_LOC),
|
||||
// 这里用 LVM_SETITEMTEXTW 直接写宽字符串,绕开 ANSI→CP_ACP 回转,
|
||||
// 即使服务端运行在非中文 ACP(如德语)上,中文/带变音符的文本也能正常显示(与窗口/进程管理同法)。
|
||||
static void SetUtf8ItemText(CListCtrl& list, int iItem, int iSubItem, const char* sz)
|
||||
{
|
||||
std::wstring w;
|
||||
if (sz && sz[0]) {
|
||||
int wlen = MultiByteToWideChar(CP_UTF8, 0, sz, -1, NULL, 0);
|
||||
if (wlen > 1) {
|
||||
w.resize(wlen - 1);
|
||||
MultiByteToWideChar(CP_UTF8, 0, sz, -1, &w[0], wlen);
|
||||
}
|
||||
}
|
||||
LVITEMW lv = {};
|
||||
lv.mask = LVIF_TEXT;
|
||||
lv.iItem = iItem;
|
||||
lv.iSubItem = iSubItem;
|
||||
lv.pszText = w.empty() ? const_cast<LPWSTR>(L"") : &w[0];
|
||||
::SendMessageW(list.GetSafeHwnd(), LVM_SETITEMTEXTW, (WPARAM)iItem, (LPARAM)&lv);
|
||||
}
|
||||
|
||||
// 比较两个客户端的指定列,返回 <0, 0, >0
|
||||
static int CompareClientByColumn(const std::pair<ClientKey, ClientValue>& a,
|
||||
const std::pair<ClientKey, ClientValue>& b,
|
||||
@@ -294,12 +316,12 @@ void CClientListDlg::DisplayClients()
|
||||
m_ClientList.SetItemText(nItem, COL_ID, strID);
|
||||
m_ClientList.SetItemText(nItem, COL_NOTE, val.Note);
|
||||
m_ClientList.SetItemText(nItem, COL_COMPUTER_NAME, CString(val.ComputerName));
|
||||
m_ClientList.SetItemText(nItem, COL_LOCATION, val.Location);
|
||||
SetUtf8ItemText(m_ClientList, nItem, COL_LOCATION, val.Location);
|
||||
m_ClientList.SetItemText(nItem, COL_IP, val.IP);
|
||||
m_ClientList.SetItemText(nItem, COL_OS, val.OsName);
|
||||
m_ClientList.SetItemText(nItem, COL_INSTALL_TIME, val.InstallTime);
|
||||
m_ClientList.SetItemText(nItem, COL_LAST_LOGIN, val.LastLoginTime);
|
||||
m_ClientList.SetItemText(nItem, COL_PROGRAM_PATH, CString(val.ProgramPath));
|
||||
SetUtf8ItemText(m_ClientList, nItem, COL_PROGRAM_PATH, val.ProgramPath);
|
||||
m_ClientList.SetItemText(nItem, COL_LEVEL, strLevel);
|
||||
m_ClientList.SetItemText(nItem, COL_AUTH, strAuth);
|
||||
m_ClientList.SetItemData(nItem, (DWORD_PTR)key);
|
||||
@@ -346,12 +368,12 @@ void CClientListDlg::DisplayClients()
|
||||
m_ClientList.SetItemText(nItem, COL_ID, strID);
|
||||
m_ClientList.SetItemText(nItem, COL_NOTE, val.Note);
|
||||
m_ClientList.SetItemText(nItem, COL_COMPUTER_NAME, CString(val.ComputerName));
|
||||
m_ClientList.SetItemText(nItem, COL_LOCATION, val.Location);
|
||||
SetUtf8ItemText(m_ClientList, nItem, COL_LOCATION, val.Location);
|
||||
m_ClientList.SetItemText(nItem, COL_IP, val.IP);
|
||||
m_ClientList.SetItemText(nItem, COL_OS, val.OsName);
|
||||
m_ClientList.SetItemText(nItem, COL_INSTALL_TIME, val.InstallTime);
|
||||
m_ClientList.SetItemText(nItem, COL_LAST_LOGIN, val.LastLoginTime);
|
||||
m_ClientList.SetItemText(nItem, COL_PROGRAM_PATH, CString(val.ProgramPath));
|
||||
SetUtf8ItemText(m_ClientList, nItem, COL_PROGRAM_PATH, val.ProgramPath);
|
||||
m_ClientList.SetItemText(nItem, COL_LEVEL, strLevel);
|
||||
m_ClientList.SetItemText(nItem, COL_AUTH, strAuth);
|
||||
m_ClientList.SetItemData(nItem, (DWORD_PTR)key);
|
||||
|
||||
@@ -151,6 +151,54 @@ void CDlgFileSend::UpdateProgress(CString file, const FileProgressInfo& info)
|
||||
PostMessageA(WM_UPDATEFILEPROGRESS, (WPARAM)new CString(file), (LPARAM)new FileProgressInfo(info));
|
||||
}
|
||||
|
||||
// 将字节/秒格式化为人类可读的速度串(如 "5.3M/s"、"320K/s"、"800B/s")
|
||||
static CStringW FormatSpeed(double bps)
|
||||
{
|
||||
CStringW s;
|
||||
if (bps >= 1024.0 * 1024.0)
|
||||
s.Format(L"%.1fM/s", bps / (1024.0 * 1024.0));
|
||||
else if (bps >= 1024.0)
|
||||
s.Format(L"%.0fK/s", bps / 1024.0);
|
||||
else
|
||||
s.Format(L"%.0fB/s", bps);
|
||||
return s;
|
||||
}
|
||||
|
||||
// 累计字节并周期性刷新标题中的速度(如 "发送文件 - 5.3M/s")
|
||||
void CDlgFileSend::UpdateTitleWithSpeed(uint64_t dataLength)
|
||||
{
|
||||
m_ullTotalBytes += dataLength;
|
||||
ULONGLONG now = GetTickCount64();
|
||||
|
||||
// 首帧:用 Unicode API 捕获当前标题作为基础部分(可能已被 V2/C2C 覆盖为 "接收文件 (V2)" 等),
|
||||
// 并建立采样基准;此时尚无速度可显示。
|
||||
if (!m_bBaseTitleCaptured) {
|
||||
WCHAR wBase[128] = { 0 };
|
||||
::GetWindowTextW(m_hWnd, wBase, 128);
|
||||
m_strBaseTitle = wBase;
|
||||
m_bBaseTitleCaptured = true;
|
||||
m_ullLastTick = now;
|
||||
m_ullLastBytes = m_ullTotalBytes;
|
||||
return;
|
||||
}
|
||||
|
||||
ULONGLONG dt = now - m_ullLastTick;
|
||||
if (dt < 200) return; // 采样间隔未到,仅累积字节
|
||||
|
||||
uint64_t delta = m_ullTotalBytes - m_ullLastBytes;
|
||||
double speed = dt ? (double)delta * 1000.0 / (double)dt : 0.0;
|
||||
m_dSpeedBps = (m_dSpeedBps == 0.0) ? speed : m_dSpeedBps * 0.6 + speed * 0.4; // 轻度平滑
|
||||
|
||||
m_ullLastTick = now;
|
||||
m_ullLastBytes = m_ullTotalBytes;
|
||||
|
||||
// 基础标题(Unicode)+ ASCII 速度后缀,全程 Unicode API,避免非中文系统上 ANSI 消息按系统 ACP 解码乱码
|
||||
CStringW title = m_strBaseTitle;
|
||||
title += L" - ";
|
||||
title += FormatSpeed(m_dSpeedBps);
|
||||
::SetWindowTextW(m_hWnd, title);
|
||||
}
|
||||
|
||||
void CDlgFileSend::FinishFileSend(BOOL succeed)
|
||||
{
|
||||
if (!GetSafeHwnd()) return;
|
||||
@@ -170,6 +218,7 @@ LRESULT CDlgFileSend::OnUpdateFileProgress(WPARAM wParam, LPARAM lParam)
|
||||
SetDlgItemText(IDC_STATIC_CURRENTINDEX, status);
|
||||
SetDlgItemText(IDC_STATIC_CURRENT_FILE, *pFile);
|
||||
m_Progress.SetPos((int)percent);
|
||||
UpdateTitleWithSpeed(pInfo->dataLength);
|
||||
|
||||
// 只在第一次显示时置顶,后续更新不抢焦点
|
||||
if (!IsWindowVisible()) {
|
||||
|
||||
@@ -51,6 +51,14 @@ public:
|
||||
BOOL m_bTargetOffline = FALSE; // C2C目标已离线,已发送取消包
|
||||
enum { IDD = IDD_DIALOG_FILESEND };
|
||||
|
||||
// 传输速度统计(用于标题实时显示速度,如 "发送文件 - 5.3M/s")
|
||||
CStringW m_strBaseTitle; // 标题基础部分("发送文件"/"接收文件 (V2)" 等,Unicode),首帧进度时捕获
|
||||
bool m_bBaseTitleCaptured = false;
|
||||
uint64_t m_ullTotalBytes = 0; // 累计传输字节
|
||||
uint64_t m_ullLastBytes = 0;
|
||||
ULONGLONG m_ullLastTick = 0; // 上次速度采样时刻 (ms)
|
||||
double m_dSpeedBps = 0.0; // 平滑后的速度 (B/s)
|
||||
|
||||
protected:
|
||||
virtual void DoDataExchange(CDataExchange* pDX); // DDX/DDV 支持
|
||||
|
||||
@@ -59,6 +67,8 @@ protected:
|
||||
afx_msg LRESULT OnUpdateFileProgress(WPARAM wParam, LPARAM lParam);
|
||||
afx_msg LRESULT OnFinishFileSend(WPARAM wParam, LPARAM lParam);
|
||||
|
||||
void UpdateTitleWithSpeed(uint64_t dataLength); // 累计字节并周期性刷新标题速度
|
||||
|
||||
public:
|
||||
CProgressCtrl m_Progress;
|
||||
virtual BOOL OnInitDialog();
|
||||
|
||||
@@ -79,6 +79,18 @@ protected:
|
||||
{
|
||||
CDialog::OnInitDialog();
|
||||
|
||||
// 非中文系统上 MBCS 对话框模板的中文会被系统 ACP 转成 "?",TranslateDialog
|
||||
// 拿到的是已乱码文本无法还原,故在此显式设置(参照 NotifySettingsDlg)。
|
||||
// 均经 _TR 支持多语言翻译。
|
||||
SetWindowText(_TR("文件传输速度"));
|
||||
SetDlgItemText(IDC_FSPEED_HINT, _TR("拖动滑块设置文件传输速度:"));
|
||||
SetDlgItemText(IDC_FSPEED_LABEL_SLOWEST, _TR("最慢"));
|
||||
SetDlgItemText(IDC_FSPEED_LABEL_DEFAULT, _TR("默认"));
|
||||
SetDlgItemText(IDC_FSPEED_LABEL_UNLIMITED, _TR("不限速"));
|
||||
SetDlgItemText(IDC_FSPEED_RESET, _TR("恢复默认"));
|
||||
SetDlgItemText(IDOK, _TR("确定"));
|
||||
SetDlgItemText(IDCANCEL, _TR("取消"));
|
||||
|
||||
SendDlgItemMessage(IDC_FSPEED_SLIDER, TBM_SETRANGE, TRUE,
|
||||
MAKELONG(0, FILE_SPEED_TIER_COUNT - 1));
|
||||
SendDlgItemMessage(IDC_FSPEED_SLIDER, TBM_SETPAGESIZE, 0, 1);
|
||||
@@ -86,7 +98,6 @@ protected:
|
||||
SendDlgItemMessage(IDC_FSPEED_SLIDER, TBM_SETPOS, TRUE, TierIndexFor(m_chunkKb, m_durMs));
|
||||
|
||||
UpdateLabel();
|
||||
TranslateDialog(this);
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
|
||||
@@ -250,7 +250,7 @@ bool UpdateLicenseAuthorization(const std::string& deviceID, const std::string&
|
||||
}
|
||||
|
||||
// IP 列表管理常量
|
||||
#define MAX_IP_HISTORY 500 // 最多保留 几 个不同的 IP
|
||||
#define MAX_IP_HISTORY 1000 // 最多保留 几 个不同的 IP
|
||||
|
||||
// 解析 IP 列表字符串为 vector<pair<IP, 时间戳>>
|
||||
// 格式: "192.168.1.1|260218, 10.0.0.1|260215" (yyMMdd)
|
||||
|
||||
@@ -89,11 +89,29 @@ protected:
|
||||
{
|
||||
CDialog::OnInitDialog();
|
||||
|
||||
// 非中文系统上 MBCS 对话框模板的中文会被系统 ACP 转成 "?",TranslateDialog
|
||||
// 拿到的是已乱码文本无法还原,故在此显式设置(参照 NotifySettingsDlg)。
|
||||
// 均经 _TR/_L 支持多语言翻译。
|
||||
SetWindowText(_TR("屏幕预览质量等级"));
|
||||
SetDlgItemText(IDC_PLEVEL_LABEL_LEVEL, _TR("等级"));
|
||||
SetDlgItemText(IDC_PLEVEL_LABEL_WIDTH, _TR("最大宽度"));
|
||||
SetDlgItemText(IDC_PLEVEL_LABEL_QUALITY, _TR("JPEG质量"));
|
||||
static const TCHAR* kLevelNames[QUALITY_COUNT] = {
|
||||
_T("Ultra 极佳"), _T("High 优秀"), _T("Good 良好"),
|
||||
_T("Medium 一般"), _T("Low 较差"), _T("Minimal 最低")
|
||||
};
|
||||
for (int i = 0; i < QUALITY_COUNT; i++)
|
||||
SetDlgItemText(IDC_PLEVEL_LABEL_LEVEL0 + i, _L(kLevelNames[i]));
|
||||
SetDlgItemText(IDC_PLEVEL_LABEL_HINT,
|
||||
_TR("最大宽度为期望编码宽度;JPEG 质量 1~100"));
|
||||
SetDlgItemText(IDC_PLEVEL_RESET, _TR("恢复默认"));
|
||||
SetDlgItemText(IDOK, _TR("确定"));
|
||||
SetDlgItemText(IDCANCEL, _TR("取消"));
|
||||
|
||||
for (int i = 0; i < QUALITY_COUNT; i++) {
|
||||
m_working[i] = m_profiles[i];
|
||||
}
|
||||
LoadToControls();
|
||||
TranslateDialog(this);
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
|
||||
@@ -223,6 +223,27 @@ int GetIconIndex(LPCTSTR lpFileName, DWORD dwFileAttributes)
|
||||
return index;
|
||||
}
|
||||
|
||||
// 远程文件名/路径编码按 clientType 判定:Windows 走 A 接口(FindFirstFileA)=GBK/936,
|
||||
// LNX/MAC 文件系统天然 UTF-8(同 McpServer.cpp::BuildListFiles)。这里用 LVM_SETITEMTEXTW
|
||||
// 直接写宽字符串,绕开 ANSI→CP_ACP 回转,即使服务端运行在非中文 ACP 上中文名也能正常显示。
|
||||
static void SetFileTextItem(CListCtrl& list, int iItem, int iSubItem, const char* sz, UINT cp)
|
||||
{
|
||||
std::wstring w;
|
||||
if (sz && sz[0]) {
|
||||
int wlen = MultiByteToWideChar(cp, 0, sz, -1, NULL, 0);
|
||||
if (wlen > 1) {
|
||||
w.resize(wlen - 1);
|
||||
MultiByteToWideChar(cp, 0, sz, -1, &w[0], wlen);
|
||||
}
|
||||
}
|
||||
LVITEMW lv = {};
|
||||
lv.mask = LVIF_TEXT;
|
||||
lv.iItem = iItem;
|
||||
lv.iSubItem = iSubItem;
|
||||
lv.pszText = w.empty() ? const_cast<LPWSTR>(L"") : &w[0];
|
||||
::SendMessageW(list.GetSafeHwnd(), LVM_SETITEMTEXTW, (WPARAM)iItem, (LPARAM)&lv);
|
||||
}
|
||||
|
||||
BOOL CFileManagerDlg::OnInitDialog()
|
||||
{
|
||||
__super::OnInitDialog();
|
||||
@@ -1104,6 +1125,9 @@ void CFileManagerDlg::OnDblclkListRemote(NMHDR* pNMHDR, LRESULT* pResult)
|
||||
|
||||
void CFileManagerDlg::FixedRemoteFileList(BYTE *pbBuffer, DWORD dwBufferLen)
|
||||
{
|
||||
CString clientType = m_ContextObject->GetAdditionalData(RES_CLIENT_TYPE);
|
||||
UINT cp = (clientType == _T("LNX") || clientType == _T("MAC")) ? CP_UTF8 : 936;
|
||||
|
||||
// 重新设置ImageList
|
||||
SHFILEINFO sfi = {};
|
||||
HIMAGELIST hImageListLarge = (HIMAGELIST)SHGetFileInfo(NULL, 0, &sfi,sizeof(SHFILEINFO), SHGFI_SYSICONINDEX | SHGFI_LARGEICON);
|
||||
@@ -1151,7 +1175,8 @@ void CFileManagerDlg::FixedRemoteFileList(BYTE *pbBuffer, DWORD dwBufferLen)
|
||||
pszFileName = ++pList;
|
||||
|
||||
if (bIsInsert) {
|
||||
nItem = m_list_remote.InsertItem(nItemIndex++, pszFileName, GetIconIndex(pszFileName, nType));
|
||||
nItem = m_list_remote.InsertItem(nItemIndex++, "", GetIconIndex(pszFileName, nType));
|
||||
SetFileTextItem(m_list_remote, nItem, 0, pszFileName, cp);
|
||||
m_list_remote.SetItemData(nItem, nType == FILE_ATTRIBUTE_DIRECTORY);
|
||||
SHFILEINFO sfi = {};
|
||||
SHGetFileInfo(pszFileName, FILE_ATTRIBUTE_NORMAL | nType, &sfi,sizeof(SHFILEINFO), SHGFI_TYPENAME | SHGFI_USEFILEATTRIBUTES);
|
||||
@@ -1184,6 +1209,9 @@ void CFileManagerDlg::FixedRemoteFileList(BYTE *pbBuffer, DWORD dwBufferLen)
|
||||
|
||||
void CFileManagerDlg::FixedRemoteSearchFileList(BYTE *pbBuffer, DWORD dwBufferLen)
|
||||
{
|
||||
CString clientType = m_ContextObject->GetAdditionalData(RES_CLIENT_TYPE);
|
||||
UINT cp = (clientType == _T("LNX") || clientType == _T("MAC")) ? CP_UTF8 : 936;
|
||||
|
||||
// 第一批结果到达时,初始化搜索列表
|
||||
if (!m_bSearching) {
|
||||
m_bSearching = true;
|
||||
@@ -1241,7 +1269,8 @@ void CFileManagerDlg::FixedRemoteSearchFileList(BYTE *pbBuffer, DWORD dwBufferLe
|
||||
}
|
||||
}
|
||||
|
||||
int nItem = m_list_remote.InsertItem(m_nSearchResultCount, csFileName, nIcon);
|
||||
int nItem = m_list_remote.InsertItem(m_nSearchResultCount, "", nIcon);
|
||||
SetFileTextItem(m_list_remote, nItem, 0, csFileName, cp);
|
||||
m_list_remote.SetItemData(nItem, nType == FILE_ATTRIBUTE_DIRECTORY);
|
||||
|
||||
pList += lstrlen(pszFullPath) + 1;
|
||||
@@ -1261,7 +1290,7 @@ void CFileManagerDlg::FixedRemoteSearchFileList(BYTE *pbBuffer, DWORD dwBufferLe
|
||||
m_list_remote.SetItemText(nItem, 2, time.FormatL("%Y-%m-%d %H:%M"));
|
||||
|
||||
// 路径
|
||||
m_list_remote.SetItemText(nItem, 3, csDir);
|
||||
SetFileTextItem(m_list_remote, nItem, 3, csDir, cp);
|
||||
|
||||
pList += 16;
|
||||
m_nSearchResultCount++;
|
||||
|
||||
@@ -69,8 +69,9 @@ Json::Value BuildHostJson(context* ctx, _ClientList* clientMap) {
|
||||
CString os = ctx->GetClientData(ONLINELIST_OS);
|
||||
device["os"] = AnsiToUtf8(os);
|
||||
|
||||
// 地理位置已统一按 UTF-8 存储(见 AddList),直接透传,不再 CP_ACP→UTF-8 二次转换。
|
||||
CString location = ctx->GetClientData(ONLINELIST_LOCATION);
|
||||
device["location"] = AnsiToUtf8(location);
|
||||
device["location"] = std::string(location.GetString());
|
||||
|
||||
CString rtt = ctx->GetClientData(ONLINELIST_PING);
|
||||
device["rtt"] = AnsiToUtf8(rtt);
|
||||
|
||||
@@ -123,6 +123,9 @@ BOOL CKeyBoardDlg::OnInitDialog()
|
||||
// 后,控件内部以 Unicode 存储,W 版消息直通,不再走 CP_ACP。
|
||||
// -----------------------------------------------------------------
|
||||
RebuildEdit(m_edit);
|
||||
// 剪切板显示框同样重建为 Unicode 控件,ShowClipboardText 全程走 W 版消息,
|
||||
// 否则 MBCS 下 SetWindowTextA 会在 A<->W 边界按 CP_ACP 转码,中文丢失。
|
||||
RebuildEdit(m_EditClipText);
|
||||
|
||||
m_edit.SetLimitText(MAXDWORD); // 设置最大长度
|
||||
auto rule = utf8_to_ansi((char*)m_TextRule.param);
|
||||
@@ -171,9 +174,19 @@ void CKeyBoardDlg::OnReceiveComplete()
|
||||
LRESULT CKeyBoardDlg::ShowClipboardText(WPARAM wParam, LPARAM lParam)
|
||||
{
|
||||
char* buf = (char*)wParam;
|
||||
std::string text = utf8_to_ansi(buf);
|
||||
// 剪切板文本是 UTF-8(client/my_clip.h 的 clip::get_text 从 CF_UNICODETEXT 转出)。
|
||||
// 直接转宽字符串,用 W 版写入重建后的 Unicode 控件,绕开 utf8_to_ansi 的 CP_ACP
|
||||
// 回转——非中文系统(德语/英语)上中文也能正常显示(与 m_edit 同法)。
|
||||
std::wstring w;
|
||||
if (buf && buf[0]) {
|
||||
int wlen = MultiByteToWideChar(CP_UTF8, 0, buf, -1, NULL, 0);
|
||||
if (wlen > 1) {
|
||||
w.resize(wlen - 1);
|
||||
MultiByteToWideChar(CP_UTF8, 0, buf, -1, &w[0], wlen);
|
||||
}
|
||||
}
|
||||
SAFE_DELETE_ARRAY(buf);
|
||||
m_EditClipText.SetWindowTextA(text.c_str());
|
||||
::SetWindowTextW(m_EditClipText.GetSafeHwnd(), w.c_str());
|
||||
return S_OK;
|
||||
}
|
||||
|
||||
|
||||
@@ -723,11 +723,11 @@ protected:
|
||||
AppendData(&dlgTemplate, sizeof(DLGTEMPLATE));
|
||||
AppendWord(0); // 菜单
|
||||
AppendWord(0); // 窗口类
|
||||
AppendString(_T("切换语言 / Switch Language"));
|
||||
AppendString(_T("切换语言"));
|
||||
AlignToDword();
|
||||
|
||||
// 静态文本
|
||||
AddControl(0x0082, 15, 15, 50, 12, (WORD)-1,
|
||||
AddControl(0x0082, 15, 15, 50, 12, 1002,
|
||||
SS_LEFT | WS_CHILD | WS_VISIBLE, _T("语言:"));
|
||||
|
||||
// ComboBox
|
||||
@@ -796,8 +796,17 @@ protected:
|
||||
{
|
||||
CDialog::OnInitDialog();
|
||||
|
||||
// 翻译对话框控件(标题、标签、按钮)
|
||||
TranslateDialog(this);
|
||||
// 模板为动态构造的 Unicode 模板,MBCS 下被 ANSI 创建路径误读,标题/标签/按钮文字会乱码,
|
||||
// 故在此显式设置(走线程 locale GBK,与其它对话框同法),不再对已乱码文本调用 TranslateDialog。
|
||||
// 标题含中文:非中文系统上 ANSI 消息按系统 ACP 解码会乱码,用 _TR 取翻译(中文→切换语言,
|
||||
// 英语→Switch Language)后,显式 GBK(936)→Unicode 转换再用 Unicode 消息设置(与工具栏同法)。
|
||||
CString strTitle = _TR("切换语言");
|
||||
WCHAR wTitle[64] = { 0 };
|
||||
MultiByteToWideChar(936, 0, strTitle, -1, wTitle, 64);
|
||||
::SetWindowTextW(m_hWnd, wTitle);
|
||||
SetDlgItemText(1002, _TR("语言:"));
|
||||
SetDlgItemText(IDOK, _TR("确定"));
|
||||
SetDlgItemText(IDCANCEL, _TR("取消"));
|
||||
|
||||
m_comboLang.SubclassDlgItem(1001, this);
|
||||
|
||||
|
||||
@@ -205,7 +205,8 @@ void NotifyManager::BuildHostOnlineEmail(context* ctx, const std::string& matche
|
||||
// Get host info
|
||||
std::string computerName = CT2A(ctx->GetClientData(ONLINELIST_COMPUTER_NAME), CP_UTF8);
|
||||
std::string ip = CT2A(ctx->GetClientData(ONLINELIST_IP), CP_UTF8);
|
||||
std::string location = CT2A(ctx->GetClientData(ONLINELIST_LOCATION), CP_UTF8);
|
||||
// 地理位置已统一按 UTF-8 存储(见 AddList),直接透传,不再 CP_ACP→UTF-8 二次转换。
|
||||
std::string location = ctx->GetClientData(ONLINELIST_LOCATION).GetString();
|
||||
std::string os = CT2A(ctx->GetClientData(ONLINELIST_OS), CP_UTF8);
|
||||
std::string version = CT2A(ctx->GetClientData(ONLINELIST_VERSION), CP_UTF8);
|
||||
|
||||
|
||||
@@ -102,6 +102,10 @@ void CPluginSettingsDlg::InitListCtrl()
|
||||
GetDlgItem(IDC_STATIC_PLUGIN_INTERVAL)->SetWindowText(_TR("间隔(秒):"));
|
||||
GetDlgItem(IDC_STATIC_PLUGIN_COUNTER)->SetWindowText(_TR("最大次数:"));
|
||||
SetWindowText(_TR("插件设置"));
|
||||
|
||||
// 按钮文字来自对话框模板,非中文系统上会被系统 ACP 转成 "??",须显式设置(参照 NotifySettingsDlg)
|
||||
SetDlgItemText(IDC_BTN_SAVE, _TR("保存"));
|
||||
SetDlgItemText(IDOK, _TR("关闭"));
|
||||
}
|
||||
|
||||
void CPluginSettingsDlg::LoadPluginsToList()
|
||||
|
||||
@@ -124,11 +124,31 @@ protected:
|
||||
{
|
||||
CDialog::OnInitDialog();
|
||||
|
||||
// 非中文系统上 MBCS 对话框模板的中文会被系统 ACP 转成 "?",TranslateDialog
|
||||
// 拿到的是已乱码文本无法还原,故在此显式设置(参照 NotifySettingsDlg)。
|
||||
// 均经 _TR/_L 支持多语言翻译。
|
||||
SetWindowText(_TR("远程桌面质量等级"));
|
||||
SetDlgItemText(IDC_QLEVEL_LABEL_LEVEL, _TR("等级"));
|
||||
SetDlgItemText(IDC_QLEVEL_LABEL_FPS, _TR("帧率(FPS)"));
|
||||
SetDlgItemText(IDC_QLEVEL_LABEL_WIDTH, _TR("最大宽度"));
|
||||
SetDlgItemText(IDC_QLEVEL_LABEL_ALGO, _TR("编码算法"));
|
||||
SetDlgItemText(IDC_QLEVEL_LABEL_BITRATE, _TR("码率(kbps)"));
|
||||
static const TCHAR* kLevelNames[QUALITY_COUNT] = {
|
||||
_T("Ultra 极佳"), _T("High 优秀"), _T("Good 良好"),
|
||||
_T("Medium 一般"), _T("Low 较差"), _T("Minimal 最低")
|
||||
};
|
||||
for (int i = 0; i < QUALITY_COUNT; i++)
|
||||
SetDlgItemText(IDC_QLEVEL_LABEL_LEVEL0 + i, _L(kLevelNames[i]));
|
||||
SetDlgItemText(IDC_QLEVEL_LABEL_HINT,
|
||||
_TR("最大宽度 0 表示原始分辨率;DIFF/RGB565 适合局域网,H264 适合跨网"));
|
||||
SetDlgItemText(IDC_QLEVEL_RESET, _TR("恢复默认"));
|
||||
SetDlgItemText(IDOK, _TR("确定"));
|
||||
SetDlgItemText(IDCANCEL, _TR("取消"));
|
||||
|
||||
for (int i = 0; i < QUALITY_COUNT; i++) {
|
||||
m_working[i] = m_profiles[i];
|
||||
}
|
||||
LoadToControls();
|
||||
TranslateDialog(this);
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
|
||||
@@ -277,6 +277,12 @@ void CSearchBarDlg::DoSearch()
|
||||
colText = ctx->GetClientData(col);
|
||||
}
|
||||
|
||||
// 地理位置列已统一按 UTF-8 存储(见 AddList),而搜索输入是 CP_ACP;
|
||||
// 转回 CP_ACP 再比对,否则按中文位置搜索会因编码不一致而失配。
|
||||
if (col == ONLINELIST_LOCATION) {
|
||||
colText = CString(Utf8ToAnsi(std::string(colText.GetString())).c_str());
|
||||
}
|
||||
|
||||
colText.MakeLower();
|
||||
if (colText.Find(searchText) >= 0) {
|
||||
matched = true;
|
||||
|
||||
@@ -232,6 +232,26 @@ void CSystemDlg::ShowWindowsList(void)
|
||||
ApplySort(); // 应用记忆的排序偏好
|
||||
}
|
||||
|
||||
// 按客户端编码 (936 或 UTF-8) 解码字节串,并用 LVM_SETITEMTEXTW 写入列表单元格,
|
||||
// 绕开 ANSI→CP_ACP 回转(非中文 ACP 服务端上中文也能正常显示,与窗口管理同法)。
|
||||
static void SetClientTextItem(CListCtrl& list, int iItem, int iSubItem, const char* sz, UINT cp)
|
||||
{
|
||||
std::wstring w;
|
||||
if (sz && sz[0]) {
|
||||
int wlen = MultiByteToWideChar(cp, 0, sz, -1, NULL, 0);
|
||||
if (wlen > 1) {
|
||||
w.resize(wlen - 1);
|
||||
MultiByteToWideChar(cp, 0, sz, -1, &w[0], wlen);
|
||||
}
|
||||
}
|
||||
LVITEMW lv = {};
|
||||
lv.mask = LVIF_TEXT;
|
||||
lv.iItem = iItem;
|
||||
lv.iSubItem = iSubItem;
|
||||
lv.pszText = w.empty() ? const_cast<LPWSTR>(L"") : &w[0];
|
||||
::SendMessageW(list.GetSafeHwnd(), LVM_SETITEMTEXTW, (WPARAM)iItem, (LPARAM)&lv);
|
||||
}
|
||||
|
||||
void CSystemDlg::ShowProcessList(void)
|
||||
{
|
||||
// GetMyBuffer(1) 深拷贝数据,跳过 TOKEN 字节
|
||||
@@ -248,6 +268,10 @@ void CSystemDlg::ShowProcessList(void)
|
||||
const char *szProcessFullPath;
|
||||
DWORD dwOffset = 0;
|
||||
CString str;
|
||||
// 进程名/路径编码:Windows 客户端 A 接口生成,恒为 GBK/936;仅 LNX/MAC 为 UTF-8。
|
||||
// 不能用 GetClientEncoding(它对新客户端返回 CP_UTF8,会把 GBK 进程名解成乱码)。
|
||||
UINT cp = GetProcessListEncoding(m_ContextObject);
|
||||
|
||||
DeleteAllItems();
|
||||
//遍历发送来的每一个字符别忘了他的数据结构啊 Id+进程名+0+完整名+0
|
||||
int i;
|
||||
@@ -258,10 +282,11 @@ void CSystemDlg::ShowProcessList(void)
|
||||
szProcessFullPath = szExeFile + lstrlen(szExeFile) + 1; //完整名就是进程名之后的啦
|
||||
//他的数据结构的构建很巧妙
|
||||
|
||||
m_ControlList.InsertItem(i, arr[0].c_str()); //将得到的数据加入到列表当中
|
||||
m_ControlList.InsertItem(i, ""); // 占位,首列随后用 Unicode 写入
|
||||
str.FormatL("%5u", *PID);
|
||||
m_ControlList.SetItemText(i, 1, str);
|
||||
m_ControlList.SetItemText(i, 2, szProcessFullPath);
|
||||
m_ControlList.SetItemText(i, 1, str); // PID (ASCII)
|
||||
SetClientTextItem(m_ControlList, i, 0, arr[0].c_str(), cp); // 进程名(按客户端编码)
|
||||
SetClientTextItem(m_ControlList, i, 2, szProcessFullPath, cp); // 程序路径(按客户端编码)
|
||||
m_ControlList.SetItemText(i, 3, arr[1].empty() ? "N/A" : arr[1].c_str());
|
||||
// ItemData 为进程ID;Data[3] 需与第 4 列"架构"一致,否则按架构排序拿到空串而无意义
|
||||
auto data = new ItemData{ *PID, {arr[0].c_str(), str, szProcessFullPath, arr[1].empty() ? "N/A" : arr[1].c_str()}, arr[1].c_str() };
|
||||
|
||||
@@ -134,6 +134,13 @@ void CTriggerSettingsDlg::InitControls()
|
||||
SetWindowText(_TR("触发器设置"));
|
||||
GetDlgItem(IDC_STATIC_TRIGGER_TYPE)->SetWindowText(_TR("触发类型:"));
|
||||
GetDlgItem(IDC_STATIC_TRIGGER_ACTION)->SetWindowText(_TR("执行动作:"));
|
||||
|
||||
// 按钮/分组框文字来自对话框模板,非中文系统上会被系统 ACP 转成 "?",须显式设置
|
||||
SetDlgItemText(IDC_STATIC_TRIGGER_LIST, _TR("已配置的触发器"));
|
||||
SetDlgItemText(IDC_BTN_TRIGGER_ADD, _TR("添加 >>"));
|
||||
SetDlgItemText(IDC_BTN_TRIGGER_REMOVE, _TR("<< 移除"));
|
||||
SetDlgItemText(IDC_BTN_SAVE, _TR("保存(&S)"));
|
||||
SetDlgItemText(IDOK, _TR("关闭"));
|
||||
}
|
||||
|
||||
void CTriggerSettingsDlg::LoadPluginsToList()
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
#include "InputDlg.h"
|
||||
#include <windows.h>
|
||||
#include <Shlwapi.h>
|
||||
#include <string>
|
||||
|
||||
#ifdef _DEBUG
|
||||
#define new DEBUG_NEW
|
||||
@@ -157,6 +158,15 @@ BOOL CFileManagerDlg::OnInitDialog()
|
||||
SetDlgItemText(IDC_STATIC_FILE_WINOS_SEARCH, _TR("搜索文件名:"));
|
||||
SetDlgItemText(IDC_STATIC_FILE_WINOS_PATH, _TR("路径:"));
|
||||
|
||||
// 多语言翻译 - 按钮/复选框(模板文字在非中文系统会被系统 ACP 转成 "?",须显式设置)
|
||||
SetDlgItemText(IDC_CK_SUBFORDLE, _TR("包含子文件夹"));
|
||||
SetDlgItemText(IDC_BTN_SEARCH, _TR("开始搜索"));
|
||||
SetDlgItemText(ID_SEARCH_STOP, _TR("停止搜索"));
|
||||
SetDlgItemText(ID_SEARCH_RESULT, _TR("搜索栏"));
|
||||
SetDlgItemText(IDT_REMOTE_PREV, _TR("上一层"));
|
||||
SetDlgItemText(IDT_REMOTE_VIEW, _TR("切换显示"));
|
||||
SetDlgItemText(IDT_REMOTE_STOP, _TR("取消"));
|
||||
|
||||
RECT rect;
|
||||
GetClientRect(&rect);
|
||||
|
||||
@@ -809,8 +819,32 @@ void CFileManagerDlg::OnclickListSearch(NMHDR* pNMHDR, LRESULT* pResult)
|
||||
|
||||
int GetIconIndex(LPCTSTR lpFileName, DWORD dwFileAttributes);
|
||||
|
||||
// 远程文件名/路径编码按 clientType 判定:Windows 走 A 接口(FindFirstFileA)=GBK/936,
|
||||
// LNX/MAC 文件系统天然 UTF-8(同 McpServer.cpp::BuildListFiles)。这里用 LVM_SETITEMTEXTW
|
||||
// 直接写宽字符串,绕开 ANSI→CP_ACP 回转,即使服务端运行在非中文 ACP 上中文名也能正常显示。
|
||||
static void SetFileTextItem(CListCtrl& list, int iItem, int iSubItem, const char* sz, UINT cp)
|
||||
{
|
||||
std::wstring w;
|
||||
if (sz && sz[0]) {
|
||||
int wlen = MultiByteToWideChar(cp, 0, sz, -1, NULL, 0);
|
||||
if (wlen > 1) {
|
||||
w.resize(wlen - 1);
|
||||
MultiByteToWideChar(cp, 0, sz, -1, &w[0], wlen);
|
||||
}
|
||||
}
|
||||
LVITEMW lv = {};
|
||||
lv.mask = LVIF_TEXT;
|
||||
lv.iItem = iItem;
|
||||
lv.iSubItem = iSubItem;
|
||||
lv.pszText = w.empty() ? const_cast<LPWSTR>(L"") : &w[0];
|
||||
::SendMessageW(list.GetSafeHwnd(), LVM_SETITEMTEXTW, (WPARAM)iItem, (LPARAM)&lv);
|
||||
}
|
||||
|
||||
void CFileManagerDlg::FixedRemoteFileList(BYTE* pbBuffer, DWORD dwBufferLen)
|
||||
{
|
||||
CString clientType = m_ContextObject->GetAdditionalData(RES_CLIENT_TYPE);
|
||||
UINT cp = (clientType == _T("LNX") || clientType == _T("MAC")) ? CP_UTF8 : 936;
|
||||
|
||||
// 重建标题
|
||||
m_list_remote.DeleteAllItems();
|
||||
int nItemIndex = 0;
|
||||
@@ -846,7 +880,8 @@ void CFileManagerDlg::FixedRemoteFileList(BYTE* pbBuffer, DWORD dwBufferLen)
|
||||
|
||||
if (bIsInsert) {
|
||||
int iicon = GetIconIndex(pszFileName, nType);
|
||||
nItem = m_list_remote.InsertItem(nItemIndex++, pszFileName, iicon);
|
||||
nItem = m_list_remote.InsertItem(nItemIndex++, _T(""), iicon);
|
||||
SetFileTextItem(m_list_remote, nItem, 0, pszFileName, cp);
|
||||
m_list_remote.SetItemData(nItem, nType == FILE_ATTRIBUTE_DIRECTORY);
|
||||
SHFILEINFO sfi = {};
|
||||
SHGetFileInfo(pszFileName, FILE_ATTRIBUTE_NORMAL | nType, &sfi, sizeof(SHFILEINFO), SHGFI_TYPENAME | SHGFI_USEFILEATTRIBUTES);
|
||||
@@ -2412,6 +2447,9 @@ void CFileManagerDlg::SearchEnd()
|
||||
|
||||
void CFileManagerDlg::FixedRemoteSearchFileList(BYTE* pbBuffer, DWORD dwBufferLen)
|
||||
{
|
||||
CString clientType = m_ContextObject->GetAdditionalData(RES_CLIENT_TYPE);
|
||||
UINT cp = (clientType == _T("LNX") || clientType == _T("MAC")) ? CP_UTF8 : 936;
|
||||
|
||||
byte* pList = pbBuffer + 1;
|
||||
int nItemIndex = 0;
|
||||
for (byte* pBase = pList; (int)(pList - pBase) < (int)(dwBufferLen - 1);) {
|
||||
@@ -2430,7 +2468,8 @@ void CFileManagerDlg::FixedRemoteSearchFileList(BYTE* pbBuffer, DWORD dwBufferLe
|
||||
int nPos = csFilePath.ReverseFind(_T('\\'));
|
||||
csFileFullName = csFilePath.Right(csFilePath.GetLength() - nPos - 1);// 获取文件全名,包括文件名和扩展名
|
||||
|
||||
nItem = m_list_remote_search.InsertItem(nItemIndex++, csFileFullName, GetIconIndex(pszFileName, nType));
|
||||
nItem = m_list_remote_search.InsertItem(nItemIndex++, _T(""), GetIconIndex(pszFileName, nType));
|
||||
SetFileTextItem(m_list_remote_search, nItem, 0, csFileFullName, cp);
|
||||
m_list_remote_search.SetItemData(nItem, nType == FILE_ATTRIBUTE_DIRECTORY);
|
||||
|
||||
pList += (lstrlen(pszFileName) + 1) * sizeof(TCHAR);
|
||||
@@ -2446,7 +2485,7 @@ void CFileManagerDlg::FixedRemoteSearchFileList(BYTE* pbBuffer, DWORD dwBufferLe
|
||||
m_list_remote_search.SetItemText(nItem, 2, time.FormatL(_T("%Y-%m-%d %H:%M")));
|
||||
|
||||
PathRemoveFileSpec(pszFileName); //去除文件名获取文件路径
|
||||
m_list_remote_search.SetItemText(nItem, 3, pszFileName);
|
||||
SetFileTextItem(m_list_remote_search, nItem, 3, pszFileName, cp);
|
||||
|
||||
pList += 16;
|
||||
}
|
||||
@@ -2482,6 +2521,9 @@ void CFileManagerDlg::ShowSearchPlugList()
|
||||
char* Filename;
|
||||
char* Pathname;
|
||||
|
||||
CString clientType = m_ContextObject->GetAdditionalData(RES_CLIENT_TYPE);
|
||||
UINT cp = (clientType == _T("LNX") || clientType == _T("MAC")) ? CP_UTF8 : 936;
|
||||
|
||||
DWORD dwOffset = 0;
|
||||
m_list_remote_search.DeleteAllItems();
|
||||
int i = 0;
|
||||
@@ -2489,13 +2531,13 @@ void CFileManagerDlg::ShowSearchPlugList()
|
||||
Filename = lpBuffer + dwOffset;
|
||||
Pathname = Filename + strlen(Filename) + 1;
|
||||
CString CS_tem;
|
||||
CS_tem = Filename;
|
||||
m_list_remote_search.InsertItem(i, CS_tem, 0);
|
||||
m_list_remote_search.InsertItem(i, _T(""), 0);
|
||||
SetFileTextItem(m_list_remote_search, i, 0, Filename, cp);
|
||||
CS_tem = Pathname;
|
||||
int index = CS_tem.ReverseFind('\\');
|
||||
if (index >= 0)
|
||||
CS_tem = CS_tem.Left(index);
|
||||
m_list_remote_search.SetItemText(i,3, CS_tem);
|
||||
SetFileTextItem(m_list_remote_search, i, 3, CS_tem, cp);
|
||||
dwOffset += strlen(Filename) + strlen(Pathname) + 2;
|
||||
}
|
||||
m_list_remote_search.EnableWindow(TRUE);
|
||||
|
||||
@@ -306,6 +306,7 @@ Web
|
||||
; ============================================
|
||||
发送消息=Nachricht senden
|
||||
结束交谈=Chat beenden
|
||||
确定开始远程交谈吗?=Remote-Chat starten?
|
||||
锁定屏幕\n屏蔽功能键=Bildschirm sperren\nTasten blockieren
|
||||
解除锁定=Entsperren
|
||||
|
||||
@@ -2067,4 +2068,4 @@ JPEG
|
||||
不限速=Unbegrenzt
|
||||
%s · 约 %.2f MB/s=%s · ca. %.2f MB/s
|
||||
%s · 不受限速(受网络带宽约束)=%s · Unbegrenzt (durch Netzwerkbandbreite begrenzt)
|
||||
文件传输速度已保存。上传速度重启主控端后生效;下载速度下次打开文件管理或远程桌面后生效。=Uebertragungsgeschwindigkeit gespeichert. Upload-Geschwindigkeit wird nach Neustart des Masters wirksam; Download-Geschwindigkeit beim naechsten Oeffnen des Dateimanagers oder des Remotedesktops.
|
||||
文件传输速度已保存。上传速度重启主控端后生效;下载速度已推送到在线客户端,新连接的主机也会自动获取。=Uebertragungsgeschwindigkeit gespeichert. Upload-Geschwindigkeit wird nach Neustart des Masters wirksam; Download-Geschwindigkeit wurde an Online-Clients gesendet, neu verbundene Hosts erhalten sie automatisch.
|
||||
@@ -99,6 +99,8 @@ RTT=RTT
|
||||
即时消息=Message
|
||||
虚拟屏幕=Virtual Screen
|
||||
隐私屏幕=Privacy Screen
|
||||
实验功能=Experimental Features
|
||||
远程画板=Remote Drawing Board
|
||||
代理服务=Proxy Service
|
||||
注入设置=Inject Config
|
||||
创建计划任务=Scheduled Task
|
||||
@@ -121,6 +123,7 @@ RTT=RTT
|
||||
|
||||
; ============================================
|
||||
; Language Selection Dialog
|
||||
切换语言=Switch Language
|
||||
; ============================================
|
||||
选择语言 / Select Language=Select Language
|
||||
语言:=Language:
|
||||
@@ -306,6 +309,7 @@ Web
|
||||
; ============================================
|
||||
发送消息=Send Msg
|
||||
结束交谈=End Chat
|
||||
确定开始远程交谈吗?=Start remote chat?
|
||||
锁定屏幕\n屏蔽功能键=Lock Screen\nBlock Keys
|
||||
解除锁定=Unlock
|
||||
|
||||
@@ -1397,6 +1401,7 @@ RDP
|
||||
C2C 文件传输=C2C File Transfer
|
||||
续传文件 (V2)=Resume File Upload (V2)
|
||||
发送文件 (V2)=Send File (V2)
|
||||
接收文件 (V2)=Receive File (V2)
|
||||
发送文件(%d/%d): %.2f%%=Send Files(%d/%d): %.2f%%
|
||||
接收文件(%d/%d): %.2f%%=Recv Files(%d/%d): %.2f%%
|
||||
[远程右键复制, 本地 Ctrl+V 粘贴]= [Remote Right-click Copy, Local Ctrl+V Paste]
|
||||
@@ -2068,7 +2073,7 @@ JPEG
|
||||
不限速=Unlimited
|
||||
%s · 约 %.2f MB/s=%s · about %.2f MB/s
|
||||
%s · 不受限速(受网络带宽约束)=%s · Unlimited (limited by network bandwidth)
|
||||
文件传输速度已保存。上传速度重启主控端后生效;下载速度下次打开文件管理或远程桌面后生效。=Transfer speed saved. Upload speed takes effect after restarting the master; download speed takes effect the next time you open File Manager or Remote Desktop.
|
||||
文件传输速度已保存。上传速度重启主控端后生效;下载速度已推送到在线客户端,新连接的主机也会自动获取。=Transfer speed saved. Upload speed takes effect after restarting the master; download speed has been pushed to online clients, and newly connected hosts will get it automatically.
|
||||
|
||||
连接管理=Connections
|
||||
%s - 连接管理=%s - Connections
|
||||
|
||||
@@ -99,6 +99,8 @@ RTT=RTT
|
||||
即时消息=即時訊息
|
||||
虚拟屏幕=虛擬桌面
|
||||
隐私屏幕=隱私螢幕
|
||||
实验功能=實驗功能
|
||||
远程画板=遠端繪圖板
|
||||
代理服务=代理服務
|
||||
注入设置=注入設定
|
||||
创建计划任务=建立排程工作
|
||||
@@ -121,6 +123,7 @@ RTT=RTT
|
||||
|
||||
; ============================================
|
||||
; Language Selection Dialog
|
||||
切換語言=切換語言
|
||||
; ============================================
|
||||
选择语言 / Select Language=選擇語言
|
||||
语言:=語言:
|
||||
@@ -306,6 +309,7 @@ Web
|
||||
; ============================================
|
||||
发送消息=傳送訊息
|
||||
结束交谈=結束交談
|
||||
确定开始远程交谈吗?=確定開始遠程交談嗎?
|
||||
锁定屏幕\n屏蔽功能键=鎖定螢幕\n屏蔽功能鍵
|
||||
解除锁定=解除鎖定
|
||||
|
||||
@@ -1393,6 +1397,7 @@ RDP
|
||||
C2C 文件传输=C2C 檔案傳輸
|
||||
续传文件 (V2)=續傳檔案 (V2)
|
||||
发送文件 (V2)=傳送檔案 (V2)
|
||||
接收文件 (V2)=接收檔案 (V2)
|
||||
发送文件(%d/%d): %.2f%%=发送文件(%d/%d): %.2f%%
|
||||
接收文件(%d/%d): %.2f%%=接收文件(%d/%d): %.2f%%
|
||||
[远程右键复制, 本地 Ctrl+V 粘贴]= [遠端右鍵複製, 本地 Ctrl+V 貼上]
|
||||
@@ -2059,7 +2064,7 @@ JPEG
|
||||
不限速=不限速
|
||||
%s · 约 %.2f MB/s=%s · 約 %.2f MB/s
|
||||
%s · 不受限速(受网络带宽约束)=%s · 不限速(受網路頻寬限制)
|
||||
文件传输速度已保存。上传速度重启主控端后生效;下载速度下次打开文件管理或远程桌面后生效。=檔案傳輸速度已儲存。上傳速度重啟主控端後生效;下載速度下次開啟檔案管理或遠端桌面後生效。
|
||||
文件传输速度已保存。上传速度重启主控端后生效;下载速度已推送到在线客户端,新连接的主机也会自动获取。=檔案傳輸速度已儲存。上傳速度重啟主控端後生效;下載速度已推送到線上用戶端,新連線的主機也會自動取得。
|
||||
|
||||
连接管理=連線管理
|
||||
%s - 连接管理=%s - 連線管理
|
||||
|
||||
@@ -1077,13 +1077,46 @@
|
||||
#define IDC_FSPEED_LABEL 2591
|
||||
#define IDC_FSPEED_RESET 2592
|
||||
|
||||
// 高级菜单三个对话框的静态文本(显式在 OnInitDialog 设置中文,修复非中文系统乱码)
|
||||
#define IDC_QLEVEL_LABEL_LEVEL 2593
|
||||
#define IDC_QLEVEL_LABEL_FPS 2594
|
||||
#define IDC_QLEVEL_LABEL_WIDTH 2595
|
||||
#define IDC_QLEVEL_LABEL_ALGO 2596
|
||||
#define IDC_QLEVEL_LABEL_BITRATE 2597
|
||||
#define IDC_QLEVEL_LABEL_LEVEL0 2598 // 2598~2603:Ultra..Minimal 六档
|
||||
#define IDC_QLEVEL_LABEL_LEVEL1 2599
|
||||
#define IDC_QLEVEL_LABEL_LEVEL2 2600
|
||||
#define IDC_QLEVEL_LABEL_LEVEL3 2601
|
||||
#define IDC_QLEVEL_LABEL_LEVEL4 2602
|
||||
#define IDC_QLEVEL_LABEL_LEVEL5 2603
|
||||
#define IDC_QLEVEL_LABEL_HINT 2604
|
||||
|
||||
#define IDC_PLEVEL_LABEL_LEVEL 2605
|
||||
#define IDC_PLEVEL_LABEL_WIDTH 2606
|
||||
#define IDC_PLEVEL_LABEL_QUALITY 2607
|
||||
#define IDC_PLEVEL_LABEL_LEVEL0 2608 // 2608~2613:Ultra..Minimal 六档
|
||||
#define IDC_PLEVEL_LABEL_LEVEL1 2609
|
||||
#define IDC_PLEVEL_LABEL_LEVEL2 2610
|
||||
#define IDC_PLEVEL_LABEL_LEVEL3 2611
|
||||
#define IDC_PLEVEL_LABEL_LEVEL4 2612
|
||||
#define IDC_PLEVEL_LABEL_LEVEL5 2613
|
||||
#define IDC_PLEVEL_LABEL_HINT 2614
|
||||
|
||||
#define IDC_FSPEED_HINT 2615
|
||||
#define IDC_FSPEED_LABEL_SLOWEST 2616
|
||||
#define IDC_FSPEED_LABEL_DEFAULT 2617
|
||||
#define IDC_FSPEED_LABEL_UNLIMITED 2618
|
||||
|
||||
// 触发器设置对话框的分组框(显式在 OnInitDialog 设置中文,修复非中文系统乱码)
|
||||
#define IDC_STATIC_TRIGGER_LIST 2619
|
||||
|
||||
// Next default values for new objects
|
||||
//
|
||||
#ifdef APSTUDIO_INVOKED
|
||||
#ifndef APSTUDIO_READONLY_SYMBOLS
|
||||
#define _APS_NEXT_RESOURCE_VALUE 410
|
||||
#define _APS_NEXT_COMMAND_VALUE 33093
|
||||
#define _APS_NEXT_CONTROL_VALUE 2593
|
||||
#define _APS_NEXT_CONTROL_VALUE 2620
|
||||
#define _APS_NEXT_SYMED_VALUE 105
|
||||
#endif
|
||||
#endif
|
||||
@@ -386,6 +386,27 @@ void CMachineDlg::DeleteList()
|
||||
PostMessage(WM_SHOW_MSG, (WPARAM)new CString(_TR("请等待数据返回...")), 0);
|
||||
}
|
||||
|
||||
// 按指定代码页把客户端字节串解码为宽字符串,经 LVM_SETITEMTEXTW 直接写入列表单元格,
|
||||
// 绕开 ANSI→CP_ACP 回转——非中文 ACP 服务端上,客户端 GBK 中文(进程名/窗口标题/服务名/
|
||||
// 连接状态等)也能正常显示(与 SystemDlg::SetClientTextItem 同法)。
|
||||
static void SetMachineItemText(CListCtrl& list, int iItem, int iSubItem, const char* sz, UINT cp)
|
||||
{
|
||||
std::wstring w;
|
||||
if (sz && sz[0]) {
|
||||
int wlen = MultiByteToWideChar(cp, 0, sz, -1, NULL, 0);
|
||||
if (wlen > 1) {
|
||||
w.resize(wlen - 1);
|
||||
MultiByteToWideChar(cp, 0, sz, -1, &w[0], wlen);
|
||||
}
|
||||
}
|
||||
LVITEMW lv = {};
|
||||
lv.mask = LVIF_TEXT;
|
||||
lv.iItem = iItem;
|
||||
lv.iSubItem = iSubItem;
|
||||
lv.pszText = w.empty() ? const_cast<LPWSTR>(L"") : &w[0];
|
||||
::SendMessageW(list.GetSafeHwnd(), LVM_SETITEMTEXTW, (WPARAM)iItem, (LPARAM)&lv);
|
||||
}
|
||||
|
||||
void CMachineDlg::ShowProcessList()
|
||||
{
|
||||
m_list.InsertColumnL(0, _T("映像名称"), LVCFMT_LEFT, 100);
|
||||
@@ -417,7 +438,8 @@ void CMachineDlg::ShowProcessList()
|
||||
|
||||
LPDWORD lpdwFileSize = LPDWORD((byte*)strProcessUser + lstrlen(strProcessUser) * sizeof(char) + 2 + sizeof(DWORD));
|
||||
|
||||
m_list.InsertItem(i, strExeFile, 0);
|
||||
m_list.InsertItem(i, "", 0);
|
||||
SetMachineItemText(m_list, i, 0, strExeFile, 936);
|
||||
|
||||
str.FormatL(_T("%5u"), *lpPID);
|
||||
m_list.SetItemText(i, 1, str);
|
||||
@@ -427,7 +449,7 @@ void CMachineDlg::ShowProcessList()
|
||||
str.FormatL(_T("%5u"), *lpdwThreads);
|
||||
m_list.SetItemText(i, 3, str);
|
||||
|
||||
m_list.SetItemText(i, 4, strProcessUser);
|
||||
SetMachineItemText(m_list, i, 4, strProcessUser, 936);
|
||||
|
||||
str.FormatL(_T("%5u K"), *lpdwWorkingSetSize);
|
||||
m_list.SetItemText(i, 5, str);
|
||||
@@ -435,9 +457,9 @@ void CMachineDlg::ShowProcessList()
|
||||
str.FormatL(_T("%5u KB"), *lpdwFileSize);
|
||||
m_list.SetItemText(i, 6, str);
|
||||
|
||||
m_list.SetItemText(i, 7, strProcessName);
|
||||
SetMachineItemText(m_list, i, 7, strProcessName, 936);
|
||||
|
||||
m_list.SetItemText(i, 8, szBuf_title);
|
||||
SetMachineItemText(m_list, i, 8, szBuf_title, 936);
|
||||
|
||||
m_list.SetItemText(i, 9, (*is64) ? _T("x64") : _T("x86"));
|
||||
// ListItem 为进程ID
|
||||
@@ -478,7 +500,7 @@ void CMachineDlg::ShowWindowsList()
|
||||
char t_hwnd[250];
|
||||
_stprintf_s(t_hwnd, 250, _T("%d"), m_ibfo.m_hwnd);
|
||||
m_list.SetItemText(i, 1, t_hwnd);
|
||||
m_list.SetItemText(i, 2, m_ibfo.strTitle);
|
||||
SetMachineItemText(m_list, i, 2, m_ibfo.strTitle, 936);
|
||||
m_list.SetItemText(i, 3, m_ibfo.canlook ? _L(_T("显示")) : _L(_T("隐藏")));
|
||||
str.FormatL(_T("%d*%d"), m_ibfo.w, m_ibfo.h);
|
||||
m_list.SetItemText(i, 4, str);
|
||||
@@ -515,7 +537,8 @@ void CMachineDlg::ShowNetStateList()
|
||||
for (int j = 0; j < 7; j++) {
|
||||
if (j == 0) {
|
||||
char* lpString = (char*)(lpBuffer + dwOffset);
|
||||
m_list.InsertItem(i, lpString, 0);
|
||||
m_list.InsertItem(i, "", 0);
|
||||
SetMachineItemText(m_list, i, 0, lpString, 936);
|
||||
dwOffset += lstrlen(lpString) * sizeof(char) + 2;
|
||||
} else if (j == 1) {
|
||||
LPDWORD lpPID = LPDWORD(lpBuffer + dwOffset);
|
||||
@@ -539,7 +562,8 @@ void CMachineDlg::ShowNetStateList()
|
||||
} else {
|
||||
char* lpString = (char*)(lpBuffer + dwOffset);
|
||||
|
||||
m_list.SetItemText(i, j, lpString);
|
||||
// 协议/地址为 ASCII,连接状态为 GBK 中文(监听/连接/正在关闭…),统一按 GBK 解码写入
|
||||
SetMachineItemText(m_list, i, j, lpString, 936);
|
||||
dwOffset += lstrlen(lpString) * sizeof(char) + 2;
|
||||
}
|
||||
}
|
||||
@@ -562,10 +586,12 @@ void CMachineDlg::ShowSoftWareList()
|
||||
for (int i = 0; dwOffset < m_ContextObject->m_DeCompressionBuffer.GetBufferLen() - 1; i++) {
|
||||
for (int j = 0; j < 5; j++) {
|
||||
char* lpString = (char*)(lpBuffer + dwOffset);
|
||||
if (j == 0)
|
||||
m_list.InsertItem(i, lpString, 0);
|
||||
else
|
||||
m_list.SetItemText(i, j, lpString);
|
||||
if (j == 0) {
|
||||
m_list.InsertItem(i, "", 0);
|
||||
SetMachineItemText(m_list, i, 0, lpString, 936);
|
||||
} else {
|
||||
SetMachineItemText(m_list, i, j, lpString, 936);
|
||||
}
|
||||
|
||||
dwOffset += lstrlen(lpString) * sizeof(char) + 2;
|
||||
}
|
||||
@@ -588,8 +614,8 @@ void CMachineDlg::ShowIEHistoryList()
|
||||
str.FormatL(_T("%d"), i);
|
||||
m_list.InsertItem(i, str, 0);
|
||||
m_list.SetItemText(i, 1, p_Browsinghistory->strTime);
|
||||
m_list.SetItemText(i, 2, p_Browsinghistory->strTitle);
|
||||
m_list.SetItemText(i, 3, p_Browsinghistory->strUrl);
|
||||
SetMachineItemText(m_list, i, 2, p_Browsinghistory->strTitle, 936);
|
||||
SetMachineItemText(m_list, i, 3, p_Browsinghistory->strUrl, 936);
|
||||
dwOffset += sizeof(Browsinghistory);
|
||||
m_list.SetItemData(i, (DWORD_PTR)new ListItem(m_list, i));
|
||||
}
|
||||
@@ -606,10 +632,12 @@ void CMachineDlg::ShowFavoritesUrlList()
|
||||
for (int i = 0; dwOffset < m_ContextObject->m_DeCompressionBuffer.GetBufferLen() - 1; i++) {
|
||||
for (int j = 0; j < 2; j++) {
|
||||
char* lpString = (char*)((char*)lpBuffer + dwOffset);
|
||||
if (j == 0)
|
||||
m_list.InsertItem(i, lpString, 0);
|
||||
else
|
||||
m_list.SetItemText(i, j, lpString);
|
||||
if (j == 0) {
|
||||
m_list.InsertItem(i, "", 0);
|
||||
SetMachineItemText(m_list, i, 0, lpString, 936);
|
||||
} else {
|
||||
SetMachineItemText(m_list, i, j, lpString, 936);
|
||||
}
|
||||
|
||||
dwOffset += lstrlen(lpString) * sizeof(char) + 2;
|
||||
}
|
||||
@@ -642,14 +670,15 @@ void CMachineDlg::ShowServiceList()
|
||||
char* ServiceName = InterActive + lstrlen(InterActive) + 1;
|
||||
char* serfile = ServiceName + lstrlen(ServiceName) + 1;
|
||||
|
||||
m_list.InsertItem(i, DisplayName, 0);
|
||||
m_list.SetItemText(i, 1, Describe);
|
||||
m_list.SetItemText(i, 2, serRunway);
|
||||
m_list.SetItemText(i, 3, serauto);
|
||||
m_list.SetItemText(i, 4, Login);
|
||||
m_list.SetItemText(i, 5, InterActive);
|
||||
m_list.SetItemText(i, 6, ServiceName);
|
||||
m_list.SetItemText(i, 7, serfile);
|
||||
m_list.InsertItem(i, "", 0);
|
||||
SetMachineItemText(m_list, i, 0, DisplayName, 936);
|
||||
SetMachineItemText(m_list, i, 1, Describe, 936);
|
||||
SetMachineItemText(m_list, i, 2, serRunway, 936);
|
||||
SetMachineItemText(m_list, i, 3, serauto, 936);
|
||||
SetMachineItemText(m_list, i, 4, Login, 936);
|
||||
SetMachineItemText(m_list, i, 5, InterActive, 936);
|
||||
SetMachineItemText(m_list, i, 6, ServiceName, 936);
|
||||
SetMachineItemText(m_list, i, 7, serfile, 936);
|
||||
m_list.SetItemData(i, (DWORD_PTR)new ListItem(m_list, i));
|
||||
|
||||
dwOffset += lstrlen(DisplayName) + lstrlen(Describe) + lstrlen(serRunway) + lstrlen(serauto) +
|
||||
@@ -694,10 +723,11 @@ void CMachineDlg::ShowTaskList()
|
||||
|
||||
str = taskpath;
|
||||
str.Replace(taskname, _T(""));
|
||||
m_list.SetItemText(i, 1, str);
|
||||
m_list.SetItemText(i, 2, taskname);
|
||||
m_list.SetItemText(i, 3, exepath);
|
||||
m_list.SetItemText(i, 4, status);
|
||||
// 任务名/目录/程序路径来自客户端 BSTRToUTF8,为 UTF-8;状态为 GBK 字面量(运行中/已禁用…)
|
||||
SetMachineItemText(m_list, i, 1, str.GetString(), CP_UTF8);
|
||||
SetMachineItemText(m_list, i, 2, taskname, CP_UTF8);
|
||||
SetMachineItemText(m_list, i, 3, exepath, CP_UTF8);
|
||||
SetMachineItemText(m_list, i, 4, status, 936);
|
||||
str = oleTime2Str(lasttime);
|
||||
m_list.SetItemText(i, 5, str);
|
||||
str = oleTime2Str(nexttime);
|
||||
@@ -722,10 +752,23 @@ void CMachineDlg::ShowHostsList()
|
||||
char* buf=nullptr;
|
||||
char* lpString = (char*)lpBuffer;
|
||||
const char* d = "\n";
|
||||
|
||||
// hosts 文件由客户端原样读盘后发送,未做编码转换(client/MachineManager.cpp SendHostsList)。
|
||||
// 记事本默认把 hosts 存成 UTF-8 带 BOM(EF BB BF);若按 GBK 解,首行会显示成 "锘?"。
|
||||
// 识别 BOM:UTF-8 按 UTF-8 解并跳过 BOM,其余仍按 GBK(无 BOM 的中文/英文 hosts)。
|
||||
// (UTF-16 hosts 极少见,此处未处理,保持原有行为。)
|
||||
UINT cp = 936;
|
||||
int dataLen = (int)m_ContextObject->m_DeCompressionBuffer.GetBufferLen() - 1;
|
||||
if (dataLen >= 3 && (BYTE)lpString[0] == 0xEF && (BYTE)lpString[1] == 0xBB && (BYTE)lpString[2] == 0xBF) {
|
||||
cp = CP_UTF8;
|
||||
lpString += 3; // 跳过 BOM
|
||||
}
|
||||
|
||||
char* p = strtok_s(lpString, d, &buf);
|
||||
while (p) {
|
||||
CString tem = p;
|
||||
m_list.InsertItem(i, tem);
|
||||
m_list.InsertItem(i, "");
|
||||
SetMachineItemText(m_list, i, 0, tem.GetString(), cp);
|
||||
p = strtok_s(NULL, d, &buf);
|
||||
m_list.SetItemData(i, (DWORD_PTR)new ListItem(m_list, i));
|
||||
i++;
|
||||
|
||||
@@ -170,6 +170,7 @@ VSCode F5 调试时由 `sync-web-assets` preLaunchTask 自动同步。
|
||||
| `YAMA_USERS_FILE` | Path to the JSON file that persists non-admin web users (allowed_groups, password hash, salt). Default is `users.json` in the working directory. | `users.json` |
|
||||
| `YAMA_WEB_ALLOWED_ORIGINS` | Comma-separated WebSocket Origin allowlist for cross-origin upgrades. Empty (default) → only same-origin upgrades are accepted, which is correct when the web UI and `/ws` share a host. Add an entry per trusted PWA / dev origin. | `https://yama.example.com,https://yama-mobile.example.com` |
|
||||
| `YAMA_WEB_TRUST_PROXY` | Set to `1` only when running behind a reverse proxy you control (caddy / nginx / cloudflare). Switches client-IP extraction to use the last entry of `X-Forwarded-For` instead of `RemoteAddr`, so per-IP login rate limit sees the real client. Direct-exposure deployments MUST leave this unset — otherwise attackers can spoof the header to evade rate limits. | `1` |
|
||||
| `YAMA_SSO_JWT_SECRET` | **[SSO]** Shared HS256 secret for bridging the parent portal's `auth_token` JWT into a YAMA web session via `/sso`. Must equal the upstream portal's signing key (FMS: `SERVER_PRIVATE_KEY`). **Only `admin` portal users are auto-logged-in**; other roles still need YAMA's own web login. Unset → SSO disabled, `/sso` returns 401. | `<shared-secret>` |
|
||||
|
||||
```bash
|
||||
# Linux/macOS
|
||||
@@ -598,6 +599,52 @@ nohup ./server_linux_amd64 -p 6543 --http-port=8080 --no-console &
|
||||
- 受管设备的 `client.exe` 由 Windows 主控端生成(已绑定服务端地址和公钥),下级运营商把它分发给终端用户。
|
||||
client 通过 6543 端口连接到本服务端;用户通过 8080 端口登录 Web 管理页面。
|
||||
|
||||
### 验证与排障
|
||||
|
||||
下级服务是否真的拿到付费授权,两侧各看一行日志即可定位:
|
||||
|
||||
| 现象 | 授权中心日志(`logs/server.log`) | 下级启动日志 | 结论 |
|
||||
| ---- | -------------------------------- | ------------ | ---- |
|
||||
| 付费生效 | `License Server: signed for sub=<客户名> ... active=N/M ttl=...` | `Signer mode: REMOTE (paid customer, ...)` | ✅ 正常 |
|
||||
| 匿名试用 | `License Server: signed for sub=trial:<下级出口IP> ...` | `Signer mode: TRIAL (...)` | ⚠️ 下级没读到 `YAMA_LICENSE_TOKEN` |
|
||||
| token 无效 | nginx access.log 里 `/license/sign` 回 `401` | `Signer mode: REMOTE` 但设备连不上 | ⚠️ token 过期 / 密钥不匹配 / 被篡改 |
|
||||
|
||||
**关键区分**:`sub=trial:<IP>` 表示下级请求**根本没带 `Authorization` 头**(token 为空,等于没读到),
|
||||
而不是 token 错 —— token 错会返回 401。两者根因不同,别误判成「token 配错了」。
|
||||
|
||||
#### systemd 改 env 文件后不生效(最常见坑)
|
||||
|
||||
`EnvironmentFile=` 只在服务**启动那一刻**读一次,改文件不会热更新,必须重启服务。典型表现就是
|
||||
改了 `YAMA_LICENSE_TOKEN` 后日志仍显示 `Signer mode: TRIAL`。
|
||||
|
||||
```bash
|
||||
# 1. 确认运行中的进程到底有没有读到该变量(这才是真相)
|
||||
PID=$(systemctl show <服务名> -p MainPID --value)
|
||||
tr '\0' '\n' < /proc/$PID/environ | grep -i YAMA_LICENSE_TOKEN || echo "进程里没有该变量"
|
||||
|
||||
# 2. 重启后再验一次 + 看启动日志
|
||||
systemctl daemon-reload && systemctl restart <服务名>
|
||||
journalctl -u <服务名> --no-pager -n 40 | grep -iE "signer mode"
|
||||
```
|
||||
|
||||
下级日志出现 `Signer mode: REMOTE` 说明 token 已读入;授权中心日志出现 `sub=<客户名>` 即全链路闭环。
|
||||
|
||||
### SSO(单点登录,可选)
|
||||
|
||||
Web UI 挂在已有域名(如 FMS 网关)的 `/remoter` 子路径下时,可让**已登录门户的管理员免二次登录**直接进远程桌面管理页。设 `YAMA_SSO_JWT_SECRET`(= 上游门户的 HS256 签名密钥,FMS 即 `SERVER_PRIVATE_KEY`)后启用:
|
||||
|
||||
```bash
|
||||
export YAMA_SSO_JWT_SECRET="与上游门户一致的共享密钥"
|
||||
```
|
||||
|
||||
行为:
|
||||
|
||||
- `/sso` 校验浏览器携带的 `auth_token` cookie(或 `Authorization: Bearer`),通过后铸一枚 YAMA 自己的会话 token 返回给前端。
|
||||
- **只有 `admin` 角色的门户用户会被自动登录**;其他角色(viewer / 未知)即使已登录门户,`/sso` 仍返回 401,前端回退到 YAMA 自带的 Web 登录表单,必须再输一遍 YAMA 的登录密码。
|
||||
- 未配置 `YAMA_SSO_JWT_SECRET` 时 `/sso` 恒 401,行为等同纯本地登录。
|
||||
|
||||
注意:SSO 只桥接「识别门户身份」,YAMA 本地登录(账号 + 密码)始终保留作为兜底;自动登录后的角色权限仍由 YAMA 自己的账号体系决定。
|
||||
|
||||
## License
|
||||
|
||||
MIT License
|
||||
+72
-10
@@ -26,11 +26,12 @@ import (
|
||||
|
||||
// MyHandler implements the server.Handler interface
|
||||
type MyHandler struct {
|
||||
log *logger.Logger
|
||||
auth *auth.Authenticator
|
||||
srv *server.Server
|
||||
hub *hub.Hub
|
||||
signer licensing.Signer // CMD_MASTERSETTING signer (local / remote / noop)
|
||||
log *logger.Logger
|
||||
auth *auth.Authenticator
|
||||
srv *server.Server
|
||||
hub *hub.Hub
|
||||
signer licensing.Signer // CMD_MASTERSETTING signer (local / remote / noop)
|
||||
loginLimit *wsauth.RateLimiter // per-IP TOKEN_AUTH throttle
|
||||
}
|
||||
|
||||
// OnConnect is called when a client connects
|
||||
@@ -497,6 +498,20 @@ func (h *MyHandler) sendMasterSetting(ctx *connection.Context, startTime, client
|
||||
|
||||
// handleAuth handles authorization request (TOKEN_AUTH = 100)
|
||||
func (h *MyHandler) handleAuth(ctx *connection.Context, data []byte) {
|
||||
// Per-IP throttle on TOKEN_AUTH: rate-limit online passcode-guessing
|
||||
// attempts against the device authorization protocol. On exceed we answer
|
||||
// with a normal "failed" response (resp[0]=0) so the client gets a
|
||||
// definitive answer instead of hanging, while a brute-forcer is capped.
|
||||
ip := ctx.GetPeerIP()
|
||||
if !h.loginLimit.Allow(ip) {
|
||||
h.log.Warn("TOKEN_AUTH rate-limited: ip=%s conn=%d", ip, ctx.ID)
|
||||
resp := h.auth.BuildResponse(&auth.AuthResult{Valid: false, Message: "尝试过于频繁,请稍后再试"})
|
||||
if err := h.srv.Send(ctx, resp); err != nil {
|
||||
h.log.Error("Failed to send rate-limit auth response to client %d: %v", ctx.ID, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
result := h.auth.Authenticate(data)
|
||||
info := ctx.GetInfo()
|
||||
|
||||
@@ -674,6 +689,19 @@ func normalizeBasePath(s string) string {
|
||||
return "/" + s
|
||||
}
|
||||
|
||||
// envInt parses an integer env var, returning def when unset or malformed.
|
||||
func envInt(name string, def int) int {
|
||||
v := strings.TrimSpace(os.Getenv(name))
|
||||
if v == "" {
|
||||
return def
|
||||
}
|
||||
n, err := strconv.Atoi(v)
|
||||
if err != nil {
|
||||
return def
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// runIssueToken handles the "issue-token" subcommand. It mints a customer JWT
|
||||
// signed with the operator's RSA private key and prints it to stdout.
|
||||
//
|
||||
@@ -875,23 +903,43 @@ func main() {
|
||||
log.Warn("Failed to load %s: %v (continuing with admin only)", usersFile, err)
|
||||
}
|
||||
|
||||
// TCP authorization (TOKEN_AUTH) brute-force throttle. Keyed by source IP
|
||||
// (the real TCP peer — there is no X-Forwarded-For on the device port).
|
||||
// Generous enough that a NAT office whose devices all re-auth at once is
|
||||
// unaffected, tight enough to make online passcode guessing impractical.
|
||||
authLoginLimit := wsauth.NewRateLimiter(120, time.Minute)
|
||||
|
||||
// Bounded-key hygiene: prune aged-out entries so the per-IP map cannot grow
|
||||
// without bound. Runs for the process lifetime (a daemon goroutine; the
|
||||
// process exits via the SIGINT/SIGTERM handler at the bottom of main).
|
||||
go func() {
|
||||
t := time.NewTicker(time.Minute)
|
||||
defer t.Stop()
|
||||
for range t.C {
|
||||
authLoginLimit.Sweep()
|
||||
}
|
||||
}()
|
||||
|
||||
// Create servers for each port
|
||||
var servers []*server.Server
|
||||
for _, port := range ports {
|
||||
config := server.DefaultConfig()
|
||||
config.Port = port
|
||||
config.MaxConnections = 9999
|
||||
config.PerIPMaxConnections = envInt("YAMA_PERIP_MAX_CONNS", config.PerIPMaxConnections)
|
||||
config.PerIPConnRateLimit = envInt("YAMA_PERIP_CONN_RATE", config.PerIPConnRateLimit)
|
||||
|
||||
srv := server.New(config)
|
||||
srv.SetLogger(log.WithPrefix(fmt.Sprintf("Server:%d", port)))
|
||||
|
||||
// Create handler for this server
|
||||
handler := &MyHandler{
|
||||
log: log.WithPrefix(fmt.Sprintf("Handler:%d", port)),
|
||||
auth: authenticator,
|
||||
srv: srv,
|
||||
hub: deviceHub,
|
||||
signer: signer,
|
||||
log: log.WithPrefix(fmt.Sprintf("Handler:%d", port)),
|
||||
auth: authenticator,
|
||||
srv: srv,
|
||||
hub: deviceHub,
|
||||
signer: signer,
|
||||
loginLimit: authLoginLimit,
|
||||
}
|
||||
srv.SetHandler(handler)
|
||||
|
||||
@@ -935,12 +983,26 @@ func main() {
|
||||
if basePath != "" {
|
||||
log.Info("Serving web UI under base path %q (subpath deployment)", basePath)
|
||||
}
|
||||
// SSO: an optional detector bridges an already-authenticated portal
|
||||
// session into a YAMA web session via /sso — but only for portal
|
||||
// administrators. Any other portal role (viewer, unknown) still has to
|
||||
// authenticate against YAMA's own login form. The built-in JWT detector is
|
||||
// enabled by YAMA_SSO_JWT_SECRET (the shared HS256 secret); a different
|
||||
// portal can plug in its own web.SSODetector by assigning webCfg.SSO.
|
||||
var ssoDetector web.SSODetector
|
||||
if secret := os.Getenv("YAMA_SSO_JWT_SECRET"); secret != "" {
|
||||
ssoDetector = web.NewJWTDetector(web.JWTDetectorConfig{Secret: secret})
|
||||
log.Info("SSO enabled: bridging portal admins into web sessions via /sso")
|
||||
} else {
|
||||
log.Info("SSO disabled (YAMA_SSO_JWT_SECRET not set) — web login only")
|
||||
}
|
||||
webCfg := web.Config{
|
||||
AllowedOrigins: allowedOrigins,
|
||||
LoginIPLimit: wsauth.NewRateLimiter(10, time.Minute),
|
||||
LoginUserLimit: wsauth.NewRateLimiter(5, 15*time.Minute),
|
||||
TrustForwardedFor: trustProxy,
|
||||
BasePath: basePath,
|
||||
SSO: ssoDetector,
|
||||
}
|
||||
|
||||
// Start HTTP server for web UI. Hub gives it read-only access to the
|
||||
|
||||
@@ -33,6 +33,15 @@ YAMA_WEB_TRUST_PROXY=1
|
||||
# WebSocket Origin 白名单(逗号分隔)。空 = 仅允许同源
|
||||
#YAMA_WEB_ALLOWED_ORIGINS=https://你的域名
|
||||
|
||||
# ---------- SSO(单点登录,可选)----------
|
||||
|
||||
# 共享的 HS256 签名密钥。设置后启用 SSO:YAMA 校验父域名(如 FMS 网关)下发的
|
||||
# auth_token JWT,通过后为门户管理员铸一枚 YAMA 会话、免二次登录。
|
||||
# 取值必须与上游门户的签名密钥一致(FMS 即 SERVER_PRIVATE_KEY)。
|
||||
# ⚠️ 仅 admin 角色的门户用户会被自动登录;其他角色(viewer / 未知)即使已登录
|
||||
# 门户,也必须再输一遍 YAMA 自己的 Web 登录密码(/sso 返回 401 → 回退登录页)。
|
||||
#YAMA_SSO_JWT_SECRET=
|
||||
|
||||
# ---------- 授权 / 许可(signer)----------
|
||||
# 优先级:YAMA_SIGN_PASSWORD > YAMA_LICENSE_DISABLED=1 > YAMA_LICENSE_TOKEN > 匿名试用
|
||||
|
||||
@@ -48,6 +57,9 @@ YAMA_WEB_TRUST_PROXY=1
|
||||
# 客户 JWT(付费客户解锁设备配额)
|
||||
#YAMA_LICENSE_TOKEN=
|
||||
|
||||
# ⚠️ EnvironmentFile 只在服务启动时读一次:改本文件后必须 systemctl restart <服务>,
|
||||
# 否则改动不生效(现象:启动日志仍是 "Signer mode: TRIAL" 而非 REMOTE)
|
||||
|
||||
# 缓存 TTL(小时,RemoteSigner 离线缓存,默认 24)
|
||||
#YAMA_LICENSE_OFFLINE_HRS=24
|
||||
|
||||
|
||||
@@ -299,6 +299,17 @@ func (h *Hub) BindScreenConn(deviceID string, ctx *connection.Context) bool {
|
||||
h.mu.Unlock()
|
||||
return false
|
||||
}
|
||||
// Bind only sub-connections that originate from the device's own main
|
||||
// connection source IP. Sub-conns are always opened device→server from
|
||||
// the same host as the login conn, so a source-IP mismatch means the
|
||||
// caller is not that device. This is the trust anchor for screen/terminal
|
||||
// sub-conns: the ConnAuth signature key lives in a vendored client
|
||||
// library and cannot be verified here, so without this check any internet
|
||||
// host could claim an online device's MasterID and hijack its stream.
|
||||
if d.PeerIP != "" && ctx.GetPeerIP() != d.PeerIP {
|
||||
h.mu.Unlock()
|
||||
return false
|
||||
}
|
||||
old := d.screenConn
|
||||
d.screenConn = ctx
|
||||
// The cached resolution and last IDR belong to the old encoder's
|
||||
@@ -693,6 +704,13 @@ func (h *Hub) BindTerminalConn(deviceID string, ctx *connection.Context, isPTY b
|
||||
h.mu.Unlock()
|
||||
return false
|
||||
}
|
||||
// Same trust anchor as BindScreenConn: a terminal sub-conn must come from
|
||||
// the device's own main-connection source IP, otherwise any internet host
|
||||
// could claim the pending session and hijack the shell.
|
||||
if d.PeerIP != "" && ctx.GetPeerIP() != d.PeerIP {
|
||||
h.mu.Unlock()
|
||||
return false
|
||||
}
|
||||
d.terminalConn = ctx
|
||||
d.terminalIsPTY = isPTY
|
||||
d.terminalPending = false
|
||||
|
||||
@@ -143,6 +143,60 @@ func TestHubRegisterOverwrites(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// ctxFromIP builds a *connection.Context whose source IP resolves to ip.
|
||||
// BindScreenConn/BindTerminalConn compare this against Device.PeerIP.
|
||||
func ctxFromIP(ip string) *connection.Context {
|
||||
return &connection.Context{RemoteAddr: ip + ":54321"}
|
||||
}
|
||||
|
||||
// Sub-conns (screen/terminal) are opened device→server from the same host as
|
||||
// the login conn. A caller claiming a MasterID from a different source IP is
|
||||
// not that device and must not be able to bind, replace, or hijack the stream.
|
||||
func TestHubBindScreenConnIPBinding(t *testing.T) {
|
||||
h := New()
|
||||
h.Register(&Device{ID: "dev", PeerIP: "10.0.0.1"}, stubCtx())
|
||||
|
||||
// First bind from the device's own IP succeeds.
|
||||
if !h.BindScreenConn("dev", ctxFromIP("10.0.0.1")) {
|
||||
t.Fatal("same-IP screen bind should succeed")
|
||||
}
|
||||
// A second conn claiming the same MasterID from a foreign IP is refused,
|
||||
// so it cannot displace the legitimate stream.
|
||||
if h.BindScreenConn("dev", ctxFromIP("203.0.113.9")) {
|
||||
t.Fatal("foreign-IP screen bind should be rejected")
|
||||
}
|
||||
|
||||
// A device that has never bound a screen conn is equally protected.
|
||||
h.Register(&Device{ID: "fresh", PeerIP: "10.0.0.2"}, stubCtx())
|
||||
if h.BindScreenConn("fresh", ctxFromIP("203.0.113.9")) {
|
||||
t.Fatal("foreign-IP first screen bind should be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHubBindTerminalConnIPBinding(t *testing.T) {
|
||||
h := New()
|
||||
h.Register(&Device{ID: "dev", PeerIP: "10.0.0.1"}, stubCtx())
|
||||
|
||||
openPending := func(id string) {
|
||||
h.mu.Lock()
|
||||
h.devices[id].terminalPending = true
|
||||
h.mu.Unlock()
|
||||
}
|
||||
openPending("dev")
|
||||
|
||||
// Same-IP terminal bind succeeds and consumes the pending slot.
|
||||
if !h.BindTerminalConn("dev", ctxFromIP("10.0.0.1"), true) {
|
||||
t.Fatal("same-IP terminal bind should succeed")
|
||||
}
|
||||
|
||||
// A second device with a pending session cannot be claimed from elsewhere.
|
||||
h.Register(&Device{ID: "dev2", PeerIP: "10.0.0.2"}, stubCtx())
|
||||
openPending("dev2")
|
||||
if h.BindTerminalConn("dev2", ctxFromIP("203.0.113.9"), true) {
|
||||
t.Fatal("foreign-IP terminal bind should be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
// Race detector should not fire under `go test -race ./hub/...`.
|
||||
func TestHubConcurrent(t *testing.T) {
|
||||
h := New()
|
||||
|
||||
+180
-7
@@ -22,18 +22,40 @@ type Config struct {
|
||||
KeepAliveTime time.Duration
|
||||
ReadTimeout time.Duration
|
||||
WriteTimeout time.Duration
|
||||
|
||||
// MaxBufferedBytes is a hard cap on unparsed inbound bytes buffered per
|
||||
// connection. The protocol's own length field is validated to 50 MiB, but
|
||||
// a peer that sends bytes that never assemble into a complete packet (e.g.
|
||||
// an HTTP-mask envelope without its terminating CRLF CRLF, which Parse()
|
||||
// waits on indefinitely) could otherwise grow the buffer without bound.
|
||||
// Default leaves slack for the largest legitimate packet plus its mask
|
||||
// envelope and a read chunk.
|
||||
MaxBufferedBytes int
|
||||
|
||||
// Per-IP connection limits. The TCP listener (port 6543) is exposed
|
||||
// directly to the public internet, so a single host must not be able to
|
||||
// exhaust the global connection budget or flood new-connection attempts.
|
||||
// Defaults are generous enough for a NAT office with dozens of devices
|
||||
// sharing one source IP; raise them if you run a larger NAT deployment.
|
||||
PerIPMaxConnections int // max concurrent connections per source IP (0 = unlimited)
|
||||
PerIPConnRateLimit int // max new connections per PerIPConnRateWindow per IP (0 = unlimited)
|
||||
PerIPConnRateWindow time.Duration // window for PerIPConnRateLimit
|
||||
}
|
||||
|
||||
// DefaultConfig returns default configuration
|
||||
func DefaultConfig() Config {
|
||||
return Config{
|
||||
Port: 6543,
|
||||
MaxConnections: 9999,
|
||||
ReadBufferSize: 8192,
|
||||
WriteBufferSize: 8192,
|
||||
KeepAliveTime: time.Minute * 5,
|
||||
ReadTimeout: time.Minute * 2,
|
||||
WriteTimeout: time.Second * 30,
|
||||
Port: 6543,
|
||||
MaxConnections: 9999,
|
||||
ReadBufferSize: 8192,
|
||||
WriteBufferSize: 8192,
|
||||
KeepAliveTime: time.Minute * 5,
|
||||
ReadTimeout: time.Minute * 2,
|
||||
WriteTimeout: time.Second * 30,
|
||||
MaxBufferedBytes: 50*1024*1024 + 64*1024,
|
||||
PerIPMaxConnections: 256,
|
||||
PerIPConnRateLimit: 120,
|
||||
PerIPConnRateWindow: time.Minute,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,6 +73,7 @@ type Server struct {
|
||||
manager *connection.Manager
|
||||
handler Handler
|
||||
parser *protocol.Parser
|
||||
ipLimit *ipLimiter
|
||||
|
||||
running atomic.Bool
|
||||
wg sync.WaitGroup
|
||||
@@ -69,6 +92,7 @@ func New(config Config) *Server {
|
||||
config: config,
|
||||
manager: connection.NewManager(config.MaxConnections),
|
||||
parser: protocol.NewParser(),
|
||||
ipLimit: newIPLimiter(config.PerIPMaxConnections, config.PerIPConnRateLimit, config.PerIPConnRateWindow),
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
logger: logger.New(logger.DefaultConfig()).WithPrefix("Server"),
|
||||
@@ -77,6 +101,110 @@ func New(config Config) *Server {
|
||||
return s
|
||||
}
|
||||
|
||||
// ipLimiter caps concurrent connections and the rate of new connections per
|
||||
// source IP. Both limits are per-host, not global, so a single attacker (or a
|
||||
// single misbehaving host) cannot starve legitimate devices of the global
|
||||
// connection budget.
|
||||
type ipLimiter struct {
|
||||
mu sync.Mutex
|
||||
maxConcurrent int
|
||||
window time.Duration
|
||||
maxPerWindow int
|
||||
concurrent map[string]int
|
||||
recent map[string][]time.Time
|
||||
}
|
||||
|
||||
func newIPLimiter(maxConcurrent, maxPerWindow int, window time.Duration) *ipLimiter {
|
||||
return &ipLimiter{
|
||||
maxConcurrent: maxConcurrent,
|
||||
maxPerWindow: maxPerWindow,
|
||||
window: window,
|
||||
concurrent: make(map[string]int),
|
||||
recent: make(map[string][]time.Time),
|
||||
}
|
||||
}
|
||||
|
||||
// allow records a new connection from ip and reports whether it is permitted.
|
||||
// It enforces both a concurrent cap and a rolling accept-rate cap. An empty ip
|
||||
// (which should not occur for a real TCP peer) is not throttled.
|
||||
func (l *ipLimiter) allow(ip string) bool {
|
||||
if ip == "" {
|
||||
return true
|
||||
}
|
||||
now := time.Now()
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
|
||||
if l.maxConcurrent > 0 && l.concurrent[ip] >= l.maxConcurrent {
|
||||
return false
|
||||
}
|
||||
|
||||
if l.maxPerWindow > 0 && l.window > 0 {
|
||||
cutoff := now.Add(-l.window)
|
||||
times := l.recent[ip]
|
||||
keep := times[:0]
|
||||
for _, t := range times {
|
||||
if t.After(cutoff) {
|
||||
keep = append(keep, t)
|
||||
}
|
||||
}
|
||||
if len(keep) >= l.maxPerWindow {
|
||||
l.recent[ip] = keep
|
||||
return false
|
||||
}
|
||||
l.recent[ip] = append(keep, now)
|
||||
}
|
||||
|
||||
l.concurrent[ip]++
|
||||
return true
|
||||
}
|
||||
|
||||
// release decrements the concurrent count for ip. Safe to call on an ip that
|
||||
// was never allowed.
|
||||
func (l *ipLimiter) release(ip string) {
|
||||
if ip == "" {
|
||||
return
|
||||
}
|
||||
l.mu.Lock()
|
||||
if n := l.concurrent[ip]; n > 1 {
|
||||
l.concurrent[ip] = n - 1
|
||||
} else {
|
||||
delete(l.concurrent, ip)
|
||||
}
|
||||
l.mu.Unlock()
|
||||
}
|
||||
|
||||
// sweep drops per-IP rate-limit entries whose timestamps have all aged out,
|
||||
// keeping the map bounded over a long-running process.
|
||||
func (l *ipLimiter) sweep() {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
cutoff := time.Now().Add(-l.window)
|
||||
for ip, times := range l.recent {
|
||||
keep := times[:0]
|
||||
for _, t := range times {
|
||||
if t.After(cutoff) {
|
||||
keep = append(keep, t)
|
||||
}
|
||||
}
|
||||
if len(keep) == 0 {
|
||||
delete(l.recent, ip)
|
||||
} else {
|
||||
l.recent[ip] = keep
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// peerIP returns the host part of a net.Addr's string form, mirroring
|
||||
// connection.Context.GetPeerIP so accept-time and release-time keys agree.
|
||||
func peerIP(addr net.Addr) string {
|
||||
host, _, err := net.SplitHostPort(addr.String())
|
||||
if err != nil {
|
||||
return addr.String()
|
||||
}
|
||||
return host
|
||||
}
|
||||
|
||||
// SetHandler sets the event handler
|
||||
func (s *Server) SetHandler(h Handler) {
|
||||
s.handler = h
|
||||
@@ -120,9 +248,32 @@ func (s *Server) Start() error {
|
||||
s.wg.Add(1)
|
||||
go s.acceptLoop()
|
||||
|
||||
// Periodically prune aged-out per-IP rate-limit entries so the map stays
|
||||
// bounded over a long-running process. Stopped by s.ctx.Done() in Stop().
|
||||
s.wg.Add(1)
|
||||
go s.ipSweepLoop()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) ipSweepLoop() {
|
||||
defer s.wg.Done()
|
||||
interval := s.config.PerIPConnRateWindow
|
||||
if interval <= 0 {
|
||||
interval = time.Minute
|
||||
}
|
||||
t := time.NewTicker(interval)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-s.ctx.Done():
|
||||
return
|
||||
case <-t.C:
|
||||
s.ipLimit.sweep()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func itoa(i int) string {
|
||||
if i == 0 {
|
||||
return "0"
|
||||
@@ -194,6 +345,15 @@ func (s *Server) acceptLoop() {
|
||||
continue
|
||||
}
|
||||
|
||||
// Per-IP connection cap + accept rate. Rejecting here (before any
|
||||
// buffer allocation or goroutine) is cheap, and a rejected device
|
||||
// simply retries on its reconnect loop.
|
||||
if !s.ipLimit.allow(peerIP(conn.RemoteAddr())) {
|
||||
s.logger.Warn("Per-IP connection limit reached, rejecting %s", conn.RemoteAddr())
|
||||
_ = conn.Close()
|
||||
continue
|
||||
}
|
||||
|
||||
// Handle each connection in its own goroutine
|
||||
go s.handleConnection(conn)
|
||||
}
|
||||
@@ -204,6 +364,10 @@ func (s *Server) handleConnection(conn net.Conn) {
|
||||
// Create context
|
||||
ctx := connection.NewContext(conn, nil)
|
||||
|
||||
// Always release the per-IP slot on every exit path, including the
|
||||
// manager.Add failure below (a rare accept/add race).
|
||||
defer s.ipLimit.release(ctx.GetPeerIP())
|
||||
|
||||
// Add to manager
|
||||
if err := s.manager.Add(ctx); err != nil {
|
||||
s.logger.Warn("Failed to add connection: %v", err)
|
||||
@@ -245,6 +409,15 @@ func (s *Server) handleConnection(conn net.Conn) {
|
||||
// Write to input buffer
|
||||
_, _ = ctx.InBuffer.Write(buf[:n])
|
||||
|
||||
// Hard cap on unparsed inbound bytes (memory-DoS guard). A peer
|
||||
// that sends bytes that never assemble into a complete packet can
|
||||
// otherwise grow this buffer without bound.
|
||||
if s.config.MaxBufferedBytes > 0 && ctx.InBuffer.Len() > s.config.MaxBufferedBytes {
|
||||
s.logger.Warn("Inbound buffer exceeded %d bytes from ip=%s conn=%d — closing",
|
||||
s.config.MaxBufferedBytes, ctx.GetPeerIP(), ctx.ID)
|
||||
break
|
||||
}
|
||||
|
||||
// Process received data
|
||||
s.processData(ctx)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"net"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestIPLimiterConcurrentCap(t *testing.T) {
|
||||
l := newIPLimiter(2, 0, 0) // 2 concurrent, no rate limit
|
||||
|
||||
if !l.allow("10.0.0.1") {
|
||||
t.Fatal("first connection from an IP should be allowed")
|
||||
}
|
||||
if !l.allow("10.0.0.1") {
|
||||
t.Fatal("second connection from an IP should be allowed")
|
||||
}
|
||||
if l.allow("10.0.0.1") {
|
||||
t.Fatal("third concurrent connection from the same IP should be rejected")
|
||||
}
|
||||
|
||||
// A different IP is unaffected.
|
||||
if !l.allow("10.0.0.2") {
|
||||
t.Fatal("a different IP should not share the first IP's cap")
|
||||
}
|
||||
|
||||
// Releasing one slot frees capacity again.
|
||||
l.release("10.0.0.1")
|
||||
if !l.allow("10.0.0.1") {
|
||||
t.Fatal("after release, the IP should have a free slot")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIPLimiterRateLimit(t *testing.T) {
|
||||
l := newIPLimiter(0, 3, time.Minute) // no concurrent cap, 3/min per IP
|
||||
|
||||
for range 3 {
|
||||
if !l.allow("10.0.0.1") {
|
||||
t.Fatal("an attempt within budget should be allowed")
|
||||
}
|
||||
}
|
||||
if l.allow("10.0.0.1") {
|
||||
t.Fatal("4th connection within the window should be rejected")
|
||||
}
|
||||
// A different IP has its own budget.
|
||||
if !l.allow("10.0.0.2") {
|
||||
t.Fatal("a different IP should have its own rate budget")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIPLimiterEmptyIPBypasses(t *testing.T) {
|
||||
l := newIPLimiter(1, 1, time.Minute)
|
||||
// An empty peer IP (shouldn't happen for real TCP) must not be throttled,
|
||||
// so a caller that loses the IP falls back to accepting.
|
||||
if !l.allow("") {
|
||||
t.Fatal("empty IP should bypass the limiter")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIPLimiterSweep(t *testing.T) {
|
||||
l := newIPLimiter(0, 1, time.Minute)
|
||||
|
||||
l.mu.Lock()
|
||||
l.recent["stale"] = []time.Time{time.Now().Add(-time.Hour)}
|
||||
l.recent["fresh"] = []time.Time{time.Now()}
|
||||
l.mu.Unlock()
|
||||
|
||||
l.sweep()
|
||||
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
if _, ok := l.recent["stale"]; ok {
|
||||
t.Fatal("stale rate-limit entry should be swept")
|
||||
}
|
||||
if _, ok := l.recent["fresh"]; !ok {
|
||||
t.Fatal("fresh rate-limit entry should survive sweep")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPeerIP(t *testing.T) {
|
||||
tests := []struct {
|
||||
addr net.Addr
|
||||
want string
|
||||
}{
|
||||
{&net.TCPAddr{IP: net.ParseIP("1.2.3.4"), Port: 1234}, "1.2.3.4"},
|
||||
{&net.TCPAddr{IP: net.ParseIP("::1"), Port: 6543}, "::1"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
if got := peerIP(tt.addr); got != tt.want {
|
||||
t.Fatalf("peerIP(%v) = %q, want %q", tt.addr, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A peer that sends bytes which never assemble into a complete packet (here an
|
||||
// HTTP-mask envelope with no terminating CRLF CRLF) must be disconnected once
|
||||
// the per-connection buffer cap is exceeded, rather than allowed to grow it
|
||||
// without bound.
|
||||
func TestHandleConnectionBufferCap(t *testing.T) {
|
||||
cfg := DefaultConfig()
|
||||
cfg.MaxBufferedBytes = 1024 // small cap for a fast, in-memory test
|
||||
cfg.ReadTimeout = time.Second
|
||||
|
||||
srv := New(cfg)
|
||||
srv.running.Store(true) // bypass acceptLoop; exercise handleConnection directly
|
||||
|
||||
client, serverConn := net.Pipe()
|
||||
defer client.Close()
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
srv.handleConnection(serverConn)
|
||||
close(done)
|
||||
}()
|
||||
|
||||
// "POST " prefix with no terminator, plus filler to exceed the cap.
|
||||
payload := append([]byte("POST /x HTTP/1.1\r\n"), bytes.Repeat([]byte{'A'}, 4096)...)
|
||||
writeErr := make(chan error, 1)
|
||||
go func() {
|
||||
_, err := client.Write(payload)
|
||||
writeErr <- err
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
// handleConnection returned because the cap was exceeded and it closed
|
||||
// the connection.
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("connection was not closed after exceeding the buffer cap")
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/yuanyuanxiang/SimpleRemoter/server/go/hub"
|
||||
@@ -32,6 +33,9 @@ type Server struct {
|
||||
loginUserLimit *wsauth.RateLimiter
|
||||
trustForwardedFor bool // honor X-Forwarded-For (behind trusted proxy only)
|
||||
basePath string // web UI mount prefix (e.g. "/remoter"); "" = domain root
|
||||
sso SSODetector // nil = SSO disabled
|
||||
sweepDone chan struct{} // closes to stop the login-limiter sweeper
|
||||
sweepStopOnce sync.Once
|
||||
}
|
||||
|
||||
// Config tunes the server's exposed-on-public-HTTPS hardening knobs.
|
||||
@@ -60,6 +64,10 @@ type Config struct {
|
||||
// https://example.com/remoter). Must be empty or a leading-slash,
|
||||
// no-trailing-slash path; "" (default) serves from the domain root.
|
||||
BasePath string
|
||||
// SSO is an optional detector that maps an already-authenticated portal
|
||||
// session (cookie/header) into a YAMA web session via /sso. nil disables
|
||||
// SSO and the front-end falls back to the built-in login form.
|
||||
SSO SSODetector
|
||||
}
|
||||
|
||||
// New creates an HTTP server bound to the given port. port=0 disables the server.
|
||||
@@ -77,6 +85,7 @@ func (s *Server) WithConfig(cfg Config) *Server {
|
||||
s.loginUserLimit = cfg.LoginUserLimit
|
||||
s.trustForwardedFor = cfg.TrustForwardedFor
|
||||
s.basePath = cfg.BasePath
|
||||
s.sso = cfg.SSO
|
||||
return s
|
||||
}
|
||||
|
||||
@@ -98,6 +107,7 @@ func (s *Server) Start() error {
|
||||
mux.HandleFunc("/health", s.handleHealth)
|
||||
mux.HandleFunc("/manifest.json", s.handleManifest)
|
||||
mux.HandleFunc("/api/devices", s.requireBearer(s.handleDevices))
|
||||
mux.HandleFunc("/sso", s.handleSSO)
|
||||
mux.HandleFunc("/ws", s.ws.serve)
|
||||
mux.HandleFunc("/static/xterm.js", staticHandler(xtermJS, "application/javascript; charset=utf-8"))
|
||||
mux.HandleFunc("/static/xterm.css", staticHandler(xtermCSS, "text/css; charset=utf-8"))
|
||||
@@ -123,11 +133,40 @@ func (s *Server) Start() error {
|
||||
s.log.Error("HTTP server stopped: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
// Prune aged-out login rate-limit entries so the key maps (especially the
|
||||
// username-keyed one, which is attacker-controlled) stay bounded.
|
||||
s.sweepDone = make(chan struct{})
|
||||
go s.sweepLoginLimiters(s.sweepDone)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// sweepLoginLimiters periodically sweeps both web login rate limiters. Runs
|
||||
// until `stop` is closed (from Stop).
|
||||
func (s *Server) sweepLoginLimiters(stop <-chan struct{}) {
|
||||
t := time.NewTicker(time.Minute)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-stop:
|
||||
return
|
||||
case <-t.C:
|
||||
if s.loginIPLimit != nil {
|
||||
s.loginIPLimit.Sweep()
|
||||
}
|
||||
if s.loginUserLimit != nil {
|
||||
s.loginUserLimit.Sweep()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Stop gracefully shuts the server down.
|
||||
func (s *Server) Stop() {
|
||||
if s.sweepDone != nil {
|
||||
s.sweepStopOnce.Do(func() { close(s.sweepDone) })
|
||||
}
|
||||
if s.ws != nil {
|
||||
s.ws.stop()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// SSODetector maps an incoming HTTP request from an already-authenticated
|
||||
// portal session into a YAMA web identity (username + role). Implementations
|
||||
// are portal-specific: each one knows how to recognize its portal's session
|
||||
// credential (a cookie, a header, a JWT, ...) and translate the caller to
|
||||
// YAMA's two roles, "admin" and "viewer".
|
||||
//
|
||||
// Detect returns ok=false when the request carries no usable credential, or
|
||||
// the credential is invalid/expired; the caller then falls back to the
|
||||
// built-in login form. username is used only for audit/logging. Whether a
|
||||
// detected identity is actually bridged into a session is handleSSO's policy:
|
||||
// currently only "admin" is bridged, everyone else falls back to the login form.
|
||||
type SSODetector interface {
|
||||
Detect(r *http.Request) (username, role string, ok bool)
|
||||
}
|
||||
|
||||
// handleSSO bridges a detected portal identity into a YAMA session token.
|
||||
// Only administrators are bridged: a valid portal session for any other role
|
||||
// (viewer, unknown) still returns 401 and the front-end falls back to the
|
||||
// built-in login form, so a low-privileged portal account cannot walk straight
|
||||
// into the remote-desktop console. When no detector is configured, or the
|
||||
// request is not recognized, it also returns 401.
|
||||
func (s *Server) handleSSO(w http.ResponseWriter, r *http.Request) {
|
||||
if s.sso == nil {
|
||||
s.unauthorized(w)
|
||||
return
|
||||
}
|
||||
username, role, ok := s.sso.Detect(r)
|
||||
if !ok {
|
||||
s.unauthorized(w)
|
||||
return
|
||||
}
|
||||
// Only the "admin" role may skip the login form via SSO. Every other role
|
||||
// (viewer, unknown) must authenticate against YAMA's own login, even with
|
||||
// a valid portal session.
|
||||
if role != "admin" {
|
||||
s.unauthorized(w)
|
||||
return
|
||||
}
|
||||
|
||||
token, err := s.auth.IssueToken(username, role)
|
||||
if err != nil {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
_, _ = w.Write([]byte(`{"error":"internal error"}`))
|
||||
return
|
||||
}
|
||||
|
||||
s.log.Info("sso: user=%s role=%s", username, role)
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{
|
||||
"token": token,
|
||||
"role": role,
|
||||
"username": username,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
)
|
||||
|
||||
// JWTDetector is an SSODetector for portals that hand the browser a signed
|
||||
// JWT as an HttpOnly session cookie (or an Authorization: Bearer header). It
|
||||
// verifies the token with a shared HMAC secret and maps the identity claims
|
||||
// to a YAMA role. It is portal-agnostic: the secret, cookie name, claim keys
|
||||
// and the role value that means "admin" are all supplied at construction, so
|
||||
// the same detector serves any portal using this pattern.
|
||||
type JWTDetector struct {
|
||||
secret []byte
|
||||
cookieName string
|
||||
usernameClaim string
|
||||
roleClaim string
|
||||
adminRole string
|
||||
}
|
||||
|
||||
// JWTDetectorConfig configures a JWTDetector. Secret is the shared HMAC key
|
||||
// (required). The remaining fields have defaults for a typical portal:
|
||||
// CookieName "auth_token", UsernameClaim "username", RoleClaim "rolename",
|
||||
// AdminRole "admin".
|
||||
type JWTDetectorConfig struct {
|
||||
Secret string
|
||||
CookieName string
|
||||
UsernameClaim string
|
||||
RoleClaim string
|
||||
AdminRole string
|
||||
}
|
||||
|
||||
// NewJWTDetector builds a JWTDetector, applying defaults for any unset
|
||||
// optional field.
|
||||
func NewJWTDetector(cfg JWTDetectorConfig) *JWTDetector {
|
||||
if cfg.CookieName == "" {
|
||||
cfg.CookieName = "auth_token"
|
||||
}
|
||||
if cfg.UsernameClaim == "" {
|
||||
cfg.UsernameClaim = "username"
|
||||
}
|
||||
if cfg.RoleClaim == "" {
|
||||
cfg.RoleClaim = "rolename"
|
||||
}
|
||||
if cfg.AdminRole == "" {
|
||||
cfg.AdminRole = "admin"
|
||||
}
|
||||
return &JWTDetector{
|
||||
secret: []byte(cfg.Secret),
|
||||
cookieName: cfg.CookieName,
|
||||
usernameClaim: cfg.UsernameClaim,
|
||||
roleClaim: cfg.RoleClaim,
|
||||
adminRole: cfg.AdminRole,
|
||||
}
|
||||
}
|
||||
|
||||
// Detect reads the JWT from the configured cookie (falling back to a Bearer
|
||||
// header), verifies its signature and expiry, and maps the identity to a YAMA
|
||||
// role. Only HMAC algorithms are accepted, rejecting algorithm-confusion
|
||||
// attempts (alg=none / asymmetric algorithms).
|
||||
func (d *JWTDetector) Detect(r *http.Request) (username, role string, ok bool) {
|
||||
tokenString := d.extract(r)
|
||||
if tokenString == "" {
|
||||
return "", "", false
|
||||
}
|
||||
|
||||
claims, ok := d.parse(tokenString)
|
||||
if !ok {
|
||||
return "", "", false
|
||||
}
|
||||
|
||||
username, _ = claims[d.usernameClaim].(string)
|
||||
if username == "" {
|
||||
return "", "", false
|
||||
}
|
||||
|
||||
rolename, _ := claims[d.roleClaim].(string)
|
||||
if rolename == d.adminRole {
|
||||
role = "admin"
|
||||
} else {
|
||||
role = "viewer"
|
||||
}
|
||||
return username, role, true
|
||||
}
|
||||
|
||||
// extract returns the raw JWT from the configured cookie, or from a Bearer
|
||||
// header as a fallback.
|
||||
func (d *JWTDetector) extract(r *http.Request) string {
|
||||
if d.cookieName != "" {
|
||||
if ck, err := r.Cookie(d.cookieName); err == nil && ck.Value != "" {
|
||||
return ck.Value
|
||||
}
|
||||
}
|
||||
if hdr := r.Header.Get("Authorization"); strings.HasPrefix(hdr, "Bearer ") {
|
||||
return strings.TrimSpace(strings.TrimPrefix(hdr, "Bearer "))
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// parse verifies a JWT against the shared HMAC secret and returns its claims.
|
||||
func (d *JWTDetector) parse(tokenString string) (jwt.MapClaims, bool) {
|
||||
if len(d.secret) == 0 {
|
||||
// Fail closed: an unset shared secret must never accept a token.
|
||||
// A detector wired up with an empty secret is a misconfiguration, and
|
||||
// accepting an empty-key-signed token here would be a trivial auth
|
||||
// bypass. Rejecting everything means SSO silently falls back to login.
|
||||
return nil, false
|
||||
}
|
||||
token, err := jwt.Parse(tokenString, func(t *jwt.Token) (interface{}, error) {
|
||||
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||
return nil, errors.New("invalid signing algorithm")
|
||||
}
|
||||
return d.secret, nil
|
||||
})
|
||||
if err != nil || !token.Valid {
|
||||
return nil, false
|
||||
}
|
||||
claims, ok := token.Claims.(jwt.MapClaims)
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
return claims, true
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
|
||||
"github.com/yuanyuanxiang/SimpleRemoter/server/go/logger"
|
||||
"github.com/yuanyuanxiang/SimpleRemoter/server/go/wsauth"
|
||||
)
|
||||
|
||||
const testSSOSecret = "test-shared-secret"
|
||||
|
||||
func signTestToken(t *testing.T, secret string, claims jwt.MapClaims) string {
|
||||
t.Helper()
|
||||
tok := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
s, err := tok.SignedString([]byte(secret))
|
||||
if err != nil {
|
||||
t.Fatalf("sign test token: %v", err)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// noneToken builds a base64url header/claims JWT with alg "none" and no
|
||||
// signature, to exercise the algorithm-confusion rejection path.
|
||||
func noneToken(claims jwt.MapClaims) string {
|
||||
h, _ := json.Marshal(map[string]string{"alg": "none", "typ": "JWT"})
|
||||
c, _ := json.Marshal(claims)
|
||||
enc := base64.RawURLEncoding.EncodeToString
|
||||
return strings.TrimRight(enc(h), "=") + "." + strings.TrimRight(enc(c), "=") + "."
|
||||
}
|
||||
|
||||
func cookieReq(token string) *http.Request {
|
||||
r := &http.Request{Header: http.Header{}}
|
||||
r.AddCookie(&http.Cookie{Name: "auth_token", Value: token})
|
||||
return r
|
||||
}
|
||||
|
||||
func bearerReq(token string) *http.Request {
|
||||
r := &http.Request{Header: http.Header{}}
|
||||
r.Header.Set("Authorization", "Bearer "+token)
|
||||
return r
|
||||
}
|
||||
|
||||
func TestJWTDetector(t *testing.T) {
|
||||
d := NewJWTDetector(JWTDetectorConfig{Secret: testSSOSecret})
|
||||
|
||||
valid := func(rolename string) jwt.MapClaims {
|
||||
return jwt.MapClaims{
|
||||
"username": "alice",
|
||||
"rolename": rolename,
|
||||
"exp": time.Now().Add(time.Hour).Unix(),
|
||||
}
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
req *http.Request
|
||||
wantUser string
|
||||
wantRole string
|
||||
wantOK bool
|
||||
}{
|
||||
{"admin maps to admin", cookieReq(signTestToken(t, testSSOSecret, valid("admin"))), "alice", "admin", true},
|
||||
{"viewer maps to viewer", cookieReq(signTestToken(t, testSSOSecret, valid("viewer"))), "alice", "viewer", true},
|
||||
{"empty rolename maps to viewer", cookieReq(signTestToken(t, testSSOSecret, valid(""))), "alice", "viewer", true},
|
||||
{"unknown rolename maps to viewer", cookieReq(signTestToken(t, testSSOSecret, valid("operator"))), "alice", "viewer", true},
|
||||
{"bearer header accepted", bearerReq(signTestToken(t, testSSOSecret, valid("admin"))), "alice", "admin", true},
|
||||
{"no credential", &http.Request{Header: http.Header{}}, "", "", false},
|
||||
{"wrong secret rejected", cookieReq(signTestToken(t, "other-secret", valid("admin"))), "", "", false},
|
||||
{"expired token rejected", cookieReq(signTestToken(t, testSSOSecret, jwt.MapClaims{
|
||||
"username": "alice", "rolename": "admin", "exp": time.Now().Add(-time.Hour).Unix(),
|
||||
})), "", "", false},
|
||||
{"missing username rejected", cookieReq(signTestToken(t, testSSOSecret, jwt.MapClaims{
|
||||
"rolename": "admin", "exp": time.Now().Add(time.Hour).Unix(),
|
||||
})), "", "", false},
|
||||
{"alg none rejected", cookieReq(noneToken(valid("admin"))), "", "", false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
user, role, ok := d.Detect(tt.req)
|
||||
if ok != tt.wantOK {
|
||||
t.Fatalf("ok = %v, want %v (user=%q role=%q)", ok, tt.wantOK, user, role)
|
||||
}
|
||||
if !tt.wantOK {
|
||||
return
|
||||
}
|
||||
if user != tt.wantUser || role != tt.wantRole {
|
||||
t.Fatalf("got (%q, %q), want (%q, %q)", user, role, tt.wantUser, tt.wantRole)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A detector with no shared secret must never accept a token, even one signed
|
||||
// with an empty key — otherwise a misconfigured deployment would silently
|
||||
// accept forged sessions.
|
||||
func TestJWTDetectorEmptySecretFailClosed(t *testing.T) {
|
||||
d := NewJWTDetector(JWTDetectorConfig{})
|
||||
tok := signTestToken(t, "", jwt.MapClaims{
|
||||
"username": "alice", "rolename": "admin", "exp": time.Now().Add(time.Hour).Unix(),
|
||||
})
|
||||
if _, _, ok := d.Detect(cookieReq(tok)); ok {
|
||||
t.Fatal("empty-secret detector accepted a token; expected fail-closed")
|
||||
}
|
||||
}
|
||||
|
||||
// Non-default config keys must be honored, so a different portal can reuse the
|
||||
// detector with no code changes.
|
||||
func TestJWTDetectorCustomConfig(t *testing.T) {
|
||||
d := NewJWTDetector(JWTDetectorConfig{
|
||||
Secret: testSSOSecret,
|
||||
CookieName: "session",
|
||||
UsernameClaim: "sub",
|
||||
RoleClaim: "role",
|
||||
AdminRole: "root",
|
||||
})
|
||||
tok := signTestToken(t, testSSOSecret, jwt.MapClaims{
|
||||
"sub": "bob", "role": "root", "exp": time.Now().Add(time.Hour).Unix(),
|
||||
})
|
||||
r := &http.Request{Header: http.Header{}}
|
||||
r.AddCookie(&http.Cookie{Name: "session", Value: tok})
|
||||
user, role, ok := d.Detect(r)
|
||||
if !ok || user != "bob" || role != "admin" {
|
||||
t.Fatalf("got (%q, %q, %v), want (%q, %q, true)", user, role, ok, "bob", "admin")
|
||||
}
|
||||
}
|
||||
|
||||
// fakeSSODetector lets the /sso handler's role policy be exercised without a
|
||||
// real portal JWT.
|
||||
type fakeSSODetector struct {
|
||||
username, role string
|
||||
ok bool
|
||||
}
|
||||
|
||||
func (f *fakeSSODetector) Detect(*http.Request) (string, string, bool) {
|
||||
return f.username, f.role, f.ok
|
||||
}
|
||||
|
||||
// The /sso handler must only bridge administrators. A valid portal session for
|
||||
// any other role — or no session at all — returns 401 so the front-end falls
|
||||
// back to the built-in login form.
|
||||
func TestHandleSSOAdminOnly(t *testing.T) {
|
||||
newSrv := func(d SSODetector) *Server {
|
||||
return &Server{
|
||||
log: logger.New(logger.Config{}),
|
||||
auth: wsauth.New(),
|
||||
sso: d,
|
||||
}
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
detector SSODetector
|
||||
wantStatus int
|
||||
}{
|
||||
{"admin is bridged", &fakeSSODetector{"alice", "admin", true}, http.StatusOK},
|
||||
{"viewer is rejected", &fakeSSODetector{"bob", "viewer", true}, http.StatusUnauthorized},
|
||||
{"unknown role is rejected", &fakeSSODetector{"carol", "operator", true}, http.StatusUnauthorized},
|
||||
{"no credential is rejected", &fakeSSODetector{ok: false}, http.StatusUnauthorized},
|
||||
{"SSO disabled", nil, http.StatusUnauthorized},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
s := newSrv(tt.detector)
|
||||
rec := httptest.NewRecorder()
|
||||
s.handleSSO(rec, httptest.NewRequest(http.MethodPost, "/sso", nil))
|
||||
if rec.Code != tt.wantStatus {
|
||||
t.Fatalf("status = %d, want %d (body=%s)", rec.Code, tt.wantStatus, rec.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -258,7 +258,6 @@ func (a *Authenticator) fakeSalt(username string) string {
|
||||
func (a *Authenticator) VerifyLogin(username, response, nonce string) (token, role string, err error) {
|
||||
a.mu.RLock()
|
||||
u, ok := a.users[username]
|
||||
expire := a.tokenExpire
|
||||
a.mu.RUnlock()
|
||||
if !ok {
|
||||
return "", "", errors.New("invalid credentials")
|
||||
@@ -268,18 +267,30 @@ func (a *Authenticator) VerifyLogin(username, response, nonce string) (token, ro
|
||||
return "", "", errors.New("invalid credentials")
|
||||
}
|
||||
|
||||
token, err = randomHex(tokenBytes)
|
||||
token, err = a.IssueToken(username, u.Role)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
return token, u.Role, nil
|
||||
}
|
||||
|
||||
// IssueToken mints and stores a fresh session token for an identity that has
|
||||
// already been authenticated out-of-band (SSO), skipping the challenge
|
||||
// response. username is used only for audit/logging; authorization keys on
|
||||
// Role. The caller must pass a role already normalized to "admin" or "viewer".
|
||||
func (a *Authenticator) IssueToken(username, role string) (string, error) {
|
||||
token, err := randomHex(tokenBytes)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
a.mu.Lock()
|
||||
a.tokens[token] = &Session{
|
||||
Username: username,
|
||||
Role: u.Role,
|
||||
ExpiresAt: time.Now().Add(expire),
|
||||
Role: role,
|
||||
ExpiresAt: time.Now().Add(a.tokenExpire),
|
||||
}
|
||||
a.mu.Unlock()
|
||||
return token, u.Role, nil
|
||||
return token, nil
|
||||
}
|
||||
|
||||
// usersFileEntry is the on-disk shape of one record in users.json. Field
|
||||
|
||||
+33
-6
@@ -1670,6 +1670,26 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Attempt single sign-on via the portal's session cookie. On success
|
||||
// the server returns a YAMA session token + role, which we persist
|
||||
// exactly like a local login; the caller then connects the WS and skips
|
||||
// the login form. Returns true on success, false to fall back to login.
|
||||
async function trySSO() {
|
||||
try {
|
||||
const resp = await fetch('__BASE_PATH__/sso', { credentials: 'same-origin' });
|
||||
if (!resp.ok) return false;
|
||||
const data = await resp.json();
|
||||
if (!data || !data.token) return false;
|
||||
token = data.token;
|
||||
currentUserRole = data.role || 'viewer';
|
||||
sessionStorage.setItem('token', token);
|
||||
sessionStorage.setItem('role', currentUserRole);
|
||||
return true;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function connectWebSocket() {
|
||||
if (!isPageVisible) return; // Don't connect in background
|
||||
const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
@@ -4480,14 +4500,21 @@
|
||||
bindKeyboardBtnEvents(document.getElementById('qc-keyboard'));
|
||||
bindKeyboardBtnEvents(document.getElementById('btn-keyboard'));
|
||||
bindKeyboardBtnEvents(document.getElementById('btn-keyboard-bar'));
|
||||
// Restore token and role from sessionStorage
|
||||
// Restore token and role from sessionStorage (local-login fallback).
|
||||
token = sessionStorage.getItem('token');
|
||||
currentUserRole = sessionStorage.getItem('role') || 'viewer';
|
||||
// Show Users button for admin only (will be updated after login verification)
|
||||
if (token && currentUserRole === 'admin') {
|
||||
document.getElementById('users-btn').classList.add('visible');
|
||||
}
|
||||
connectWebSocket();
|
||||
// Attempt portal SSO first: if the user is already logged into the
|
||||
// parent domain, the server bridges the portal session cookie into
|
||||
// a YAMA session token so the login form is skipped. On failure
|
||||
// (not logged in, or SSO disabled) fall back to the restored
|
||||
// session or the built-in login form.
|
||||
trySSO().then(() => {
|
||||
// Show Users button for admin only (final role known after SSO).
|
||||
if (token && currentUserRole === 'admin') {
|
||||
document.getElementById('users-btn').classList.add('visible');
|
||||
}
|
||||
connectWebSocket();
|
||||
});
|
||||
};
|
||||
</script>
|
||||
</body>
|
||||
|
||||
Reference in new issue
Block a user