16 Commits
Author SHA1 Message Date
yuanyuanxianganddeepseek-v4-pro 8d4c118ae6 Fix: Skip video capture when camera fails to open
CCaptureVideo::Open now returns S_FALSE when BindVideoFilter cannot bind a capture device, instead of S_OK. CVideoManager records the result in m_bVideoOpened and exits WorkThread before SendBitMapInfor, so a host without a webcam no longer sends a 0x0 BITMAPINFO to the controller.

Co-Authored-By: deepseek-v4-pro
2026-10-13 17:05:37 +02:00
yuanyuanxianganddeepseek-v4-pro d477cbfee9 Fix: Treat a zero MaxBufferedBytes as disabled
Consistent with the per-IP limits, a zero cap now means "no limit" instead
of closing every connection after its first byte, so a Config built from
zero values cannot wedge the listener.

Co-Authored-By: deepseek-v4-pro
2026-10-13 16:28:13 +02:00
yuanyuanxianganddeepseek-v4-pro 870a5832ea Security: Cap per-connection inbound buffer to prevent memory DoS
The protocol length field is validated to 50 MiB, but a peer that sends
bytes which never assemble into a complete packet — for example an HTTP-mask
envelope without its terminating CRLF CRLF, which Parse() waits on
indefinitely — could grow the per-connection input buffer without bound.
handleConnection now closes the connection once unparsed buffered bytes
exceed MaxBufferedBytes (default 50 MiB + 64 KiB slack, covering the largest
legitimate packet plus its mask envelope and a read chunk).

Co-Authored-By: deepseek-v4-pro
2026-10-13 16:27:20 +02:00
yuanyuanxianganddeepseek-v4-pro 8e0f1283c6 Security: Add per-IP connection and login rate limiting
The TCP device port is exposed directly to the public internet, so a single
host must not be able to exhaust the global connection budget, flood new
connection attempts, or brute-force the device authorization passcode.

- server: cap concurrent connections and the accept rate per source IP
  (defaults 256 concurrent / 120 per minute, overridable via
  YAMA_PERIP_MAX_CONNS and YAMA_PERIP_CONN_RATE). Rejections happen at
  accept time, before any buffer or goroutine is allocated, and a rejected
  device simply retries on its reconnect loop.
- cmd: throttle TOKEN_AUTH per source IP (120/min) and answer over-limit
  attempts with a normal failed response so clients get a definitive reply.
- web: periodically sweep the login rate-limit maps so the username-keyed
  one (which is attacker-controlled) cannot grow without bound.

Per-IP defaults are generous enough that a NAT office whose devices share
one source IP is unaffected.

Co-Authored-By: deepseek-v4-pro
2026-10-13 16:25:34 +02:00
yuanyuanxianganddeepseek-v4-pro 2de21b2b6b Fix: Bind screen/terminal sub-conns only to the device's source IP
A screen or terminal sub-connection was keyed solely by a MasterID that any
TCP peer may supply, so any internet host could claim an online device's
MasterID and bind a sub-conn in its place, hijacking the live screen stream
or a pending shell session. The ConnAuth signature cannot be verified on the
server because its key lives in a vendored client library, so the trust
anchor here is the source IP: sub-conns are always opened device-to-server
from the same host as the login conn. BindScreenConn and BindTerminalConn
now reject a sub-conn whose peer IP differs from the device's recorded
main-connection PeerIP.

Co-Authored-By: deepseek-v4-pro
2026-10-13 16:19:39 +02:00
yuanyuanxianganddeepseek-v4-pro 53b7151fb7 Feature: Add admin-only SSO bridge to web UI
Bridge an already-authenticated portal session into a YAMA web session via
a new /sso endpoint, so portal administrators can enter the remote-desktop
UI without a second login. Only the "admin" portal role is bridged; any
other role (viewer, unknown) still falls back to the built-in login form,
so a low-privileged portal account cannot bypass authentication.

- wsauth: extract IssueToken from VerifyLogin so an out-of-band-authenticated
  identity (SSO) can mint a session token without the challenge/response.
- web: add the SSODetector interface, a fail-closed JWT detector (HMAC-only),
  and the /sso handler gated to the admin role.
- cmd: enable the JWT detector via YAMA_SSO_JWT_SECRET, the shared HS256
  secret matching the parent portal.
- web UI: attempt SSO before showing the login form, falling back to login
  when the server returns 401.
- docs: document SSO (admin-only) and license trial-vs-paid troubleshooting
  in README and the env example.

Co-Authored-By: deepseek-v4-pro
2026-10-13 15:46:04 +02:00
yuanyuanxianganddeepseek-v4-pro d2149851b2 Fix: Decode host-management and clipboard text as GBK/UTF-8 on non-Chinese systems
The 主机管理 dialog wrote client data (process names, window titles, net
connection state, software entries, IE history, favorites, services,
scheduled tasks, hosts) through the ANSI InsertItem/SetItemText path, which
re-converts through CP_ACP and garbles Chinese when the operator machine
runs a non-Chinese ACP. Decode each client string with its real code page
and write it via LVM_SETITEMTEXTW, mirroring SystemDlg::SetClientTextItem.
Scheduled-task columns are split by encoding: name/path/exe come from the
client as UTF-8 (BSTRToUTF8) while the status literal stays GBK. Also detect
a UTF-8 BOM in the hosts file so a Notepad-saved hosts (EF BB BF) no longer
shows "锘?" on its first line.

In the keyboard dialog, the clipboard preview edit was an ANSI-class window
filled via utf8_to_ansi (CP_ACP) + SetWindowTextA, so Chinese was replaced
with '?' before it reached the control. Rebuild m_EditClipText as a
Unicode-class window and write the UTF-8 clipboard text via SetWindowTextW,
matching the m_edit record control.

Co-Authored-By: deepseek-v4-pro
2026-10-12 15:41:24 +02:00
yuanyuanxianganddeepseek-v4-pro ca6f76565c Change: Hide client chat dialog on close instead of terminating
Closing the client-side chat dialog (via the "end chat" button or the title-bar
X) now only hides the window and keeps the session alive, instead of destroying
it. Closing the chat stays server-initiated: the next message from the server
reopens the hidden dialog via the existing SW_RESTORE path in OnReceive, and the
dialog is only truly destroyed on COMMAND_CHAT_CLOSE or connection close.

Co-Authored-By: deepseek-v4-pro
2026-10-10 14:39:34 +02:00
yuanyuanxianganddeepseek-v4-pro d15cd77a28 Fix: Decode geographic location as UTF-8 across client and server
Geographic location (city, country) from the geo APIs is UTF-8, but the client
and several server paths converted or stored it in the system ANSI code page,
turning umlauts ("ö"/"ä") into "?" or GBK mojibake ("B枚blingen"). Keep the
value UTF-8 end to end: the client caches it as UTF-8 and re-queries once after
an upgrade (keyed by __DATE__, the app's version convention), while the server
stores and displays it as UTF-8 in the online list, the activity log, the
client-info export, JSON/email payloads and search.

Also add a confirmation prompt before opening a remote chat from the toolbar,
with German, English and Traditional-Chinese translations.

Co-Authored-By: deepseek-v4-pro
2026-10-10 13:58:38 +02:00
yuanyuanxianganddeepseek-v4-pro 1a9312d2d1 Fix: Decode remote file-list names/paths from GBK/UTF-8 on non-Chinese systems
The Windows ghost client lists directories with FindFirstFileA, so remote
file names arrive as GBK/936 bytes. Both file-manager dialogs wrote them
back through ANSI InsertItem/SetItemText, which re-converts through CP_ACP
and garbles Chinese names when the operator machine runs a non-Chinese ACP.

Decode the name/path from the client encoding (GBK for Windows, UTF-8 for
LNX/MAC, chosen by RES_CLIENT_TYPE) and write the text via LVM_SETITEMTEXTW,
keeping the icon through an empty InsertItem - the same pattern used for the
process list, window list and client-list path.

Co-Authored-By: deepseek-v4-pro
2026-10-09 16:23:23 +02:00
yuanyuanxianganddeepseek-v4-pro f3a8406050 Fix: Decode client file path correctly in client list and info popup
The client reports its own exe path as UTF-8 in RES_FILE_PATH. Two
display sites re-decoded those bytes with the wrong code page and turned
Chinese paths into mojibake on non-Chinese servers:

- The client-list "程序路径" column wrote ProgramPath through the ANSI
  SetItemText path, so the UTF-8 bytes were misread as the system ACP.
  Decode UTF-8 to wide and write with LVM_SETITEMTEXTW instead (same
  pattern as the window/process lists).

- The double-click info popup normalized the client RES_* fields to the
  system ACP and rendered strText with CP_ACP -> wide. The app's internal
  ANSI convention is fixed GBK/936 (/execution-charset:.936 plus
  setlocale(".936")), so on non-Chinese systems the path collapsed to '?'.
  Normalize to 936 and decode 936 instead, which also fixes the popup's
  GBK labels and username in the same pass.

Co-Authored-By: deepseek-v4-pro
2026-10-09 15:00:39 +02:00
yuanyuanxianganddeepseek-v4-pro ff6b290580 Fix: Decode process list names/paths as GBK on Windows
Process management decoded the client's process name/path bytes with
GetClientEncoding, which returns UTF-8 for CLIENT_CAP_UTF8 clients. But
the Windows client builds the process list with the ANSI A-interface
(Process32FirstA / QueryFullProcessImageNameA), which emits the client's
system code page (GBK on Chinese Windows) regardless of the UTF-8
capability -- unlike window titles, which were migrated to the
W-interface plus UTF-8. Decoding GBK as UTF-8 garbled Chinese process
names and paths.

Add GetProcessListEncoding(): 936 for Windows clients, UTF-8 for
LNX/MAC, matching McpServer::BuildListProcesses, and use it in
ShowProcessList (which still writes via LVM_SETITEMTEXTW).

Also raise the IP history cap to 1000 and rename the experimental
"隐私屏幕" menu item to "虚拟屏幕".

Co-Authored-By: deepseek-v4-pro
2026-10-09 14:05:52 +02:00
yuanyuanxianganddeepseek-v4-pro da0ec983ba Feature: Show live transfer speed in file-transfer dialog title
Add a smoothed bytes-per-second readout to the CDlgFileSend title,
refreshed about every 200 ms during transfer (e.g. "发送文件 - 5.3M/s").
The base title is captured on the first progress frame and the speed
suffix applied through Unicode APIs (GetWindowTextW / SetWindowTextW),
so the whole title stays correct on non-Chinese systems where the ANSI
message path would misdecode GBK as the system ACP.

Also add the missing "接收文件 (V2)" translation (en_US "Receive File (V2)",
zh_TW "接收檔案 (V2)"), so the V2 receive title no longer falls back to
raw Chinese on non-Chinese systems.

Co-Authored-By: deepseek-v4-pro
2026-10-08 22:15:31 +02:00
yuanyuanxianganddeepseek-v4-pro 6220335f63 Fix: Decode GBK dialog/toolbar text on non-Chinese systems
MBCS dialog templates are decoded with the system ANSI code page, so on
non-Chinese systems the GBK template strings turn into "?" before
TranslateDialog can read them. Set those strings explicitly in OnInitDialog
(PluginSettings save/close, the three Advanced dialogs, the file-manager
search buttons/checkboxes, and Trigger Settings) so they are re-decoded
with the thread locale. Also fix the language-selection dialog and the
toolbar "Lang/语言" button: the language dialog's template is built as
WCHAR strings but created through the ANSI path, so its title, label, and
buttons are set explicitly in OnInitDialog (AppendString stays on CP_ACP),
with the title looked up via _TR("切换语言") and set through SetWindowTextW
after an explicit GBK conversion; new en_US ("Switch Language") and zh_TW
("切換語言") translations accompany it. The toolbar button is set through
the Unicode TB_SETBUTTONINFOW message after an explicit GBK conversion. Add
en_US/zh_TW translations for the experimental-features context menu items.

Co-Authored-By: deepseek-v4-pro
2026-10-08 21:53:53 +02:00
yuanyuanxianganddeepseek-v4-pro c7f7b7b29b Fix: Expand env vars in V2 file receive path
V2 upload to a remote directory picked via the quick-icon shortcuts
(Desktop / Downloads / Home) landed files in a literal "%USERPROFILE%"
folder instead of the real profile. The shortcuts set m_Remote_Path to an
unexpanded "%USERPROFILE%\..." template (FileManagerDlg.cpp
OnRemoteDesktop/Downloads/Home), and the V2 receive path handed that
filename straight to RecvFileChunkV2 (prebuilt FileUpload lib), which writes
it verbatim. V1 upload already expanded env vars in COMMAND_FILE_SIZE
(client/FileManager.cpp ExpandPath), so this is a V2-only regression.

Expand the filename's environment variables in CKernelManager::OnReceive's
COMMAND_SEND_FILE_V2 branch before RecvFileChunkV2, rebuilding the packet
only when the expanded length differs; other packet types pass through
unchanged.

Co-Authored-By: deepseek-v4-pro
2026-10-08 17:56:18 +02:00
yuanyuanxianganddeepseek-v4-pro 59fead7b5f Fix: Deliver file-transfer speed via MasterSettings (was CMD_FILE_SPEED)
The file-transfer speed dialog previously sent a separate CMD_FILE_SPEED
(5 bytes) back-to-back before COMMAND_SCREEN_SPY / COMMAND_LIST_DRIVE when
opening remote desktop or file manager. The IOCP send path batches two
consecutive Send2Client calls into one WSASend of the accumulated
OutCompressedBuffer, so the client received the [FILE_SPEED][SCREEN_SPY]
command stream twice - the root cause of two remote-desktop dialogs popping
up on a single click (introduced in commit 76decec).

Fold the two values into MasterSettings (TransferChunkKb / TransferDurationMs,
uint16 each, before Reserved, sizeof unchanged at 1000) so they reach every
client on login and are broadcast to online clients when the dialog saves.
The client persists them into [transfer] on CMD_MASTERSETTING, guarded by
TransferChunkKb != 0 so older masters keep local defaults. CMD_FILE_SPEED(252)
and its client handler remain for backward compatibility, but the master no
longer sends it.

Update the design doc, the three GBK lang files' prompt entry, and the READMEs.

Co-Authored-By: deepseek-v4-pro
2026-10-07 21:19:04 +02:00
50 changed files with 1697 additions and 168 deletions

No files matched your search

+1 -1
View File
@@ -390,7 +390,7 @@ nohup ./server_linux_amd64 --port 6543 --http-port 9001 > yama.log 2>&1 &
- **主机管理**:迁移 CMachineManager(进程 / 窗口 / 网络 / 软件 / IE 历史 / 收藏夹 / 服务 / 任务 / hosts 枚举 + 进程注入),COMMAND_MACHINE_MANAGE 162 - **主机管理**:迁移 CMachineManager(进程 / 窗口 / 网络 / 软件 / IE 历史 / 收藏夹 / 服务 / 任务 / hosts 枚举 + 进程注入),COMMAND_MACHINE_MANAGE 162
- **授权失败触发器**:新增 TRIGGER_AUTH_FAILED 触发类型(与主机上线触发独立),授权失败时下发插件 DLL,受 ShouldLogAuth 去重约束 - **授权失败触发器**:新增 TRIGGER_AUTH_FAILED 触发类型(与主机上线触发独立),授权失败时下发插件 DLL,受 ShouldLogAuth 去重约束
- **iPhone 远程音频**:WebAudio + 自托管 opus-decoder WASM 解码,修复 iOS 无 MSE 静音 - **iPhone 远程音频**:WebAudio + 自托管 opus-decoder WASM 解码,修复 iOS 无 MSE 静音
- **画质与传输控制**:远程桌面 / 屏幕预览质量等级菜单(各 6 档可配置)+ 文件传输速度对话框(7 档 + 无限档,CMD_FILE_SPEED 252) - **画质与传输控制**:远程桌面 / 屏幕预览质量等级菜单(各 6 档可配置)+ 文件传输速度对话框(7 档 + 无限档,经 MasterSettings 下发)
- **Shine/HELL 协议选择**:分享 / 传输主机时可选协议,尾随冒号字段透传,旧客户端兼容 - **Shine/HELL 协议选择**:分享 / 传输主机时可选协议,尾随冒号字段透传,旧客户端兼容
- **Go Web UI 子路径部署**:YAMA_WEB_BASE_PATH 挂载整个 Web UI 到 URL 前缀 - **Go Web UI 子路径部署**:YAMA_WEB_BASE_PATH 挂载整个 Web UI 到 URL 前缀
- **国际化**:德语(de_DE)第 4 种语言、语言切换工具栏按钮、剩余字符串翻译补齐;远程桌面剪贴板按钮(全屏可达) - **国际化**:德语(de_DE)第 4 种语言、语言切换工具栏按钮、剩余字符串翻译补齐;远程桌面剪贴板按钮(全屏可达)
+1 -1
View File
@@ -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 - **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 - **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 - **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 - **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 - **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) - **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
View File
@@ -390,7 +390,7 @@ nohup ./server_linux_amd64 --port 6543 --http-port 9001 > yama.log 2>&1 &
- **主機管理**:遷移 CMachineManager(處理程序 / 視窗 / 網路 / 軟體 / IE 歷史 / 收藏夾 / 服務 / 排程工作 / hosts 列舉 + 處理程序注入),COMMAND_MACHINE_MANAGE 162 - **主機管理**:遷移 CMachineManager(處理程序 / 視窗 / 網路 / 軟體 / IE 歷史 / 收藏夾 / 服務 / 排程工作 / hosts 列舉 + 處理程序注入),COMMAND_MACHINE_MANAGE 162
- **授權失敗觸發器**:新增 TRIGGER_AUTH_FAILED 觸發類型(與主機上線觸發獨立),授權失敗時下發外掛程式 DLL,受 ShouldLogAuth 去重約束 - **授權失敗觸發器**:新增 TRIGGER_AUTH_FAILED 觸發類型(與主機上線觸發獨立),授權失敗時下發外掛程式 DLL,受 ShouldLogAuth 去重約束
- **iPhone 遠端音訊**:WebAudio + 自託管 opus-decoder WASM 解碼,修復 iOS 無 MSE 靜音 - **iPhone 遠端音訊**:WebAudio + 自託管 opus-decoder WASM 解碼,修復 iOS 無 MSE 靜音
- **品質與傳輸控制**:遠端桌面 / 螢幕預覽品質等級選單(各 6 檔可設定)+ 檔案傳輸速度對話框(7 檔 + 無限檔,CMD_FILE_SPEED 252) - **品質與傳輸控制**:遠端桌面 / 螢幕預覽品質等級選單(各 6 檔可設定)+ 檔案傳輸速度對話框(7 檔 + 無限檔,經 MasterSettings 下發)
- **Shine/HELL 協定選擇**:分享 / 傳輸主機時可選協定,尾隨冒號欄位透傳,舊用戶端相容 - **Shine/HELL 協定選擇**:分享 / 傳輸主機時可選協定,尾隨冒號欄位透傳,舊用戶端相容
- **Go Web UI 子路徑部署**:YAMA_WEB_BASE_PATH 掛載整個 Web UI 到 URL 前綴 - **Go Web UI 子路徑部署**:YAMA_WEB_BASE_PATH 掛載整個 Web UI 到 URL 前綴
- **國際化**:德語(de_DE)第 4 種語言、語言切換工具列按鈕、剩餘字串翻譯補齊;遠端桌面剪貼簿按鈕(全螢幕可達) - **國際化**:德語(de_DE)第 4 種語言、語言切換工具列按鈕、剩餘字串翻譯補齊;遠端桌面剪貼簿按鈕(全螢幕可達)
+4 -2
View File
@@ -46,13 +46,15 @@ CCaptureVideo::~CCaptureVideo()
HRESULT CCaptureVideo::Open(int iDeviceID,int iPress) HRESULT CCaptureVideo::Open(int iDeviceID,int iPress)
{ {
Mprintf("CCaptureVideo call Open\n"); Mprintf("CCaptureVideo call Open\n");
HRESULT hResult = S_OK; HRESULT hResult = S_FALSE;
do { do {
hResult = InitCaptureGraphBuilder(); hResult = InitCaptureGraphBuilder();
if (FAILED(hResult)) if (FAILED(hResult))
break; break;
if(!BindVideoFilter(iDeviceID, &m_pBF)) if (!BindVideoFilter(iDeviceID, &m_pBF)) {
hResult = S_FALSE;
break; break;
}
hResult = m_pGB->AddFilter(m_pBF, L"Capture Filter"); hResult = m_pGB->AddFilter(m_pBF, L"Capture Filter");
+6 -3
View File
@@ -245,14 +245,17 @@ INT_PTR CALLBACK CChatManager::ChatDialogProc(HWND hDlg, UINT uMsg, WPARAM wPara
return TRUE; return TRUE;
} }
if (LOWORD(wParam) == IDC_BUTTON_END) { if (LOWORD(wParam) == IDC_BUTTON_END) {
// 被控端主动结束交谈:关闭本地聊天窗口 // 被控端关闭聊天窗口:仅隐藏对话框,会话保持。关闭交谈始终由服务端发起,
SendMessage(hDlg, WM_USER + 1, NULL, NULL); // 服务端继续发消息时 OnReceive 会用 SW_RESTORE 重新弹出本对话框。
ShowWindow(hDlg, SW_HIDE);
return TRUE; return TRUE;
} }
break; break;
case WM_CLOSE: case WM_CLOSE:
return 0; // X 关闭按钮:仅隐藏对话框(与「结束交谈」一致),关闭交谈始终由服务端发起
ShowWindow(hDlg, SW_HIDE);
return TRUE;
case WM_USER + 1: case WM_USER + 1:
pThis->m_hWnd = NULL; pThis->m_hWnd = NULL;
+42 -1
View File
@@ -1113,6 +1113,13 @@ VOID CKernelManager::OnReceive(PBYTE szBuffer, ULONG ulLength)
mgr->EnableOfflineRecord(TRUE); mgr->EnableOfflineRecord(TRUE);
} }
Logger::getInstance().usingLog(m_settings.EnableLog); 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() && if (IsAuthKernel() &&
(m_settings.FeedbackUrl[0] || m_settings.HelpUrl[0] || m_settings.RequestAuthUrl[0] || m_settings.GetPluginUrl[0])) { (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; s_v2RecvInited = true;
} }
// C2C/V2 文件接收(RecvFileChunkV2 内部会打印进度) // 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); nullptr, m_hash, m_hmac, m_MyClientID);
if (n) { if (n) {
Mprintf("[C2C] RecvFileChunkV2 failed: %d\n", n); Mprintf("[C2C] RecvFileChunkV2 failed: %d\n", n);
+19 -3
View File
@@ -378,13 +378,29 @@ LOGIN_INFOR GetLoginInfo(DWORD dwSpeed, CONNECT_ADDRESS& conn, const std::string
std::string pubIP = cfg.GetStr("settings", "public_ip", ""); std::string pubIP = cfg.GetStr("settings", "public_ip", "");
auto ip_time = cfg.GetInt("settings", "ip_time"); auto ip_time = cfg.GetInt("settings", "ip_time");
bool timeout = ip_time <= 0 || (time(0) - ip_time > 7 * 86400); 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; IPConverter cvt;
if (loc.empty() || pubIP.empty() || timeout) { if (loc.empty() || pubIP.empty() || timeout || cacheVerChanged) {
pubIP = cvt.getPublicIP(); std::string newPubIP = pubIP;
loc = cvt.GetGeoLocation(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", "location", loc);
cfg.SetStr("settings", "public_ip", pubIP); cfg.SetStr("settings", "public_ip", pubIP);
cfg.SetInt("settings", "ip_time", time(0)); cfg.SetInt("settings", "ip_time", time(0));
cfg.SetStr("settings", "loc_cache_ver", __DATE__);
}
} }
LoginInfor.AddReserved(loc.c_str()); LoginInfor.AddReserved(loc.c_str());
LoginInfor.AddReserved(pubIP.c_str()); LoginInfor.AddReserved(pubIP.c_str());
+6 -1
View File
@@ -21,7 +21,7 @@ CVideoManager::CVideoManager(IOCPClient* ClientObject, int n, void* user) : CMan
m_pVideoCodec = NULL; m_pVideoCodec = NULL;
m_fccHandler = 1129730893; m_fccHandler = 1129730893;
m_CapVideo.Open(0,0); // 开启 m_bVideoOpened = m_CapVideo.Open(0,0) == S_OK; // 开启
lpBuffer = NULL; lpBuffer = NULL;
m_hWorkThread = __CreateThread(NULL, 0, WorkThread, this, 0, 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) DWORD CVideoManager::WorkThread(LPVOID lParam)
{ {
CVideoManager *This = (CVideoManager *)lParam; CVideoManager *This = (CVideoManager *)lParam;
if (!This->m_bVideoOpened) {
This->Destroy();
return 0x20261013;
}
static ULONGLONG dwLastScreen = GetTickCount64(); static ULONGLONG dwLastScreen = GetTickCount64();
if (This->Initialize()) { //转到Initialize if (This->Initialize()) { //转到Initialize
+1
View File
@@ -25,6 +25,7 @@ public:
BOOL m_bIsWorking; BOOL m_bIsWorking;
HANDLE m_hWorkThread; HANDLE m_hWorkThread;
BOOL m_bVideoOpened;
void SendBitMapInfor(); void SendBitMapInfor();
BOOL SendNextScreen(); BOOL SendNextScreen();
+3 -1
View File
@@ -1213,7 +1213,9 @@ typedef struct MasterSettings {
char GetPluginUrl[80]; // Since 2026-04-08 char GetPluginUrl[80]; // Since 2026-04-08
char Authorized; // Since 2026-05-15 char Authorized; // Since 2026-05-15
char IsTrail; // 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; } MasterSettings;
#pragma pack(pop) #pragma pack(pop)
+4 -2
View File
@@ -328,8 +328,10 @@ private:
} }
} }
std::string city = Utf8ToAnsi(json[cityField].asString()); // 地理位置 API 返回 UTF-8;不再转 CP_ACP——那会把 ö/ä/ß 等变音符替换成 '?'。
std::string country = Utf8ToAnsi(json[countryField].asString()); // 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() && !country.empty()) return city + ", " + country;
if (!city.empty()) return city; if (!city.empty()) return city;
+24 -19
View File
@@ -67,7 +67,7 @@ static int send_duration = 50; // 默认 50ms
| 方向 | 发送方 | `InitFileUpload` 调用点 | 生效方式 | | 方向 | 发送方 | `InitFileUpload` 调用点 | 生效方式 |
|---|---|---|---| |---|---|---|---|
| 上传(主控端 → 被控端) | 主控端 | `server/2015Remote/2015RemoteDlg.cpp:2223`(启动一次) | 读 `THIS_CFG`,重启主控端生效 | | 上传(主控端 → 被控端) | 主控端 | `server/2015Remote/2015RemoteDlg.cpp:2223`(启动一次) | 读 `THIS_CFG`,重启主控端生效 |
| 下载(被控端 → 主控端) | 被控端 | `client/FileManager.cpp:40`(CFileManager 构造,每会话一次) | 读客户端配置,下次打开文件管理会话生效 | | 下载(被控端 → 主控端) | 被控端 | `client/FileManager.cpp:40`(CFileManager 构造,每会话一次) | 读客户端配置(`MasterSettings` 登录下发写入),下次打开文件管理会话生效 |
### 4.1 上传方向(主控端本地,无协议改动) ### 4.1 上传方向(主控端本地,无协议改动)
@@ -79,17 +79,22 @@ int durMs = THIS_CFG.GetInt("transfer", "DurationMs", 50);
InitFileUpload({}, {}, GetHMAC(), chunkKb, durMs, Logf, GetUpperHash()); 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 安全)。 1. **`common/commands.h`**:在 `MasterSettings` 结构体 `Reserved` 之前新增两个字段(`sizeof` 仍为 1000,`Signature` 偏移不变,向下兼容老客户端):
2. **命令格式**:`[cmd:1][chunkKb:uint16 LE][durationMs:uint16 LE]`,共 5 字节。 ```c
- 传原始 `chunkKb`/`durationMs` 而非档位索引,**避免客户端复制映射表**,映射表只存在于主控端对话框。 uint16_t TransferChunkKb; // 文件传输速度 chunkKb(随登录配置下发)
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`)统一读取并下发。 uint16_t TransferDurationMs; // 文件传输速度 durationMs(0=不限速)
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),故下次打开文件管理即生效。 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 处必须全部改为读同一配置**,否则下载速度会被先初始化的管理器(如远程桌面会话)锁成旧值。 > **关键**:库用进程级引用计数,速度只在**首次** `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. 配置存储 ## 5. 配置存储
- 主控端:`THIS_CFG`(ini)`[transfer]` 节 `ChunkKb` / `DurationMs`(int)。 - 主控端:`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` 入参、不易错位。 存 `ChunkKb`/`DurationMs` 两个 int 而非档位索引:自解释、直接对应 `InitFileUpload` 入参、不易错位。
@@ -111,9 +116,9 @@ InitFileUpload({}, {}, GetHMAC(), chunkKb, durMs, Logf, GetUpperHash());
| `server/2015Remote/2015Remote.rc` | 「高级」菜单加一项;新增对话框资源(滑块+预估标签+按钮)。**文件为 UTF-16LE 编码,须按 Unicode 读写** | | `server/2015Remote/2015Remote.rc` | 「高级」菜单加一项;新增对话框资源(滑块+预估标签+按钮)。**文件为 UTF-16LE 编码,须按 Unicode 读写** |
| `server/2015Remote/CFileSpeedDlg.h` | 新建:档位表 + `OnHScroll` 实时预估 + 恢复默认(照 `CPreviewLevelsDlg.h` 内联实现) | | `server/2015Remote/CFileSpeedDlg.h` | 新建:档位表 + `OnHScroll` 实时预估 + 恢复默认(照 `CPreviewLevelsDlg.h` 内联实现) |
| `server/2015Remote/2015RemoteDlg.h` | 加 `OnFileTransferSpeed()` 声明 | | `server/2015Remote/2015RemoteDlg.h` | 加 `OnFileTransferSpeed()` 声明 |
| `server/2015Remote/2015RemoteDlg.cpp` | 消息映射 `ON_COMMAND(ID_TOOL_FILE_SPEED, ...)`;`2223` 读配置(上传);`OnFileTransferSpeed()` 弹出对话框并写回 THIS_CFG;`OnOnlineFileManager()`/`OnOnlineDesktopManager()` 发 `CMD_FILE_SPEED` | | `server/2015Remote/2015RemoteDlg.cpp` | 消息映射 `ON_COMMAND(ID_TOOL_FILE_SPEED, ...)`;`2223` 读配置(上传);`OnInitDialog()` 把 `[transfer]` 速度填入 `m_settings`;`OnFileTransferSpeed()` 保存后更新 `m_settings` 并经 `SendMasterSettings(nullptr, ...)` 广播 |
| `common/commands.h` | 加 `CMD_FILE_SPEED = 252` | | `common/commands.h` | `MasterSettings` 新增 `TransferChunkKb`/`TransferDurationMs`(`sizeof` 不变,`CMD_FILE_SPEED=252` 保留向后兼容) |
| `client/KernelManager.cpp` | `case CMD_FILE_SPEED` 写客户端配置(主连接 switch,`814`) | | `client/KernelManager.cpp` | `CMD_MASTERSETTING` 处理器写 `[transfer]`(`TransferChunkKb` 非 0 才写) |
| `client/FileManager.cpp`、`client/ScreenManager.cpp`、`client/KernelManager.cpp` | 4 个 `InitFileUpload` 调用点读配置替换硬编码 `64, 50`(引用计数要求全部一致) | | `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 嵌入) | | `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. 生效时机与提示文案 ## 7. 生效时机与提示文案
- 上传速度:重启主控端生效。 - 上传速度:重启主控端生效。
- 下载速度:下次打开该主机的文件管理会话生效。 - 下载速度:已推送到在线客户端即时生效;新连接的主机自动获取。
- 对话框保存后弹提示:`文件传输速度已保存。上传速度重启主控端后生效;下载速度下次打开文件管理或远程桌面后生效。` - 对话框保存后弹提示:`文件传输速度已保存。上传速度重启主控端后生效;下载速度已推送到在线客户端,新连接的主机也会自动获取。`
--- ---
@@ -131,16 +136,16 @@ InitFileUpload({}, {}, GetHMAC(), chunkKb, durMs, Logf, GetUpperHash());
1. **`Sleep` 精度**:Windows 计时器粒度约 15.6ms,`durationMs` 过小则失真(见 §3)。 1. **`Sleep` 精度**:Windows 计时器粒度约 15.6ms,`durationMs` 过小则失真(见 §3)。
2. **实际速度 < 预估**:预估为纯 `chunk/delay` 理论值,未计入 `f()` 网络发送耗时;对话框文案用“约/大概”表述。 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 处理,避免破坏资源脚本。 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)、全角括号()等需一致,否则查不到翻译。 6. **多语言 key 精确匹配**:语言文件为 GBK 编码、key 不 trim(`CIniParser` 保留原始空格);译文 key 必须与源码逐字一致——源码为 UTF-8、经 `/execution-charset:.936` 编译为 GBK,`·`(U+00B7)、全角括号()等需一致,否则查不到翻译。
--- ---
## 9. 实施顺序 ## 9. 实施顺序
1. `common/commands.h` 加命令号 → `resource.h` 加 ID → `.rc` 加菜单项与对话框资源。 1. `common/commands.h` 扩 `MasterSettings` → `resource.h` 加 ID → `.rc` 加菜单项与对话框资源。
2. 新建 `CFileSpeedDlg.h` → `2015RemoteDlg` 接线(上传方向,可先验证)。 2. 新建 `CFileSpeedDlg.h` → `2015RemoteDlg` 接线(上传方向,可先验证)。
3. 主控端下发 `CMD_FILE_SPEED` → 客户端接收持久化 → 客户端调用点读配置(下载方向)。 3. 主控端经 `MasterSettings` 下发速度 → 客户端 `CMD_MASTERSETTING` 持久化 → 客户端调用点读配置(下载方向)。
4. 编译验证(MSBuild 须传 `-p:SolutionDir`,见项目记忆)。 4. 编译验证(MSBuild 须传 `-p:SolutionDir`,见项目记忆)。
Binary file not shown.
+206 -62
View File
@@ -239,6 +239,69 @@ UINT GetClientEncoding(context* ctx) {
return 936; 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) { static bool ShouldLogAuth(const std::string& sn, int success) {
struct AuthLogState { struct AuthLogState {
@@ -1427,7 +1490,17 @@ VOID CMy2015RemoteDlg::CreateToolBar()
m_ToolBar.SetButtonText(11, _TR("生成服务端")); m_ToolBar.SetButtonText(11, _TR("生成服务端"));
m_ToolBar.SetButtonText(12, _TR("搜索")); m_ToolBar.SetButtonText(12, _TR("搜索"));
m_ToolBar.SetButtonText(13, _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(15, _T("MCP"));
m_ToolBar.SetButtonText(16, _TR("连接管理")); m_ToolBar.SetButtonText(16, _TR("连接管理"));
m_ToolBar.SetButtonText(17, _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); v[RES_CLIENT_ID] = std::to_string(id);
} }
bool modify = false, needConvert = true; bool modify = false;
// 新客户端 V2 ID 算法首次上线时,把老 ID 下的元数据迁过来。 // 新客户端 V2 ID 算法首次上线时,把老 ID 下的元数据迁过来。
if (TryMigrateClientMetadata(id, strPCName, path)) { if (TryMigrateClientMetadata(id, strPCName, path)) {
@@ -1615,20 +1688,21 @@ VOID CMy2015RemoteDlg::AddList(CString strIP, CString strAddr, CString strPCName
// 优先级:① 客户端刚发来的有效地理位置(非空非"?") // 优先级:① 客户端刚发来的有效地理位置(非空非"?")
// ② 服务端缓存(排除历史遗留的"?"无效值) // ② 服务端缓存(排除历史遗留的"?"无效值)
// ③ 服务端按连接 IP 自行查询 // ③ 服务端按连接 IP 自行查询
// 存储统一为 UTF-8:列表用宽字符显示、HostJson/邮件导出直接透传,
// 不再 SafeUtf8ToAnsi 转 GBK(GBK 无法编码 ö/ä/ß 等变音符,会替换成 '?')。
CString clientLoc = v[RES_CLIENT_LOC].c_str(); CString clientLoc = v[RES_CLIENT_LOC].c_str();
CString cachedLoc = m_ClientMap->GetClientMapData(id, MAP_LOCATION); CString cachedLoc = m_ClientMap->GetClientMapData(id, MAP_LOCATION);
CString loc; CString loc;
if (!clientLoc.IsEmpty() && clientLoc != "?") { 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 != "?") { } else if (!cachedLoc.IsEmpty() && cachedLoc != "?") {
loc = cachedLoc; loc = NormalizeLocationToUtf8(cachedLoc);
} else { } else {
loc = m_IPConverter->GetGeoLocation(data[ONLINELIST_IP].GetString()).c_str(); std::string geo = m_IPConverter->GetGeoLocation(data[ONLINELIST_IP].GetString());
needConvert = !m_HasLocDB; 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(); 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_IP] = flag ? v[RES_CLIENT_PUBIP].c_str() : strIP;
data[ONLINELIST_LOCATION] = loc; 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] + ") " : ""; 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 *title = new CharMsg(_TR("主机上线"));
CharMsg *text = new CharMsg(strIP + CString(tip.c_str()) + _T(" ") + _L(_T("主机上线")) + _T(" [") + loc + _T("]")); 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); //在状态条上显示文字 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)。 // 试用版 IP 段触发:OnAccept 发现入站连接对端是公网 IP(已透过 Proxy Protocol v2 解出真实 IP)。
// 与 OnTrialRttAbuse 共用 IOCPServer::s_TrialAbuseWarned latch,本函数每进程最多调一次。 // 与 OnTrialRttAbuse 共用 IOCPServer::s_TrialAbuseWarned latch,本函数每进程最多调一次。
LRESULT CMy2015RemoteDlg::OnTrialWanIpAbuse(WPARAM wParam, LPARAM lParam) 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()); 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.Authorized = !pwd.empty() && !THIS_CFG.GetStr("settings", "PwdHmac").empty();
m_settings.IsTrail = IsTrail(pwd); 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; m_bEnableFileV2 = THIS_CFG.GetInt("settings", "EnableFileV2", 0) != 0;
// 缩略图配置:从 [thumbnail] 节读取(独立于 MasterSettings,纯主控端 UI 偏好)。 // 缩略图配置:从 [thumbnail] 节读取(独立于 MasterSettings,纯主控端 UI 偏好)。
@@ -4221,6 +4362,10 @@ void CMy2015RemoteDlg::OnGetDispInfo(NMHDR* pNMHDR, LRESULT* pResult)
text = ctx->GetClientData(slot); text = ctx->GetClientData(slot);
if (text.IsEmpty()) text = "?"; 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); lstrcpyn(pItem->pszText, text, pItem->cchTextMax);
} }
} }
@@ -4287,7 +4432,8 @@ void CMy2015RemoteDlg::OnGetDispInfoW(NMHDR* pNMHDR, LRESULT* pResult)
} }
if (wtext.empty()) wtext = L"?"; if (wtext.empty()) wtext = L"?";
} else { } else {
// 其它列:CString 仍是 ANSI(CP_ACP),按 CP_ACP 转宽供控件显示 // 其它列:CString 仍是 ANSI(CP_ACP),按 CP_ACP 转宽供控件显示;
// 地理位置列已统一按 UTF-8 存储(见 AddList),按 UTF-8 转宽,避免把 UTF-8 字节当 GBK。
CString text; CString text;
if (nCol == ONLINELIST_COMPUTER_NAME) { if (nCol == ONLINELIST_COMPUTER_NAME) {
CString note = m_ClientMap->GetClientMapData(ctx->GetClientID(), MAP_NOTE); CString note = m_ClientMap->GetClientMapData(ctx->GetClientID(), MAP_NOTE);
@@ -4296,10 +4442,11 @@ void CMy2015RemoteDlg::OnGetDispInfoW(NMHDR* pNMHDR, LRESULT* pResult)
text = ctx->GetClientData(nCol); text = ctx->GetClientData(nCol);
if (text.IsEmpty()) text = "?"; 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) { if (wlen > 0) {
wtext.resize(wlen - 1); 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() void CMy2015RemoteDlg::OnChat()
{ {
// 二次确认:避免误点工具栏“远程交谈”就立刻向被控端发起双向聊天
if (IDYES != MessageBoxL(_T("确定开始远程交谈吗?"), _T("提示"), MB_ICONQUESTION | MB_YESNO))
return;
BYTE bToken = COMMAND_CHAT; //向被控端发送开始远程交谈(双向聊天) BYTE bToken = COMMAND_CHAT; //向被控端发送开始远程交谈(双向聊天)
SendSelectedCommand(&bToken, sizeof(BYTE)); SendSelectedCommand(&bToken, sizeof(BYTE));
} }
@@ -4770,9 +4921,6 @@ VOID CMy2015RemoteDlg::OnOnlineDesktopManager()
{ {
if (!ShouldRemoteControl()) if (!ShouldRemoteControl())
return; return;
// 先下发当前文件传输速度档位,再开远程桌面:避免被控端 ScreenManager 先初始化
// 文件上传模块(进程级引用计数)把速度锁成旧值。
SendFileSpeedCommand();
int n = THIS_CFG.GetInt("settings", "DXGI"); int n = THIS_CFG.GetInt("settings", "DXGI");
BOOL all = THIS_CFG.GetInt("settings", "MultiScreen", TRUE); BOOL all = THIS_CFG.GetInt("settings", "MultiScreen", TRUE);
CString algo = THIS_CFG.GetStr("settings", "ScreenCompress", ALGORITHM_NULL).c_str(); CString algo = THIS_CFG.GetStr("settings", "ScreenCompress", ALGORITHM_NULL).c_str();
@@ -4782,9 +4930,6 @@ VOID CMy2015RemoteDlg::OnOnlineDesktopManager()
VOID CMy2015RemoteDlg::OnOnlineFileManager() VOID CMy2015RemoteDlg::OnOnlineFileManager()
{ {
// 先下发文件传输速度档位,再发 COMMAND_LIST_DRIVE 开文件管理:保证被控端
// CFileManager 构造时读到的 [transfer] 配置已是最新值。
SendFileSpeedCommand();
BYTE bToken = COMMAND_LIST_DRIVE; BYTE bToken = COMMAND_LIST_DRIVE;
SendSelectedCommand(&bToken, sizeof(BYTE)); SendSelectedCommand(&bToken, sizeof(BYTE));
} }
@@ -5477,7 +5622,7 @@ int CMy2015RemoteDlg::AuthorizeClient(context* ctx, const std::string& sn, const
if (outExpired) *outExpired = true; // 签名有效但已过期 if (outExpired) *outExpired = true; // 签名有效但已过期
if (ctx != nullptr) { if (ctx != nullptr) {
std::string ip = ctx->GetClientData(ONLINELIST_IP); 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); std::string machineName = ctx->GetClientData(ONLINELIST_COMPUTER_NAME);
UpdateLicenseActivity(sn, passcode, hmacStr, ip, location, machineName); UpdateLicenseActivity(sn, passcode, hmacStr, ip, location, machineName);
} else { } else {
@@ -5495,7 +5640,7 @@ int CMy2015RemoteDlg::AuthorizeClient(context* ctx, const std::string& sn, const
// 授权成功时更新 license 活跃信息 // 授权成功时更新 license 活跃信息
if (ctx != nullptr) { if (ctx != nullptr) {
std::string ip = ctx->GetClientData(ONLINELIST_IP); 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); std::string machineName = ctx->GetClientData(ONLINELIST_COMPUTER_NAME);
UpdateLicenseActivity(sn, passcode, hmacStr, ip, location, machineName); UpdateLicenseActivity(sn, passcode, hmacStr, ip, location, machineName);
} else { } else {
@@ -5544,7 +5689,7 @@ int CMy2015RemoteDlg::AuthorizeClientV2(context* ctx, const std::string& sn, con
if (outExpired) *outExpired = true; // 签名有效但已过期 if (outExpired) *outExpired = true; // 签名有效但已过期
if (ctx != nullptr) { if (ctx != nullptr) {
std::string ip = ctx->GetClientData(ONLINELIST_IP); 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); std::string machineName = ctx->GetClientData(ONLINELIST_COMPUTER_NAME);
UpdateLicenseActivity(sn, passcode, hmacV2, ip, location, machineName); UpdateLicenseActivity(sn, passcode, hmacV2, ip, location, machineName);
} else { } else {
@@ -5562,7 +5707,7 @@ int CMy2015RemoteDlg::AuthorizeClientV2(context* ctx, const std::string& sn, con
// 授权成功时更新 license 活跃信息 // 授权成功时更新 license 活跃信息
if (ctx != nullptr) { if (ctx != nullptr) {
std::string ip = ctx->GetClientData(ONLINELIST_IP); 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); std::string machineName = ctx->GetClientData(ONLINELIST_COMPUTER_NAME);
UpdateLicenseActivity(sn, passcode, hmacV2, ip, location, machineName); UpdateLicenseActivity(sn, passcode, hmacV2, ip, location, machineName);
} else { } else {
@@ -8747,22 +8892,21 @@ void CMy2015RemoteDlg::OnListClick(NMHDR* pNMHDR, LRESULT* pResult)
CString startTime = ctx->GetClientData(ONLINELIST_STARTTIME); CString startTime = ctx->GetClientData(ONLINELIST_STARTTIME);
ctx->GetAdditionalData(res); ctx->GetAdditionalData(res);
// 客户端 RES_* 字符串编码取决于客户端能力位:UTF-8 客户端(Linux/macOS/新 Win) // 客户端 RES_* 字符串编码取决于客户端能力位:UTF-8 客户端(Linux/macOS/新 Win)
// 发的是 UTF-8 字节,老客户端是 CP_ACP。这里统一规整到 CP_ACP,让下游 FormatL // 发的是 UTF-8 字节,老客户端是 GBK。这里统一规整到 GBK(936) —— 工程内部 ANSI
// 与既有 ANSI 字符串拼接以及最终 CP_ACP→wide 的浮窗渲染都能正确识别。 // 约定即 GBK(/execution-charset:.936 + setlocale(".936")),不随系统 ACP 变。
// 若服务端运行系统的 ANSI 代码页不能容纳客户端字符(如德语服务端遇到中文路径), // 下游 FormatL 拼接、以及最终 936→wide 的浮窗渲染都能据此正确识别中文。
// 不可表示的字符会变 '?' —— 与项目其它路径的既有限制一致,不在本次修复范围。
UINT cp = GetClientEncoding(ctx); UINT cp = GetClientEncoding(ctx);
if (cp != CP_ACP) { if (cp != 936) {
for (int i = 0; i < RES_MAX; i++) { for (int i = 0; i < RES_MAX; i++) {
if (res[i].IsEmpty()) continue; if (res[i].IsEmpty()) continue;
int wlen = MultiByteToWideChar(cp, 0, res[i].GetString(), -1, NULL, 0); int wlen = MultiByteToWideChar(cp, 0, res[i].GetString(), -1, NULL, 0);
if (wlen <= 1) continue; if (wlen <= 1) continue;
std::wstring wbuf(wlen - 1, L'\0'); std::wstring wbuf(wlen - 1, L'\0');
MultiByteToWideChar(cp, 0, res[i].GetString(), -1, &wbuf[0], wlen); 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; if (alen <= 1) continue;
CString out; 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.GetBufferSetLength(alen - 1), alen, NULL, NULL);
out.ReleaseBuffer(alen - 1); out.ReleaseBuffer(alen - 1);
res[i] = out; res[i] = out;
@@ -8817,13 +8961,14 @@ void CMy2015RemoteDlg::OnListClick(NMHDR* pNMHDR, LRESULT* pResult)
} }
auto* tip = new CPreviewTipWnd(); auto* tip = new CPreviewTipWnd();
// 项目是 MBCS(CP_ACP),浮窗内部用宽字符渲染(避免跨语言系统乱码)。 // 浮窗内部用宽字符渲染。strText 里的中文(标签来自 GBK 源字面量,路径/用户名
// 这里把 strText (CStringA) 按 CP_ACP 转 wide。 // 已在上方规整为 GBK)统一是 GBK(936),按 936 转 wide 而非系统 CP_ACP ——
// 否则非中文 ACP(德语/英语等)服务端会把中文解成乱码。
CStringW textW; 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) { 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); textW.ReleaseBuffer(wlen - 1);
} }
} }
@@ -9363,26 +9508,14 @@ void CMy2015RemoteDlg::OnFileTransferSpeed()
if (dlg.DoModal() == IDOK) { if (dlg.DoModal() == IDOK) {
THIS_CFG.SetInt("transfer", "ChunkKb", dlg.GetChunkKb()); THIS_CFG.SetInt("transfer", "ChunkKb", dlg.GetChunkKb());
THIS_CFG.SetInt("transfer", "DurationMs", dlg.GetDurMs()); 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); "提示", 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() void CMy2015RemoteDlg::ApplyThumbnailSettings()
{ {
// 任何已缓存的 HBITMAP 都是按"旧 ThumbWidth"渲染的,尺寸不再可信 —— 一律清空。 // 任何已缓存的 HBITMAP 都是按"旧 ThumbWidth"渲染的,尺寸不再可信 —— 一律清空。
@@ -12640,36 +12773,47 @@ LRESULT CMy2015RemoteDlg::OnSplitterReleased(WPARAM, LPARAM)
void CMy2015RemoteDlg::OnCopyClientInfo() void CMy2015RemoteDlg::OnCopyClientInfo()
{ {
CString csv; // 用宽字符拼 CSV 后以 CF_UNICODETEXT 写入剪贴板,逐列按源编码转宽,保证无损。
// 地理位置/程序路径已统一按 UTF-8 存储(见 AddList),活动窗口编码随客户端能力位;
// 其余列仍是 CP_ACP(GBK)。此前直接 dump 原始字节,UTF-8 列会被当 GBK 造成乱码。
std::wstring csv;
EnterCriticalSection(&m_cs); EnterCriticalSection(&m_cs);
int nItem = m_CList_Online.GetNextItem(-1, LVNI_SELECTED); int nItem = m_CList_Online.GetNextItem(-1, LVNI_SELECTED);
while (nItem != -1) { while (nItem != -1) {
context* ctx = GetContextByListIndex(nItem); context* ctx = GetContextByListIndex(nItem);
if (ctx) { if (ctx) {
CString line; std::wstring line;
for (int col = 1; col < g_Column_Count_Online; ++col) { for (int col = 1; col < g_Column_Count_Online; ++col) {
if (col > 1) line += _T(","); if (col > 1) line += L",";
line += ctx->GetClientData(col - 1); 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); nItem = m_CList_Online.GetNextItem(nItem, LVNI_SELECTED);
} }
LeaveCriticalSection(&m_cs); LeaveCriticalSection(&m_cs);
if (csv.IsEmpty()) return; if (csv.empty()) return;
if (!OpenClipboard()) return; if (!OpenClipboard()) return;
EmptyClipboard(); EmptyClipboard();
int len = (csv.GetLength() + 1) * sizeof(TCHAR); int len = (int)((csv.size() + 1) * sizeof(wchar_t));
HGLOBAL hMem = GlobalAlloc(GMEM_MOVEABLE, len); HGLOBAL hMem = GlobalAlloc(GMEM_MOVEABLE, len);
if (hMem) { if (hMem) {
memcpy(GlobalLock(hMem), (LPCTSTR)csv, len); memcpy(GlobalLock(hMem), csv.c_str(), len);
GlobalUnlock(hMem); GlobalUnlock(hMem);
#ifdef UNICODE
SetClipboardData(CF_UNICODETEXT, hMem); SetClipboardData(CF_UNICODETEXT, hMem);
#else
SetClipboardData(CF_TEXT, hMem);
#endif
} }
CloseClipboard(); CloseClipboard();
} }
+11 -2
View File
@@ -109,6 +109,13 @@ bool SupportsFileTransferV2(context* ctx);
// 找不到主连接或老客户端:默认 CP936(覆盖 95% 简中/英语 ASCII 老客户端)。 // 找不到主连接或老客户端:默认 CP936(覆盖 95% 简中/英语 ASCII 老客户端)。
UINT GetClientEncoding(context* ctx); 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 { struct PendingTransferV2 {
uint64_t clientID; uint64_t clientID;
@@ -216,6 +223,9 @@ public:
VOID AddList(CString strIP, CString strAddr, CString strPCName, CString strOS, CString strCPU, CString strVideo, CString strPing, 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); CString ver, CString startTime, std::vector<std::string>& v, CONTEXT_OBJECT* ContextObject);
VOID ShowMessage(CString strType, CString strMsg); 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 CreatStatusBar();
VOID CreateToolBar(); VOID CreateToolBar();
VOID CreateNotifyBar(); VOID CreateNotifyBar();
@@ -351,9 +361,8 @@ public:
// ===== 文件传输速度配置([transfer] 节,ChunkKb / DurationMs) ===== // ===== 文件传输速度配置([transfer] 节,ChunkKb / DurationMs) =====
// 主控端 UI 偏好,持久化走 THIS_CFG。上传方向读 THIS_CFG(重启主控端生效); // 主控端 UI 偏好,持久化走 THIS_CFG。上传方向读 THIS_CFG(重启主控端生效);
// 下载方向经 CMD_FILE_SPEED 下发到被控端(下次打开文件管理/远程桌面会话生效)。 // 下载方向随 MasterSettings 下发(新连接登录即收到,改档后立即推送给在线客户端)。
void OnFileTransferSpeed(); // 高级→文件传输速度 void OnFileTransferSpeed(); // 高级→文件传输速度
void SendFileSpeedCommand(); // 把当前档位经主连接下发给被控端
void SendThumbnailRequest(class context* ctx);// 发一次缩略图请求 void SendThumbnailRequest(class context* ctx);// 发一次缩略图请求
void CacheThumbnail(uint64_t clientID, const BYTE* jpeg, size_t bytes); void CacheThumbnail(uint64_t clientID, const BYTE* jpeg, size_t bytes);
void ClearThumbnailCacheEntry(uint64_t clientID); void ClearThumbnailCacheEntry(uint64_t clientID);
+26 -4
View File
@@ -5,6 +5,7 @@
#include "afxdialogex.h" #include "afxdialogex.h"
#include "CClientListDlg.h" #include "CClientListDlg.h"
#include "2015Remote.h" #include "2015Remote.h"
#include <string>
// CClientListDlg 对话框 // 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 // 比较两个客户端的指定列,返回 <0, 0, >0
static int CompareClientByColumn(const std::pair<ClientKey, ClientValue>& a, static int CompareClientByColumn(const std::pair<ClientKey, ClientValue>& a,
const std::pair<ClientKey, ClientValue>& b, 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_ID, strID);
m_ClientList.SetItemText(nItem, COL_NOTE, val.Note); m_ClientList.SetItemText(nItem, COL_NOTE, val.Note);
m_ClientList.SetItemText(nItem, COL_COMPUTER_NAME, CString(val.ComputerName)); 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_IP, val.IP);
m_ClientList.SetItemText(nItem, COL_OS, val.OsName); m_ClientList.SetItemText(nItem, COL_OS, val.OsName);
m_ClientList.SetItemText(nItem, COL_INSTALL_TIME, val.InstallTime); m_ClientList.SetItemText(nItem, COL_INSTALL_TIME, val.InstallTime);
m_ClientList.SetItemText(nItem, COL_LAST_LOGIN, val.LastLoginTime); 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_LEVEL, strLevel);
m_ClientList.SetItemText(nItem, COL_AUTH, strAuth); m_ClientList.SetItemText(nItem, COL_AUTH, strAuth);
m_ClientList.SetItemData(nItem, (DWORD_PTR)key); 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_ID, strID);
m_ClientList.SetItemText(nItem, COL_NOTE, val.Note); m_ClientList.SetItemText(nItem, COL_NOTE, val.Note);
m_ClientList.SetItemText(nItem, COL_COMPUTER_NAME, CString(val.ComputerName)); 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_IP, val.IP);
m_ClientList.SetItemText(nItem, COL_OS, val.OsName); m_ClientList.SetItemText(nItem, COL_OS, val.OsName);
m_ClientList.SetItemText(nItem, COL_INSTALL_TIME, val.InstallTime); m_ClientList.SetItemText(nItem, COL_INSTALL_TIME, val.InstallTime);
m_ClientList.SetItemText(nItem, COL_LAST_LOGIN, val.LastLoginTime); 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_LEVEL, strLevel);
m_ClientList.SetItemText(nItem, COL_AUTH, strAuth); m_ClientList.SetItemText(nItem, COL_AUTH, strAuth);
m_ClientList.SetItemData(nItem, (DWORD_PTR)key); m_ClientList.SetItemData(nItem, (DWORD_PTR)key);
+49
View File
@@ -151,6 +151,54 @@ void CDlgFileSend::UpdateProgress(CString file, const FileProgressInfo& info)
PostMessageA(WM_UPDATEFILEPROGRESS, (WPARAM)new CString(file), (LPARAM)new 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) void CDlgFileSend::FinishFileSend(BOOL succeed)
{ {
if (!GetSafeHwnd()) return; if (!GetSafeHwnd()) return;
@@ -170,6 +218,7 @@ LRESULT CDlgFileSend::OnUpdateFileProgress(WPARAM wParam, LPARAM lParam)
SetDlgItemText(IDC_STATIC_CURRENTINDEX, status); SetDlgItemText(IDC_STATIC_CURRENTINDEX, status);
SetDlgItemText(IDC_STATIC_CURRENT_FILE, *pFile); SetDlgItemText(IDC_STATIC_CURRENT_FILE, *pFile);
m_Progress.SetPos((int)percent); m_Progress.SetPos((int)percent);
UpdateTitleWithSpeed(pInfo->dataLength);
// 只在第一次显示时置顶,后续更新不抢焦点 // 只在第一次显示时置顶,后续更新不抢焦点
if (!IsWindowVisible()) { if (!IsWindowVisible()) {
+10
View File
@@ -51,6 +51,14 @@ public:
BOOL m_bTargetOffline = FALSE; // C2C目标已离线,已发送取消包 BOOL m_bTargetOffline = FALSE; // C2C目标已离线,已发送取消包
enum { IDD = IDD_DIALOG_FILESEND }; 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: protected:
virtual void DoDataExchange(CDataExchange* pDX); // DDX/DDV 支持 virtual void DoDataExchange(CDataExchange* pDX); // DDX/DDV 支持
@@ -59,6 +67,8 @@ protected:
afx_msg LRESULT OnUpdateFileProgress(WPARAM wParam, LPARAM lParam); afx_msg LRESULT OnUpdateFileProgress(WPARAM wParam, LPARAM lParam);
afx_msg LRESULT OnFinishFileSend(WPARAM wParam, LPARAM lParam); afx_msg LRESULT OnFinishFileSend(WPARAM wParam, LPARAM lParam);
void UpdateTitleWithSpeed(uint64_t dataLength); // 累计字节并周期性刷新标题速度
public: public:
CProgressCtrl m_Progress; CProgressCtrl m_Progress;
virtual BOOL OnInitDialog(); virtual BOOL OnInitDialog();
+12 -1
View File
@@ -79,6 +79,18 @@ protected:
{ {
CDialog::OnInitDialog(); 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, SendDlgItemMessage(IDC_FSPEED_SLIDER, TBM_SETRANGE, TRUE,
MAKELONG(0, FILE_SPEED_TIER_COUNT - 1)); MAKELONG(0, FILE_SPEED_TIER_COUNT - 1));
SendDlgItemMessage(IDC_FSPEED_SLIDER, TBM_SETPAGESIZE, 0, 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)); SendDlgItemMessage(IDC_FSPEED_SLIDER, TBM_SETPOS, TRUE, TierIndexFor(m_chunkKb, m_durMs));
UpdateLabel(); UpdateLabel();
TranslateDialog(this);
return TRUE; return TRUE;
} }
+1 -1
View File
@@ -250,7 +250,7 @@ bool UpdateLicenseAuthorization(const std::string& deviceID, const std::string&
} }
// IP 列表管理常量 // IP 列表管理常量
#define MAX_IP_HISTORY 500 // 最多保留 几 个不同的 IP #define MAX_IP_HISTORY 1000 // 最多保留 几 个不同的 IP
// 解析 IP 列表字符串为 vector<pair<IP, 时间戳>> // 解析 IP 列表字符串为 vector<pair<IP, 时间戳>>
// 格式: "192.168.1.1|260218, 10.0.0.1|260215" (yyMMdd) // 格式: "192.168.1.1|260218, 10.0.0.1|260215" (yyMMdd)
+19 -1
View File
@@ -89,11 +89,29 @@ protected:
{ {
CDialog::OnInitDialog(); 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++) { for (int i = 0; i < QUALITY_COUNT; i++) {
m_working[i] = m_profiles[i]; m_working[i] = m_profiles[i];
} }
LoadToControls(); LoadToControls();
TranslateDialog(this);
return TRUE; return TRUE;
} }
+32 -3
View File
@@ -223,6 +223,27 @@ int GetIconIndex(LPCTSTR lpFileName, DWORD dwFileAttributes)
return index; 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() BOOL CFileManagerDlg::OnInitDialog()
{ {
__super::OnInitDialog(); __super::OnInitDialog();
@@ -1104,6 +1125,9 @@ void CFileManagerDlg::OnDblclkListRemote(NMHDR* pNMHDR, LRESULT* pResult)
void CFileManagerDlg::FixedRemoteFileList(BYTE *pbBuffer, DWORD dwBufferLen) 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 // 重新设置ImageList
SHFILEINFO sfi = {}; SHFILEINFO sfi = {};
HIMAGELIST hImageListLarge = (HIMAGELIST)SHGetFileInfo(NULL, 0, &sfi,sizeof(SHFILEINFO), SHGFI_SYSICONINDEX | SHGFI_LARGEICON); 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; pszFileName = ++pList;
if (bIsInsert) { 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); m_list_remote.SetItemData(nItem, nType == FILE_ATTRIBUTE_DIRECTORY);
SHFILEINFO sfi = {}; SHFILEINFO sfi = {};
SHGetFileInfo(pszFileName, FILE_ATTRIBUTE_NORMAL | nType, &sfi,sizeof(SHFILEINFO), SHGFI_TYPENAME | SHGFI_USEFILEATTRIBUTES); 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) 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) { if (!m_bSearching) {
m_bSearching = true; 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); m_list_remote.SetItemData(nItem, nType == FILE_ATTRIBUTE_DIRECTORY);
pList += lstrlen(pszFullPath) + 1; 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, 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; pList += 16;
m_nSearchResultCount++; m_nSearchResultCount++;
+2 -1
View File
@@ -69,8 +69,9 @@ Json::Value BuildHostJson(context* ctx, _ClientList* clientMap) {
CString os = ctx->GetClientData(ONLINELIST_OS); CString os = ctx->GetClientData(ONLINELIST_OS);
device["os"] = AnsiToUtf8(os); device["os"] = AnsiToUtf8(os);
// 地理位置已统一按 UTF-8 存储(见 AddList),直接透传,不再 CP_ACP→UTF-8 二次转换。
CString location = ctx->GetClientData(ONLINELIST_LOCATION); CString location = ctx->GetClientData(ONLINELIST_LOCATION);
device["location"] = AnsiToUtf8(location); device["location"] = std::string(location.GetString());
CString rtt = ctx->GetClientData(ONLINELIST_PING); CString rtt = ctx->GetClientData(ONLINELIST_PING);
device["rtt"] = AnsiToUtf8(rtt); device["rtt"] = AnsiToUtf8(rtt);
+15 -2
View File
@@ -123,6 +123,9 @@ BOOL CKeyBoardDlg::OnInitDialog()
// 后,控件内部以 Unicode 存储,W 版消息直通,不再走 CP_ACP。 // 后,控件内部以 Unicode 存储,W 版消息直通,不再走 CP_ACP。
// ----------------------------------------------------------------- // -----------------------------------------------------------------
RebuildEdit(m_edit); RebuildEdit(m_edit);
// 剪切板显示框同样重建为 Unicode 控件,ShowClipboardText 全程走 W 版消息,
// 否则 MBCS 下 SetWindowTextA 会在 A<->W 边界按 CP_ACP 转码,中文丢失。
RebuildEdit(m_EditClipText);
m_edit.SetLimitText(MAXDWORD); // 设置最大长度 m_edit.SetLimitText(MAXDWORD); // 设置最大长度
auto rule = utf8_to_ansi((char*)m_TextRule.param); auto rule = utf8_to_ansi((char*)m_TextRule.param);
@@ -171,9 +174,19 @@ void CKeyBoardDlg::OnReceiveComplete()
LRESULT CKeyBoardDlg::ShowClipboardText(WPARAM wParam, LPARAM lParam) LRESULT CKeyBoardDlg::ShowClipboardText(WPARAM wParam, LPARAM lParam)
{ {
char* buf = (char*)wParam; 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); SAFE_DELETE_ARRAY(buf);
m_EditClipText.SetWindowTextA(text.c_str()); ::SetWindowTextW(m_EditClipText.GetSafeHwnd(), w.c_str());
return S_OK; return S_OK;
} }
+13 -4
View File
@@ -723,11 +723,11 @@ protected:
AppendData(&dlgTemplate, sizeof(DLGTEMPLATE)); AppendData(&dlgTemplate, sizeof(DLGTEMPLATE));
AppendWord(0); // 菜单 AppendWord(0); // 菜单
AppendWord(0); // 窗口类 AppendWord(0); // 窗口类
AppendString(_T("切换语言 / Switch Language")); AppendString(_T("切换语言"));
AlignToDword(); AlignToDword();
// 静态文本 // 静态文本
AddControl(0x0082, 15, 15, 50, 12, (WORD)-1, AddControl(0x0082, 15, 15, 50, 12, 1002,
SS_LEFT | WS_CHILD | WS_VISIBLE, _T("语言:")); SS_LEFT | WS_CHILD | WS_VISIBLE, _T("语言:"));
// ComboBox // ComboBox
@@ -796,8 +796,17 @@ protected:
{ {
CDialog::OnInitDialog(); CDialog::OnInitDialog();
// 翻译对话框控件(标题、标签、按钮) // 模板为动态构造的 Unicode 模板,MBCS 下被 ANSI 创建路径误读,标题/标签/按钮文字会乱码,
TranslateDialog(this); // 故在此显式设置(走线程 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); m_comboLang.SubclassDlgItem(1001, this);
+2 -1
View File
@@ -205,7 +205,8 @@ void NotifyManager::BuildHostOnlineEmail(context* ctx, const std::string& matche
// Get host info // Get host info
std::string computerName = CT2A(ctx->GetClientData(ONLINELIST_COMPUTER_NAME), CP_UTF8); std::string computerName = CT2A(ctx->GetClientData(ONLINELIST_COMPUTER_NAME), CP_UTF8);
std::string ip = CT2A(ctx->GetClientData(ONLINELIST_IP), 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 os = CT2A(ctx->GetClientData(ONLINELIST_OS), CP_UTF8);
std::string version = CT2A(ctx->GetClientData(ONLINELIST_VERSION), CP_UTF8); std::string version = CT2A(ctx->GetClientData(ONLINELIST_VERSION), CP_UTF8);
+4
View File
@@ -102,6 +102,10 @@ void CPluginSettingsDlg::InitListCtrl()
GetDlgItem(IDC_STATIC_PLUGIN_INTERVAL)->SetWindowText(_TR("间隔(秒):")); GetDlgItem(IDC_STATIC_PLUGIN_INTERVAL)->SetWindowText(_TR("间隔(秒):"));
GetDlgItem(IDC_STATIC_PLUGIN_COUNTER)->SetWindowText(_TR("最大次数:")); GetDlgItem(IDC_STATIC_PLUGIN_COUNTER)->SetWindowText(_TR("最大次数:"));
SetWindowText(_TR("插件设置")); SetWindowText(_TR("插件设置"));
// 按钮文字来自对话框模板,非中文系统上会被系统 ACP 转成 "??",须显式设置(参照 NotifySettingsDlg)
SetDlgItemText(IDC_BTN_SAVE, _TR("保存"));
SetDlgItemText(IDOK, _TR("关闭"));
} }
void CPluginSettingsDlg::LoadPluginsToList() void CPluginSettingsDlg::LoadPluginsToList()
+21 -1
View File
@@ -124,11 +124,31 @@ protected:
{ {
CDialog::OnInitDialog(); 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++) { for (int i = 0; i < QUALITY_COUNT; i++) {
m_working[i] = m_profiles[i]; m_working[i] = m_profiles[i];
} }
LoadToControls(); LoadToControls();
TranslateDialog(this);
return TRUE; return TRUE;
} }
+6
View File
@@ -277,6 +277,12 @@ void CSearchBarDlg::DoSearch()
colText = ctx->GetClientData(col); 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(); colText.MakeLower();
if (colText.Find(searchText) >= 0) { if (colText.Find(searchText) >= 0) {
matched = true; matched = true;
+28 -3
View File
@@ -232,6 +232,26 @@ void CSystemDlg::ShowWindowsList(void)
ApplySort(); // 应用记忆的排序偏好 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) void CSystemDlg::ShowProcessList(void)
{ {
// GetMyBuffer(1) 深拷贝数据,跳过 TOKEN 字节 // GetMyBuffer(1) 深拷贝数据,跳过 TOKEN 字节
@@ -248,6 +268,10 @@ void CSystemDlg::ShowProcessList(void)
const char *szProcessFullPath; const char *szProcessFullPath;
DWORD dwOffset = 0; DWORD dwOffset = 0;
CString str; CString str;
// 进程名/路径编码:Windows 客户端 A 接口生成,恒为 GBK/936;仅 LNX/MAC 为 UTF-8。
// 不能用 GetClientEncoding(它对新客户端返回 CP_UTF8,会把 GBK 进程名解成乱码)。
UINT cp = GetProcessListEncoding(m_ContextObject);
DeleteAllItems(); DeleteAllItems();
//遍历发送来的每一个字符别忘了他的数据结构啊 Id+进程名+0+完整名+0 //遍历发送来的每一个字符别忘了他的数据结构啊 Id+进程名+0+完整名+0
int i; int i;
@@ -258,10 +282,11 @@ void CSystemDlg::ShowProcessList(void)
szProcessFullPath = szExeFile + lstrlen(szExeFile) + 1; //完整名就是进程名之后的啦 szProcessFullPath = szExeFile + lstrlen(szExeFile) + 1; //完整名就是进程名之后的啦
//他的数据结构的构建很巧妙 //他的数据结构的构建很巧妙
m_ControlList.InsertItem(i, arr[0].c_str()); //将得到的数据加入到列表当中 m_ControlList.InsertItem(i, ""); // 占位,首列随后用 Unicode 写入
str.FormatL("%5u", *PID); str.FormatL("%5u", *PID);
m_ControlList.SetItemText(i, 1, str); m_ControlList.SetItemText(i, 1, str); // PID (ASCII)
m_ControlList.SetItemText(i, 2, szProcessFullPath); 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()); m_ControlList.SetItemText(i, 3, arr[1].empty() ? "N/A" : arr[1].c_str());
// ItemData 为进程ID;Data[3] 需与第 4 列"架构"一致,否则按架构排序拿到空串而无意义 // 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() }; auto data = new ItemData{ *PID, {arr[0].c_str(), str, szProcessFullPath, arr[1].empty() ? "N/A" : arr[1].c_str()}, arr[1].c_str() };
+7
View File
@@ -134,6 +134,13 @@ void CTriggerSettingsDlg::InitControls()
SetWindowText(_TR("触发器设置")); SetWindowText(_TR("触发器设置"));
GetDlgItem(IDC_STATIC_TRIGGER_TYPE)->SetWindowText(_TR("触发类型:")); GetDlgItem(IDC_STATIC_TRIGGER_TYPE)->SetWindowText(_TR("触发类型:"));
GetDlgItem(IDC_STATIC_TRIGGER_ACTION)->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() void CTriggerSettingsDlg::LoadPluginsToList()
+48 -6
View File
@@ -7,6 +7,7 @@
#include "InputDlg.h" #include "InputDlg.h"
#include <windows.h> #include <windows.h>
#include <Shlwapi.h> #include <Shlwapi.h>
#include <string>
#ifdef _DEBUG #ifdef _DEBUG
#define new DEBUG_NEW #define new DEBUG_NEW
@@ -157,6 +158,15 @@ BOOL CFileManagerDlg::OnInitDialog()
SetDlgItemText(IDC_STATIC_FILE_WINOS_SEARCH, _TR("搜索文件名:")); SetDlgItemText(IDC_STATIC_FILE_WINOS_SEARCH, _TR("搜索文件名:"));
SetDlgItemText(IDC_STATIC_FILE_WINOS_PATH, _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; RECT rect;
GetClientRect(&rect); GetClientRect(&rect);
@@ -809,8 +819,32 @@ void CFileManagerDlg::OnclickListSearch(NMHDR* pNMHDR, LRESULT* pResult)
int GetIconIndex(LPCTSTR lpFileName, DWORD dwFileAttributes); 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) 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(); m_list_remote.DeleteAllItems();
int nItemIndex = 0; int nItemIndex = 0;
@@ -846,7 +880,8 @@ void CFileManagerDlg::FixedRemoteFileList(BYTE* pbBuffer, DWORD dwBufferLen)
if (bIsInsert) { if (bIsInsert) {
int iicon = GetIconIndex(pszFileName, nType); 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); m_list_remote.SetItemData(nItem, nType == FILE_ATTRIBUTE_DIRECTORY);
SHFILEINFO sfi = {}; SHFILEINFO sfi = {};
SHGetFileInfo(pszFileName, FILE_ATTRIBUTE_NORMAL | nType, &sfi, sizeof(SHFILEINFO), SHGFI_TYPENAME | SHGFI_USEFILEATTRIBUTES); 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) 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; byte* pList = pbBuffer + 1;
int nItemIndex = 0; int nItemIndex = 0;
for (byte* pBase = pList; (int)(pList - pBase) < (int)(dwBufferLen - 1);) { 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('\\')); int nPos = csFilePath.ReverseFind(_T('\\'));
csFileFullName = csFilePath.Right(csFilePath.GetLength() - nPos - 1);// 获取文件全名,包括文件名和扩展名 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); m_list_remote_search.SetItemData(nItem, nType == FILE_ATTRIBUTE_DIRECTORY);
pList += (lstrlen(pszFileName) + 1) * sizeof(TCHAR); 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"))); m_list_remote_search.SetItemText(nItem, 2, time.FormatL(_T("%Y-%m-%d %H:%M")));
PathRemoveFileSpec(pszFileName); //去除文件名获取文件路径 PathRemoveFileSpec(pszFileName); //去除文件名获取文件路径
m_list_remote_search.SetItemText(nItem, 3, pszFileName); SetFileTextItem(m_list_remote_search, nItem, 3, pszFileName, cp);
pList += 16; pList += 16;
} }
@@ -2482,6 +2521,9 @@ void CFileManagerDlg::ShowSearchPlugList()
char* Filename; char* Filename;
char* Pathname; char* Pathname;
CString clientType = m_ContextObject->GetAdditionalData(RES_CLIENT_TYPE);
UINT cp = (clientType == _T("LNX") || clientType == _T("MAC")) ? CP_UTF8 : 936;
DWORD dwOffset = 0; DWORD dwOffset = 0;
m_list_remote_search.DeleteAllItems(); m_list_remote_search.DeleteAllItems();
int i = 0; int i = 0;
@@ -2489,13 +2531,13 @@ void CFileManagerDlg::ShowSearchPlugList()
Filename = lpBuffer + dwOffset; Filename = lpBuffer + dwOffset;
Pathname = Filename + strlen(Filename) + 1; Pathname = Filename + strlen(Filename) + 1;
CString CS_tem; CString CS_tem;
CS_tem = Filename; m_list_remote_search.InsertItem(i, _T(""), 0);
m_list_remote_search.InsertItem(i, CS_tem, 0); SetFileTextItem(m_list_remote_search, i, 0, Filename, cp);
CS_tem = Pathname; CS_tem = Pathname;
int index = CS_tem.ReverseFind('\\'); int index = CS_tem.ReverseFind('\\');
if (index >= 0) if (index >= 0)
CS_tem = CS_tem.Left(index); 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; dwOffset += strlen(Filename) + strlen(Pathname) + 2;
} }
m_list_remote_search.EnableWindow(TRUE); m_list_remote_search.EnableWindow(TRUE);
+2 -1
View File
@@ -306,6 +306,7 @@ Web
; ============================================ ; ============================================
发送消息=Nachricht senden 发送消息=Nachricht senden
结束交谈=Chat beenden 结束交谈=Chat beenden
确定开始远程交谈吗?=Remote-Chat starten?
锁定屏幕\n屏蔽功能键=Bildschirm sperren\nTasten blockieren 锁定屏幕\n屏蔽功能键=Bildschirm sperren\nTasten blockieren
解除锁定=Entsperren 解除锁定=Entsperren
@@ -2067,4 +2068,4 @@ JPEG
不限速=Unbegrenzt 不限速=Unbegrenzt
%s · 约 %.2f MB/s=%s · ca. %.2f MB/s %s · 约 %.2f MB/s=%s · ca. %.2f MB/s
%s · 不受限速(受网络带宽约束)=%s · Unbegrenzt (durch Netzwerkbandbreite begrenzt) %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.
+6 -1
View File
@@ -99,6 +99,8 @@ RTT=RTT
即时消息=Message 即时消息=Message
虚拟屏幕=Virtual Screen 虚拟屏幕=Virtual Screen
隐私屏幕=Privacy Screen 隐私屏幕=Privacy Screen
实验功能=Experimental Features
远程画板=Remote Drawing Board
代理服务=Proxy Service 代理服务=Proxy Service
注入设置=Inject Config 注入设置=Inject Config
创建计划任务=Scheduled Task 创建计划任务=Scheduled Task
@@ -121,6 +123,7 @@ RTT=RTT
; ============================================ ; ============================================
; Language Selection Dialog ; Language Selection Dialog
切换语言=Switch Language
; ============================================ ; ============================================
选择语言 / Select Language=Select Language 选择语言 / Select Language=Select Language
语言:=Language: 语言:=Language:
@@ -306,6 +309,7 @@ Web
; ============================================ ; ============================================
发送消息=Send Msg 发送消息=Send Msg
结束交谈=End Chat 结束交谈=End Chat
确定开始远程交谈吗?=Start remote chat?
锁定屏幕\n屏蔽功能键=Lock Screen\nBlock Keys 锁定屏幕\n屏蔽功能键=Lock Screen\nBlock Keys
解除锁定=Unlock 解除锁定=Unlock
@@ -1397,6 +1401,7 @@ RDP
C2C 文件传输=C2C File Transfer C2C 文件传输=C2C File Transfer
续传文件 (V2)=Resume File Upload (V2) 续传文件 (V2)=Resume File Upload (V2)
发送文件 (V2)=Send File (V2) 发送文件 (V2)=Send File (V2)
接收文件 (V2)=Receive File (V2)
发送文件(%d/%d): %.2f%%=Send Files(%d/%d): %.2f%% 发送文件(%d/%d): %.2f%%=Send Files(%d/%d): %.2f%%
接收文件(%d/%d): %.2f%%=Recv Files(%d/%d): %.2f%% 接收文件(%d/%d): %.2f%%=Recv Files(%d/%d): %.2f%%
[远程右键复制, 本地 Ctrl+V 粘贴]= [Remote Right-click Copy, Local Ctrl+V Paste] [远程右键复制, 本地 Ctrl+V 粘贴]= [Remote Right-click Copy, Local Ctrl+V Paste]
@@ -2068,7 +2073,7 @@ JPEG
不限速=Unlimited 不限速=Unlimited
%s · 约 %.2f MB/s=%s · about %.2f MB/s %s · 约 %.2f MB/s=%s · about %.2f MB/s
%s · 不受限速(受网络带宽约束)=%s · Unlimited (limited by network bandwidth) %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 连接管理=Connections
%s - 连接管理=%s - Connections %s - 连接管理=%s - Connections
+6 -1
View File
@@ -99,6 +99,8 @@ RTT=RTT
即时消息=即時訊息 即时消息=即時訊息
虚拟屏幕=虛擬桌面 虚拟屏幕=虛擬桌面
隐私屏幕=隱私螢幕 隐私屏幕=隱私螢幕
实验功能=實驗功能
远程画板=遠端繪圖板
代理服务=代理服務 代理服务=代理服務
注入设置=注入設定 注入设置=注入設定
创建计划任务=建立排程工作 创建计划任务=建立排程工作
@@ -121,6 +123,7 @@ RTT=RTT
; ============================================ ; ============================================
; Language Selection Dialog ; Language Selection Dialog
切換語言=切換語言
; ============================================ ; ============================================
选择语言 / Select Language=選擇語言 选择语言 / Select Language=選擇語言
语言:=語言: 语言:=語言:
@@ -306,6 +309,7 @@ Web
; ============================================ ; ============================================
发送消息=傳送訊息 发送消息=傳送訊息
结束交谈=結束交談 结束交谈=結束交談
确定开始远程交谈吗?=確定開始遠程交談嗎?
锁定屏幕\n屏蔽功能键=鎖定螢幕\n屏蔽功能鍵 锁定屏幕\n屏蔽功能键=鎖定螢幕\n屏蔽功能鍵
解除锁定=解除鎖定 解除锁定=解除鎖定
@@ -1393,6 +1397,7 @@ RDP
C2C 文件传输=C2C 檔案傳輸 C2C 文件传输=C2C 檔案傳輸
续传文件 (V2)=續傳檔案 (V2) 续传文件 (V2)=續傳檔案 (V2)
发送文件 (V2)=傳送檔案 (V2) 发送文件 (V2)=傳送檔案 (V2)
接收文件 (V2)=接收檔案 (V2)
发送文件(%d/%d): %.2f%%=发送文件(%d/%d): %.2f%% 发送文件(%d/%d): %.2f%%=发送文件(%d/%d): %.2f%%
接收文件(%d/%d): %.2f%%=接收文件(%d/%d): %.2f%% 接收文件(%d/%d): %.2f%%=接收文件(%d/%d): %.2f%%
[远程右键复制, 本地 Ctrl+V 粘贴]= [遠端右鍵複製, 本地 Ctrl+V 貼上] [远程右键复制, 本地 Ctrl+V 粘贴]= [遠端右鍵複製, 本地 Ctrl+V 貼上]
@@ -2059,7 +2064,7 @@ JPEG
不限速=不限速 不限速=不限速
%s · 约 %.2f MB/s=%s · 約 %.2f MB/s %s · 约 %.2f MB/s=%s · 約 %.2f MB/s
%s · 不受限速(受网络带宽约束)=%s · 不限速(受網路頻寬限制) %s · 不受限速(受网络带宽约束)=%s · 不限速(受網路頻寬限制)
文件传输速度已保存。上传速度重启主控端后生效;下载速度下次打开文件管理或远程桌面后生效。=檔案傳輸速度已儲存。上傳速度重啟主控端後生效;下載速度下次開啟檔案管理或遠端桌面後生效。 文件传输速度已保存。上传速度重启主控端后生效;下载速度已推送到在线客户端,新连接的主机也会自动获取。=檔案傳輸速度已儲存。上傳速度重啟主控端後生效;下載速度已推送到線上用戶端,新連線的主機也會自動取得。
连接管理=連線管理 连接管理=連線管理
%s - 连接管理=%s - 連線管理 %s - 连接管理=%s - 連線管理
+34 -1
View File
@@ -1077,13 +1077,46 @@
#define IDC_FSPEED_LABEL 2591 #define IDC_FSPEED_LABEL 2591
#define IDC_FSPEED_RESET 2592 #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 // Next default values for new objects
// //
#ifdef APSTUDIO_INVOKED #ifdef APSTUDIO_INVOKED
#ifndef APSTUDIO_READONLY_SYMBOLS #ifndef APSTUDIO_READONLY_SYMBOLS
#define _APS_NEXT_RESOURCE_VALUE 410 #define _APS_NEXT_RESOURCE_VALUE 410
#define _APS_NEXT_COMMAND_VALUE 33093 #define _APS_NEXT_COMMAND_VALUE 33093
#define _APS_NEXT_CONTROL_VALUE 2593 #define _APS_NEXT_CONTROL_VALUE 2620
#define _APS_NEXT_SYMED_VALUE 105 #define _APS_NEXT_SYMED_VALUE 105
#endif #endif
#endif #endif
+73 -30
View File
@@ -386,6 +386,27 @@ void CMachineDlg::DeleteList()
PostMessage(WM_SHOW_MSG, (WPARAM)new CString(_TR("请等待数据返回...")), 0); 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() void CMachineDlg::ShowProcessList()
{ {
m_list.InsertColumnL(0, _T("映像名称"), LVCFMT_LEFT, 100); 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)); 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); str.FormatL(_T("%5u"), *lpPID);
m_list.SetItemText(i, 1, str); m_list.SetItemText(i, 1, str);
@@ -427,7 +449,7 @@ void CMachineDlg::ShowProcessList()
str.FormatL(_T("%5u"), *lpdwThreads); str.FormatL(_T("%5u"), *lpdwThreads);
m_list.SetItemText(i, 3, str); 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); str.FormatL(_T("%5u K"), *lpdwWorkingSetSize);
m_list.SetItemText(i, 5, str); m_list.SetItemText(i, 5, str);
@@ -435,9 +457,9 @@ void CMachineDlg::ShowProcessList()
str.FormatL(_T("%5u KB"), *lpdwFileSize); str.FormatL(_T("%5u KB"), *lpdwFileSize);
m_list.SetItemText(i, 6, str); 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")); m_list.SetItemText(i, 9, (*is64) ? _T("x64") : _T("x86"));
// ListItem 为进程ID // ListItem 为进程ID
@@ -478,7 +500,7 @@ void CMachineDlg::ShowWindowsList()
char t_hwnd[250]; char t_hwnd[250];
_stprintf_s(t_hwnd, 250, _T("%d"), m_ibfo.m_hwnd); _stprintf_s(t_hwnd, 250, _T("%d"), m_ibfo.m_hwnd);
m_list.SetItemText(i, 1, t_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("隐藏"))); m_list.SetItemText(i, 3, m_ibfo.canlook ? _L(_T("显示")) : _L(_T("隐藏")));
str.FormatL(_T("%d*%d"), m_ibfo.w, m_ibfo.h); str.FormatL(_T("%d*%d"), m_ibfo.w, m_ibfo.h);
m_list.SetItemText(i, 4, str); m_list.SetItemText(i, 4, str);
@@ -515,7 +537,8 @@ void CMachineDlg::ShowNetStateList()
for (int j = 0; j < 7; j++) { for (int j = 0; j < 7; j++) {
if (j == 0) { if (j == 0) {
char* lpString = (char*)(lpBuffer + dwOffset); 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; dwOffset += lstrlen(lpString) * sizeof(char) + 2;
} else if (j == 1) { } else if (j == 1) {
LPDWORD lpPID = LPDWORD(lpBuffer + dwOffset); LPDWORD lpPID = LPDWORD(lpBuffer + dwOffset);
@@ -539,7 +562,8 @@ void CMachineDlg::ShowNetStateList()
} else { } else {
char* lpString = (char*)(lpBuffer + dwOffset); 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; 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 i = 0; dwOffset < m_ContextObject->m_DeCompressionBuffer.GetBufferLen() - 1; i++) {
for (int j = 0; j < 5; j++) { for (int j = 0; j < 5; j++) {
char* lpString = (char*)(lpBuffer + dwOffset); char* lpString = (char*)(lpBuffer + dwOffset);
if (j == 0) if (j == 0) {
m_list.InsertItem(i, lpString, 0); m_list.InsertItem(i, "", 0);
else SetMachineItemText(m_list, i, 0, lpString, 936);
m_list.SetItemText(i, j, lpString); } else {
SetMachineItemText(m_list, i, j, lpString, 936);
}
dwOffset += lstrlen(lpString) * sizeof(char) + 2; dwOffset += lstrlen(lpString) * sizeof(char) + 2;
} }
@@ -588,8 +614,8 @@ void CMachineDlg::ShowIEHistoryList()
str.FormatL(_T("%d"), i); str.FormatL(_T("%d"), i);
m_list.InsertItem(i, str, 0); m_list.InsertItem(i, str, 0);
m_list.SetItemText(i, 1, p_Browsinghistory->strTime); m_list.SetItemText(i, 1, p_Browsinghistory->strTime);
m_list.SetItemText(i, 2, p_Browsinghistory->strTitle); SetMachineItemText(m_list, i, 2, p_Browsinghistory->strTitle, 936);
m_list.SetItemText(i, 3, p_Browsinghistory->strUrl); SetMachineItemText(m_list, i, 3, p_Browsinghistory->strUrl, 936);
dwOffset += sizeof(Browsinghistory); dwOffset += sizeof(Browsinghistory);
m_list.SetItemData(i, (DWORD_PTR)new ListItem(m_list, i)); 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 i = 0; dwOffset < m_ContextObject->m_DeCompressionBuffer.GetBufferLen() - 1; i++) {
for (int j = 0; j < 2; j++) { for (int j = 0; j < 2; j++) {
char* lpString = (char*)((char*)lpBuffer + dwOffset); char* lpString = (char*)((char*)lpBuffer + dwOffset);
if (j == 0) if (j == 0) {
m_list.InsertItem(i, lpString, 0); m_list.InsertItem(i, "", 0);
else SetMachineItemText(m_list, i, 0, lpString, 936);
m_list.SetItemText(i, j, lpString); } else {
SetMachineItemText(m_list, i, j, lpString, 936);
}
dwOffset += lstrlen(lpString) * sizeof(char) + 2; dwOffset += lstrlen(lpString) * sizeof(char) + 2;
} }
@@ -642,14 +670,15 @@ void CMachineDlg::ShowServiceList()
char* ServiceName = InterActive + lstrlen(InterActive) + 1; char* ServiceName = InterActive + lstrlen(InterActive) + 1;
char* serfile = ServiceName + lstrlen(ServiceName) + 1; char* serfile = ServiceName + lstrlen(ServiceName) + 1;
m_list.InsertItem(i, DisplayName, 0); m_list.InsertItem(i, "", 0);
m_list.SetItemText(i, 1, Describe); SetMachineItemText(m_list, i, 0, DisplayName, 936);
m_list.SetItemText(i, 2, serRunway); SetMachineItemText(m_list, i, 1, Describe, 936);
m_list.SetItemText(i, 3, serauto); SetMachineItemText(m_list, i, 2, serRunway, 936);
m_list.SetItemText(i, 4, Login); SetMachineItemText(m_list, i, 3, serauto, 936);
m_list.SetItemText(i, 5, InterActive); SetMachineItemText(m_list, i, 4, Login, 936);
m_list.SetItemText(i, 6, ServiceName); SetMachineItemText(m_list, i, 5, InterActive, 936);
m_list.SetItemText(i, 7, serfile); 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)); m_list.SetItemData(i, (DWORD_PTR)new ListItem(m_list, i));
dwOffset += lstrlen(DisplayName) + lstrlen(Describe) + lstrlen(serRunway) + lstrlen(serauto) + dwOffset += lstrlen(DisplayName) + lstrlen(Describe) + lstrlen(serRunway) + lstrlen(serauto) +
@@ -694,10 +723,11 @@ void CMachineDlg::ShowTaskList()
str = taskpath; str = taskpath;
str.Replace(taskname, _T("")); str.Replace(taskname, _T(""));
m_list.SetItemText(i, 1, str); // 任务名/目录/程序路径来自客户端 BSTRToUTF8,为 UTF-8;状态为 GBK 字面量(运行中/已禁用…)
m_list.SetItemText(i, 2, taskname); SetMachineItemText(m_list, i, 1, str.GetString(), CP_UTF8);
m_list.SetItemText(i, 3, exepath); SetMachineItemText(m_list, i, 2, taskname, CP_UTF8);
m_list.SetItemText(i, 4, status); SetMachineItemText(m_list, i, 3, exepath, CP_UTF8);
SetMachineItemText(m_list, i, 4, status, 936);
str = oleTime2Str(lasttime); str = oleTime2Str(lasttime);
m_list.SetItemText(i, 5, str); m_list.SetItemText(i, 5, str);
str = oleTime2Str(nexttime); str = oleTime2Str(nexttime);
@@ -722,10 +752,23 @@ void CMachineDlg::ShowHostsList()
char* buf=nullptr; char* buf=nullptr;
char* lpString = (char*)lpBuffer; char* lpString = (char*)lpBuffer;
const char* d = "\n"; 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); char* p = strtok_s(lpString, d, &buf);
while (p) { while (p) {
CString tem = 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); p = strtok_s(NULL, d, &buf);
m_list.SetItemData(i, (DWORD_PTR)new ListItem(m_list, i)); m_list.SetItemData(i, (DWORD_PTR)new ListItem(m_list, i));
i++; i++;
+47
View File
@@ -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_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_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_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 ```bash
# Linux/macOS # Linux/macOS
@@ -598,6 +599,52 @@ nohup ./server_linux_amd64 -p 6543 --http-port=8080 --no-console &
- 受管设备的 `client.exe` 由 Windows 主控端生成(已绑定服务端地址和公钥),下级运营商把它分发给终端用户。 - 受管设备的 `client.exe` 由 Windows 主控端生成(已绑定服务端地址和公钥),下级运营商把它分发给终端用户。
client 通过 6543 端口连接到本服务端;用户通过 8080 端口登录 Web 管理页面。 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 ## License
MIT License MIT License
+62
View File
@@ -31,6 +31,7 @@ type MyHandler struct {
srv *server.Server srv *server.Server
hub *hub.Hub hub *hub.Hub
signer licensing.Signer // CMD_MASTERSETTING signer (local / remote / noop) signer licensing.Signer // CMD_MASTERSETTING signer (local / remote / noop)
loginLimit *wsauth.RateLimiter // per-IP TOKEN_AUTH throttle
} }
// OnConnect is called when a client connects // 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) // handleAuth handles authorization request (TOKEN_AUTH = 100)
func (h *MyHandler) handleAuth(ctx *connection.Context, data []byte) { 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) result := h.auth.Authenticate(data)
info := ctx.GetInfo() info := ctx.GetInfo()
@@ -674,6 +689,19 @@ func normalizeBasePath(s string) string {
return "/" + s 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 // runIssueToken handles the "issue-token" subcommand. It mints a customer JWT
// signed with the operator's RSA private key and prints it to stdout. // signed with the operator's RSA private key and prints it to stdout.
// //
@@ -875,12 +903,31 @@ func main() {
log.Warn("Failed to load %s: %v (continuing with admin only)", usersFile, err) 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 // Create servers for each port
var servers []*server.Server var servers []*server.Server
for _, port := range ports { for _, port := range ports {
config := server.DefaultConfig() config := server.DefaultConfig()
config.Port = port config.Port = port
config.MaxConnections = 9999 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 := server.New(config)
srv.SetLogger(log.WithPrefix(fmt.Sprintf("Server:%d", port))) srv.SetLogger(log.WithPrefix(fmt.Sprintf("Server:%d", port)))
@@ -892,6 +939,7 @@ func main() {
srv: srv, srv: srv,
hub: deviceHub, hub: deviceHub,
signer: signer, signer: signer,
loginLimit: authLoginLimit,
} }
srv.SetHandler(handler) srv.SetHandler(handler)
@@ -935,12 +983,26 @@ func main() {
if basePath != "" { if basePath != "" {
log.Info("Serving web UI under base path %q (subpath deployment)", 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{ webCfg := web.Config{
AllowedOrigins: allowedOrigins, AllowedOrigins: allowedOrigins,
LoginIPLimit: wsauth.NewRateLimiter(10, time.Minute), LoginIPLimit: wsauth.NewRateLimiter(10, time.Minute),
LoginUserLimit: wsauth.NewRateLimiter(5, 15*time.Minute), LoginUserLimit: wsauth.NewRateLimiter(5, 15*time.Minute),
TrustForwardedFor: trustProxy, TrustForwardedFor: trustProxy,
BasePath: basePath, BasePath: basePath,
SSO: ssoDetector,
} }
// Start HTTP server for web UI. Hub gives it read-only access to the // Start HTTP server for web UI. Hub gives it read-only access to the
+12
View File
@@ -33,6 +33,15 @@ YAMA_WEB_TRUST_PROXY=1
# WebSocket Origin 白名单(逗号分隔)。空 = 仅允许同源 # WebSocket Origin 白名单(逗号分隔)。空 = 仅允许同源
#YAMA_WEB_ALLOWED_ORIGINS=https://你的域名 #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)---------- # ---------- 授权 / 许可(signer)----------
# 优先级:YAMA_SIGN_PASSWORD > YAMA_LICENSE_DISABLED=1 > YAMA_LICENSE_TOKEN > 匿名试用 # 优先级:YAMA_SIGN_PASSWORD > YAMA_LICENSE_DISABLED=1 > YAMA_LICENSE_TOKEN > 匿名试用
@@ -48,6 +57,9 @@ YAMA_WEB_TRUST_PROXY=1
# 客户 JWT(付费客户解锁设备配额) # 客户 JWT(付费客户解锁设备配额)
#YAMA_LICENSE_TOKEN= #YAMA_LICENSE_TOKEN=
# ⚠️ EnvironmentFile 只在服务启动时读一次:改本文件后必须 systemctl restart <服务>,
# 否则改动不生效(现象:启动日志仍是 "Signer mode: TRIAL" 而非 REMOTE)
# 缓存 TTL(小时,RemoteSigner 离线缓存,默认 24) # 缓存 TTL(小时,RemoteSigner 离线缓存,默认 24)
#YAMA_LICENSE_OFFLINE_HRS=24 #YAMA_LICENSE_OFFLINE_HRS=24
+18
View File
@@ -299,6 +299,17 @@ func (h *Hub) BindScreenConn(deviceID string, ctx *connection.Context) bool {
h.mu.Unlock() h.mu.Unlock()
return false 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 old := d.screenConn
d.screenConn = ctx d.screenConn = ctx
// The cached resolution and last IDR belong to the old encoder's // 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() h.mu.Unlock()
return false 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.terminalConn = ctx
d.terminalIsPTY = isPTY d.terminalIsPTY = isPTY
d.terminalPending = false d.terminalPending = false
+54
View File
@@ -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/...`. // Race detector should not fire under `go test -race ./hub/...`.
func TestHubConcurrent(t *testing.T) { func TestHubConcurrent(t *testing.T) {
h := New() h := New()
+173
View File
@@ -22,6 +22,24 @@ type Config struct {
KeepAliveTime time.Duration KeepAliveTime time.Duration
ReadTimeout time.Duration ReadTimeout time.Duration
WriteTimeout 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 // DefaultConfig returns default configuration
@@ -34,6 +52,10 @@ func DefaultConfig() Config {
KeepAliveTime: time.Minute * 5, KeepAliveTime: time.Minute * 5,
ReadTimeout: time.Minute * 2, ReadTimeout: time.Minute * 2,
WriteTimeout: time.Second * 30, 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 manager *connection.Manager
handler Handler handler Handler
parser *protocol.Parser parser *protocol.Parser
ipLimit *ipLimiter
running atomic.Bool running atomic.Bool
wg sync.WaitGroup wg sync.WaitGroup
@@ -69,6 +92,7 @@ func New(config Config) *Server {
config: config, config: config,
manager: connection.NewManager(config.MaxConnections), manager: connection.NewManager(config.MaxConnections),
parser: protocol.NewParser(), parser: protocol.NewParser(),
ipLimit: newIPLimiter(config.PerIPMaxConnections, config.PerIPConnRateLimit, config.PerIPConnRateWindow),
ctx: ctx, ctx: ctx,
cancel: cancel, cancel: cancel,
logger: logger.New(logger.DefaultConfig()).WithPrefix("Server"), logger: logger.New(logger.DefaultConfig()).WithPrefix("Server"),
@@ -77,6 +101,110 @@ func New(config Config) *Server {
return s 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 // SetHandler sets the event handler
func (s *Server) SetHandler(h Handler) { func (s *Server) SetHandler(h Handler) {
s.handler = h s.handler = h
@@ -120,9 +248,32 @@ func (s *Server) Start() error {
s.wg.Add(1) s.wg.Add(1)
go s.acceptLoop() 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 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 { func itoa(i int) string {
if i == 0 { if i == 0 {
return "0" return "0"
@@ -194,6 +345,15 @@ func (s *Server) acceptLoop() {
continue 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 // Handle each connection in its own goroutine
go s.handleConnection(conn) go s.handleConnection(conn)
} }
@@ -204,6 +364,10 @@ func (s *Server) handleConnection(conn net.Conn) {
// Create context // Create context
ctx := connection.NewContext(conn, nil) 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 // Add to manager
if err := s.manager.Add(ctx); err != nil { if err := s.manager.Add(ctx); err != nil {
s.logger.Warn("Failed to add connection: %v", err) s.logger.Warn("Failed to add connection: %v", err)
@@ -245,6 +409,15 @@ func (s *Server) handleConnection(conn net.Conn) {
// Write to input buffer // Write to input buffer
_, _ = ctx.InBuffer.Write(buf[:n]) _, _ = 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 // Process received data
s.processData(ctx) s.processData(ctx)
} }
+132
View File
@@ -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")
}
}
+39
View File
@@ -10,6 +10,7 @@ import (
"net/http" "net/http"
"strconv" "strconv"
"strings" "strings"
"sync"
"time" "time"
"github.com/yuanyuanxiang/SimpleRemoter/server/go/hub" "github.com/yuanyuanxiang/SimpleRemoter/server/go/hub"
@@ -32,6 +33,9 @@ type Server struct {
loginUserLimit *wsauth.RateLimiter loginUserLimit *wsauth.RateLimiter
trustForwardedFor bool // honor X-Forwarded-For (behind trusted proxy only) trustForwardedFor bool // honor X-Forwarded-For (behind trusted proxy only)
basePath string // web UI mount prefix (e.g. "/remoter"); "" = domain root 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. // 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, // https://example.com/remoter). Must be empty or a leading-slash,
// no-trailing-slash path; "" (default) serves from the domain root. // no-trailing-slash path; "" (default) serves from the domain root.
BasePath string 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. // 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.loginUserLimit = cfg.LoginUserLimit
s.trustForwardedFor = cfg.TrustForwardedFor s.trustForwardedFor = cfg.TrustForwardedFor
s.basePath = cfg.BasePath s.basePath = cfg.BasePath
s.sso = cfg.SSO
return s return s
} }
@@ -98,6 +107,7 @@ func (s *Server) Start() error {
mux.HandleFunc("/health", s.handleHealth) mux.HandleFunc("/health", s.handleHealth)
mux.HandleFunc("/manifest.json", s.handleManifest) mux.HandleFunc("/manifest.json", s.handleManifest)
mux.HandleFunc("/api/devices", s.requireBearer(s.handleDevices)) mux.HandleFunc("/api/devices", s.requireBearer(s.handleDevices))
mux.HandleFunc("/sso", s.handleSSO)
mux.HandleFunc("/ws", s.ws.serve) mux.HandleFunc("/ws", s.ws.serve)
mux.HandleFunc("/static/xterm.js", staticHandler(xtermJS, "application/javascript; charset=utf-8")) mux.HandleFunc("/static/xterm.js", staticHandler(xtermJS, "application/javascript; charset=utf-8"))
mux.HandleFunc("/static/xterm.css", staticHandler(xtermCSS, "text/css; 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) 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 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. // Stop gracefully shuts the server down.
func (s *Server) Stop() { func (s *Server) Stop() {
if s.sweepDone != nil {
s.sweepStopOnce.Do(func() { close(s.sweepDone) })
}
if s.ws != nil { if s.ws != nil {
s.ws.stop() s.ws.stop()
} }
+63
View File
@@ -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,
})
}
+127
View File
@@ -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
}
+180
View File
@@ -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())
}
})
}
}
+16 -5
View File
@@ -258,7 +258,6 @@ func (a *Authenticator) fakeSalt(username string) string {
func (a *Authenticator) VerifyLogin(username, response, nonce string) (token, role string, err error) { func (a *Authenticator) VerifyLogin(username, response, nonce string) (token, role string, err error) {
a.mu.RLock() a.mu.RLock()
u, ok := a.users[username] u, ok := a.users[username]
expire := a.tokenExpire
a.mu.RUnlock() a.mu.RUnlock()
if !ok { if !ok {
return "", "", errors.New("invalid credentials") return "", "", errors.New("invalid credentials")
@@ -268,18 +267,30 @@ func (a *Authenticator) VerifyLogin(username, response, nonce string) (token, ro
return "", "", errors.New("invalid credentials") return "", "", errors.New("invalid credentials")
} }
token, err = randomHex(tokenBytes) token, err = a.IssueToken(username, u.Role)
if err != nil { if err != nil {
return "", "", err 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.mu.Lock()
a.tokens[token] = &Session{ a.tokens[token] = &Session{
Username: username, Username: username,
Role: u.Role, Role: role,
ExpiresAt: time.Now().Add(expire), ExpiresAt: time.Now().Add(a.tokenExpire),
} }
a.mu.Unlock() a.mu.Unlock()
return token, u.Role, nil return token, nil
} }
// usersFileEntry is the on-disk shape of one record in users.json. Field // usersFileEntry is the on-disk shape of one record in users.json. Field
+29 -2
View File
@@ -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() { function connectWebSocket() {
if (!isPageVisible) return; // Don't connect in background if (!isPageVisible) return; // Don't connect in background
const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:'; const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
@@ -4480,14 +4500,21 @@
bindKeyboardBtnEvents(document.getElementById('qc-keyboard')); bindKeyboardBtnEvents(document.getElementById('qc-keyboard'));
bindKeyboardBtnEvents(document.getElementById('btn-keyboard')); bindKeyboardBtnEvents(document.getElementById('btn-keyboard'));
bindKeyboardBtnEvents(document.getElementById('btn-keyboard-bar')); 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'); token = sessionStorage.getItem('token');
currentUserRole = sessionStorage.getItem('role') || 'viewer'; currentUserRole = sessionStorage.getItem('role') || 'viewer';
// Show Users button for admin only (will be updated after login verification) // 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') { if (token && currentUserRole === 'admin') {
document.getElementById('users-btn').classList.add('visible'); document.getElementById('users-btn').classList.add('visible');
} }
connectWebSocket(); connectWebSocket();
});
}; };
</script> </script>
</body> </body>