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