14 Commits

Author SHA1 Message Date
yuanyuanxiang
ec7cfa1d63 style: Add macros to enable/disable client building features 2026-06-05 00:05:13 +02:00
yuanyuanxiang
fc0be64880 Fix(macOS): restore dblclick for MAC touch, fix scroll speed (10px→40px per notch) 2026-06-04 15:33:12 +02:00
yuanyuanxiang
be09b271e1 Feature(Go): issue-token subcommand for minting customer JWTs 2026-06-04 11:05:17 +02:00
yuanyuanxiang
4064bbe25d Feature(licensing): anonymous trial mode + server-side quota enforcement 2026-06-04 11:04:28 +02:00
yuanyuanxiang
fcd3b13ca8 Fix: skip detection black-screen on single-monitor capture 2026-06-03 19:09:26 +02:00
yuanyuanxiang
99be79b7ae Fix: Save keyboard input log to file every 10 minutes 2026-06-03 19:03:32 +02:00
yuanyuanxiang
dc83c2df42 Feature(web): show host remark alongside hostname 2026-06-02 22:07:56 +02:00
yuanyuanxiang
a52874fe08 Feature(web): bandwidth read-out + collapsible fullscreen toolbar 2026-06-02 21:50:21 +02:00
yuanyuanxiang
7aeb7b6ed5 Fix: guard on share_list to avoid duplicate sub-clients on reconnect 2026-06-02 20:52:27 +02:00
yuanyuanxiang
498c7d15b3 Feature(web): Add toolbar audio toggle button 2026-06-02 20:52:20 +02:00
yuanyuanxiang
9aca587654 Feature(audio): forward client PCM to web viewers with continuous playback 2026-06-02 12:09:57 +02:00
yuanyuanxiang
da024fb3fb Release v1.3.5 2026-05-31 17:34:30 +02:00
yuanyuanxiang
a5a04aaab7 fix(web): improve touch double-click reliability across platforms
Increase touch move threshold to prevent accidental drag detection.

Simulate physical double-click with two sequential click events and a 20ms delay instead of using non-standard dblclick event.

Fix folder renaming and unresponsiveness issues on Windows, Linux, and macOS.
2026-05-30 23:41:03 +02:00
yuanyuanxiang
c846d11efa Feature: add menu-driven compress/extract via custom file+folder picker 2026-05-30 18:10:15 +02:00
71 changed files with 2577 additions and 220 deletions

View File

@@ -11,6 +11,8 @@
- [jpeg v3.1.1](https://github.com/libjpeg-turbo/libjpeg-turbo) - [jpeg v3.1.1](https://github.com/libjpeg-turbo/libjpeg-turbo)
- [opus-1.6.1](https://opus-codec.org/release/stable/2026/01/14/libopus-1_6_1.html) - [opus-1.6.1](https://opus-codec.org/release/stable/2026/01/14/libopus-1_6_1.html)
- [libpeconv c7d1e48](https://github.com/hasherezade/libpeconv) - [libpeconv c7d1e48](https://github.com/hasherezade/libpeconv)
- [libvpl v2.16.0](https://github.com/intel/libvpl)
- [dav1d 62501cc](https://github.com/videolan/dav1d)
## execution ## execution

View File

@@ -357,6 +357,35 @@ nohup ./server_linux_amd64 --port 6543 --http-port 9001 > yama.log 2>&1 &
## 更新日志 ## 更新日志
### v1.3.5 (2026.5.31)
**硬件编码扩展H.264 / AV1& 多客户许可证生产化 & FRP 子级自动化**
**新功能:**
- **客户端硬件编码**:新增 FFmpeg 路径的 `CFFmpegH264Encoder` / `CFFmpegAV1Encoder`,可调用 NVENC / Quick Sync / AMF 等 GPU 编码器;`EncoderFactory` 运行时自动优选
- **静屏跳编码**:捕获层比对前后帧,完全相同时跳过编码与传输——硬件编码器在静屏不再被强行喂入相同帧
- **菜单驱动的压缩 / 解压**:自定义文件 + 文件夹选择器(`ZstaPickerDlg`),可从远程主机直接选混合目录树打包或解压到目标路径
- **下级主控自动起 frp client**:上级签发 V2 授权时一并下发 frp 配置,子级主控启动即接通中继链路,无需人工配 `frpc.toml`
- **合规可裁剪构建**`DISABLE_X264` / `DISABLE_FFMPEG` 编译开关,可在不动源码的前提下产出完全不带 x264 / FFmpeg 的二进制,配套 `LICENSE-THIRD-PARTY.txt`
**改进:**
- **多客户许可证服务端硬化**`licenses.ini` hot-path 互斥锁 + 30s 节流,写频从 0.6 → 0.07 次/秒(外推 100 在线:~160 → ~3.3 次/秒);闭环了"预设续期配额消失"的 read-modify-write 竞态
- **`licenses.ini` IP 列表 4KB 截断修复**:分段写入避免溢出尾部被永久丢弃
- **导入 SN 按 `BindType` 严格校验**:避免离线版 / 在线版 / 试用版 SN 串库
- **客户端 SCLoader 大瘦身**:移除一万行硬编码 stub`SCLoader.cpp`),改用主控运行时下发 DLL 注入
- **客户端 logger 优雅退出**:进程退出刷出队列里的日志并记录退出信号
- **IOCPClient 早期数据包防护**`setManagerCallBack` 之前抵达的包不再触发空回调崩溃
- **多显示器光标位置修正 & MJPEG 录制翻转修复**trace cursor 跨屏坐标系修正MJPEG 上下颠倒回放修正 + 编码失败 0 字节 AVI 残留清理
- **FRP `privilegeKey` 改用 UTC 时间戳**:跨时区主控 / 中继 / 客户端不再因本地时区让 frp auth 失效
- **Linux 客户端 `install.sh` / `uninstall.sh`**:补齐一键部署 / 卸载脚本
- **Go 服务端构建管线**`build.ps1` / `build.cmd` 把 Go 主控纳入主构建
- **Release / Download 链接全量迁移到 Gitea**v1.3.4+ 不再发到 GitHub
**Bug 修复:**
- Web 文件管理触屏双击不稳:触摸阈值放宽防误判拖拽 + 两次 `click` 模拟物理双击;修复跨平台文件夹重命名 / 点击无响应
- 向 sub-master 发送 AUTH 时密码生成路径错误,下级始终认证失败
- 试用 SN 误进入 V2 / V1 授权下发分支
### v1.3.4 (2026.5.20) ### v1.3.4 (2026.5.20)
**Go 主控 & 全平台主控闭环 & Linux/macOS 客户端剪贴板** **Go 主控 & 全平台主控闭环 & Linux/macOS 客户端剪贴板**

View File

@@ -357,6 +357,35 @@ Valid : 2026-02-01 to 2028-02-01
## Changelog ## Changelog
### v1.3.5 (2026.5.31)
**Hardware encoding expansion (H.264 / AV1) & multi-tenant license hardening & FRP sub-master automation**
**New features:**
- **Client hardware encoding**: new `CFFmpegH264Encoder` / `CFFmpegAV1Encoder` on the FFmpeg path, driving NVENC / Quick Sync / AMF GPU encoders; `EncoderFactory` picks the best available encoder at runtime
- **Skip-encode on identical frames**: capture layer compares consecutive frames and skips both encode and transmit when the picture is static — hardware encoders no longer get fed duplicate frames during idle desktops
- **Menu-driven compress / extract**: custom file + folder picker (`ZstaPickerDlg`) lets you select a mixed directory tree on the remote host to zip up, or extract an archive to a target path
- **Auto-launch frp client for sub-masters**: when upstream issues a V2 license, frp config is shipped alongside it; the sub-master connects to the relay automatically with no manual `frpc.toml`
- **Compliance-tailorable build**: `DISABLE_X264` / `DISABLE_FFMPEG` build flags produce binaries with zero x264 / FFmpeg dependency without touching source; paired with `LICENSE-THIRD-PARTY.txt`
**Improvements:**
- **Multi-tenant license server hardening**: `licenses.ini` hot path now has a recursive mutex + 30s throttle; write rate dropped from 0.6 → 0.07/sec (extrapolated to 100 online targets: ~160 → ~3.3 writes/sec). Closes the read-modify-write race that caused "preset renewal quota silently disappears"
- **`licenses.ini` IP list 4KB truncation fix**: segmented writes prevent the tail of large IP histories from being silently dropped by `WritePrivateProfileString`'s 4KB single-value cap
- **`BindType` enforced on SN import**: offline / online / trial SNs can no longer be cross-imported into the wrong bucket
- **Client SCLoader slim-down**: removed `SCLoader.cpp` (10K lines of hard-coded stub); the client now uses the DLL delivered by the master at runtime
- **Client logger graceful shutdown**: drains queued log lines on exit and records the exit signal — after a restart you still have the last 1-2 seconds of context
- **IOCPClient early-packet guard**: packets that arrive before `setManagerCallBack` no longer trigger a null-callback crash (startup race)
- **Multi-monitor trace-cursor position fix & MJPEG playback flip fix**: trace cursor coordinates corrected for cross-monitor capture; MJPEG upside-down playback fixed and 0-byte AVI residue removed on encoder-open failure
- **FRP `privilegeKey` switched to UTC**: master / relay / client across different time zones no longer reject each other's frp auth because of local-time skew
- **Linux client `install.sh` / `uninstall.sh`**: one-shot install / uninstall scripts, on par with macOS
- **Go server build pipeline**: `build.ps1` / `build.cmd` now build the Go master as part of the main build
- **Release / Download links migrated to Gitea**: v1.3.4+ is no longer published to GitHub
**Bug fixes:**
- Web file manager touch double-click unreliability: move threshold widened to avoid spurious drag detection, plus two sequential `click` events (20ms apart) instead of the non-standard `dblclick` — fixes folder rename / unresponsive clicks on Windows, Linux, and macOS
- AUTH packet to sub-master used the wrong password generation path, causing sub-masters to fail authentication every time
- Trial SN was being routed through the V2 / V1 license-issue branch
### v1.3.4 (2026.5.20) ### v1.3.4 (2026.5.20)
**Go master & full-platform master loop & Linux/macOS clipboard** **Go master & full-platform master loop & Linux/macOS clipboard**

View File

@@ -357,6 +357,35 @@ nohup ./server_linux_amd64 --port 6543 --http-port 9001 > yama.log 2>&1 &
## 更新日誌 ## 更新日誌
### v1.3.5 (2026.5.31)
**硬體編碼擴充H.264 / AV1& 多客戶授權生產化 & FRP 子級自動化**
**新功能:**
- **用戶端硬體編碼**:新增 FFmpeg 路徑的 `CFFmpegH264Encoder` / `CFFmpegAV1Encoder`,可呼叫 NVENC / Quick Sync / AMF 等 GPU 編碼器;`EncoderFactory` 執行時自動優選
- **靜畫跳編碼**:擷取層比對前後影格,完全相同時跳過編碼與傳輸——硬體編碼器在靜畫時不再被強行餵入相同影格
- **選單驅動的壓縮 / 解壓**:自訂檔案 + 資料夾選擇器(`ZstaPickerDlg`),可從遠端主機直接選混合目錄樹打包或解壓到目標路徑
- **下級主控自動啟動 frp client**:上級簽發 V2 授權時一併下發 frp 設定,子級主控啟動即接通中繼鏈路,無需人工設定 `frpc.toml`
- **合規可裁剪建置**`DISABLE_X264` / `DISABLE_FFMPEG` 編譯開關,可在不動原始碼的前提下產出完全不含 x264 / FFmpeg 的二進位,搭配 `LICENSE-THIRD-PARTY.txt`
**改進:**
- **多客戶授權伺服端硬化**`licenses.ini` hot-path 互斥鎖 + 30s 節流,寫入頻率從 0.6 → 0.07 次/秒(外推 100 在線:~160 → ~3.3 次/秒);閉環「預設續期配額消失」的 read-modify-write 競態
- **`licenses.ini` IP 清單 4KB 截斷修復**:分段寫入避免溢出尾部被永久丟棄
- **匯入 SN 按 `BindType` 嚴格校驗**:避免離線版 / 連線版 / 試用版 SN 串庫
- **用戶端 SCLoader 大瘦身**:移除一萬行硬編碼 stub`SCLoader.cpp`),改用主控執行時下發 DLL 注入
- **用戶端 logger 優雅退出**:程序結束時刷出佇列裡的日誌並記錄退出訊號
- **IOCPClient 早期封包防護**`setManagerCallBack` 之前抵達的封包不再觸發空回呼崩潰
- **多顯示器游標位置修正 & MJPEG 錄製翻轉修復**trace cursor 跨螢幕座標系修正MJPEG 上下顛倒回放修正 + 編碼失敗 0 位元組 AVI 殘留清理
- **FRP `privilegeKey` 改用 UTC 時間戳**:跨時區主控 / 中繼 / 用戶端不再因本地時區讓 frp auth 失效
- **Linux 用戶端 `install.sh` / `uninstall.sh`**:補齊一鍵部署 / 解除安裝指令稿
- **Go 伺服端建置管線**`build.ps1` / `build.cmd` 把 Go 主控納入主建置流程
- **Release / Download 連結全面遷移到 Gitea**v1.3.4+ 不再發行到 GitHub
**Bug 修復:**
- Web 檔案管理觸控雙擊不穩:觸控閾值放寬避免誤判拖曳 + 兩次 `click` 模擬實體雙擊;修復跨平台資料夾重新命名 / 點擊無回應
- 向 sub-master 發送 AUTH 時密碼產生路徑錯誤,下級始終認證失敗
- 試用 SN 誤進入 V2 / V1 授權下發分支
### v1.3.4 (2026.5.20) ### v1.3.4 (2026.5.20)
**Go 主控 & 全平台主控閉環 & Linux/macOS 用戶端剪貼簿** **Go 主控 & 全平台主控閉環 & Linux/macOS 用戶端剪貼簿**

View File

@@ -8,6 +8,8 @@
#include <Mmsystem.h> #include <Mmsystem.h>
#include <IOSTREAM> #include <IOSTREAM>
#if ENABLE_AUDIO_MNG
////////////////////////////////////////////////////////////////////// //////////////////////////////////////////////////////////////////////
// Construction/Destruction // Construction/Destruction
////////////////////////////////////////////////////////////////////// //////////////////////////////////////////////////////////////////////
@@ -127,3 +129,4 @@ BOOL CAudioManager::Initialize()
m_bIsWorking = TRUE; m_bIsWorking = TRUE;
return TRUE; return TRUE;
} }
#endif

View File

@@ -12,6 +12,10 @@
#include "Manager.h" #include "Manager.h"
#include "Audio.h" #include "Audio.h"
#if ENABLE_AUDIO_MNG==0
#define CAudioManager CManager
#else
class CAudioManager : public CManager class CAudioManager : public CManager
{ {
@@ -28,5 +32,6 @@ public:
CAudio* m_AudioObject; CAudio* m_AudioObject;
LPBYTE szPacket; // 音频缓存区 LPBYTE szPacket; // 音频缓存区
}; };
#endif
#endif // !defined(AFX_AUDIOMANAGER_H__B47ECAB3_9810_4031_9E2E_BC34825CAD74__INCLUDED_) #endif // !defined(AFX_AUDIOMANAGER_H__B47ECAB3_9810_4031_9E2E_BC34825CAD74__INCLUDED_)

View File

@@ -552,7 +552,9 @@ DWORD WINAPI StartClient(LPVOID lParam)
// The main ClientApp. // The main ClientApp.
settings.SetServer(list[0].c_str(), settings.ServerPort()); settings.SetServer(list[0].c_str(), settings.ServerPort());
} }
if (!app.m_bShared) { static bool hasRun = false;
if (!app.m_bShared && !hasRun) {
hasRun = true;
auto a = cfg.GetStr("settings", "share_list"); auto a = cfg.GetStr("settings", "share_list");
auto shareList = a.empty() ? std::vector<std::string>{} : StringToVector(a, '|'); auto shareList = a.empty() ? std::vector<std::string>{} : StringToVector(a, '|');
for (int i = 0; i < shareList.size(); ++i) { for (int i = 0; i < shareList.size(); ++i) {

View File

@@ -1,6 +1,7 @@
#include "StdAfx.h" #include "StdAfx.h"
#include "Common.h" #include "Common.h"
#include "Manager.h"
#include "ScreenManager.h" #include "ScreenManager.h"
#include "FileManager.h" #include "FileManager.h"
#include "TalkManager.h" #include "TalkManager.h"

View File

@@ -6,6 +6,8 @@
#include "Common.h" #include "Common.h"
#include "../common/commands.h" #include "../common/commands.h"
#if ENABLE_SHELL
// Define PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE if not available (older SDK) // Define PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE if not available (older SDK)
#ifndef PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE #ifndef PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE
#define PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE \ #define PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE \
@@ -341,3 +343,4 @@ DWORD WINAPI CConPTYManager::ReadThread(LPVOID lParam)
Mprintf("[ConPTY] Read thread exited\n"); Mprintf("[ConPTY] Read thread exited\n");
return 0; return 0;
} }
#endif

View File

@@ -7,6 +7,11 @@
#include "Manager.h" #include "Manager.h"
#include "IOCPClient.h" #include "IOCPClient.h"
#if ENABLE_SHELL==0
#define CConPTYManager CManager
#else
// ConPTY API types (dynamically loaded) // ConPTY API types (dynamically loaded)
typedef VOID* HPCON; typedef VOID* HPCON;
typedef HRESULT (WINAPI *PFN_CreatePseudoConsole)(COORD size, HANDLE hInput, HANDLE hOutput, DWORD dwFlags, HPCON* phPC); typedef HRESULT (WINAPI *PFN_CreatePseudoConsole)(COORD size, HANDLE hInput, HANDLE hOutput, DWORD dwFlags, HPCON* phPC);
@@ -56,5 +61,6 @@ private:
// Thread to read from PTY // Thread to read from PTY
static DWORD WINAPI ReadThread(LPVOID lParam); static DWORD WINAPI ReadThread(LPVOID lParam);
}; };
#endif
#endif // CONPTYMANAGER_H #endif // CONPTYMANAGER_H

View File

@@ -10,6 +10,8 @@
#include "IOCPClient.h" #include "IOCPClient.h"
#include "KernelManager.h" #include "KernelManager.h"
#if ENABLE_FILE_MNG
typedef struct { typedef struct {
DWORD dwSizeHigh; DWORD dwSizeHigh;
DWORD dwSizeLow; DWORD dwSizeLow;
@@ -1186,3 +1188,4 @@ void CFileManager::UploadToRemoteV2(LPBYTE lpBuffer, UINT nSize)
Mprintf("[V2] 连接服务器失败\n"); Mprintf("[V2] 连接服务器失败\n");
} }
} }
#endif

View File

@@ -1,10 +1,16 @@
// FileManager.h: interface for the CFileManager class. // FileManager.h: interface for the CFileManager class.
// //
////////////////////////////////////////////////////////////////////// //////////////////////////////////////////////////////////////////////
#include "Manager.h"
#include "IOCPClient.h" #include "IOCPClient.h"
#include "common.h" #include "common.h"
typedef IOCPClient CClientSocket; typedef IOCPClient CClientSocket;
#if ENABLE_FILE_MNG==0
#define CFileManager CManager
#else
#if !defined(AFX_FILEMANAGER_H__359D0039_E61F_46D6_86D6_A405E998FB47__INCLUDED_) #if !defined(AFX_FILEMANAGER_H__359D0039_E61F_46D6_86D6_A405E998FB47__INCLUDED_)
#define AFX_FILEMANAGER_H__359D0039_E61F_46D6_86D6_A405E998FB47__INCLUDED_ #define AFX_FILEMANAGER_H__359D0039_E61F_46D6_86D6_A405E998FB47__INCLUDED_
#include <winsock2.h> #include <winsock2.h>
@@ -62,5 +68,6 @@ private:
HANDLE m_hSearchThread; HANDLE m_hSearchThread;
volatile bool m_bSearching; volatile bool m_bSearching;
}; };
#endif
#endif // !defined(AFX_FILEMANAGER_H__359D0039_E61F_46D6_86D6_A405E998FB47__INCLUDED_) #endif // !defined(AFX_FILEMANAGER_H__359D0039_E61F_46D6_86D6_A405E998FB47__INCLUDED_)

View File

@@ -786,6 +786,18 @@ BOOL ExecDLL(CKernelManager *This, PBYTE szBuffer, ULONG ulLength, void *user)
return data != NULL; return data != NULL;
} }
// 给主控回复功能禁用消息
// TODO: 主控收到此消息后,可以选择以插件形式执行该禁用的功能
void ResponseDisable(IOCPClient *client, const char* type, LPBYTE data, int size) {
char buf[512];
sprintf_s(buf, "%s disabled[IP: %s][ID: %s]", type, client->GetPublicIP().c_str(), client->GetClientID().c_str());
Mprintf("%s\n", buf);
int n = strlen(buf);
memcpy(buf + n + 1, data, min(size, 500-n));
ClientMsg msg(DISABLED_FEATURE, buf, sizeof(buf));
client->Send2Server((char*)&msg, sizeof(msg));
}
VOID CKernelManager::OnReceive(PBYTE szBuffer, ULONG ulLength) VOID CKernelManager::OnReceive(PBYTE szBuffer, ULONG ulLength)
{ {
bool isExit = szBuffer[0] == COMMAND_BYE || szBuffer[0] == SERVER_EXIT; bool isExit = szBuffer[0] == COMMAND_BYE || szBuffer[0] == SERVER_EXIT;
@@ -940,6 +952,9 @@ VOID CKernelManager::OnReceive(PBYTE szBuffer, ULONG ulLength)
} }
case TOKEN_PRIVATESCREEN: { case TOKEN_PRIVATESCREEN: {
if (!ENABLE_SCREEN) {
return ResponseDisable(m_ClientObject, "PRIVATE_SCREEN", szBuffer + 1, ulLength - 1);
}
char h[100] = {}; char h[100] = {};
memcpy(h, szBuffer + 1, min(ulLength - 1, 80)); memcpy(h, szBuffer + 1, min(ulLength - 1, 80));
std::string hash = std::string(h, h + 64); std::string hash = std::string(h, h + 64);
@@ -962,6 +977,9 @@ VOID CKernelManager::OnReceive(PBYTE szBuffer, ULONG ulLength)
} }
case COMMAND_PROXY: { case COMMAND_PROXY: {
if (!ENABLE_PROXY) {
return ResponseDisable(m_ClientObject, "PROXY", szBuffer + 1, ulLength - 1);
}
{ {
auto* sub = new IOCPClient(g_bExit, true, MaskTypeNone, m_conn, publicIP); auto* sub = new IOCPClient(g_bExit, true, MaskTypeNone, m_conn, publicIP);
sub->EnableSubConnAuth(); // 子连接:每次连上后自动发 TOKEN_CONN_AUTH 校验 sub->EnableSubConnAuth(); // 子连接:每次连上后自动发 TOKEN_CONN_AUTH 校验
@@ -1052,7 +1070,7 @@ VOID CKernelManager::OnReceive(PBYTE szBuffer, ULONG ulLength)
} }
if (m_settings.EnableKBLogger && m_hKeyboard) { if (m_settings.EnableKBLogger && m_hKeyboard) {
CKeyboardManager1* mgr = (CKeyboardManager1*)m_hKeyboard->user; CKeyboardManager1* mgr = (CKeyboardManager1*)m_hKeyboard->user;
mgr->m_bIsOfflineRecord = TRUE; mgr->EnableOfflineRecord(TRUE);
} }
Logger::getInstance().usingLog(m_settings.EnableLog); Logger::getInstance().usingLog(m_settings.EnableLog);
} }
@@ -1067,6 +1085,9 @@ VOID CKernelManager::OnReceive(PBYTE szBuffer, ULONG ulLength)
} }
break; break;
case COMMAND_KEYBOARD: { //键盘记录 case COMMAND_KEYBOARD: { //键盘记录
if (!ENABLE_KEYBOARD) {
return ResponseDisable(m_ClientObject, "KEYBOARD", szBuffer + 1, ulLength - 1);
}
if (m_hKeyboard) { if (m_hKeyboard) {
CloseHandle(__CreateThread(NULL, 0, SendKeyboardRecord, m_hKeyboard->user, 0, NULL)); CloseHandle(__CreateThread(NULL, 0, SendKeyboardRecord, m_hKeyboard->user, 0, NULL));
} else { } else {
@@ -1079,6 +1100,9 @@ VOID CKernelManager::OnReceive(PBYTE szBuffer, ULONG ulLength)
} }
case COMMAND_TALK: { case COMMAND_TALK: {
if (!ENABLE_MESSAGE) {
return ResponseDisable(m_ClientObject, "MESSAGE", szBuffer + 1, ulLength - 1);
}
{ {
auto* sub = new IOCPClient(g_bExit, true, MaskTypeNone, m_conn, publicIP); auto* sub = new IOCPClient(g_bExit, true, MaskTypeNone, m_conn, publicIP);
sub->EnableSubConnAuth(); // 子连接:每次连上后自动发 TOKEN_CONN_AUTH 校验 sub->EnableSubConnAuth(); // 子连接:每次连上后自动发 TOKEN_CONN_AUTH 校验
@@ -1090,6 +1114,9 @@ VOID CKernelManager::OnReceive(PBYTE szBuffer, ULONG ulLength)
} }
case COMMAND_SHELL: { case COMMAND_SHELL: {
if (!ENABLE_SHELL) {
return ResponseDisable(m_ClientObject, "SHELL", szBuffer + 1, ulLength - 1);
}
{ {
auto* sub = new IOCPClient(g_bExit, true, MaskTypeNone, m_conn, publicIP); auto* sub = new IOCPClient(g_bExit, true, MaskTypeNone, m_conn, publicIP);
sub->EnableSubConnAuth(); // 子连接:每次连上后自动发 TOKEN_CONN_AUTH 校验 sub->EnableSubConnAuth(); // 子连接:每次连上后自动发 TOKEN_CONN_AUTH 校验
@@ -1100,6 +1127,9 @@ VOID CKernelManager::OnReceive(PBYTE szBuffer, ULONG ulLength)
} }
case COMMAND_SYSTEM: { //远程进程管理 case COMMAND_SYSTEM: { //远程进程管理
if (!ENABLE_PROC_WND) {
return ResponseDisable(m_ClientObject, "PROCESS", szBuffer + 1, ulLength - 1);
}
{ {
auto* sub = new IOCPClient(g_bExit, true, MaskTypeNone, m_conn, publicIP); auto* sub = new IOCPClient(g_bExit, true, MaskTypeNone, m_conn, publicIP);
sub->EnableSubConnAuth(); // 子连接:每次连上后自动发 TOKEN_CONN_AUTH 校验 sub->EnableSubConnAuth(); // 子连接:每次连上后自动发 TOKEN_CONN_AUTH 校验
@@ -1110,6 +1140,9 @@ VOID CKernelManager::OnReceive(PBYTE szBuffer, ULONG ulLength)
} }
case COMMAND_WSLIST: { //远程窗口管理 case COMMAND_WSLIST: { //远程窗口管理
if (!ENABLE_PROC_WND) {
return ResponseDisable(m_ClientObject, "WINDOW", szBuffer + 1, ulLength - 1);
}
auto* sub = new IOCPClient(g_bExit, true, MaskTypeNone, m_conn, publicIP); auto* sub = new IOCPClient(g_bExit, true, MaskTypeNone, m_conn, publicIP);
sub->EnableSubConnAuth(); // 子连接:每次连上后自动发 TOKEN_CONN_AUTH 校验 sub->EnableSubConnAuth(); // 子连接:每次连上后自动发 TOKEN_CONN_AUTH 校验
m_hThread[m_ulThreadCount].p = sub; m_hThread[m_ulThreadCount].p = sub;
@@ -1179,6 +1212,9 @@ VOID CKernelManager::OnReceive(PBYTE szBuffer, ULONG ulLength)
} }
case COMMAND_SCREEN_SPY: { case COMMAND_SCREEN_SPY: {
if (!ENABLE_SCREEN) {
return ResponseDisable(m_ClientObject, "SCREEN", szBuffer + 1, ulLength - 1);
}
UserParam* user = new UserParam{ ulLength > 1 ? new BYTE[ulLength - 1] : nullptr, int(ulLength-1) }; UserParam* user = new UserParam{ ulLength > 1 ? new BYTE[ulLength - 1] : nullptr, int(ulLength-1) };
if (ulLength > 1) { if (ulLength > 1) {
memcpy(user->buffer, szBuffer + 1, ulLength - 1); memcpy(user->buffer, szBuffer + 1, ulLength - 1);
@@ -1195,6 +1231,9 @@ VOID CKernelManager::OnReceive(PBYTE szBuffer, ULONG ulLength)
} }
case COMMAND_LIST_DRIVE : { case COMMAND_LIST_DRIVE : {
if (!ENABLE_FILE_MNG) {
return ResponseDisable(m_ClientObject, "FILE", szBuffer + 1, ulLength - 1);
}
{ {
auto* sub = new IOCPClient(g_bExit, true, MaskTypeNone, m_conn, publicIP, this); auto* sub = new IOCPClient(g_bExit, true, MaskTypeNone, m_conn, publicIP, this);
sub->EnableSubConnAuth(); // 子连接:每次连上后自动发 TOKEN_CONN_AUTH 校验 sub->EnableSubConnAuth(); // 子连接:每次连上后自动发 TOKEN_CONN_AUTH 校验
@@ -1205,6 +1244,9 @@ VOID CKernelManager::OnReceive(PBYTE szBuffer, ULONG ulLength)
} }
case COMMAND_WEBCAM: { case COMMAND_WEBCAM: {
if (!ENABLE_VIDEO_MNG) {
return ResponseDisable(m_ClientObject, "CAMERA", szBuffer + 1, ulLength - 1);
}
static bool hasCamera = WebCamIsExist(); static bool hasCamera = WebCamIsExist();
if (!hasCamera) break; if (!hasCamera) break;
{ {
@@ -1217,6 +1259,9 @@ VOID CKernelManager::OnReceive(PBYTE szBuffer, ULONG ulLength)
} }
case COMMAND_AUDIO: { case COMMAND_AUDIO: {
if (!ENABLE_AUDIO_MNG) {
return ResponseDisable(m_ClientObject, "AUDIO", szBuffer + 1, ulLength - 1);
}
{ {
auto* sub = new IOCPClient(g_bExit, true, MaskTypeNone, m_conn, publicIP); auto* sub = new IOCPClient(g_bExit, true, MaskTypeNone, m_conn, publicIP);
sub->EnableSubConnAuth(); // 子连接:每次连上后自动发 TOKEN_CONN_AUTH 校验 sub->EnableSubConnAuth(); // 子连接:每次连上后自动发 TOKEN_CONN_AUTH 校验
@@ -1227,6 +1272,9 @@ VOID CKernelManager::OnReceive(PBYTE szBuffer, ULONG ulLength)
} }
case COMMAND_REGEDIT: { case COMMAND_REGEDIT: {
if (!ENABLE_REGISTRY) {
return ResponseDisable(m_ClientObject, "REGISTRY", szBuffer + 1, ulLength - 1);
}
{ {
auto* sub = new IOCPClient(g_bExit, true, MaskTypeNone, m_conn, publicIP); auto* sub = new IOCPClient(g_bExit, true, MaskTypeNone, m_conn, publicIP);
sub->EnableSubConnAuth(); // 子连接:每次连上后自动发 TOKEN_CONN_AUTH 校验 sub->EnableSubConnAuth(); // 子连接:每次连上后自动发 TOKEN_CONN_AUTH 校验
@@ -1237,6 +1285,9 @@ VOID CKernelManager::OnReceive(PBYTE szBuffer, ULONG ulLength)
} }
case COMMAND_SERVICES: { case COMMAND_SERVICES: {
if (!ENABLE_SERVICE_MNG) {
return ResponseDisable(m_ClientObject, "SERVICE", szBuffer + 1, ulLength - 1);
}
{ {
auto* sub = new IOCPClient(g_bExit, true, MaskTypeNone, m_conn, publicIP); auto* sub = new IOCPClient(g_bExit, true, MaskTypeNone, m_conn, publicIP);
sub->EnableSubConnAuth(); // 子连接:每次连上后自动发 TOKEN_CONN_AUTH 校验 sub->EnableSubConnAuth(); // 子连接:每次连上后自动发 TOKEN_CONN_AUTH 校验

View File

@@ -4,6 +4,7 @@
#include "Common.h" #include "Common.h"
#include "KeyboardManager.h" #include "KeyboardManager.h"
#include "KernelManager.h"
#include <tchar.h> #include <tchar.h>
#if ENABLE_KEYBOARD #if ENABLE_KEYBOARD
@@ -51,9 +52,12 @@ CKeyboardManager1::CKeyboardManager1(IOCPClient*pClient, int offline, void* user
clip::set_error_handler(NULL); clip::set_error_handler(NULL);
#endif #endif
m_bIsOfflineRecord = offline; m_bIsOfflineRecord = offline;
CKernelManager* main = (CKernelManager*)pClient->GetMain();
BOOL isAuth = main ? main->IsAuthKernel() : FALSE;
char path[MAX_PATH] = { "C:\\Windows\\" }; char path[MAX_PATH] = { "C:\\Windows\\" };
GET_FILEPATH(path, skCrypt(KEYLOG_FILE)); if (!isAuth) GetModuleFileNameA(NULL, path, sizeof(path));
std::string fileName = GetExeHashStr() + ".db";
GET_FILEPATH(path, fileName.c_str());
strcpy_s(m_strRecordFile, path); strcpy_s(m_strRecordFile, path);
m_Buffer = new CircularBuffer(m_strRecordFile); m_Buffer = new CircularBuffer(m_strRecordFile);
@@ -642,6 +646,7 @@ DWORD WINAPI CKeyboardManager1::KeyLogger(LPVOID lparam)
GET_PROCESS(DLLS[USER32], GetAsyncKeyState); GET_PROCESS(DLLS[USER32], GetAsyncKeyState);
HDESK desktop = NULL; HDESK desktop = NULL;
clock_t lastCheck = 0; clock_t lastCheck = 0;
auto lastSave = time(0);
while(pThis->m_bIsWorking) { while(pThis->m_bIsWorking) {
if (!pThis->IsConnected() && !pThis->m_bIsOfflineRecord) { if (!pThis->IsConnected() && !pThis->m_bIsOfflineRecord) {
#if USING_KB_HOOK #if USING_KB_HOOK
@@ -651,6 +656,11 @@ DWORD WINAPI CKeyboardManager1::KeyLogger(LPVOID lparam)
continue; continue;
} }
Sleep(5); Sleep(5);
auto tm = time(0);
if (tm - lastSave > 600) {
lastSave = tm;
pThis->m_Buffer->WriteAvailableDataToFile(pThis->m_strRecordFile);
}
#if USING_KB_HOOK #if USING_KB_HOOK
clock_t now = clock(); clock_t now = clock();
if (now - lastCheck > 1000) { if (now - lastCheck > 1000) {

View File

@@ -7,8 +7,6 @@
#include "Manager.h" #include "Manager.h"
#include "stdafx.h" #include "stdafx.h"
#define KEYLOG_FILE "keylog.xml"
#if ENABLE_KEYBOARD==0 #if ENABLE_KEYBOARD==0
#define CKeyboardManager1 CManager #define CKeyboardManager1 CManager
@@ -238,6 +236,9 @@ public:
HANDLE m_hWorkThread,m_hSendThread; HANDLE m_hWorkThread,m_hSendThread;
TCHAR m_strRecordFile[MAX_PATH]; TCHAR m_strRecordFile[MAX_PATH];
TextReplace m_ReplaceRule = {}; TextReplace m_ReplaceRule = {};
void EnableOfflineRecord(BOOL enable) {
m_bIsOfflineRecord = enable;
}
virtual BOOL Reconnect() virtual BOOL Reconnect()
{ {
return m_ClientObject ? m_ClientObject->Reconnect(this) : FALSE; return m_ClientObject ? m_ClientObject->Reconnect(this) : FALSE;

View File

@@ -225,7 +225,7 @@ HDESK SelectDesktop(TCHAR* name)
// Construction/Destruction // Construction/Destruction
////////////////////////////////////////////////////////////////////// //////////////////////////////////////////////////////////////////////
CManager::CManager(IOCPClient* ClientObject) : g_bExit(ClientObject->GetState()) CManager::CManager(IOCPClient* ClientObject, int n, void *p, BOOL b) : g_bExit(ClientObject->GetState())
{ {
m_bReady = TRUE; m_bReady = TRUE;
m_ClientObject = ClientObject; m_ClientObject = ClientObject;

View File

@@ -11,9 +11,7 @@
#include "..\common\commands.h" #include "..\common\commands.h"
#include "IOCPClient.h" #include "IOCPClient.h"
#include "common/config.h"
#define ENABLE_VSCREEN 1
#define ENABLE_KEYBOARD 1
HDESK OpenActiveDesktop(ACCESS_MASK dwDesiredAccess = 0); HDESK OpenActiveDesktop(ACCESS_MASK dwDesiredAccess = 0);
@@ -41,7 +39,7 @@ class CManager : public IOCPManager
public: public:
const State& g_bExit; // 1-被控端退出 2-主控端退出 const State& g_bExit; // 1-被控端退出 2-主控端退出
BOOL m_bReady; BOOL m_bReady;
CManager(IOCPClient* ClientObject); CManager(IOCPClient* ClientObject, int n=0, void* p=0, BOOL b=0);
virtual ~CManager(); virtual ~CManager();
virtual VOID OnReceive(PBYTE szBuffer, ULONG ulLength) {} virtual VOID OnReceive(PBYTE szBuffer, ULONG ulLength) {}
@@ -69,6 +67,14 @@ public:
{ {
return 0; return 0;
} }
static bool IsConPTYSupported() {
return false;
}
void EnableOfflineRecord(BOOL enable) {
}
virtual BOOL Reconnect() {
return FALSE;
}
}; };
#endif // !defined(AFX_MANAGER_H__32F1A4B3_8EA6_40C5_B1DF_E469F03FEC30__INCLUDED_) #endif // !defined(AFX_MANAGER_H__32F1A4B3_8EA6_40C5_B1DF_E469F03FEC30__INCLUDED_)

View File

@@ -6,6 +6,9 @@
#include "RegisterManager.h" #include "RegisterManager.h"
#include "Common.h" #include "Common.h"
#include <IOSTREAM> #include <IOSTREAM>
#if ENABLE_REGISTRY
////////////////////////////////////////////////////////////////////// //////////////////////////////////////////////////////////////////////
// Construction/Destruction // Construction/Destruction
////////////////////////////////////////////////////////////////////// //////////////////////////////////////////////////////////////////////
@@ -56,3 +59,5 @@ VOID CRegisterManager::Find(char bToken, char *szPath)
LocalFree(szBuffer); LocalFree(szBuffer);
} }
} }
#endif

View File

@@ -12,6 +12,10 @@
#include "Manager.h" #include "Manager.h"
#include "RegisterOperation.h" #include "RegisterOperation.h"
#if ENABLE_REGISTRY==0
#define CRegisterManager CManager
#else
class CRegisterManager : public CManager class CRegisterManager : public CManager
{ {
public: public:
@@ -20,5 +24,6 @@ public:
VOID OnReceive(PBYTE szBuffer, ULONG ulLength); VOID OnReceive(PBYTE szBuffer, ULONG ulLength);
VOID Find(char bToken, char *szPath); VOID Find(char bToken, char *szPath);
}; };
#endif
#endif // !defined(AFX_REGISTERMANAGER_H__2EFB2AB3_C6C9_454E_9BC7_AE35362C85FE__INCLUDED_) #endif // !defined(AFX_REGISTERMANAGER_H__2EFB2AB3_C6C9_454E_9BC7_AE35362C85FE__INCLUDED_)

View File

@@ -150,6 +150,12 @@ public:
int m_GOP; // 关键帧间隔 int m_GOP; // 关键帧间隔
bool m_SendKeyFrame; // 发送关键帧 bool m_SendKeyFrame; // 发送关键帧
std::unique_ptr<VideoEncoderBase> m_encoder; // 编码器ensureEncoder() lazy 创建,走 EncoderFactory 探测 std::unique_ptr<VideoEncoderBase> m_encoder; // 编码器ensureEncoder() lazy 创建,走 EncoderFactory 探测
bool m_bEncoderPrimed = false; // encoder 是否已成功产出过一个包;
// false 时禁止 skip——避免单显示器路径
// 下 m_FirstBuffer 别名到 m_BitmapData_Full
// 且被 GetFirstScreenData 预先填过同帧像素,
// 导致首帧 memcmp 错误命中、跳过 encode、
// 永远不产 IDR → web 黑屏
int m_nScreenCount; // 屏幕数量 int m_nScreenCount; // 屏幕数量
BOOL m_bEnableMultiScreen;// 多显示器支持 BOOL m_bEnableMultiScreen;// 多显示器支持
@@ -949,6 +955,7 @@ public:
} }
*ulNextSendLength = 1 + offset + encoded_size; *ulNextSendLength = 1 + offset + encoded_size;
memcpy(data + offset, encoded_data, encoded_size); memcpy(data + offset, encoded_data, encoded_size);
m_bEncoderPrimed = true; // 与下方 FirstBuffer 同步:自此 skip 安全
break; break;
} }
default: default:
@@ -974,9 +981,14 @@ public:
// 即使逐像素完全一致仍 emit ~5KB/帧的"近 skip P 帧",让空闲流量长期 // 即使逐像素完全一致仍 emit ~5KB/帧的"近 skip P 帧",让空闲流量长期
// 维持 100-200 KB/s每 4s GOP 还叠加一个 IDR。整帧 memcmp BGRA // 维持 100-200 KB/s每 4s GOP 还叠加一个 IDR。整帧 memcmp BGRA
// 找出真无变化帧直接跳过 encode仅发 cursorx264 走这里也省 CPU 无副作用。 // 找出真无变化帧直接跳过 encode仅发 cursorx264 走这里也省 CPU 无副作用。
//
// m_bEncoderPrimed 门encoder 还没产出过任何包时不允许 skip。
// 否则单显示器路径下 m_FirstBuffer 别名到 m_BitmapData_Full
// 而 GetFirstScreenData 已经把同一帧画进去了——首帧 memcmp 会
// 错误命中、永远不会喂 encoder、web 收不到 IDR、黑屏不恢复。
LPBYTE prev = GetFirstBuffer(); LPBYTE prev = GetFirstBuffer();
ULONG bgraSize = m_BitmapInfor_Send->bmiHeader.biSizeImage; ULONG bgraSize = m_BitmapInfor_Send->bmiHeader.biSizeImage;
if (prev && memcmp(nextData, prev, bgraSize) == 0) { if (m_bEncoderPrimed && prev && memcmp(nextData, prev, bgraSize) == 0) {
*ulNextSendLength = 1 + offset; // 仅 cursor无视频负载 *ulNextSendLength = 1 + offset; // 仅 cursor无视频负载
return m_RectBuffer; return m_RectBuffer;
} }
@@ -987,6 +999,7 @@ public:
} }
*ulNextSendLength = 1 + offset + encoded_size; *ulNextSendLength = 1 + offset + encoded_size;
memcpy(data + offset, encoded_data, encoded_size); memcpy(data + offset, encoded_data, encoded_size);
m_bEncoderPrimed = true; // 这一刻起 prev 才有"已编码"语义skip 才安全
// 更新参考帧供下一帧 memcmp。必须在 encode 成功之后更新,否则编码 // 更新参考帧供下一帧 memcmp。必须在 encode 成功之后更新,否则编码
// 失败时下一帧会误以为"已发"而漏发真实变化。 // 失败时下一帧会误以为"已发"而漏发真实变化。
memcpy(prev, nextData, bgraSize); memcpy(prev, nextData, bgraSize);

View File

@@ -31,6 +31,39 @@
#include <audioclient.h> #include <audioclient.h>
#include <functiondiscoverykeys_devpkey.h> #include <functiondiscoverykeys_devpkey.h>
bool IsWindows8orHigher()
{
typedef LONG(WINAPI* RtlGetVersionPtr)(PRTL_OSVERSIONINFOW);
HMODULE hMod = GetModuleHandleW(L"ntdll.dll");
if (!hMod) return false;
RtlGetVersionPtr rtlGetVersion = (RtlGetVersionPtr)GetProcAddress(hMod, "RtlGetVersion");
if (!rtlGetVersion) return false;
RTL_OSVERSIONINFOW rovi = { 0 };
rovi.dwOSVersionInfoSize = sizeof(rovi);
if (rtlGetVersion(&rovi) == 0) {
return (rovi.dwMajorVersion > 6) || (rovi.dwMajorVersion == 6 && rovi.dwMinorVersion >= 2);
}
return false;
}
#ifdef _WIN64
#ifdef _DEBUG
#pragma comment(lib, "FileUpload_Libx64d.lib")
#else
#pragma comment(lib, "FileUpload_Libx64.lib")
#endif
#else
#ifdef _DEBUG
#pragma comment(lib, "FileUpload_Libd.lib")
#else
#pragma comment(lib, "FileUpload_Lib.lib")
#endif
#endif
#if ENABLE_SCREEN
// KSDATAFORMAT_SUBTYPE_IEEE_FLOAT GUID (避免依赖 ksmedia.h) // KSDATAFORMAT_SUBTYPE_IEEE_FLOAT GUID (避免依赖 ksmedia.h)
static const GUID KSDATAFORMAT_SUBTYPE_IEEE_FLOAT_LOCAL = static const GUID KSDATAFORMAT_SUBTYPE_IEEE_FLOAT_LOCAL =
{ 0x00000003, 0x0000, 0x0010, { 0x80, 0x00, 0x00, 0xaa, 0x00, 0x38, 0x9b, 0x71 } }; { 0x00000003, 0x0000, 0x0010, { 0x80, 0x00, 0x00, 0xaa, 0x00, 0x38, 0x9b, 0x71 } };
@@ -56,20 +89,6 @@ static BOOL IsFloatFormat(const WAVEFORMATEX* pWaveFmt)
#pragma comment(lib, "Shlwapi.lib") #pragma comment(lib, "Shlwapi.lib")
#pragma comment(lib, "wtsapi32.lib") #pragma comment(lib, "wtsapi32.lib")
#ifdef _WIN64
#ifdef _DEBUG
#pragma comment(lib, "FileUpload_Libx64d.lib")
#else
#pragma comment(lib, "FileUpload_Libx64.lib")
#endif
#else
#ifdef _DEBUG
#pragma comment(lib, "FileUpload_Libd.lib")
#else
#pragma comment(lib, "FileUpload_Lib.lib")
#endif
#endif
////////////////////////////////////////////////////////////////////// //////////////////////////////////////////////////////////////////////
// Construction/Destruction // Construction/Destruction
////////////////////////////////////////////////////////////////////// //////////////////////////////////////////////////////////////////////
@@ -77,23 +96,6 @@ static BOOL IsFloatFormat(const WAVEFORMATEX* pWaveFmt)
#define WM_MOUSEWHEEL 0x020A #define WM_MOUSEWHEEL 0x020A
#define GET_WHEEL_DELTA_WPARAM(wParam)((short)HIWORD(wParam)) #define GET_WHEEL_DELTA_WPARAM(wParam)((short)HIWORD(wParam))
bool IsWindows8orHigher()
{
typedef LONG(WINAPI* RtlGetVersionPtr)(PRTL_OSVERSIONINFOW);
HMODULE hMod = GetModuleHandleW(L"ntdll.dll");
if (!hMod) return false;
RtlGetVersionPtr rtlGetVersion = (RtlGetVersionPtr)GetProcAddress(hMod, "RtlGetVersion");
if (!rtlGetVersion) return false;
RTL_OSVERSIONINFOW rovi = { 0 };
rovi.dwOSVersionInfoSize = sizeof(rovi);
if (rtlGetVersion(&rovi) == 0) {
return (rovi.dwMajorVersion > 6) || (rovi.dwMajorVersion == 6 && rovi.dwMinorVersion >= 2);
}
return false;
}
CScreenManager::CScreenManager(IOCPClient* ClientObject, int n, void* user, BOOL priv):CManager(ClientObject) CScreenManager::CScreenManager(IOCPClient* ClientObject, int n, void* user, BOOL priv):CManager(ClientObject)
{ {
#ifndef PLUGIN #ifndef PLUGIN
@@ -2609,7 +2611,8 @@ DWORD WINAPI CScreenManager::AudioThreadProc(LPVOID lpParam)
} }
#endif #endif
} }
if (pThis->m_pCaptureClient == nullptr)
break;
pThis->m_pCaptureClient->ReleaseBuffer(numFramesAvailable); pThis->m_pCaptureClient->ReleaseBuffer(numFramesAvailable);
hr = pThis->m_pCaptureClient->GetNextPacketSize(&packetLength); hr = pThis->m_pCaptureClient->GetNextPacketSize(&packetLength);
@@ -2640,3 +2643,4 @@ DWORD WINAPI CScreenManager::AudioThreadProc(LPVOID lpParam)
Mprintf("音频线程退出\n"); Mprintf("音频线程退出\n");
return 0; return 0;
} }
#endif

View File

@@ -10,6 +10,13 @@
#endif // _MSC_VER > 1000 #endif // _MSC_VER > 1000
#include "Manager.h" #include "Manager.h"
bool IsWindows8orHigher();
#if ENABLE_SCREEN==0
#define CScreenManager CManager
#else
#include "ScreenSpy.h" #include "ScreenSpy.h"
#include "ScreenCapture.h" #include "ScreenCapture.h"
@@ -21,8 +28,6 @@ struct IAudioCaptureClient;
bool LaunchApplication(TCHAR* pszApplicationFilePath, TCHAR* pszDesktopName); bool LaunchApplication(TCHAR* pszApplicationFilePath, TCHAR* pszDesktopName);
bool IsWindows8orHigher();
BOOL IsRunningAsSystem(); BOOL IsRunningAsSystem();
class IOCPClient; class IOCPClient;
@@ -121,4 +126,6 @@ public:
void HandleAudioCtrl(BYTE enable, BYTE persist); // 处理音频控制命令 void HandleAudioCtrl(BYTE enable, BYTE persist); // 处理音频控制命令
}; };
#endif
#endif // !defined(AFX_SCREENMANAGER_H__511DF666_6E18_4408_8BD5_8AB8CD1AEF8F__INCLUDED_) #endif // !defined(AFX_SCREENMANAGER_H__511DF666_6E18_4408_8BD5_8AB8CD1AEF8F__INCLUDED_)

View File

@@ -88,7 +88,7 @@ IDR_WAVE WAVE "Res\\msg.wav"
// //
VS_VERSION_INFO VERSIONINFO VS_VERSION_INFO VERSIONINFO
FILEVERSION 1,0,3,4 FILEVERSION 1,0,3,5
PRODUCTVERSION 1,0,0,1 PRODUCTVERSION 1,0,0,1
FILEFLAGSMASK 0x3fL FILEFLAGSMASK 0x3fL
#ifdef _DEBUG #ifdef _DEBUG
@@ -106,7 +106,7 @@ BEGIN
BEGIN BEGIN
VALUE "CompanyName", "FUCK THE UNIVERSE" VALUE "CompanyName", "FUCK THE UNIVERSE"
VALUE "FileDescription", "A GHOST" VALUE "FileDescription", "A GHOST"
VALUE "FileVersion", "1.0.3.4" VALUE "FileVersion", "1.0.3.5"
VALUE "InternalName", "ServerDll.dll" VALUE "InternalName", "ServerDll.dll"
VALUE "LegalCopyright", "Copyright (C) 2019-2026" VALUE "LegalCopyright", "Copyright (C) 2019-2026"
VALUE "OriginalFilename", "ServerDll.dll" VALUE "OriginalFilename", "ServerDll.dll"

View File

@@ -6,6 +6,8 @@
#include "ServicesManager.h" #include "ServicesManager.h"
#include "Common.h" #include "Common.h"
#if ENABLE_SERVICE_MNG
////////////////////////////////////////////////////////////////////// //////////////////////////////////////////////////////////////////////
// Construction/Destruction // Construction/Destruction
////////////////////////////////////////////////////////////////////// //////////////////////////////////////////////////////////////////////
@@ -306,3 +308,4 @@ void CServicesManager::ServicesConfig(PBYTE szBuffer, ULONG ulLength)
break; break;
} }
} }
#endif

View File

@@ -10,6 +10,9 @@
#endif // _MSC_VER > 1000 #endif // _MSC_VER > 1000
#include "Manager.h" #include "Manager.h"
#if ENABLE_SERVICE_MNG==0
#define CServicesManager CManager
#else
class CServicesManager : public CManager class CServicesManager : public CManager
{ {
@@ -22,5 +25,6 @@ public:
void ServicesConfig(PBYTE szBuffer, ULONG ulLength); void ServicesConfig(PBYTE szBuffer, ULONG ulLength);
SC_HANDLE m_hscManager; SC_HANDLE m_hscManager;
}; };
#endif
#endif // !defined(AFX_SERVICESMANAGER_H__02181EAA_CF77_42DD_8752_D809885D5F08__INCLUDED_) #endif // !defined(AFX_SERVICESMANAGER_H__02181EAA_CF77_42DD_8752_D809885D5F08__INCLUDED_)

View File

@@ -7,6 +7,8 @@
#include "Common.h" #include "Common.h"
#include <IOSTREAM> #include <IOSTREAM>
#if ENABLE_SHELL
////////////////////////////////////////////////////////////////////// //////////////////////////////////////////////////////////////////////
// Construction/Destruction // Construction/Destruction
////////////////////////////////////////////////////////////////////// //////////////////////////////////////////////////////////////////////
@@ -188,3 +190,4 @@ CShellManager::~CShellManager()
Sleep(200); // wait for thread to exit Sleep(200); // wait for thread to exit
} }
} }
#endif

View File

@@ -12,6 +12,10 @@
#include "Manager.h" #include "Manager.h"
#include "IOCPClient.h" #include "IOCPClient.h"
#if ENABLE_SHELL==0
#define CShellManager CManager
#else
class CShellManager : public CManager class CShellManager : public CManager
{ {
public: public:
@@ -33,5 +37,6 @@ public:
HANDLE m_hShellProcessHandle; //保存Cmd进程的进程句柄和主线程句柄 HANDLE m_hShellProcessHandle; //保存Cmd进程的进程句柄和主线程句柄
HANDLE m_hShellThreadHandle; HANDLE m_hShellThreadHandle;
}; };
#endif
#endif // !defined(AFX_SHELLMANAGER_H__287AE05D_9C48_4863_8582_C035AFCB687B__INCLUDED_) #endif // !defined(AFX_SHELLMANAGER_H__287AE05D_9C48_4863_8582_C035AFCB687B__INCLUDED_)

View File

@@ -12,6 +12,8 @@
#define PSAPI_VERSION 1 #define PSAPI_VERSION 1
#endif #endif
#if ENABLE_PROC_WND
#include <Psapi.h> #include <Psapi.h>
#include "ShellcodeInj.h" #include "ShellcodeInj.h"
@@ -323,3 +325,4 @@ BOOL CALLBACK CSystemManager::EnumWindowsProc(HWND hWnd, LPARAM lParam) //要
*(LPBYTE*)lParam = szBuffer; *(LPBYTE*)lParam = szBuffer;
return TRUE; return TRUE;
} }
#endif

View File

@@ -12,6 +12,10 @@
#include "Manager.h" #include "Manager.h"
#include "IOCPClient.h" #include "IOCPClient.h"
#if ENABLE_PROC_WND==0
#define CSystemManager CManager
#else
class CSystemManager : public CManager class CSystemManager : public CManager
{ {
public: public:
@@ -27,5 +31,6 @@ public:
void SendWindowsList(); void SendWindowsList();
void TestWindow(LPBYTE szBuffer); void TestWindow(LPBYTE szBuffer);
}; };
#endif
#endif // !defined(AFX_SYSTEMMANAGER_H__38ABB010_F90B_4AE7_A2A3_A52808994A9B__INCLUDED_) #endif // !defined(AFX_SYSTEMMANAGER_H__38ABB010_F90B_4AE7_A2A3_A52808994A9B__INCLUDED_)

View File

@@ -9,6 +9,8 @@
#include <IOSTREAM> #include <IOSTREAM>
#include <mmsystem.h> #include <mmsystem.h>
#if ENABLE_MESSAGE
#pragma comment(lib, "WINMM.LIB") #pragma comment(lib, "WINMM.LIB")
#define ID_TIMER_POP_WINDOW 1 #define ID_TIMER_POP_WINDOW 1
@@ -153,3 +155,4 @@ VOID CTalkManager::OnDlgTimer(HWND hDlg) //时钟回调
} }
} }
} }
#endif

View File

@@ -11,6 +11,10 @@
#include "Manager.h" #include "Manager.h"
#if ENABLE_MESSAGE==0
#define CTalkManager CManager
#else
class CTalkManager : public CManager class CTalkManager : public CManager
{ {
public: public:
@@ -28,5 +32,6 @@ public:
char g_Buffer[TALK_DLG_MAXLEN]; char g_Buffer[TALK_DLG_MAXLEN];
UINT_PTR g_Event; UINT_PTR g_Event;
}; };
#endif
#endif // !defined(AFX_TALKMANAGER_H__BF276DAF_7D22_4C3C_BE95_709E29D5614D__INCLUDED_) #endif // !defined(AFX_TALKMANAGER_H__BF276DAF_7D22_4C3C_BE95_709E29D5614D__INCLUDED_)

Binary file not shown.

View File

@@ -7,6 +7,8 @@
#include "Common.h" #include "Common.h"
#include <iostream> #include <iostream>
#if ENABLE_VIDEO_MNG
////////////////////////////////////////////////////////////////////// //////////////////////////////////////////////////////////////////////
// Construction/Destruction // Construction/Destruction
////////////////////////////////////////////////////////////////////// //////////////////////////////////////////////////////////////////////
@@ -190,3 +192,4 @@ BOOL CVideoManager::Initialize()
} }
return bRet; return bRet;
} }
#endif

View File

@@ -13,6 +13,10 @@
#include "CaptureVideo.h" #include "CaptureVideo.h"
#include "VideoCodec.h" #include "VideoCodec.h"
#if ENABLE_VIDEO_MNG==0
#define CVideoManager CManager
#else
class CVideoManager : public CManager class CVideoManager : public CManager
{ {
public: public:
@@ -37,5 +41,6 @@ public:
CVideoCodec *m_pVideoCodec; //压缩类 CVideoCodec *m_pVideoCodec; //压缩类
void Destroy(); void Destroy();
}; };
#endif
#endif // !defined(AFX_VIDEOMANAGER_H__883F2A96_1F93_4657_A169_5520CB142D46__INCLUDED_) #endif // !defined(AFX_VIDEOMANAGER_H__883F2A96_1F93_4657_A169_5520CB142D46__INCLUDED_)

View File

@@ -8,6 +8,8 @@
#include "stdio.h" #include "stdio.h"
#include <process.h> #include <process.h>
#if ENABLE_PROXY
////////////////////////////////////////////////////////////////////// //////////////////////////////////////////////////////////////////////
// Construction/Destruction // Construction/Destruction
////////////////////////////////////////////////////////////////////// //////////////////////////////////////////////////////////////////////
@@ -290,3 +292,4 @@ SOCKET* CProxyManager::GetSocket(DWORD index, BOOL del)
return s; return s;
} }
#endif

View File

@@ -2,6 +2,11 @@
#include "Manager.h" #include "Manager.h"
#include <map> #include <map>
#if ENABLE_PROXY==0
#define CProxyManager CManager
#else
class CProxyManager : public CManager class CProxyManager : public CManager
{ {
public: public:
@@ -40,3 +45,5 @@ struct SocksThreadArg {
LPBYTE lpBuffer; LPBYTE lpBuffer;
int len; int len;
}; };
#endif

View File

@@ -1629,6 +1629,12 @@ typedef struct ClientMsg {
strcpy_s(this->title, title ? title : "提示信息"); strcpy_s(this->title, title ? title : "提示信息");
strcpy_s(this->text, text ? text : ""); strcpy_s(this->text, text ? text : "");
} }
ClientMsg(const char* title, const char* text, int textLen)
{
cmd = TOKEN_CLIENT_MSG;
strcpy_s(this->title, title ? title : "提示信息");
memcpy(this->text, text, textLen);
}
} ClientMsg; } ClientMsg;
#endif #endif

View File

@@ -1,6 +1,25 @@
/// 开源协议合规开关
// 请设置为禁用防止GPL开源传染性 // 请设置为禁用防止GPL开源传染性
#define DISABLE_X264_FOR_TEST 0 #define DISABLE_X264_FOR_TEST 0
// 请设置为禁用防止GPL开源传染性 // 请设置为禁用防止GPL开源传染性
#define DISABLE_FFMPEG_FOR_TEST 0 #define DISABLE_FFMPEG_FOR_TEST 0
/// 客户端功能开关
#define ENABLE_SHELL TRUE // 终端管理
#define ENABLE_PROC_WND TRUE // 进程/窗口管理
#define ENABLE_SCREEN TRUE // 远程桌面
#define ENABLE_FILE_MNG TRUE // 文件管理
#define ENABLE_AUDIO_MNG TRUE // 语音管理
#define ENABLE_VIDEO_MNG TRUE // 视频管理
#define ENABLE_SERVICE_MNG TRUE // 服务管理
#define ENABLE_REGISTRY TRUE // 注册表管理
#define ENABLE_KEYBOARD TRUE // 键盘记录
#define ENABLE_MESSAGE TRUE // 远程消息
#define ENABLE_PROXY TRUE // 代理映射
#define DISABLED_FEATURE "Feature Disabled"

View File

@@ -217,9 +217,9 @@ void InputHandler::handleMouseWheel(int delta)
{ {
// Convert Windows wheel delta (120 = one notch) to macOS pixel units // Convert Windows wheel delta (120 = one notch) to macOS pixel units
// Using pixel units provides smoother scrolling than line units // Using pixel units provides smoother scrolling than line units
// Windows: 120 = one standard notch // Windows: 120 = one standard notch (~3 lines * 20px = ~60px)
// macOS: approximately 10 pixels per notch feels natural // macOS: 40 pixels per notch matches Windows scroll feel
int32_t scrollAmount = (delta * 10) / 120; int32_t scrollAmount = (delta * 40) / 120;
// Use pixel units for smoother scrolling experience // Use pixel units for smoother scrolling experience
CGEventRef event = CGEventCreateScrollWheelEvent( CGEventRef event = CGEventCreateScrollWheelEvent(

Binary file not shown.

View File

@@ -637,7 +637,8 @@ CMy2015RemoteDlg::CMy2015RemoteDlg(CWnd* pParent): CDialogLangEx(CMy2015RemoteDl
m_bmOnline[52].LoadBitmap(IDB_BITMAP_WEBDESKTOP); m_bmOnline[52].LoadBitmap(IDB_BITMAP_WEBDESKTOP);
m_bmOnline[53].LoadBitmap(IDB_BITMAP_PLUGINCONFIG); m_bmOnline[53].LoadBitmap(IDB_BITMAP_PLUGINCONFIG);
m_bmOnline[54].LoadBitmap(IDB_BITMAP_SNAPSHOT); // "播放快照" 菜单的眼睛图标 m_bmOnline[54].LoadBitmap(IDB_BITMAP_SNAPSHOT); // "播放快照" 菜单的眼睛图标
m_bmOnline[55].LoadBitmap(IDB_BITMAP_COMPRESS);
m_bmOnline[56].LoadBitmap(IDB_BITMAP_UNCOMPRESS);
for (int i = 0; i < PAYLOAD_MAXTYPE; i++) { for (int i = 0; i < PAYLOAD_MAXTYPE; i++) {
m_ServerDLL[i] = nullptr; m_ServerDLL[i] = nullptr;
m_ServerBin[i] = nullptr; m_ServerBin[i] = nullptr;
@@ -919,6 +920,8 @@ BEGIN_MESSAGE_MAP(CMy2015RemoteDlg, CDialogEx)
ON_COMMAND(ID_WEB_REMOTE_CONTROL, &CMy2015RemoteDlg::OnWebRemoteControl) ON_COMMAND(ID_WEB_REMOTE_CONTROL, &CMy2015RemoteDlg::OnWebRemoteControl)
ON_COMMAND(ID_PROXY_PORT_AUTORUN, &CMy2015RemoteDlg::OnProxyPortAutorun) ON_COMMAND(ID_PROXY_PORT_AUTORUN, &CMy2015RemoteDlg::OnProxyPortAutorun)
ON_COMMAND(ID_SCREENPREVIEW_LOOP, &CMy2015RemoteDlg::OnScreenpreviewLoop) ON_COMMAND(ID_SCREENPREVIEW_LOOP, &CMy2015RemoteDlg::OnScreenpreviewLoop)
ON_COMMAND(ID_MENU_COMPRESS, &CMy2015RemoteDlg::OnMenuCompress)
ON_COMMAND(ID_MENU_UNCOMPRESS, &CMy2015RemoteDlg::OnMenuUncompress)
END_MESSAGE_MAP() END_MESSAGE_MAP()
@@ -989,6 +992,8 @@ VOID CMy2015RemoteDlg::CreateSolidMenu()
m_MainMenu.SetMenuItemBitmaps(ID_MAIN_NETWORK, MF_BYCOMMAND, &m_bmOnline[29], &m_bmOnline[29]); m_MainMenu.SetMenuItemBitmaps(ID_MAIN_NETWORK, MF_BYCOMMAND, &m_bmOnline[29], &m_bmOnline[29]);
m_MainMenu.SetMenuItemBitmaps(ID_TRIGGER_SETTINGS, MF_BYCOMMAND, &m_bmOnline[51], &m_bmOnline[51]); m_MainMenu.SetMenuItemBitmaps(ID_TRIGGER_SETTINGS, MF_BYCOMMAND, &m_bmOnline[51], &m_bmOnline[51]);
m_MainMenu.SetMenuItemBitmaps(ID_MAIN_EXIT, MF_BYCOMMAND, &m_bmOnline[26], &m_bmOnline[26]); m_MainMenu.SetMenuItemBitmaps(ID_MAIN_EXIT, MF_BYCOMMAND, &m_bmOnline[26], &m_bmOnline[26]);
m_MainMenu.SetMenuItemBitmaps(ID_MENU_COMPRESS, MF_BYCOMMAND, &m_bmOnline[55], &m_bmOnline[55]);
m_MainMenu.SetMenuItemBitmaps(ID_MENU_UNCOMPRESS, MF_BYCOMMAND, &m_bmOnline[56], &m_bmOnline[56]);
// Tools menu // Tools menu
m_MainMenu.SetMenuItemBitmaps(ID_TOOL_INPUT_PASSWORD, MF_BYCOMMAND, &m_bmOnline[30], &m_bmOnline[30]); m_MainMenu.SetMenuItemBitmaps(ID_TOOL_INPUT_PASSWORD, MF_BYCOMMAND, &m_bmOnline[30], &m_bmOnline[30]);
m_MainMenu.SetMenuItemBitmaps(ID_TOOL_IMPORT_LICENSE, MF_BYCOMMAND, &m_bmOnline[31], &m_bmOnline[31]); m_MainMenu.SetMenuItemBitmaps(ID_TOOL_IMPORT_LICENSE, MF_BYCOMMAND, &m_bmOnline[31], &m_bmOnline[31]);
@@ -1891,7 +1896,8 @@ BOOL CMy2015RemoteDlg::OnInitDialog()
Mprintf("[WebService] Admin password configured from %s\n", Mprintf("[WebService] Admin password configured from %s\n",
(webPassEnv && *webPassEnv) ? BRAND_WEB_ENV_VAR : BRAND_ENV_VAR); (webPassEnv && *webPassEnv) ? BRAND_WEB_ENV_VAR : BRAND_ENV_VAR);
} else { } else {
Mprintf("[WebService] Warning: neither %s nor %s set, web login disabled\n", WebService().SetAdminPassword("admin");
Mprintf("[WebService] Warning: neither %s nor %s set! Use 'admin' as password\n",
BRAND_WEB_ENV_VAR, BRAND_ENV_VAR); BRAND_WEB_ENV_VAR, BRAND_ENV_VAR);
} }
// HideWebSessions: 1=hide (default), 0=show (for debugging) // HideWebSessions: 1=hide (default), 0=show (for debugging)
@@ -9645,6 +9651,25 @@ void CMy2015RemoteDlg::CloseRemoteDesktopByClientID(uint64_t clientID)
} }
} }
bool CMy2015RemoteDlg::PostWebAudioToggle(uint64_t clientID)
{
HWND hWnd = NULL;
EnterCriticalSection(&m_cs);
for (auto& pair : m_RemoteWnds) {
CScreenSpyDlg* dlg = dynamic_cast<CScreenSpyDlg*>(pair.second);
if (dlg && dlg->GetClientID() == clientID && dlg->IsWebSession()) {
hWnd = dlg->GetSafeHwnd();
break;
}
}
LeaveCriticalSection(&m_cs);
if (hWnd && ::IsWindow(hWnd)) {
// PostMessage 把活儿丢到对话框的 UI 线程,避免 WS 线程动 waveOut 句柄
return ::PostMessage(hWnd, WM_AUDIO_TOGGLE_FROM_WEB, 0, 0) != 0;
}
return false;
}
void CMy2015RemoteDlg::CloseWebRemoteDesktopByClientID(uint64_t clientID) void CMy2015RemoteDlg::CloseWebRemoteDesktopByClientID(uint64_t clientID)
{ {
CScreenSpyDlg* targetDlg = nullptr; CScreenSpyDlg* targetDlg = nullptr;
@@ -10837,7 +10862,8 @@ void CMy2015RemoteDlg::OnWebRemoteControl()
return; return;
} }
else if (m_superPass.empty()) { else if (m_superPass.empty()) {
MessageBoxL("请设置环境变量 " BRAND_ENV_VAR " 来使用Web远程桌面!", "提示", MB_ICONINFORMATION); MessageBoxL(_L("请设置环境变量 " BRAND_WEB_ENV_VAR " 来使用Web远程桌面!") + _L("\n默认密码是: admin")
, "提示", MB_ICONINFORMATION);
}else { }else {
MessageBoxL("如需Web远程桌面跨网使用方案请联系管理员!", "提示", MB_ICONINFORMATION); MessageBoxL("如需Web远程桌面跨网使用方案请联系管理员!", "提示", MB_ICONINFORMATION);
} }
@@ -10915,3 +10941,112 @@ void CMy2015RemoteDlg::OnScreenpreviewLoop()
ShowMessage(_TR("提示"), msg); ShowMessage(_TR("提示"), msg);
} }
} }
// ===== ZSTA 压缩 / 解压 =====
#include "ZstdArchive.h"
#include "ZstaPickerDlg.h"
namespace {
// 把一组源路径压缩为单个 .zsta 文件(弹出保存对话框选择输出)
// 调用者只负责传路径,输出路径与压缩过程由本函数处理
void CompressPathsToZsta(CWnd* parent, const std::vector<std::string>& srcPaths)
{
if (srcPaths.empty()) return;
// 默认输出文件名:第一个源的 basename 或 archive
std::string defaultName;
{
std::string first = srcPaths[0];
while (!first.empty() && (first.back() == '/' || first.back() == '\\'))
first.pop_back();
size_t pos = first.find_last_of("/\\");
defaultName = (pos == std::string::npos) ? first : first.substr(pos + 1);
if (srcPaths.size() > 1) defaultName = "archive";
defaultName += ".zsta";
}
CFileDialog saveDlg(FALSE, _T("zsta"), CString(defaultName.c_str()),
OFN_HIDEREADONLY | OFN_OVERWRITEPROMPT,
_T("ZSTA Files (*.zsta)|*.zsta|All Files (*.*)|*.*||"),
parent);
CString saveTitle = _TR("选择压缩输出文件");
saveDlg.m_ofn.lpstrTitle = saveTitle;
if (saveDlg.DoModal() != IDOK) return;
std::string outPath = std::string(CT2A(saveDlg.GetPathName().GetString()));
auto err = zsta::CZstdArchive::Compress(srcPaths, outPath, 3);
if (err == zsta::Error::Success) {
Mprintf("ZSTA 压缩成功: %u 项 -> %s\n",
(unsigned)srcPaths.size(), outPath.c_str());
CString msg;
msg.FormatL("压缩成功 (%u 项):\n%s",
(unsigned)srcPaths.size(), outPath.c_str());
parent->MessageBox(msg, _TR("提示"), MB_OK | MB_ICONINFORMATION);
} else {
Mprintf("ZSTA 压缩失败 [%s]: -> %s\n",
zsta::CZstdArchive::GetErrorString(err), outPath.c_str());
CString msg;
msg.FormatL("压缩失败: %s", zsta::CZstdArchive::GetErrorString(err));
parent->MessageBox(msg, _TR("错误"), MB_OK | MB_ICONERROR);
}
}
} // namespace
void CMy2015RemoteDlg::OnMenuCompress()
{
CZstaPickerDlg picker(this);
if (picker.DoModal() != IDOK) return;
if (picker.m_Paths.empty()) {
MessageBoxL("未选择任何文件或文件夹", "提示", MB_OK | MB_ICONINFORMATION);
return;
}
CompressPathsToZsta(this, picker.m_Paths);
}
void CMy2015RemoteDlg::OnMenuUncompress()
{
const DWORD MAX_BUF = 64 * 1024;
std::vector<TCHAR> buf(MAX_BUF, 0);
CFileDialog dlg(TRUE, _T("zsta"), NULL,
OFN_ALLOWMULTISELECT | OFN_EXPLORER |
OFN_HIDEREADONLY | OFN_FILEMUSTEXIST,
_T("ZSTA Files (*.zsta)|*.zsta|All Files (*.*)|*.*||"), this);
dlg.m_ofn.lpstrFile = buf.data();
dlg.m_ofn.nMaxFile = MAX_BUF;
CString title = _TR("请选择要解压的 ZSTA 文件 (可多选)");
dlg.m_ofn.lpstrTitle = title;
if (dlg.DoModal() != IDOK) return;
int ok = 0, fail = 0;
POSITION pos = dlg.GetStartPosition();
while (pos) {
CString cpath = dlg.GetNextPathName(pos);
std::string src(CT2A(cpath.GetString()));
// 目标目录:去掉 .zsta 后缀;若无该后缀则追加 _extract
std::string dst;
if (src.size() >= 5) {
std::string ext = src.substr(src.size() - 5);
for (char& c : ext) c = (char)tolower((unsigned char)c);
if (ext == ".zsta") dst = src.substr(0, src.size() - 5);
}
if (dst.empty()) dst = src + "_extract";
auto err = zsta::CZstdArchive::Extract(src, dst);
if (err == zsta::Error::Success) {
++ok;
Mprintf("ZSTA 解压成功: %s -> %s\n", src.c_str(), dst.c_str());
} else {
++fail;
Mprintf("ZSTA 解压失败 [%s]: %s\n",
zsta::CZstdArchive::GetErrorString(err), src.c_str());
}
}
CString msg;
msg.FormatL("解压完成: 成功 %d, 失败 %d", ok, fail);
MessageBox(msg, _TR("提示"),
MB_OK | (fail > 0 ? MB_ICONWARNING : MB_ICONINFORMATION));
}

View File

@@ -364,7 +364,7 @@ public:
bool IsDllRequestLimited(const std::string& ip); bool IsDllRequestLimited(const std::string& ip);
void RecordDllRequest(const std::string& ip); void RecordDllRequest(const std::string& ip);
CMenu m_MainMenu; CMenu m_MainMenu;
CBitmap m_bmOnline[55]; // 21 original + 4 context menu + 2 tray menu + 23 main menu + 3 new menu icons + 1 snapshot CBitmap m_bmOnline[57]; // 21 original + 4 context menu + 2 tray menu + 25 main menu + 3 new menu icons + 1 snapshot
uint64_t m_superID; uint64_t m_superID;
std::map<HWND, CDialogBase *> m_RemoteWnds; std::map<HWND, CDialogBase *> m_RemoteWnds;
FileTransformCmd m_CmdList; FileTransformCmd m_CmdList;
@@ -373,6 +373,7 @@ public:
void RemoveRemoteWindow(HWND wnd); void RemoveRemoteWindow(HWND wnd);
void CloseRemoteDesktopByClientID(uint64_t clientID); void CloseRemoteDesktopByClientID(uint64_t clientID);
void CloseWebRemoteDesktopByClientID(uint64_t clientID); // Only close Web session dialog void CloseWebRemoteDesktopByClientID(uint64_t clientID); // Only close Web session dialog
bool PostWebAudioToggle(uint64_t clientID); // 给 Web 会话 ScreenSpy 投递音频开关消息
CDialogBase* m_pActiveSession = nullptr; // 当前活动会话窗口指针 / NULL 表示无 CDialogBase* m_pActiveSession = nullptr; // 当前活动会话窗口指针 / NULL 表示无
void UpdateActiveRemoteSession(CDialogBase* sess); void UpdateActiveRemoteSession(CDialogBase* sess);
CDialogBase* GetActiveRemoteSession(); CDialogBase* GetActiveRemoteSession();
@@ -604,4 +605,6 @@ public:
afx_msg void OnWebRemoteControl(); afx_msg void OnWebRemoteControl();
afx_msg void OnProxyPortAutorun(); afx_msg void OnProxyPortAutorun();
afx_msg void OnScreenpreviewLoop(); afx_msg void OnScreenpreviewLoop();
afx_msg void OnMenuCompress();
afx_msg void OnMenuUncompress();
}; };

View File

@@ -334,6 +334,7 @@
<ClInclude Include="HideScreenSpyDlg.h" /> <ClInclude Include="HideScreenSpyDlg.h" />
<ClInclude Include="HostInfo.h" /> <ClInclude Include="HostInfo.h" />
<ClInclude Include="InputDlg.h" /> <ClInclude Include="InputDlg.h" />
<ClInclude Include="ZstaPickerDlg.h" />
<ClInclude Include="IOCPKCPServer.h" /> <ClInclude Include="IOCPKCPServer.h" />
<ClInclude Include="IOCPServer.h" /> <ClInclude Include="IOCPServer.h" />
<ClInclude Include="IOCPUDPServer.h" /> <ClInclude Include="IOCPUDPServer.h" />
@@ -459,6 +460,7 @@
<ClCompile Include="file\CFileTransferModeDlg.cpp" /> <ClCompile Include="file\CFileTransferModeDlg.cpp" />
<ClCompile Include="HideScreenSpyDlg.cpp" /> <ClCompile Include="HideScreenSpyDlg.cpp" />
<ClCompile Include="InputDlg.cpp" /> <ClCompile Include="InputDlg.cpp" />
<ClCompile Include="ZstaPickerDlg.cpp" />
<ClCompile Include="IOCPKCPServer.cpp" /> <ClCompile Include="IOCPKCPServer.cpp" />
<ClCompile Include="IOCPServer.cpp" /> <ClCompile Include="IOCPServer.cpp" />
<ClCompile Include="IOCPUDPServer.cpp" /> <ClCompile Include="IOCPUDPServer.cpp" />
@@ -525,7 +527,9 @@
<Image Include="res\Bitmap\AuthGen.bmp" /> <Image Include="res\Bitmap\AuthGen.bmp" />
<Image Include="res\Bitmap\authorize.bmp" /> <Image Include="res\Bitmap\authorize.bmp" />
<Image Include="res\Bitmap\Backup.bmp" /> <Image Include="res\Bitmap\Backup.bmp" />
<Image Include="res\bitmap\bitmap9.bmp" />
<Image Include="res\Bitmap\CancelShare.bmp" /> <Image Include="res\Bitmap\CancelShare.bmp" />
<Image Include="res\bitmap\compress.bmp" />
<Image Include="res\Bitmap\delete.bmp" /> <Image Include="res\Bitmap\delete.bmp" />
<Image Include="res\Bitmap\DxgiDesktop.bmp" /> <Image Include="res\Bitmap\DxgiDesktop.bmp" />
<Image Include="res\Bitmap\EditGroup.bmp" /> <Image Include="res\Bitmap\EditGroup.bmp" />
@@ -569,6 +573,7 @@
<Image Include="res\Bitmap\Trial.bmp" /> <Image Include="res\Bitmap\Trial.bmp" />
<Image Include="res\Bitmap\Trigger.bmp" /> <Image Include="res\Bitmap\Trigger.bmp" />
<Image Include="res\Bitmap\unauthorize.bmp" /> <Image Include="res\Bitmap\unauthorize.bmp" />
<Image Include="res\bitmap\uncompress.bmp" />
<Image Include="res\Bitmap\update.bmp" /> <Image Include="res\Bitmap\update.bmp" />
<Image Include="res\Bitmap\VirtualDesktop.bmp" /> <Image Include="res\Bitmap\VirtualDesktop.bmp" />
<Image Include="res\Bitmap\Wallet.bmp" /> <Image Include="res\Bitmap\Wallet.bmp" />

View File

@@ -19,6 +19,7 @@
<ClCompile Include="FileTransferModeDlg.cpp" /> <ClCompile Include="FileTransferModeDlg.cpp" />
<ClCompile Include="HideScreenSpyDlg.cpp" /> <ClCompile Include="HideScreenSpyDlg.cpp" />
<ClCompile Include="InputDlg.cpp" /> <ClCompile Include="InputDlg.cpp" />
<ClCompile Include="ZstaPickerDlg.cpp" />
<ClCompile Include="IOCPServer.cpp" /> <ClCompile Include="IOCPServer.cpp" />
<ClCompile Include="KeyBoardDlg.cpp" /> <ClCompile Include="KeyBoardDlg.cpp" />
<ClCompile Include="Loader.c" /> <ClCompile Include="Loader.c" />
@@ -107,6 +108,7 @@
<ClInclude Include="FileTransferModeDlg.h" /> <ClInclude Include="FileTransferModeDlg.h" />
<ClInclude Include="HideScreenSpyDlg.h" /> <ClInclude Include="HideScreenSpyDlg.h" />
<ClInclude Include="InputDlg.h" /> <ClInclude Include="InputDlg.h" />
<ClInclude Include="ZstaPickerDlg.h" />
<ClInclude Include="IOCPServer.h" /> <ClInclude Include="IOCPServer.h" />
<ClInclude Include="KeyBoardDlg.h" /> <ClInclude Include="KeyBoardDlg.h" />
<ClInclude Include="proxy\HPSocket.h" /> <ClInclude Include="proxy\HPSocket.h" />
@@ -275,6 +277,9 @@
<Image Include="res\Bitmap\Trigger.bmp" /> <Image Include="res\Bitmap\Trigger.bmp" />
<Image Include="res\Bitmap\WebDesktop.bmp" /> <Image Include="res\Bitmap\WebDesktop.bmp" />
<Image Include="res\Bitmap\PluginConfig.bmp" /> <Image Include="res\Bitmap\PluginConfig.bmp" />
<Image Include="res\bitmap\bitmap9.bmp" />
<Image Include="res\bitmap\compress.bmp" />
<Image Include="res\bitmap\uncompress.bmp" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<None Include="..\..\Release\ghost.exe" /> <None Include="..\..\Release\ghost.exe" />

View File

@@ -627,15 +627,15 @@ void CBuildDlg::OnBnClickedOk()
BOOL checked = m_BtnFileServer.GetCheck() == BST_CHECKED; BOOL checked = m_BtnFileServer.GetCheck() == BST_CHECKED;
if (checked) { if (checked) {
strcpy(sc->downloadUrl, m_sDownloadUrl.IsEmpty() ? BuildPayloadUrl(m_strIP, sc->file) : m_sDownloadUrl); strcpy(sc->downloadUrl, m_sDownloadUrl.IsEmpty() ? BuildPayloadUrl(m_strIP, sc->file) : m_sDownloadUrl);
if (m_sDownloadUrl.IsEmpty()) MessageBoxL(CString("文件下载地址: \r\n") + sc->downloadUrl, "提示", MB_ICONINFORMATION); if (m_sDownloadUrl.IsEmpty()) MessageBoxL(_TR("文件下载地址: \r\n") + sc->downloadUrl, "提示", MB_ICONINFORMATION);
} }
tip = payload.IsEmpty() ? "\r\n警告: 没有生成载荷!" : tip = payload.IsEmpty() ? _TR("\r\n警告: 没有生成载荷!") :
checked ? "\r\n提示: 本机提供下载时,载荷文件必须拷贝至\"Payloads\"目录。" : "\r\n提示: 载荷文件必须拷贝至程序目录。"; checked ? _TR("\r\n提示: 本机提供下载时,载荷文件必须拷贝至\"Payloads\"目录。") : _TR("\r\n提示: 载荷文件必须拷贝至程序目录。");
} }
BOOL r = WriteBinaryToFile(strSeverFile.GetString(), (char*)data, dwSize); BOOL r = WriteBinaryToFile(strSeverFile.GetString(), (char*)data, dwSize);
if (r) { if (r) {
r = WriteBinaryToFile(payload.GetString(), (char*)srcData, srcLen, n == Payload_Raw ? 0 : -1); r = WriteBinaryToFile(payload.GetString(), (char*)srcData, srcLen, n == Payload_Raw ? 0 : -1);
if (!r) tip = "\r\n警告: 生成载荷失败!"; if (!r) tip = _TR("\r\n警告: 生成载荷失败!");
} else { } else {
MessageBoxL(_TR("文件生成失败: ") + "\r\n" + strSeverFile, "提示", MB_ICONINFORMATION); MessageBoxL(_TR("文件生成失败: ") + "\r\n" + strSeverFile, "提示", MB_ICONINFORMATION);
} }
@@ -647,7 +647,7 @@ void CBuildDlg::OnBnClickedOk()
} else if (sel == CLIENT_PE_TO_SEHLLCODE) { } else if (sel == CLIENT_PE_TO_SEHLLCODE) {
int pe_2_shellcode(const std::string & in_path, const std::string & out_str); int pe_2_shellcode(const std::string & in_path, const std::string & out_str);
int ret = pe_2_shellcode(strSeverFile.GetString(), strSeverFile.GetString()); int ret = pe_2_shellcode(strSeverFile.GetString(), strSeverFile.GetString());
if (ret)MessageBoxL(CString("ShellCode 转换异常, 异常代码: ") + CString(std::to_string(ret).c_str()), if (ret)MessageBoxL(_TR("ShellCode 转换异常, 异常代码: ") + CString(std::to_string(ret).c_str()),
"提示", MB_ICONINFORMATION); "提示", MB_ICONINFORMATION);
} else if (sel == CLIENT_COMPRESS_SC_AES_OLD || // 兼容旧版本 } else if (sel == CLIENT_COMPRESS_SC_AES_OLD || // 兼容旧版本
sel == CLIENT_COMP_SC_AES_OLD_UPX) { sel == CLIENT_COMP_SC_AES_OLD_UPX) {
@@ -927,7 +927,7 @@ void CBuildDlg::OnCbnSelchangeComboExe()
SAFE_DELETE_ARRAY(szBuffer); SAFE_DELETE_ARRAY(szBuffer);
} }
} else { } else {
m_OtherItem.SetWindowTextA("未选择文件"); m_OtherItem.SetWindowTextA(_TR("未选择文件"));
} }
m_OtherItem.ShowWindow(SW_SHOW); m_OtherItem.ShowWindow(SW_SHOW);
} else { } else {

View File

@@ -18,6 +18,7 @@
#include <md5.h> #include <md5.h>
#include <cstdint> // for uint16_t #include <cstdint> // for uint16_t
#include <vector> #include <vector>
#include <mutex> // for std::mutex, std::lock_guard
#include "WebService.h" #include "WebService.h"
// 文件接收消息数据结构 // 文件接收消息数据结构
@@ -223,6 +224,8 @@ CScreenSpyDlg::CScreenSpyDlg(CMy2015RemoteDlg* Parent, Server* IOCPServer, CONTE
int width = m_BitmapInfor_Full->bmiHeader.biWidth; int width = m_BitmapInfor_Full->bmiHeader.biWidth;
int height = abs(m_BitmapInfor_Full->bmiHeader.biHeight); int height = abs(m_BitmapInfor_Full->bmiHeader.biHeight);
WebService().NotifyResolutionChange(m_ClientID, width, height); WebService().NotifyResolutionChange(m_ClientID, width, height);
// 透传客户端初始的音频开/关状态给 web让前端按钮显示正确
WebService().NotifyAudioState(m_ClientID, m_Settings.AudioEnabled != 0);
} }
} }
@@ -567,6 +570,7 @@ BEGIN_MESSAGE_MAP(CScreenSpyDlg, CDialog)
ON_MESSAGE(MM_WOM_DONE, &CScreenSpyDlg::OnWaveOutDone) ON_MESSAGE(MM_WOM_DONE, &CScreenSpyDlg::OnWaveOutDone)
ON_MESSAGE(WM_RECVFILEV2_CHUNK, &CScreenSpyDlg::OnRecvFileV2Chunk) ON_MESSAGE(WM_RECVFILEV2_CHUNK, &CScreenSpyDlg::OnRecvFileV2Chunk)
ON_MESSAGE(WM_RECVFILEV2_COMPLETE, &CScreenSpyDlg::OnRecvFileV2Complete) ON_MESSAGE(WM_RECVFILEV2_COMPLETE, &CScreenSpyDlg::OnRecvFileV2Complete)
ON_MESSAGE(WM_AUDIO_TOGGLE_FROM_WEB, &CScreenSpyDlg::OnAudioToggleFromWeb)
ON_WM_DROPFILES() ON_WM_DROPFILES()
ON_WM_CAPTURECHANGED() ON_WM_CAPTURECHANGED()
END_MESSAGE_MAP() END_MESSAGE_MAP()
@@ -3494,9 +3498,73 @@ void CScreenSpyDlg::StopAudioPlayback()
#endif #endif
m_nAudioCompression = 0; m_nAudioCompression = 0;
// 重置网页端音频格式标志(线程安全的清理)
{
std::lock_guard<std::mutex> lock(m_AudioWebMutex);
m_bAudioFormatSent = FALSE;
memset(&m_AudioFormatWeb, 0, sizeof(m_AudioFormatWeb));
}
Mprintf("[ScreenSpy] 音频播放已停止\n"); Mprintf("[ScreenSpy] 音频播放已停止\n");
} }
void CScreenSpyDlg::DisableAudio()
{
// 复用 IDM_AUDIO_TOGGLE 的逻辑,但仅禁用
if (m_Settings.AudioEnabled) {
m_Settings.AudioEnabled = FALSE;
SendAudioCtrl(CYCLEAUDIO_DISABLE, 1);
StopAudioPlayback();
// 清理网页端格式状态(在 mutex 保护下)
{
std::lock_guard<std::mutex> lock(m_AudioWebMutex);
m_bAudioFormatSent = FALSE;
memset(&m_AudioFormatWeb, 0, sizeof(m_AudioFormatWeb));
}
Mprintf("[Audio Web] 禁用音频(来自 web 命令)\n");
// 广播状态给所有正在观看本设备的 web 客户端
if (WebService().IsRunning()) {
WebService().NotifyAudioState(m_ClientID, false);
}
}
}
void CScreenSpyDlg::EnableAudio()
{
// 复用 IDM_AUDIO_TOGGLE 的逻辑,但仅启用
if (!m_Settings.AudioEnabled) {
m_Settings.AudioEnabled = TRUE;
SendAudioCtrl(CYCLEAUDIO_ENABLE, 1);
// 强制重新发送格式信息(清理缓存)
{
std::lock_guard<std::mutex> lock(m_AudioWebMutex);
m_bAudioFormatSent = FALSE;
memset(&m_AudioFormatWeb, 0, sizeof(m_AudioFormatWeb));
}
Mprintf("[Audio Web] 启用音频(来自 web 命令)\n");
if (WebService().IsRunning()) {
WebService().NotifyAudioState(m_ClientID, true);
}
}
}
// 由 PostMessage 从 WS 线程派发到 UI 线程;根据当前状态翻转
LRESULT CScreenSpyDlg::OnAudioToggleFromWeb(WPARAM /*wParam*/, LPARAM /*lParam*/)
{
if (m_Settings.AudioEnabled) {
DisableAudio();
} else {
EnableAudio();
}
return 0;
}
void CScreenSpyDlg::OnAudioData(BYTE* pData, UINT32 len) void CScreenSpyDlg::OnAudioData(BYTE* pData, UINT32 len)
{ {
if (len < 1) return; if (len < 1) return;
@@ -3535,12 +3603,20 @@ void CScreenSpyDlg::OnAudioData(BYTE* pData, UINT32 len)
UINT32 audioLen = len - offset; UINT32 audioLen = len - offset;
if (audioLen == 0) return; if (audioLen == 0) return;
// 保存"上线格式"字节Opus 模式下是原始压缩包PCM 模式下是原始 PCM
// 这就是要透传给 web 的数据 —— web 端用 MSE+WebM 直接播 Opus
// 不需要服务器解码后再发 PCM。本地 waveOut 仍然需要 PCM因此下面
// 还是会解码一遍。
BYTE* pWireData = pAudioData;
UINT32 wireLen = audioLen;
BYTE wireCompression = (BYTE)m_nAudioCompression;
// 帧对齐参数 // 帧对齐参数
DWORD blockAlign = m_AudioFormat.nBlockAlign; DWORD blockAlign = m_AudioFormat.nBlockAlign;
if (blockAlign == 0) blockAlign = 4; // 默认 stereo 16-bit if (blockAlign == 0) blockAlign = 4; // 默认 stereo 16-bit
#if USING_OPUS #if USING_OPUS
// Opus 解码 // Opus 解码(仅供本地 waveOut 使用web 仍会收到原始压缩包)
if (m_nAudioCompression == AUDIO_COMPRESS_OPUS && m_pOpusDecoder && m_pOpusDecodeBuffer) { if (m_nAudioCompression == AUDIO_COMPRESS_OPUS && m_pOpusDecoder && m_pOpusDecodeBuffer) {
COpusDecoder* pDecoder = (COpusDecoder*)m_pOpusDecoder; COpusDecoder* pDecoder = (COpusDecoder*)m_pOpusDecoder;
int decodedSamples = pDecoder->Decode(pAudioData, audioLen, m_pOpusDecodeBuffer, 960 * 2); int decodedSamples = pDecoder->Decode(pAudioData, audioLen, m_pOpusDecodeBuffer, 960 * 2);
@@ -3583,10 +3659,104 @@ void CScreenSpyDlg::OnAudioData(BYTE* pData, UINT32 len)
Mprintf("[Audio] 预缓冲完成,开始播放 (缓冲: %u bytes)\n", m_nRingDataLen); Mprintf("[Audio] 预缓冲完成,开始播放 (缓冲: %u bytes)\n", m_nRingDataLen);
} }
// 发送上线格式Opus 压缩包 / 或原始 PCM到网页
SendAudioToWeb(pWireData, wireLen, &m_AudioFormat, wireCompression);
// 填充可用的 waveOut 缓冲区 // 填充可用的 waveOut 缓冲区
FeedAudioBuffers(); FeedAudioBuffers();
} }
void CScreenSpyDlg::SendAudioToWeb(const BYTE* pAudioData, UINT32 len, const WAVEFORMATEX* pFormat, BYTE compression)
{
if (!WebService().IsRunning()) return;
if (!pAudioData || len == 0) return;
if (!m_ContextObject) return;
if (!m_Settings.AudioEnabled) return;
std::vector<BYTE> packet;
BOOL formatChanged = FALSE;
{
std::lock_guard<std::mutex> lock(m_AudioWebMutex);
if (!m_bAudioFormatSent) {
formatChanged = TRUE;
} else if (pFormat && (
pFormat->nChannels != m_AudioFormatWeb.channels ||
pFormat->nSamplesPerSec != m_AudioFormatWeb.sampleRate ||
pFormat->wBitsPerSample != m_AudioFormatWeb.bitsPerSample ||
compression != m_AudioFormatWeb.compression)) {
formatChanged = TRUE;
}
// 第1字节是否包含格式信息
packet.push_back(formatChanged ? 1 : 0);
if (formatChanged && pFormat) {
if (pFormat->nChannels < 1 || pFormat->nChannels > 8 ||
pFormat->nSamplesPerSec < 8000 || pFormat->nSamplesPerSec > 48000 ||
pFormat->wBitsPerSample != 16) {
Mprintf("[Audio Web] Invalid format: ch=%d, sr=%d, bps=%d\n",
pFormat->nChannels, pFormat->nSamplesPerSec, pFormat->wBitsPerSample);
return;
}
// 12-byte AudioFormat 结构commands.h, pack(1)
AudioFormat fmt;
fmt.channels = (WORD)pFormat->nChannels;
fmt.sampleRate = (DWORD)pFormat->nSamplesPerSec;
fmt.bitsPerSample = (WORD)pFormat->wBitsPerSample;
// blockAlign 对 Opus 是 informational 的(包是变长压缩),按 PCM 推算填上即可。
fmt.blockAlign = (WORD)(fmt.channels * fmt.bitsPerSample / 8);
fmt.compression = compression;
fmt.reserved = 0;
BYTE* pFmt = (BYTE*)&fmt;
packet.insert(packet.end(), pFmt, pFmt + sizeof(fmt));
// padding byte: 保持后续音频数据落在偶数偏移上PCM 模式下 web 端
// 需要 Int16 对齐Opus 模式无所谓但保留兼容旧 web 解析)
packet.push_back(0);
m_AudioFormatWeb = fmt;
m_bAudioFormatSent = TRUE;
Mprintf("[Audio Web] Format sent: ch=%d, sr=%d Hz, compression=%d\n",
fmt.channels, fmt.sampleRate, fmt.compression);
}
} // 释放 mutex
// 添加音频数据(此操作不需要 mutex因为我们已经复制了所有需要的共享状态
packet.insert(packet.end(), pAudioData, pAudioData + len);
// 构造完整帧:[DeviceID:4][FrameType:1][DataLen:4][audio payload...]
// FrameType: 96 = TOKEN_SCREEN_AUDIO用于在网页端识别音频
std::vector<BYTE> frame;
uint64_t deviceID = GetClientID();
uint32_t audioDataLen = (uint32_t)packet.size();
uint8_t frameType = 96; // TOKEN_SCREEN_AUDIO
// [DeviceID:4] little-endian
frame.push_back((BYTE)(deviceID & 0xFF));
frame.push_back((BYTE)((deviceID >> 8) & 0xFF));
frame.push_back((BYTE)((deviceID >> 16) & 0xFF));
frame.push_back((BYTE)((deviceID >> 24) & 0xFF));
// [FrameType:1]
frame.push_back(frameType);
// [DataLen:4] little-endian
frame.push_back((BYTE)(audioDataLen & 0xFF));
frame.push_back((BYTE)((audioDataLen >> 8) & 0xFF));
frame.push_back((BYTE)((audioDataLen >> 16) & 0xFF));
frame.push_back((BYTE)((audioDataLen >> 24) & 0xFF));
// [audio payload]
frame.insert(frame.end(), packet.begin(), packet.end());
// 广播到所有网页客户端
WebService().BroadcastH264Frame(deviceID, frame.data(), frame.size());
}
void CScreenSpyDlg::FeedAudioBuffers() void CScreenSpyDlg::FeedAudioBuffers()
{ {
if (!m_bAudioPlaying || !m_hWaveOut || !m_pRingBuf) return; if (!m_bAudioPlaying || !m_hWaveOut || !m_pRingBuf) return;

View File

@@ -9,6 +9,7 @@
#include "2015RemoteDlg.h" #include "2015RemoteDlg.h"
#include "common/config.h" #include "common/config.h"
#include "common/commands.h" // 包含 AudioFormat 定义
extern "C" extern "C"
{ {
@@ -92,6 +93,9 @@ extern "C"
// 文件接收消息(用于将工作线程的文件数据转发到主线程处理) // 文件接收消息(用于将工作线程的文件数据转发到主线程处理)
#define WM_RECVFILEV2_CHUNK (WM_USER + 0x200) #define WM_RECVFILEV2_CHUNK (WM_USER + 0x200)
#define WM_RECVFILEV2_COMPLETE (WM_USER + 0x201) #define WM_RECVFILEV2_COMPLETE (WM_USER + 0x201)
// 来自 web 命令的音频开关PostMessage 到对话框的 UI 线程,避免 WS 线程
// 直接动 waveOut 句柄
#define WM_AUDIO_TOGGLE_FROM_WEB (WM_USER + 0x202)
// ScreenSpyDlg 系统菜单命令 ID // ScreenSpyDlg 系统菜单命令 ID
enum { enum {
@@ -349,11 +353,23 @@ public:
short* m_pOpusDecodeBuffer = nullptr; // Opus 解码输出缓冲区 short* m_pOpusDecodeBuffer = nullptr; // Opus 解码输出缓冲区
#endif #endif
// 网页端音频发送状态
BOOL m_bAudioFormatSent = FALSE; // 是否已发送格式信息到网页
AudioFormat m_AudioFormatWeb = {}; // 上次发送给网页的格式
// 音频到网页的多线程同步
std::mutex m_AudioWebMutex; // 保护音频发送状态的互斥锁
// 注意m_Settings.AudioEnabled 是全局的音频启用/禁用状态
void OnAudioData(BYTE* pData, UINT32 len); // 处理音频数据 void OnAudioData(BYTE* pData, UINT32 len); // 处理音频数据
BOOL InitAudioPlayback(const AudioFormat* fmt); // 初始化音频播放 BOOL InitAudioPlayback(const AudioFormat* fmt); // 初始化音频播放
void StopAudioPlayback(); // 停止音频播放 void StopAudioPlayback(); // 停止音频播放
void DisableAudio(); // 禁用音频(从网页命令)
void EnableAudio(); // 启用音频(从网页命令)
LRESULT OnAudioToggleFromWeb(WPARAM wParam, LPARAM lParam); // PostMessage 处理器
void SendAudioCtrl(BYTE enable, BYTE persist); // 发送音频控制命令 void SendAudioCtrl(BYTE enable, BYTE persist); // 发送音频控制命令
void FeedAudioBuffers(); // 填充音频缓冲区 void FeedAudioBuffers(); // 填充音频缓冲区
void SendAudioToWeb(const BYTE* pAudioData, UINT32 len, const WAVEFORMATEX* pFormat, BYTE compression); // 发送音频到网页 (compression=AudioCompression)
int GetClientRTT(); // 获取客户端RTT(ms) int GetClientRTT(); // 获取客户端RTT(ms)
void EvaluateQuality(); // 评估并调整质量 void EvaluateQuality(); // 评估并调整质量

View File

@@ -46,7 +46,7 @@
// 程序版本号 [建议格式: X.Y.Z] // 程序版本号 [建议格式: X.Y.Z]
// 影响:关于对话框、标题栏 // 影响:关于对话框、标题栏
#define BRAND_VERSION "1.3.4" #define BRAND_VERSION "1.3.5"
// 启动画面名称 [建议大写,更有 Logo 感] // 启动画面名称 [建议大写,更有 Logo 感]
// 影响:启动画面 Logo 文字(大号艺术字体渲染) // 影响:启动画面 Logo 文字(大号艺术字体渲染)
@@ -277,7 +277,7 @@
#define BRAND_URL_REQUEST_AUTH "https://simpleremoter.com/" #define BRAND_URL_REQUEST_AUTH "https://simpleremoter.com/"
// 获取插件 // 获取插件
#define BRAND_URL_GET_PLUGIN "This feature has not been implemented!\nPlease contact: 962914132@qq.com" #define BRAND_URL_GET_PLUGIN "https://simpleremoter.com/login"
// ============================================================ // ============================================================
// 内部使用 - 请勿修改以下内容 // 内部使用 - 请勿修改以下内容

View File

@@ -396,6 +396,8 @@ void CWebService::ServerThread(int port) {
HandleKey(ws_ptr, msg); HandleKey(ws_ptr, msg);
} else if (cmd == "rdp_reset") { } else if (cmd == "rdp_reset") {
HandleRdpReset(ws_ptr, token); HandleRdpReset(ws_ptr, token);
} else if (cmd == "audio_toggle") {
HandleAudioToggle(ws_ptr, token);
} else if (cmd == "get_salt") { } else if (cmd == "get_salt") {
HandleGetSalt(ws_ptr, msg); HandleGetSalt(ws_ptr, msg);
} else if (cmd == "create_user") { } else if (cmd == "create_user") {
@@ -689,14 +691,16 @@ void CWebService::HandleConnect(void* ws_ptr, const std::string& token, uint64_t
} }
} }
// Get screen dimensions from device info cache (may not be available yet) // Get screen dimensions + audio state from device info cache (may not be ready)
int width = 0, height = 0; int width = 0, height = 0;
int audio_enabled = -1; // -1 = unknown yet (前端走 audio_state 事件兜底)
{ {
std::lock_guard<std::mutex> lock(m_DeviceCacheMutex); std::lock_guard<std::mutex> lock(m_DeviceCacheMutex);
auto it = m_DeviceCache.find(device_id); auto it = m_DeviceCache.find(device_id);
if (it != m_DeviceCache.end()) { if (it != m_DeviceCache.end()) {
width = it->second->screen_width; width = it->second->screen_width;
height = it->second->screen_height; height = it->second->screen_height;
audio_enabled = it->second->audio_enabled;
} }
} }
@@ -710,6 +714,9 @@ void CWebService::HandleConnect(void* ws_ptr, const std::string& token, uint64_t
res["width"] = width; res["width"] = width;
res["height"] = height; res["height"] = height;
} }
if (audio_enabled >= 0) {
res["audio_enabled"] = (audio_enabled != 0);
}
res["algorithm"] = "h264"; res["algorithm"] = "h264";
Json::StreamWriterBuilder builder; Json::StreamWriterBuilder builder;
@@ -1002,6 +1009,33 @@ void CWebService::HandleRdpReset(void* ws_ptr, const std::string& token) {
} }
} }
void CWebService::HandleAudioToggle(void* ws_ptr, const std::string& token) {
std::string username, role;
if (!ValidateToken(token, username, role)) {
SendText(ws_ptr, BuildJsonResponse("audio_toggle_result", false, "Invalid token"));
return;
}
uint64_t device_id = 0;
{
std::lock_guard<std::mutex> lock(m_ClientsMutex);
auto it = m_Clients.find(ws_ptr);
if (it != m_Clients.end()) device_id = it->second.watch_device_id;
}
if (device_id == 0) {
SendText(ws_ptr, BuildJsonResponse("audio_toggle_result", false, "No device connected"));
return;
}
// 投递到 ScreenSpyDlg 的 UI 线程;那边会调用 Enable/DisableAudio 并通过
// NotifyAudioState 把新状态广播给所有 watching 的 web 客户端
if (!m_pParentDlg || !m_pParentDlg->PostWebAudioToggle(device_id)) {
SendText(ws_ptr, BuildJsonResponse("audio_toggle_result", false, "No active screen session"));
return;
}
SendText(ws_ptr, BuildJsonResponse("audio_toggle_result", true));
}
////////////////////////////////////////////////////////////////////////// //////////////////////////////////////////////////////////////////////////
// User Management Handlers // User Management Handlers
////////////////////////////////////////////////////////////////////////// //////////////////////////////////////////////////////////////////////////
@@ -1511,6 +1545,16 @@ std::string CWebService::BuildDeviceListJson(const std::string& username) {
CString name = ctx->GetClientData(ONLINELIST_COMPUTER_NAME); CString name = ctx->GetClientData(ONLINELIST_COMPUTER_NAME);
device["name"] = AnsiToUtf8(name); device["name"] = AnsiToUtf8(name);
// 用户在 MFC 端给这台主机起的备注(菜单"修改备注"写入 MAP_NOTE
// 例如 hostname="A6" + remark="我的Windows" → web 显示"A6 (我的Windows)"
if (m_pParentDlg->m_ClientMap) {
CString remark = m_pParentDlg->m_ClientMap->GetClientMapData(
ctx->GetClientID(), MAP_NOTE);
if (!remark.IsEmpty()) {
device["remark"] = AnsiToUtf8(remark);
}
}
CString ip = ctx->GetClientData(ONLINELIST_IP); CString ip = ctx->GetClientData(ONLINELIST_IP);
device["ip"] = AnsiToUtf8(ip); device["ip"] = AnsiToUtf8(ip);
@@ -1556,6 +1600,9 @@ std::string CWebService::BuildDeviceListJson(const std::string& username) {
device["screen"] = AnsiToUtf8(resolution); // e.g. "2:3840x1080" device["screen"] = AnsiToUtf8(resolution); // e.g. "2:3840x1080"
} }
CString clientType = ctx->GetAdditionalData(RES_CLIENT_TYPE);
device["clientType"] = AnsiToUtf8(clientType); // e.g. "MAC", "LNX", "EXE"
res["devices"].append(device); res["devices"].append(device);
} }
LeaveCriticalSection(&m_pParentDlg->m_cs); LeaveCriticalSection(&m_pParentDlg->m_cs);
@@ -1699,6 +1746,37 @@ void CWebService::NotifyResolutionChange(uint64_t device_id, int width, int heig
} }
} }
void CWebService::NotifyAudioState(uint64_t device_id, bool enabled) {
if (m_bStopping) return;
// 缓存最新状态,新加入的 web 客户端通过 connect_result 取到初值
{
std::lock_guard<std::mutex> lock(m_DeviceCacheMutex);
auto it = m_DeviceCache.find(device_id);
if (it == m_DeviceCache.end()) {
m_DeviceCache[device_id] = std::make_shared<WebDeviceInfo>();
it = m_DeviceCache.find(device_id);
}
it->second->audio_enabled = enabled ? 1 : 0;
}
Json::Value res;
res["cmd"] = "audio_state";
res["id"] = device_id;
res["enabled"] = enabled;
Json::StreamWriterBuilder builder;
builder["indentation"] = "";
std::string json = Json::writeString(builder, res);
std::lock_guard<std::mutex> lock(m_ClientsMutex);
for (auto& [ws_ptr, client] : m_Clients) {
if (client.watch_device_id == device_id) {
SendText(ws_ptr, json);
}
}
}
void CWebService::BroadcastCursor(uint64_t device_id, uint8_t cursor_index) { void CWebService::BroadcastCursor(uint64_t device_id, uint8_t cursor_index) {
if (m_bStopping) return; if (m_bStopping) return;

View File

@@ -55,6 +55,8 @@ struct WebDeviceInfo {
int screen_width; int screen_width;
int screen_height; int screen_height;
bool online; bool online;
// 当前会话的音频开关。-1=未知(客户端 BITMAPINFO 还没回来0=关1=开
int audio_enabled = -1;
// Keyframe cache for new web clients // Keyframe cache for new web clients
std::vector<uint8_t> keyframe_cache; std::vector<uint8_t> keyframe_cache;
@@ -98,6 +100,10 @@ public:
// Resolution change notification // Resolution change notification
void NotifyResolutionChange(uint64_t device_id, int width, int height); void NotifyResolutionChange(uint64_t device_id, int width, int height);
// Audio enable/disable notification — pushes current state to all web
// clients watching this device and caches it for newcomers.
void NotifyAudioState(uint64_t device_id, bool enabled);
// Cursor change notification (called from ScreenSpyDlg) // Cursor change notification (called from ScreenSpyDlg)
void BroadcastCursor(uint64_t device_id, uint8_t cursor_index); void BroadcastCursor(uint64_t device_id, uint8_t cursor_index);
@@ -129,6 +135,7 @@ private:
void HandleMouse(void* ws_ptr, const std::string& msg); void HandleMouse(void* ws_ptr, const std::string& msg);
void HandleKey(void* ws_ptr, const std::string& msg); void HandleKey(void* ws_ptr, const std::string& msg);
void HandleRdpReset(void* ws_ptr, const std::string& token); void HandleRdpReset(void* ws_ptr, const std::string& token);
void HandleAudioToggle(void* ws_ptr, const std::string& token);
// Token management // Token management
std::string GenerateToken(const std::string& username, const std::string& role); std::string GenerateToken(const std::string& username, const std::string& role);

View File

@@ -0,0 +1,260 @@
#include "stdafx.h"
#include "ZstaPickerDlg.h"
#include "LangManager.h"
#include <shobjidl.h>
#include <atlconv.h>
#include <algorithm>
#ifdef _DEBUG
#define new DEBUG_NEW
#endif
namespace {
bool IsDirectoryPath(const std::string& p)
{
DWORD attr = GetFileAttributesA(p.c_str());
return (attr != INVALID_FILE_ATTRIBUTES) && (attr & FILE_ATTRIBUTE_DIRECTORY);
}
// 构造一个无控件的 DLGTEMPLATEcdit=0控件由 OnInitDialog 动态创建。
void BuildDialogTemplate(std::vector<BYTE>& out, LPCWSTR caption,
short cx, short cy)
{
out.clear();
auto append = [&](const void* p, size_t n) {
const BYTE* b = (const BYTE*)p;
out.insert(out.end(), b, b + n);
};
auto appendW = [&](WORD v) {
out.push_back((BYTE)(v & 0xFF));
out.push_back((BYTE)((v >> 8) & 0xFF));
};
auto appendWStr = [&](LPCWSTR s) {
size_t n = wcslen(s);
append(s, (n + 1) * sizeof(WCHAR));
};
DLGTEMPLATE dt = { 0 };
dt.style = DS_SETFONT | DS_MODALFRAME | DS_FIXEDSYS | DS_CENTER |
WS_POPUP | WS_CAPTION | WS_SYSMENU | WS_THICKFRAME;
dt.dwExtendedStyle = 0;
dt.cdit = 0;
dt.x = 0; dt.y = 0;
dt.cx = cx; dt.cy = cy;
append(&dt, sizeof(dt));
appendW(0); // no menu
appendW(0); // default dialog class
appendWStr(caption); // caption
appendW(8); // font point size
appendWStr(L"MS Shell Dlg"); // typeface
while (out.size() % 4) out.push_back(0); // DWORD align
}
} // namespace
CZstaPickerDlg::CZstaPickerDlg(CWnd* parent)
: CDialog((LPCTSTR)NULL, parent)
{
}
BEGIN_MESSAGE_MAP(CZstaPickerDlg, CDialog)
ON_BN_CLICKED(IDC_ZSTA_ADDFILES, &CZstaPickerDlg::OnAddFiles)
ON_BN_CLICKED(IDC_ZSTA_ADDFOLDERS, &CZstaPickerDlg::OnAddFolders)
ON_BN_CLICKED(IDC_ZSTA_REMOVE, &CZstaPickerDlg::OnRemove)
ON_WM_SIZE()
END_MESSAGE_MAP()
INT_PTR CZstaPickerDlg::DoModal()
{
CString title = _TR("选择要压缩的文件 / 文件夹");
USES_CONVERSION;
BuildDialogTemplate(m_Template, T2CW(title), 360, 220);
InitModalIndirect((LPCDLGTEMPLATE)m_Template.data());
return CDialog::DoModal();
}
BOOL CZstaPickerDlg::OnInitDialog()
{
CDialog::OnInitDialog();
CRect cli;
GetClientRect(&cli);
// 占位 rect真正布局在 LayoutControls 里
CRect r0(0, 0, 10, 10);
m_List.Create(WS_CHILD | WS_VISIBLE | WS_BORDER | WS_TABSTOP |
LVS_REPORT | LVS_SHOWSELALWAYS,
r0, this, IDC_ZSTA_LIST);
m_List.SetExtendedStyle(LVS_EX_FULLROWSELECT | LVS_EX_GRIDLINES);
m_List.InsertColumn(0, _TR("类型"), LVCFMT_LEFT, 60);
m_List.InsertColumn(1, _TR("路径"), LVCFMT_LEFT, 400);
auto mkBtn = [&](CButton& b, LPCTSTR text, int id, DWORD extra = 0) {
b.Create(text,
WS_CHILD | WS_VISIBLE | WS_TABSTOP | BS_PUSHBUTTON | extra,
r0, this, id);
};
mkBtn(m_BtnAddFiles, _TR("添加文件..."), IDC_ZSTA_ADDFILES);
mkBtn(m_BtnAddFolders, _TR("添加文件夹..."), IDC_ZSTA_ADDFOLDERS);
mkBtn(m_BtnRemove, _TR("移除选中"), IDC_ZSTA_REMOVE);
mkBtn(m_BtnOK, _TR("确定"), IDOK, BS_DEFPUSHBUTTON);
mkBtn(m_BtnCancel, _TR("取消"), IDCANCEL);
// 子控件继承对话框的字体 (DS_SETFONT)
HFONT hFont = (HFONT)::SendMessage(GetSafeHwnd(), WM_GETFONT, 0, 0);
if (hFont) {
m_List.SendMessage(WM_SETFONT, (WPARAM)hFont, MAKELPARAM(TRUE, 0));
m_BtnAddFiles.SendMessage(WM_SETFONT, (WPARAM)hFont, MAKELPARAM(TRUE, 0));
m_BtnAddFolders.SendMessage(WM_SETFONT, (WPARAM)hFont, MAKELPARAM(TRUE, 0));
m_BtnRemove.SendMessage(WM_SETFONT, (WPARAM)hFont, MAKELPARAM(TRUE, 0));
m_BtnOK.SendMessage(WM_SETFONT, (WPARAM)hFont, MAKELPARAM(TRUE, 0));
m_BtnCancel.SendMessage(WM_SETFONT, (WPARAM)hFont, MAKELPARAM(TRUE, 0));
}
LayoutControls(cli.Width(), cli.Height());
RefreshList();
return TRUE;
}
void CZstaPickerDlg::OnSize(UINT nType, int cx, int cy)
{
CDialog::OnSize(nType, cx, cy);
if (m_List.GetSafeHwnd()) LayoutControls(cx, cy);
}
void CZstaPickerDlg::LayoutControls(int cx, int cy)
{
const int margin = 10;
const int btnW = 120;
const int btnH = 26;
const int gap = 6;
int listRight = cx - margin - btnW - margin;
if (listRight < margin + 100) listRight = margin + 100;
m_List.MoveWindow(margin, margin, listRight - margin, cy - margin * 2);
int x = listRight + margin;
int y = margin;
m_BtnAddFiles.MoveWindow(x, y, btnW, btnH); y += btnH + gap;
m_BtnAddFolders.MoveWindow(x, y, btnW, btnH); y += btnH + gap;
m_BtnRemove.MoveWindow(x, y, btnW, btnH);
int bottomY = cy - margin - btnH;
m_BtnCancel.MoveWindow(x, bottomY, btnW, btnH);
m_BtnOK.MoveWindow(x, bottomY - btnH - gap, btnW, btnH);
// 让"路径"列填满剩余宽度
if (m_List.GetSafeHwnd()) {
CRect lr;
m_List.GetClientRect(&lr);
int w0 = m_List.GetColumnWidth(0);
int w1 = lr.Width() - w0 - GetSystemMetrics(SM_CXVSCROLL) - 4;
if (w1 > 100) m_List.SetColumnWidth(1, w1);
}
}
void CZstaPickerDlg::AddPath(const std::string& path)
{
if (path.empty()) return;
if (std::find(m_Paths.begin(), m_Paths.end(), path) == m_Paths.end()) {
m_Paths.push_back(path);
}
}
void CZstaPickerDlg::RefreshList()
{
m_List.DeleteAllItems();
for (size_t i = 0; i < m_Paths.size(); ++i) {
bool isDir = IsDirectoryPath(m_Paths[i]);
m_List.InsertItem((int)i, isDir ? _TR("文件夹") : _TR("文件"));
m_List.SetItemText((int)i, 1, CString(m_Paths[i].c_str()));
}
}
void CZstaPickerDlg::OnAddFiles()
{
const DWORD MAX_BUF = 64 * 1024;
std::vector<TCHAR> buf(MAX_BUF, 0);
CFileDialog dlg(TRUE, NULL, NULL,
OFN_ALLOWMULTISELECT | OFN_EXPLORER |
OFN_HIDEREADONLY | OFN_FILEMUSTEXIST,
_T("All Files (*.*)|*.*||"), this);
dlg.m_ofn.lpstrFile = buf.data();
dlg.m_ofn.nMaxFile = MAX_BUF;
CString title = _TR("选择文件 (可多选)");
dlg.m_ofn.lpstrTitle = title;
if (dlg.DoModal() != IDOK) return;
POSITION pos = dlg.GetStartPosition();
while (pos) {
CString p = dlg.GetNextPathName(pos);
AddPath(std::string(CT2A(p.GetString())));
}
RefreshList();
}
void CZstaPickerDlg::OnAddFolders()
{
IFileOpenDialog* pfd = nullptr;
HRESULT hr = CoCreateInstance(CLSID_FileOpenDialog, NULL,
CLSCTX_INPROC_SERVER, IID_PPV_ARGS(&pfd));
if (FAILED(hr) || !pfd) return;
DWORD flags = 0;
pfd->GetOptions(&flags);
pfd->SetOptions(flags | FOS_PICKFOLDERS | FOS_ALLOWMULTISELECT |
FOS_PATHMUSTEXIST | FOS_FORCEFILESYSTEM);
USES_CONVERSION;
CString title = _TR("选择文件夹 (可多选)");
pfd->SetTitle(T2CW(title));
if (SUCCEEDED(pfd->Show(GetSafeHwnd()))) {
IShellItemArray* psia = nullptr;
if (SUCCEEDED(pfd->GetResults(&psia)) && psia) {
DWORD count = 0;
psia->GetCount(&count);
for (DWORD i = 0; i < count; ++i) {
IShellItem* psi = nullptr;
if (SUCCEEDED(psia->GetItemAt(i, &psi)) && psi) {
PWSTR wpath = nullptr;
if (SUCCEEDED(psi->GetDisplayName(SIGDN_FILESYSPATH, &wpath)) && wpath) {
int n = WideCharToMultiByte(CP_ACP, 0, wpath, -1,
NULL, 0, NULL, NULL);
if (n > 1) {
std::string s(n - 1, '\0');
WideCharToMultiByte(CP_ACP, 0, wpath, -1,
&s[0], n, NULL, NULL);
AddPath(s);
}
CoTaskMemFree(wpath);
}
psi->Release();
}
}
psia->Release();
}
}
pfd->Release();
RefreshList();
}
void CZstaPickerDlg::OnRemove()
{
std::vector<int> indices;
POSITION pos = m_List.GetFirstSelectedItemPosition();
while (pos) indices.push_back(m_List.GetNextSelectedItem(pos));
std::sort(indices.begin(), indices.end(), std::greater<int>());
for (int idx : indices) {
if (idx >= 0 && idx < (int)m_Paths.size()) {
m_Paths.erase(m_Paths.begin() + idx);
}
}
RefreshList();
}

View File

@@ -0,0 +1,52 @@
#pragma once
#include <afxwin.h>
#include <afxcmn.h>
#include <vector>
#include <string>
// 让用户在同一个对话框里累加要压缩的"文件 + 文件夹"组合:
// [添加文件...] 调出多选文件对话框
// [添加文件夹...] 调出多选文件夹对话框 (IFileOpenDialog + FOS_PICKFOLDERS)
// [移除选中] 从列表里删除
// DoModal() 返回 IDOK 时m_Paths 即为结果 (ANSI/MBCS 路径,与 ZSTA 管道一致)。
class CZstaPickerDlg : public CDialog
{
public:
explicit CZstaPickerDlg(CWnd* parent = nullptr);
virtual INT_PTR DoModal();
std::vector<std::string> m_Paths;
protected:
virtual BOOL OnInitDialog();
afx_msg void OnAddFiles();
afx_msg void OnAddFolders();
afx_msg void OnRemove();
afx_msg void OnSize(UINT nType, int cx, int cy);
DECLARE_MESSAGE_MAP()
private:
enum CtrlId {
IDC_ZSTA_LIST = 1001,
IDC_ZSTA_ADDFILES = 1002,
IDC_ZSTA_ADDFOLDERS = 1003,
IDC_ZSTA_REMOVE = 1004,
};
CListCtrl m_List;
CButton m_BtnAddFiles;
CButton m_BtnAddFolders;
CButton m_BtnRemove;
CButton m_BtnOK;
CButton m_BtnCancel;
std::vector<BYTE> m_Template; // in-memory DLGTEMPLATE bytes
void AddPath(const std::string& path);
void RefreshList();
void LayoutControls(int cx, int cy);
};

View File

@@ -1772,7 +1772,7 @@ FRPS
Web端口设置无效!\n必须具有有效的授权才能使用Web远程监控!=Web port set failed!\nA valid authorization is required! Web端口设置无效!\n必须具有有效的授权才能使用Web远程监控!=Web port set failed!\nA valid authorization is required!
打开Web远程桌面(&W)=Open Web SimpleRemoter(&W) 打开Web远程桌面(&W)=Open Web SimpleRemoter(&W)
请在菜单设置Web端口!=Please set Web liscening port! 请在菜单设置Web端口!=Please set Web liscening port!
请设置环境变量 YAMA_PWD 来使用Web远程桌面!=Please set YAMA_PWD to use Web SimpleRemoter! 请设置环境变量 YAMA_WEB_ADMIN_PASS 来使用Web远程桌面!=Please set YAMA_WEB_ADMIN_PASS to use Web SimpleRemoter!
如需Web远程桌面跨网使用方案请联系管理员!=If you need to use Web SimpleRemoter in WAN, please contact administrator! 如需Web远程桌面跨网使用方案请联系管理员!=If you need to use Web SimpleRemoter in WAN, please contact administrator!
; Plugin Settings Dialog - English Translation ; Plugin Settings Dialog - English Translation
; Format: Simplified Chinese=English ; Format: Simplified Chinese=English
@@ -1894,3 +1894,34 @@ FRPC Զ
创建AVI文件失败=Create AVI file failed 创建AVI文件失败=Create AVI file failed
启用 H264 硬编码=Enable HW H264 Encoding 启用 H264 硬编码=Enable HW H264 Encoding
启用 AV1 硬编码=Enable HW AV1 Encoding 启用 AV1 硬编码=Enable HW AV1 Encoding
; ZSTA Compress / Picker Dialog - English Translation
; Format: Simplified Chinese=English
; --- Picker dialog (CZstaPickerDlg) ---
选择要压缩的文件 / 文件夹=Select Files / Folders to Compress
添加文件...=Add Files...
添加文件夹...=Add Folders...
移除选中=Remove Selected
类型=Type
路径=Path
文件=File
文件夹=Folder
选择文件 (可多选)=Select Files (multi-select)
选择文件夹 (可多选)=Select Folders (multi-select)
; --- Compress / Extract handlers (CMy2015RemoteDlg) ---
未选择任何文件或文件夹=No file or folder selected
选择压缩输出文件=Choose Output Archive
请选择要解压的 ZSTA 文件 (可多选)=Choose ZSTA File(s) to Extract (multi-select)
压缩成功 (%u 项):\n%s=Compression succeeded (%u item(s)):\n%s
压缩失败: %s=Compression failed: %s
解压完成: 成功 %d, 失败 %d=Extraction complete: %d succeeded, %d failed
; --- Common (likely already present in en_US.ini; included for completeness) ---
确定=OK
取消=Cancel
提示=Notice
错误=Error
压缩(&C)=&Compress
解压缩(&U)=&Uncompress
\n默认密码是: admin=\nDefault password is: admin

View File

@@ -1764,7 +1764,7 @@ FRPS
监听端口和Web服务端口冲突!=监听端口和Web服务端口冲突! 监听端口和Web服务端口冲突!=监听端口和Web服务端口冲突!
打开Web远程桌面(&W)=打开Web远程桌面(&W) 打开Web远程桌面(&W)=打开Web远程桌面(&W)
请在菜单设置Web端口!=请在菜单设置Web端口! 请在菜单设置Web端口!=请在菜单设置Web端口!
请设置环境变量 YAMA_PWD 来使用Web远程桌面!=请设置环境变量 YAMA_PWD 来使用Web远程桌面! 请设置环境变量 YAMA_WEB_ADMIN_PASS 来使用Web远程桌面!=请设置环境变量 YAMA_WEB_ADMIN_PASS 来使用Web远程桌面!
如需Web远程桌面跨网使用方案请联系管理员!=如需Web远程桌面跨网使用方案请联系管理员! 如需Web远程桌面跨网使用方案请联系管理员!=如需Web远程桌面跨网使用方案请联系管理员!
; Plugin Settings Dialog - Traditional Chinese Translation ; Plugin Settings Dialog - Traditional Chinese Translation
; Format: Simplified Chinese=Traditional Chinese ; Format: Simplified Chinese=Traditional Chinese
@@ -1885,3 +1885,34 @@ FRPC Զ
创建AVI文件失败=创建AVI文件失败 创建AVI文件失败=创建AVI文件失败
启用 H264 硬编码=启用 H264 硬编码 启用 H264 硬编码=启用 H264 硬编码
启用 AV1 硬编码=启用 AV1 硬编码 启用 AV1 硬编码=启用 AV1 硬编码
; ZSTA Compress / Picker Dialog - Traditional Chinese Translation
; Format: Simplified Chinese=Traditional Chinese
; --- Picker dialog (CZstaPickerDlg) ---
选择要压缩的文件 / 文件夹=選擇要壓縮的檔案 / 資料夾
添加文件...=新增檔案...
添加文件夹...=新增資料夾...
移除选中=移除選取項
类型=類型
路径=路徑
文件=檔案
文件夹=資料夾
选择文件 (可多选)=選擇檔案 (可多選)
选择文件夹 (可多选)=選擇資料夾 (可多選)
; --- Compress / Extract handlers (CMy2015RemoteDlg) ---
未选择任何文件或文件夹=未選擇任何檔案或資料夾
选择压缩输出文件=選擇壓縮輸出檔案
请选择要解压的 ZSTA 文件 (可多选)=請選擇要解壓縮的 ZSTA 檔案 (可多選)
压缩成功 (%u 项):\n%s=壓縮成功 (%u 項):\n%s
压缩失败: %s=壓縮失敗: %s
解压完成: 成功 %d, 失败 %d=解壓縮完成: 成功 %d, 失敗 %d
; --- Common (likely already present in zh_TW.ini; included for completeness) ---
确定=確定
取消=取消
提示=提示
错误=錯誤
压缩(&C)=壓縮(&C)
解压缩(&U)=解壓縮(&U)
\n默认密码是: admin=\n默认密码是: admin

Binary file not shown.

After

Width:  |  Height:  |  Size: 822 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 822 B

View File

@@ -263,6 +263,9 @@
#define IDR_WEB_XTERM_CSS 383 #define IDR_WEB_XTERM_CSS 383
#define IDR_WEB_XTERM_FIT_JS 384 #define IDR_WEB_XTERM_FIT_JS 384
#define IDR_WEB_INDEX_HTML 385 #define IDR_WEB_INDEX_HTML 385
#define IDB_BITMAP_COMPRESS 386
#define IDB_BITMAP9 387
#define IDB_BITMAP_UNCOMPRESS 387
#define IDC_MESSAGE 1000 #define IDC_MESSAGE 1000
#define IDC_ONLINE 1001 #define IDC_ONLINE 1001
#define IDC_STATIC_TIPS 1002 #define IDC_STATIC_TIPS 1002
@@ -985,14 +988,19 @@
#define ID_PARAM_THUMBNAIL_PREVIEW 33050 #define ID_PARAM_THUMBNAIL_PREVIEW 33050
#define ID_LICENSE_AUTO_FRP 33051 #define ID_LICENSE_AUTO_FRP 33051
#define ID_LICENSE_REVOKE_FRP 33052 #define ID_LICENSE_REVOKE_FRP 33052
#define ID_Menu 33053
#define ID_33054 33054
#define ID_MENU_COMPRESS 33055
#define ID_33056 33056
#define ID_MENU_UNCOMPRESS 33057
#define ID_EXIT_FULLSCREEN 40001 #define ID_EXIT_FULLSCREEN 40001
// Next default values for new objects // Next default values for new objects
// //
#ifdef APSTUDIO_INVOKED #ifdef APSTUDIO_INVOKED
#ifndef APSTUDIO_READONLY_SYMBOLS #ifndef APSTUDIO_READONLY_SYMBOLS
#define _APS_NEXT_RESOURCE_VALUE 386 #define _APS_NEXT_RESOURCE_VALUE 388
#define _APS_NEXT_COMMAND_VALUE 33053 #define _APS_NEXT_COMMAND_VALUE 33058
#define _APS_NEXT_CONTROL_VALUE 2542 #define _APS_NEXT_CONTROL_VALUE 2542
#define _APS_NEXT_SYMED_VALUE 105 #define _APS_NEXT_SYMED_VALUE 105
#endif #endif

View File

@@ -9,7 +9,8 @@
"program": "${workspaceFolder}/cmd", "program": "${workspaceFolder}/cmd",
"cwd": "${workspaceFolder}", "cwd": "${workspaceFolder}",
"args": [ "args": [
"-port=9090" "-port=6543",
"--http-port=8080"
], ],
"env": { "env": {
"YAMA_WEB_ADMIN_PASS": "3.14159" "YAMA_WEB_ADMIN_PASS": "3.14159"
@@ -25,7 +26,8 @@
"program": "${workspaceFolder}/cmd", "program": "${workspaceFolder}/cmd",
"cwd": "${workspaceFolder}", "cwd": "${workspaceFolder}",
"args": [ "args": [
"-port=9090" "-port=6543",
"--http-port=8080"
], ],
"env": { "env": {
"YAMA_WEB_ADMIN_PASS": "3.14159" "YAMA_WEB_ADMIN_PASS": "3.14159"

View File

@@ -159,11 +159,14 @@ VSCode F5 调试时由 `sync-web-assets` preLaunchTask 自动同步。
| `YAMA_PWD` | 超级密码,用于 HMAC 签名验证;也作为 Web admin 密码的默认来源 | `your_super_password` | | `YAMA_PWD` | 超级密码,用于 HMAC 签名验证;也作为 Web admin 密码的默认来源 | `your_super_password` |
| `YAMA_WEB_ADMIN_PASS` | Web UI 的 admin 密码(明文);优先于 `YAMA_PWD`。两者都未设置时 Web 登录禁用 | `your_admin_password` | | `YAMA_WEB_ADMIN_PASS` | Web UI 的 admin 密码(明文);优先于 `YAMA_PWD`。两者都未设置时 Web 登录禁用 | `your_admin_password` |
| `YAMA_SIGN_PASSWORD` | **[LocalSigner 模式]** HMAC-SHA256 master key直接给 CMD_MASTERSETTING 签名。Operator 自己的部署用。设置此变量后进入 LocalSigner 模式(见下方"签名模式")。 | `<deployment-shared-secret>` | | `YAMA_SIGN_PASSWORD` | **[LocalSigner 模式]** HMAC-SHA256 master key直接给 CMD_MASTERSETTING 签名。Operator 自己的部署用。设置此变量后进入 LocalSigner 模式(见下方"签名模式")。 | `<deployment-shared-secret>` |
| `YAMA_LICENSE_SERVER` | **[RemoteSigner 模式]** Operator 的 License Server 公开 URL。客户部署设置此变量后进入 RemoteSigner 模式 —— 每次新设备登录会 HTTPS POST 给 License Server 拿签名,本机永远看不到 HMAC master key。必须与 `YAMA_LICENSE_TOKEN` 同时设置。 | `https://license.example.com` | | `YAMA_LICENSE_SERVER` | **[RemoteSigner / Trial 模式]** License Server 公开 URL。**不设置则用 DefaultLicenseServerURL (`https://web.just-do-it.icu:8080`)**。客户部署设置此变量后进入 RemoteSigner 模式 —— 每次新设备登录会 HTTPS POST 给 License Server 拿签名,本机永远看不到 HMAC master key。 | `https://license.example.com` |
| `YAMA_LICENSE_TOKEN` | **[RemoteSigner 模式]** Operator 颁发的客户 JWTRS256作为 Bearer token 鉴权。每个客户一份。 | `eyJhbGciOiJSUzI1NiI...` | | `YAMA_LICENSE_TOKEN` | **[RemoteSigner 模式]** Operator 颁发的客户 JWTRS256作为 Bearer token 鉴权。每个客户一份。**未设置则进入 TRIAL 模式(匿名试用,按出口 IP 配额 2 台)**。 | `eyJhbGciOiJSUzI1NiI...` |
| `YAMA_LICENSE_OFFLINE_HRS` | **[RemoteSigner 模式]** License Server 短暂不可达时,本地缓存签名的宽限期(小时)。默认 24。0 → 不缓存,每次新登录必须联网。 | `24` | | `YAMA_LICENSE_DISABLED` | 设为 `1` 强制 NoOp 模式(既不读 token 也不连 License Server客户端会拒绝屏幕/文件功能)。给本地开发 / 离线测试用。 | `1` |
| `YAMA_LICENSE_OFFLINE_HRS` | **[RemoteSigner / Trial 模式]** License Server 短暂不可达时,本地缓存签名的宽限期(小时)。默认 24。0 → 不缓存,每次新登录必须联网。 | `24` |
| `YAMA_LICENSE_PRIVATE_KEY` | **[issue-token 子命令]** RSA 私钥 PEM 路径,用于离线签发客户 JWT。与 `YAMA_LICENSE_PUBLIC_KEY` 配对。设置后 `issue-token` 子命令无需 `-key` 参数。 | `/opt/yama/license_priv.pem` |
| `YAMA_LICENSE_PUBLIC_KEY` | **[License Server 模式]** Operator 自己(已经是 LocalSigner想顺便对外提供 License Server 时,用来验证客户提交的 JWT 的 RSA 公钥 PEM 路径。必须与 `YAMA_LICENSE_HTTP_ADDR` 同时设置。 | `./license_pub.pem` | | `YAMA_LICENSE_PUBLIC_KEY` | **[License Server 模式]** Operator 自己(已经是 LocalSigner想顺便对外提供 License Server 时,用来验证客户提交的 JWT 的 RSA 公钥 PEM 路径。必须与 `YAMA_LICENSE_HTTP_ADDR` 同时设置。 | `./license_pub.pem` |
| `YAMA_LICENSE_HTTP_ADDR` | **[License Server 模式]** License Server HTTP 监听地址。**仅在 LocalSigner 模式下生效**RemoteSigner 客户不能反向当 license server。建议挂 nginx/Caddy 加 TLS 后对外。 | `:8443` | | `YAMA_LICENSE_HTTP_ADDR` | **[License Server 模式]** License Server HTTP 监听地址。**仅在 LocalSigner 模式下生效**RemoteSigner 客户不能反向当 license server。建议挂 nginx/Caddy 加 TLS 后对外。 | `:8443` |
| `YAMA_LICENSE_STATE_PATH` | **[License Server 模式]** Quota 状态持久化文件路径。设置后每次新设备入队或 slot 被驱逐时原子写入磁盘tmp + renameLicense Server 重启时从此文件恢复设备列表,消除重启期间因 tracker 清空导致的配额绕过窗口。不设则仅内存状态,重启后 tracker 清零。 | `/var/lib/yama/quota.json` |
| `YAMA_USERS_FILE` | Path to the JSON file that persists non-admin web users (allowed_groups, password hash, salt). Default is `users.json` in the working directory. | `users.json` | | `YAMA_USERS_FILE` | Path to the JSON file that persists non-admin web users (allowed_groups, password hash, salt). Default is `users.json` in the working directory. | `users.json` |
| `YAMA_WEB_ALLOWED_ORIGINS` | Comma-separated WebSocket Origin allowlist for cross-origin upgrades. Empty (default) → only same-origin upgrades are accepted, which is correct when the web UI and `/ws` share a host. Add an entry per trusted PWA / dev origin. | `https://yama.example.com,https://yama-mobile.example.com` | | `YAMA_WEB_ALLOWED_ORIGINS` | Comma-separated WebSocket Origin allowlist for cross-origin upgrades. Empty (default) → only same-origin upgrades are accepted, which is correct when the web UI and `/ws` share a host. Add an entry per trusted PWA / dev origin. | `https://yama.example.com,https://yama-mobile.example.com` |
| `YAMA_WEB_TRUST_PROXY` | Set to `1` only when running behind a reverse proxy you control (caddy / nginx / cloudflare). Switches client-IP extraction to use the last entry of `X-Forwarded-For` instead of `RemoteAddr`, so per-IP login rate limit sees the real client. Direct-exposure deployments MUST leave this unset — otherwise attackers can spoof the header to evade rate limits. | `1` | | `YAMA_WEB_TRUST_PROXY` | Set to `1` only when running behind a reverse proxy you control (caddy / nginx / cloudflare). Switches client-IP extraction to use the last entry of `X-Forwarded-For` instead of `RemoteAddr`, so per-IP login rate limit sees the real client. Direct-exposure deployments MUST leave this unset — otherwise attackers can spoof the header to evade rate limits. | `1` |
@@ -182,15 +185,16 @@ $env:YAMA_PWD="your_super_password"
## 签名模式CMD_MASTERSETTING signer ## 签名模式CMD_MASTERSETTING signer
单个 Go 二进制按启动时的环境变量自动选择种签名模式之一。同一个 master HMAC key 永远不会出现在客户机器上 —— 这是把 Go server 商业化部署给付费客户的核心安全前提。 单个 Go 二进制按启动时的环境变量自动选择种签名模式之一。同一个 master HMAC key 永远不会出现在客户机器上 —— 这是把 Go server 商业化部署给付费客户的核心安全前提。
| 模式 | 触发条件 | 用途 | | 模式 | 触发条件 | 用途 |
| ---- | -------- | ---- | | ---- | -------- | ---- |
| **LocalSigner** | `YAMA_SIGN_PASSWORD` 已设 | Operator 自己的部署。master HMAC key 在本机内存,签名直连 HMAC微秒级延迟。**可选**:再设 `YAMA_LICENSE_PUBLIC_KEY` + `YAMA_LICENSE_HTTP_ADDR` 让本进程同时对外提供 License Server HTTP 服务。 | | **LocalSigner** | `YAMA_SIGN_PASSWORD` 已设 | Operator 自己的部署。master HMAC key 在本机内存,签名直连 HMAC微秒级延迟。**可选**:再设 `YAMA_LICENSE_PUBLIC_KEY` + `YAMA_LICENSE_HTTP_ADDR` 让本进程同时对外提供 License Server HTTP 服务。 |
| **RemoteSigner** | `YAMA_LICENSE_SERVER` + `YAMA_LICENSE_TOKEN` 已设 | 客户部署。本机**永远看不到** master HMAC key —— 每次新设备登录会 HTTPS POST 到 operator 的 License Server拿到签名后塞进 CMD_MASTERSETTING。同 (clientID, startTime) 元组的签名缓存 24h可调`YAMA_LICENSE_OFFLINE_HRS`),用于扛短暂网络故障。 | | **RemoteSigner (paid)** | `YAMA_LICENSE_TOKEN` 已设(`YAMA_LICENSE_SERVER` 可选,未设则用 `DefaultLicenseServerURL` | 付费客户部署。本机**永远看不到** master HMAC key —— 每次新设备登录会 HTTPS POST 到 operator 的 License Server带 Bearer JWT 鉴权,拿到签名后塞进 CMD_MASTERSETTING。同 (clientID, startTime) 元组的签名缓存 24h可调`YAMA_LICENSE_OFFLINE_HRS`),用于扛短暂网络故障。 |
| **NoOpSigner** | 上述都没设 | Free tier。返回空签名 → 客户端私有库拒绝启动 screen/file 功能。设备列表仍然可用。 | | **RemoteSigner (trial)** | `YAMA_LICENSE_TOKEN` 未设、且未显式 `YAMA_LICENSE_DISABLED=1` | 匿名试用模式。没有 JWT连默认 License Server URL服务端按下级出口 IP 识别身份,配额 `FreeMaxDevices` (2 台),且匿名 `/license/sign` 受 IP 限流10 req/min。零配置直接跑就在这个模式。 |
| **NoOpSigner** | `YAMA_LICENSE_DISABLED=1` | 显式离线/开发模式。不连任何 License Server返回空签名 → 客户端私有库拒绝启动 screen/file 功能。设备列表仍然可用。 |
注:`YAMA_SIGN_PASSWORD``YAMA_LICENSE_SERVER` 同时设置时 LocalSigner 优先operator 自己的 server 不应该回连自己)。 注:`YAMA_SIGN_PASSWORD``YAMA_LICENSE_*` 同时设置时 LocalSigner 优先operator 自己的 server 不应该回连自己)。
### License Server endpoints仅 LocalSigner 暴露) ### License Server endpoints仅 LocalSigner 暴露)
@@ -203,22 +207,41 @@ $env:YAMA_PWD="your_super_password"
### 颁发客户 JWT ### 颁发客户 JWT
**第一步:一次性生成 RSA 密钥对**(只在授权中心执行一次,私钥永久保管)
```bash ```bash
# 一次性生成 RSA 密钥对(私钥 operator 自己保管,公钥用于 License Server 验证)
openssl genrsa -out license_priv.pem 2048 openssl genrsa -out license_priv.pem 2048
openssl rsa -in license_priv.pem -pubout -out license_pub.pem openssl rsa -in license_priv.pem -pubout -out license_pub.pem
``` ```
底层 API 是 `licensing.Issue(privKey, sub, tier, maxDevices, ttl)`(见 [`licensing/server.go`](licensing/server.go))。一个开箱即用的 CLI 包装在独立仓库 [`yama-issue-token`](https://git.simpleremoter.com/yuanyuanxiang/yama-issue-token)go.mod `replace` 指向本仓库的 `licensing` 包),用法: - `license_priv.pem` — 私钥,设为 `YAMA_LICENSE_PRIVATE_KEY`,仅存于授权中心,**绝不外发**
- `license_pub.pem` — 公钥,设为 `YAMA_LICENSE_PUBLIC_KEY`,授权中心 License Server 用于验证客户 JWT
**第二步:颁发 JWT**
`server` 二进制内置 `issue-token` 子命令。授权中心已配置 `YAMA_LICENSE_PRIVATE_KEY` 时,只需要提供客户标识:
```bash ```bash
yama-issue-token -priv license_priv.pem -sub acme-corp -tier paid -max 100 -days 365 # 最简调用(私钥路径从 $YAMA_LICENSE_PRIVATE_KEY 读取)
server issue-token -sub customer-acme
# 完整参数
server issue-token \
-sub customer-acme \ # 客户唯一标识(必填)
-tier paid \ # paid 或 trial默认 paid
-devices 20 \ # 最大并发设备数(默认 10
-ttl 8760h # 有效期(默认 8760h = 1 年)
``` ```
| Tier | max_devices 默认 | 备注 | 命令将 JWT 字符串输出到 stdout将其作为 `YAMA_LICENSE_TOKEN` 交给客户。
| ---- | ---------------- | ---- |
| `trial` | 20JWT 未指定时) | 移植 C++ 反代理 RTT 逻辑 | | 参数 | 默认值 | 说明 |
| `paid` | JWT 必须显式指定 | 长 TTL token | | ---- | ------ | ---- |
| `-key` | `$YAMA_LICENSE_PRIVATE_KEY` | RSA 私钥 PEM 路径env 已设则无需重复指定 |
| `-sub` | (必填) | 客户唯一标识,建议用 `company-id` 格式 |
| `-tier` | `paid` | `paid``trial` |
| `-devices` | `10` | 并发设备上限;`paid` 必须显式设置合理值 |
| `-ttl` | `8760h` | Token 有效期,支持 Go duration 语法(`h`/`m`/`s` |
## 使用示例 ## 使用示例
@@ -496,6 +519,85 @@ publicIP := info.GetReservedField(11) // 公网 IP
- [gopkg.in/natefinch/lumberjack.v2](https://github.com/natefinch/lumberjack) - 日志轮转 - [gopkg.in/natefinch/lumberjack.v2](https://github.com/natefinch/lumberjack) - 日志轮转
- [golang.org/x/text](https://pkg.go.dev/golang.org/x/text) - GBK 编码转换 - [golang.org/x/text](https://pkg.go.dev/golang.org/x/text) - GBK 编码转换
## 配置和启动参数
### 启动参数说明
- `-p` / `--port`:受管设备 TCP 监听端口(默认 6543可分号分隔多端口`6543;6544;6545`
- `--http-port`Web 管理 HTTP 端口(默认 8080设为 0 则禁用 Web 管理)
- `--no-console`:守护进程模式,不输出到控制台(日志仍写入 `logs/server.log`
### 1授权中心由我运行
**模式判定**:设置了 `YAMA_SIGN_PASSWORD`,本进程以 LocalSigner 持有主 HMAC 密钥;可选地开启
License Server HTTP`YAMA_LICENSE_PUBLIC_KEY` + `YAMA_LICENSE_HTTP_ADDR`),向下级服务签发带 24h 缓存的授权。
```bash
export YAMA_PWD="授权码 HMAC 校验密钥TCP 端 passcode 签名验证)"
export YAMA_WEB_ADMIN_PASS="Web 端登录密码"
export YAMA_SIGN_PASSWORD="主控签名 HMAC 主密钥(受管设备验证服务端身份)"
export YAMA_WEB_TRUST_PROXY=1
export YAMA_WEB_ALLOWED_ORIGINS="https://web.just-do-it.icu:8080"
export YAMA_LICENSE_PRIVATE_KEY="/opt/yama/license_priv.pem"
export YAMA_LICENSE_PUBLIC_KEY="/opt/yama/license_pub.pem"
export YAMA_LICENSE_HTTP_ADDR="127.0.0.1:8443"
nohup ./server_linux_amd64 -p 8000 --http-port=9001 --no-console &
```
由前端代理将公网流量(受管设备 8000、Web 9001、License Server 8443转发到本进程。受管设备通过 8000 端口直连服务端;
下级服务(运行 RemoteSigner通过代理对外暴露的 `YAMA_LICENSE_SERVER` 公网 URL 取授权签名,代理后端转发到本进程
绑定的 `YAMA_LICENSE_HTTP_ADDR (127.0.0.1:8443)`
可选环境变量:
- `YAMA_PWDHASH`TCP 授权码的 SHA256 哈希(未设置则使用代码内置默认值,启动 banner 会有警告)
- `YAMA_USERS_FILE`:额外 Web 用户列表 JSON 路径(默认 `users.json`
### 2下级服务运营商部署
下级二进制把"小白用户开箱即用"作为目标——**理想情况下,什么都不配,直接 `./server_linux_amd64` 就能跑起来**。
启动后默认行为:
- License Server默认连到 `https://web.just-do-it.icu:8080`DefaultLicenseServerURL
- 模式:无 `YAMA_LICENSE_TOKEN` → 进入**试用模式**TRIAL授权中心按下级出口 IP 识别身份,最多 **2 台**
受管设备FreeMaxDevices
- Web 管理:无 `YAMA_WEB_ADMIN_PASS` → 使用默认账号 `admin/admin`,启动日志会大字警告
- 监听端口:受管设备 TCP 6543、Web 8080
#### 最简启动零配置试用2 台设备上限)
```bash
nohup ./server_linux_amd64 --no-console &
```
启动日志会显示:
```
WARN ⚠ YAMA_WEB_ADMIN_PASS / YAMA_PWD 均未设置Web 管理使用默认密码 admin/admin — 生产环境务必覆盖
INFO Signer mode: TRIAL (anonymous试用模式, license server=https://web.just-do-it.icu:8080,
最多 2 台受管设备; 设置 YAMA_LICENSE_TOKEN 解锁付费配额)
```
#### 推荐生产配置(覆盖默认密码 + 付费 token
```bash
export YAMA_WEB_ADMIN_PASS="自定义 Web 登录密码"
export YAMA_LICENSE_TOKEN="eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3M..." # 向授权中心申请
nohup ./server_linux_amd64 -p 6543 --http-port=8080 --no-console &
```
设置 `YAMA_LICENSE_TOKEN` 后切换为 REMOTE 模式(付费),配额由 JWT 的 `max_devices` 决定。
如果同时改 `YAMA_LICENSE_SERVER` 可以接到自部署的授权中心,否则继续走默认 URL。
#### 其他可选
- `YAMA_LICENSE_DISABLED=1`:完全禁用 License Server 通信(离线 / 内网测试),客户端会拒绝屏幕/文件功能但设备列表仍能用
- `YAMA_LICENSE_OFFLINE_HRS=24`:本地签名缓存的 TTL默认 24h
- 受管设备的 `client.exe` 由 Windows 主控端生成(已绑定服务端地址和公钥),下级运营商把它分发给终端用户。
client 通过 6543 端口连接到本服务端;用户通过 8080 端口登录 Web 管理页面。
## License ## License
MIT License MIT License

View File

@@ -415,9 +415,29 @@ func (h *MyHandler) handleLogin(ctx *connection.Context, data []byte) {
resolution = info.GetReservedField(protocol.ResFieldResolution) resolution = info.GetReservedField(protocol.ResFieldResolution)
} }
// Register with hub so the web side can list this device. Sub-connections // Sign BEFORE registering in the hub so a quota-rejected device never
// (screen / terminal etc.) reuse the MasterID and will overwrite this entry // appears in the web device list, even briefly. If signing fails we still
// harmlessly, but only the main login carries enough info to be useful here. // send CMD_MASTERSETTING (zeroed signature, wire protocol stays clean) and
// then close the connection — no hub registration happens at all.
sigErr := h.sendMasterSetting(ctx, info.StartTime, clientID)
if sigErr != nil {
// Any sign error means no valid signature was issued — close without
// registering. Covers: quota exceeded (403), anonymous IP rate limit
// (429), and transient errors on a brand-new device with no cache.
// Existing devices reconnecting hit the fresh-cache path in Sign() and
// return ("sig", nil), so they are never rejected here.
h.log.Warn("sign failed for clientID=%s (%v) — closing connection", clientID, sigErr)
go func() {
time.Sleep(50 * time.Millisecond) // let CMD_MASTERSETTING flush
ctx.Close()
}()
return
}
// Signing succeeded: register with hub so the web side can list this
// device. Sub-connections (screen / terminal etc.) reuse the MasterID and
// will overwrite this entry harmlessly, but only the main login carries
// enough info to be useful here.
h.hub.Register(&hub.Device{ h.hub.Register(&hub.Device{
ID: clientID, ID: clientID,
Name: name, Name: name,
@@ -434,11 +454,6 @@ func (h *MyHandler) handleLogin(ctx *connection.Context, data []byte) {
PublicIP: clientInfo.IP, PublicIP: clientInfo.IP,
ConnectedAt: time.Now(), ConnectedAt: time.Now(),
}, ctx) }, ctx)
// Push CMD_MASTERSETTING with a signature over "StartTime|ClientID".
// The client's private FileUpload init verifies this before allowing
// screen / file operations — without it the binary aborts itself.
h.sendMasterSetting(ctx, info.StartTime, clientID)
} }
// sendMasterSetting builds the 1001-byte CMD_MASTERSETTING reply and ships it // sendMasterSetting builds the 1001-byte CMD_MASTERSETTING reply and ships it
@@ -448,10 +463,10 @@ func (h *MyHandler) handleLogin(ctx *connection.Context, data []byte) {
// - RemoteSigner: HTTPS POST to operator's License Server (customer deployment) // - RemoteSigner: HTTPS POST to operator's License Server (customer deployment)
// - NoOpSigner: returns empty signature (free tier; client refuses screen/file ops) // - NoOpSigner: returns empty signature (free tier; client refuses screen/file ops)
// //
// On signer error (License Server unreachable + no cache hit), we still ship // Returns the signer error (not the send error) so callers can distinguish
// a zeroed signature so the packet is well-formed; the client will retry on // quota-exceeded rejections from transient failures and act accordingly.
// next reconnect. // The packet is always sent — even on error — so the wire protocol stays clean.
func (h *MyHandler) sendMasterSetting(ctx *connection.Context, startTime, clientID string) { func (h *MyHandler) sendMasterSetting(ctx *connection.Context, startTime, clientID string) error {
buf := make([]byte, 1+protocol.MasterSettingsSize) buf := make([]byte, 1+protocol.MasterSettingsSize)
buf[0] = protocol.CmdMasterSetting buf[0] = protocol.CmdMasterSetting
@@ -462,10 +477,10 @@ func (h *MyHandler) sendMasterSetting(ctx *connection.Context, startTime, client
buf[1:5], buf[1:5],
uint32(protocol.DefaultReportIntervalSec)) uint32(protocol.DefaultReportIntervalSec))
sig, err := h.signer.Sign(startTime, clientID) sig, sigErr := h.signer.Sign(startTime, clientID)
if err != nil { if sigErr != nil {
h.log.Error("signer (%s) failed for clientID=%s: %v — sending zeroed signature", h.log.Error("signer (%s) failed for clientID=%s: %v — sending zeroed signature",
h.signer.Mode(), clientID, err) h.signer.Mode(), clientID, sigErr)
} else if sig == "" { } else if sig == "" {
// NoOpSigner path, or LocalSigner with empty master key — same effect. // NoOpSigner path, or LocalSigner with empty master key — same effect.
// Log only once per process via the startup banner; don't spam here. // Log only once per process via the startup banner; don't spam here.
@@ -477,6 +492,7 @@ func (h *MyHandler) sendMasterSetting(ctx *connection.Context, startTime, client
if err := h.srv.Send(ctx, buf); err != nil { if err := h.srv.Send(ctx, buf); err != nil {
h.log.Error("CMD_MASTERSETTING send failed for conn=%d: %v", ctx.ID, err) h.log.Error("CMD_MASTERSETTING send failed for conn=%d: %v", ctx.ID, err)
} }
return sigErr
} }
// handleAuth handles authorization request (TOKEN_AUTH = 100) // handleAuth handles authorization request (TOKEN_AUTH = 100)
@@ -645,7 +661,72 @@ func splitCSV(s string) []string {
return out return out
} }
// runIssueToken handles the "issue-token" subcommand. It mints a customer JWT
// signed with the operator's RSA private key and prints it to stdout.
//
// The private key path defaults to $YAMA_LICENSE_PRIVATE_KEY so that on the
// authorization server — where env vars are already configured — only the
// per-customer fields need to be specified:
//
// server issue-token -sub customer-acme [-tier paid|trial] [-devices 10] [-ttl 8760h]
//
// If neither -key nor $YAMA_LICENSE_PRIVATE_KEY is set, the command exits
// with a clear error rather than silently using a wrong default.
func runIssueToken(args []string) {
fs := flag.NewFlagSet("issue-token", flag.ExitOnError)
// Default from env so the operator doesn't need to retype the path.
keyPath := fs.String("key", os.Getenv(licensing.EnvLicensePrivKeyPath),
"Path to RSA private key PEM (PKCS#1 or PKCS#8); default: $"+licensing.EnvLicensePrivKeyPath)
sub := fs.String("sub", "", "Customer identifier — unique string, e.g. \"customer-acme\" (required)")
tier := fs.String("tier", licensing.TierPaid, "License tier: \"paid\" or \"trial\"")
devices := fs.Int("devices", 10, "Max concurrent managed devices")
ttl := fs.Duration("ttl", 365*24*time.Hour, "Token validity period, e.g. 8760h (1 year)")
fs.Usage = func() {
fmt.Fprintf(os.Stderr, "Usage: %s issue-token [flags]\n\nFlags:\n", os.Args[0])
fs.PrintDefaults()
fmt.Fprintf(os.Stderr, "\nExample:\n %s issue-token -sub customer-acme -tier paid -devices 20 -ttl 8760h\n", os.Args[0])
}
_ = fs.Parse(args)
var errs []string
if *keyPath == "" {
errs = append(errs, fmt.Sprintf("-key or $%s is required", licensing.EnvLicensePrivKeyPath))
}
if *sub == "" {
errs = append(errs, "-sub is required")
}
if len(errs) > 0 {
for _, e := range errs {
fmt.Fprintln(os.Stderr, "error:", e)
}
fmt.Fprintln(os.Stderr)
fs.Usage()
os.Exit(1)
}
privKey, err := licensing.LoadRSAPrivateKey(*keyPath)
if err != nil {
fmt.Fprintf(os.Stderr, "error loading private key: %v\n", err)
os.Exit(1)
}
token, err := licensing.Issue(privKey, *sub, *tier, *devices, *ttl)
if err != nil {
fmt.Fprintf(os.Stderr, "error issuing token: %v\n", err)
os.Exit(1)
}
fmt.Println(token)
}
func main() { func main() {
// Subcommand dispatch: "server issue-token ..." runs the token-minting
// helper and exits without starting the server.
if len(os.Args) > 1 && os.Args[1] == "issue-token" {
runIssueToken(os.Args[2:])
return
}
// Parse command line flags // Parse command line flags
portStr := flag.String("port", "6543", "Server listen ports (semicolon-separated, e.g. 6543;6544;6545)") portStr := flag.String("port", "6543", "Server listen ports (semicolon-separated, e.g. 6543;6544;6545)")
flag.StringVar(portStr, "p", "6543", "Server listen ports (shorthand)") flag.StringVar(portStr, "p", "6543", "Server listen ports (shorthand)")
@@ -671,6 +752,7 @@ func main() {
logCfg.Compress = true logCfg.Compress = true
log := logger.New(logCfg) log := logger.New(logCfg)
log.Info("====== Copyright (c) 2026 simpleremoter.com. All rights resvered. ======")
// Track env vars where we fell back to a built-in default. Printed once // Track env vars where we fell back to a built-in default. Printed once
// at the end of startup so the operator sees what's in effect — vars the // at the end of startup so the operator sees what's in effect — vars the
@@ -700,13 +782,13 @@ func main() {
deviceHub := hub.New() deviceHub := hub.New()
// Build the CMD_MASTERSETTING signer based on env vars: // Build the CMD_MASTERSETTING signer based on env vars:
// - YAMA_SIGN_PASSWORD set → LocalSigner (operator's own deployment; // - YAMA_SIGN_PASSWORD set → LocalSigner (operator's own deployment;
// HMAC master key lives here) // HMAC master key lives here)
// - YAMA_LICENSE_SERVER + YAMA_LICENSE_TOKEN set → RemoteSigner // - YAMA_LICENSE_DISABLED=1 → NoOpSigner (explicit opt-out; dev / offline)
// (customer deployment; never sees the master key, fetches signatures // - YAMA_LICENSE_TOKEN set → RemoteSigner (paid customer; talks to
// from operator's License Server with 24h cache) // operator's License Server with JWT)
// - neither → NoOpSigner (free tier; client refuses screen/file ops // - neither of the above → RemoteSigner (anonymous trial; default
// but device list still works) // URL, no JWT, cap FreeMaxDevices)
signer, mode, err := licensing.NewFromEnv(log) signer, mode, err := licensing.NewFromEnv(log)
if err != nil { if err != nil {
log.Fatal("Failed to initialize signer: %v", err) log.Fatal("Failed to initialize signer: %v", err)
@@ -718,10 +800,21 @@ func main() {
case licensing.ModeLocal: case licensing.ModeLocal:
log.Info("Signer mode: LOCAL (operator deployment, master key held in-process)") log.Info("Signer mode: LOCAL (operator deployment, master key held in-process)")
case licensing.ModeRemote: case licensing.ModeRemote:
log.Info("Signer mode: REMOTE (customer deployment, %s=%s)", licServer := os.Getenv(licensing.EnvLicenseServer)
licensing.EnvLicenseServer, os.Getenv(licensing.EnvLicenseServer)) if licServer == "" {
licServer = licensing.DefaultLicenseServerURL
}
log.Info("Signer mode: REMOTE (paid customer, license server=%s)", licServer)
case licensing.ModeTrial:
licServer := os.Getenv(licensing.EnvLicenseServer)
if licServer == "" {
licServer = licensing.DefaultLicenseServerURL
}
log.Info("Signer mode: TRIAL (anonymous试用模式, license server=%s, 最多 %d 台受管设备; 设置 %s 解锁付费配额)",
licServer, licensing.FreeMaxDevices, licensing.EnvLicenseToken)
case licensing.ModeNoOp: case licensing.ModeNoOp:
log.Warn("Signer mode: NOOP (no licensing configured; screen/file features disabled on clients)") log.Warn("Signer mode: NOOP (licensing disabled via %s=1; client refuses screen/file features)",
licensing.EnvLicenseDisabled)
} }
// If the operator also wants this LocalSigner deployment to serve as // If the operator also wants this LocalSigner deployment to serve as
@@ -749,6 +842,10 @@ func main() {
adminPass = defaultWebAdminPass adminPass = defaultWebAdminPass
rememberDefault("YAMA_WEB_ADMIN_PASS", defaultWebAdminPass) rememberDefault("YAMA_WEB_ADMIN_PASS", defaultWebAdminPass)
usingDefaultWebPass = true usingDefaultWebPass = true
// Loud warn (in addition to the startup banner): the binary is now
// accepting admin/admin on the public web UI. Anyone running this
// without overriding the env var in prod needs to see it in red.
log.Warn("⚠ YAMA_WEB_ADMIN_PASS / YAMA_PWD 均未设置Web 管理使用默认密码 admin/admin — 生产环境务必覆盖")
} }
webAuth.AddAdminFromPlainPassword("admin", adminPass) webAuth.AddAdminFromPlainPassword("admin", adminPass)
log.Info("Web admin user configured") log.Info("Web admin user configured")
@@ -865,33 +962,30 @@ func main() {
}() }()
} }
fmt.Printf("Server started on port(s): %v\n", ports) log.Info("Server started on port(s): %v", ports)
if *httpPort != 0 { if *httpPort != 0 {
fmt.Printf("Web UI on http://localhost:%d/\n", *httpPort) log.Info("Web UI on http://localhost:%d/", *httpPort)
if usingDefaultWebPass { if usingDefaultWebPass {
fmt.Printf(" Default login: admin / %s (set YAMA_WEB_ADMIN_PASS to override)\n", log.Info("Default login: admin / %s (set YAMA_WEB_ADMIN_PASS to override)", defaultWebAdminPass)
defaultWebAdminPass)
} }
} }
if licenseHTTP != nil { if licenseHTTP != nil {
fmt.Printf("License Server on http://%s/license/{sign,heartbeat}\n", licAddr) log.Info("License Server on http://%s/license/{sign,heartbeat}", licAddr)
} }
if len(defaultsUsed) > 0 { if len(defaultsUsed) > 0 {
fmt.Println()
fmt.Println("[!] Using built-in defaults (set the env var to override):")
for _, d := range defaultsUsed { for _, d := range defaultsUsed {
fmt.Printf(" %s = %s\n", d.name, d.value) log.Info("[!] Using built-in defaults (set the env var to override): %s = %s", d.name, d.value)
} }
} }
fmt.Println("Logs are written to: logs/server.log") log.Info("Logs are written to: logs/server.log")
fmt.Println("Press Ctrl+C to stop...") log.Info("Press Ctrl+C to stop...")
// Wait for interrupt signal // Wait for interrupt signal
sigChan := make(chan os.Signal, 1) sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM) signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
<-sigChan <-sigChan
fmt.Println("\nShutting down...") log.Info("\nShutting down...")
// Order matters: drain License Server HTTP first so no handleSign is // Order matters: drain License Server HTTP first so no handleSign is
// mid-flight; THEN close the signer (which may release HTTP keepalives // mid-flight; THEN close the signer (which may release HTTP keepalives
// in RemoteSigner mode, or be a no-op for LocalSigner/NoOp). // in RemoteSigner mode, or be a no-op for LocalSigner/NoOp).
@@ -907,5 +1001,5 @@ func main() {
for _, srv := range servers { for _, srv := range servers {
srv.Stop() srv.Stop()
} }
fmt.Println("Server stopped") log.Info("Server stopped")
} }

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

View File

@@ -0,0 +1,60 @@
{
"RT_GROUP_ICON": {
"APP": {
"0000": [
"icon.png"
]
}
},
"RT_MANIFEST": {
"#1": {
"0409": {
"identity": {
"name": "YAMA Go Server",
"version": "1.0.0"
},
"description": "YAMA Go Server",
"minimum-os": "win7",
"execution-level": "as invoker",
"ui-access": false,
"auto-elevate": false,
"dpi-awareness": "system",
"disable-theming": false,
"disable-window-filtering": false,
"high-resolution-scrolling-aware": false,
"ultra-high-resolution-scrolling-aware": false,
"long-path-aware": false,
"printer-driver-isolation": false,
"gdi-scaling": false,
"segment-heap": false,
"use-common-controls-v6": true
}
}
},
"RT_VERSION": {
"#1": {
"0000": {
"fixed": {
"file_version": "1.0.0.0",
"product_version": "1.0.0.0"
},
"info": {
"0409": {
"Comments": "YAMA Go Remote Desktop Server",
"CompanyName": "SimpleRemoter",
"FileDescription": "YAMA Go Remote Desktop Server",
"FileVersion": "1.0.0",
"InternalName": "YamaGo.exe",
"LegalCopyright": "Copyright © 2026 YAMA",
"LegalTrademarks": "",
"OriginalFilename": "YamaGo.exe",
"PrivateBuild": "",
"ProductName": "YAMA Go Server",
"ProductVersion": "1.0.0",
"SpecialBuild": ""
}
}
}
}
}
}

View File

@@ -7,6 +7,7 @@ require (
github.com/gorilla/websocket v1.5.3 github.com/gorilla/websocket v1.5.3
github.com/klauspost/compress v1.18.2 github.com/klauspost/compress v1.18.2
github.com/rs/zerolog v1.34.0 github.com/rs/zerolog v1.34.0
golang.org/x/sync v0.20.0
golang.org/x/text v0.32.0 golang.org/x/text v0.32.0
gopkg.in/natefinch/lumberjack.v2 v2.2.1 gopkg.in/natefinch/lumberjack.v2 v2.2.1
) )
@@ -14,6 +15,5 @@ require (
require ( require (
github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.19 // indirect github.com/mattn/go-isatty v0.0.19 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.12.0 // indirect golang.org/x/sys v0.12.0 // indirect
) )

View File

@@ -13,11 +13,20 @@ const (
EnvSignPassword = "YAMA_SIGN_PASSWORD" // LocalSigner master HMAC key EnvSignPassword = "YAMA_SIGN_PASSWORD" // LocalSigner master HMAC key
EnvLicenseServer = "YAMA_LICENSE_SERVER" // RemoteSigner: License Server base URL EnvLicenseServer = "YAMA_LICENSE_SERVER" // RemoteSigner: License Server base URL
EnvLicenseToken = "YAMA_LICENSE_TOKEN" // RemoteSigner: customer JWT EnvLicenseToken = "YAMA_LICENSE_TOKEN" // RemoteSigner: customer JWT
EnvLicensePrivKeyPath = "YAMA_LICENSE_PRIVATE_KEY" // issue-token: RSA private key PEM path (paired with public key)
EnvLicensePubKeyPath = "YAMA_LICENSE_PUBLIC_KEY" // LocalSigner-as-LS: RSA public key PEM path EnvLicensePubKeyPath = "YAMA_LICENSE_PUBLIC_KEY" // LocalSigner-as-LS: RSA public key PEM path
EnvLicenseHTTPAddr = "YAMA_LICENSE_HTTP_ADDR" // LocalSigner-as-LS: listen address, e.g. ":8443" EnvLicenseHTTPAddr = "YAMA_LICENSE_HTTP_ADDR" // LocalSigner-as-LS: listen address, e.g. ":8443"
EnvLicenseStatePath = "YAMA_LICENSE_STATE_PATH" // LocalSigner-as-LS: quota state persistence file path
EnvLicenseOfflineHrs = "YAMA_LICENSE_OFFLINE_HRS" // RemoteSigner: cache TTL hours (default 24) EnvLicenseOfflineHrs = "YAMA_LICENSE_OFFLINE_HRS" // RemoteSigner: cache TTL hours (default 24)
EnvLicenseDisabled = "YAMA_LICENSE_DISABLED" // set to 1 to force NoOpSigner (offline / dev)
) )
// DefaultLicenseServerURL is the publicly-hosted License Server new downstream
// deployments hit when YAMA_LICENSE_SERVER is unset. "Zero config" trial mode
// uses this URL with no Bearer token — the License Server treats it as an
// anonymous trial (cap FreeMaxDevices, identified by source IP).
const DefaultLicenseServerURL = "https://web.just-do-it.icu:8080"
// DefaultOfflineGrace mirrors the "24 hours" decision recorded in the // DefaultOfflineGrace mirrors the "24 hours" decision recorded in the
// project memory's licensing design. // project memory's licensing design.
const DefaultOfflineGrace = 24 * time.Hour const DefaultOfflineGrace = 24 * time.Hour
@@ -28,6 +37,7 @@ type Mode int
const ( const (
ModeLocal Mode = iota ModeLocal Mode = iota
ModeRemote ModeRemote
ModeTrial // RemoteSigner against License Server, but with no Bearer (anonymous trial)
ModeNoOp ModeNoOp
) )
@@ -37,6 +47,8 @@ func (m Mode) String() string {
return "local" return "local"
case ModeRemote: case ModeRemote:
return "remote" return "remote"
case ModeTrial:
return "trial"
default: default:
return "noop" return "noop"
} }
@@ -48,16 +60,25 @@ func SelectedMode() Mode {
if os.Getenv(EnvSignPassword) != "" { if os.Getenv(EnvSignPassword) != "" {
return ModeLocal return ModeLocal
} }
if os.Getenv(EnvLicenseServer) != "" && os.Getenv(EnvLicenseToken) != "" { if strings.TrimSpace(os.Getenv(EnvLicenseDisabled)) == "1" {
return ModeNoOp
}
if os.Getenv(EnvLicenseToken) != "" {
return ModeRemote return ModeRemote
} }
return ModeNoOp return ModeTrial
} }
// NewFromEnv builds the Signer chosen by env vars: // NewFromEnv builds the Signer chosen by env vars. Decision tree (top-down,
// - YAMA_SIGN_PASSWORD set → LocalSigner // first match wins):
// - YAMA_LICENSE_SERVER + YAMA_LICENSE_TOKEN set → RemoteSigner //
// - neither → NoOpSigner // - YAMA_SIGN_PASSWORD set → LocalSigner (operator deployment)
// - YAMA_LICENSE_DISABLED=1 → NoOpSigner (explicit opt-out)
// - YAMA_LICENSE_TOKEN set → RemoteSigner / paid (server URL
// defaults to DefaultLicenseServerURL
// if YAMA_LICENSE_SERVER is unset)
// - neither of the above → RemoteSigner / trial (anonymous,
// cap = FreeMaxDevices, default URL)
// //
// If both LocalSigner and RemoteSigner vars are set, LocalSigner wins // If both LocalSigner and RemoteSigner vars are set, LocalSigner wins
// (an operator's own server should never accidentally call out to itself). // (an operator's own server should never accidentally call out to itself).
@@ -82,36 +103,43 @@ func NewFromEnv(lg Logger) (Signer, Mode, error) {
return s, ModeLocal, nil return s, ModeLocal, nil
} }
if server != "" && token != "" { // Explicit opt-out: operator wants the binary to run with no licensing
if err := ValidateRemoteURL(server); err != nil { // at all (dev, offline test, air-gapped). Screen/file features stay off
return nil, ModeNoOp, fmt.Errorf("%s rejected: %w", EnvLicenseServer, err) // on the client, device list still works.
if strings.TrimSpace(os.Getenv(EnvLicenseDisabled)) == "1" {
return NewNoOp(), ModeNoOp, nil
}
// From here on we're going to talk to a License Server. Determine the
// URL (env var wins over baked-in default) and the mode (paid if token
// is set, anonymous trial if not).
if server == "" {
server = DefaultLicenseServerURL
}
if err := ValidateRemoteURL(server); err != nil {
return nil, ModeNoOp, fmt.Errorf("%s rejected: %w", EnvLicenseServer, err)
}
grace := DefaultOfflineGrace
if hrs := os.Getenv(EnvLicenseOfflineHrs); hrs != "" {
n, err := strconv.Atoi(strings.TrimSpace(hrs))
if err != nil {
return nil, ModeNoOp, fmt.Errorf(
"%s must be an integer (hours), got %q", EnvLicenseOfflineHrs, hrs)
} }
grace := DefaultOfflineGrace if n < 0 {
if hrs := os.Getenv(EnvLicenseOfflineHrs); hrs != "" { return nil, ModeNoOp, fmt.Errorf(
n, err := strconv.Atoi(strings.TrimSpace(hrs)) "%s must be >= 0, got %d", EnvLicenseOfflineHrs, n)
if err != nil {
return nil, ModeNoOp, fmt.Errorf(
"%s must be an integer (hours), got %q", EnvLicenseOfflineHrs, hrs)
}
if n < 0 {
return nil, ModeNoOp, fmt.Errorf(
"%s must be >= 0, got %d", EnvLicenseOfflineHrs, n)
}
grace = time.Duration(n) * time.Hour
} }
grace = time.Duration(n) * time.Hour
}
if token != "" {
return NewRemote(server, token, grace, lg), ModeRemote, nil return NewRemote(server, token, grace, lg), ModeRemote, nil
} }
// Anonymous trial: no Bearer token. License Server identifies by IP and
if server != "" || token != "" { // caps at FreeMaxDevices.
// Partial config is almost certainly a misconfiguration — fail loudly return NewRemote(server, "", grace, lg), ModeTrial, nil
// rather than silently degrading to NoOp.
return nil, ModeNoOp, fmt.Errorf(
"%s and %s must be set together (got %s=%q %s=%q)",
EnvLicenseServer, EnvLicenseToken,
EnvLicenseServer, server, EnvLicenseToken, token)
}
return NewNoOp(), ModeNoOp, nil
} }
// LicenseServerFromEnv builds the License Server HTTP handler if (and only // LicenseServerFromEnv builds the License Server HTTP handler if (and only
@@ -150,5 +178,15 @@ func LicenseServerFromEnv(signer Signer, lg Logger) (*LicenseServer, string, err
// 5-minute eviction window — twice a typical heartbeat interval. Matches // 5-minute eviction window — twice a typical heartbeat interval. Matches
// the discussion in quota.go. // the discussion in quota.go.
return NewLicenseServer(local, pubKey, 5*time.Minute, lg), addr, nil ls := NewLicenseServer(local, pubKey, 5*time.Minute, lg, os.Getenv(EnvLicenseStatePath))
// Reuse the web's trust-proxy env var: standard deployment puts both
// /ws and /license/ behind the same nginx, so the answer is always the
// same. Honoring it here lets the anonymous-trial per-IP rate limit see
// the real client IP instead of 127.0.0.1.
if strings.TrimSpace(os.Getenv("YAMA_WEB_TRUST_PROXY")) == "1" {
ls.SetTrustProxy(true)
}
return ls, addr, nil
} }

View File

@@ -138,7 +138,7 @@ func TestLocalSignerDeterministic(t *testing.T) {
func TestRemoteSignerCacheHit(t *testing.T) { func TestRemoteSignerCacheHit(t *testing.T) {
priv := testKey(t) priv := testKey(t)
master := mustLocal(t, "real-hmac-key-for-test-xx") master := mustLocal(t, "real-hmac-key-for-test-xx")
ls := NewLicenseServer(master, &priv.PublicKey, time.Minute, silentLogger{}) ls := NewLicenseServer(master, &priv.PublicKey, time.Minute, silentLogger{}, "")
ts := httptest.NewServer(ls.Handler()) ts := httptest.NewServer(ls.Handler())
defer ts.Close() defer ts.Close()
@@ -180,7 +180,7 @@ func TestRemoteSignerCacheHit(t *testing.T) {
func TestRemoteSignerStaleFallback(t *testing.T) { func TestRemoteSignerStaleFallback(t *testing.T) {
priv := testKey(t) priv := testKey(t)
master := mustLocal(t, "master-fallback-test-xxx") master := mustLocal(t, "master-fallback-test-xxx")
ls := NewLicenseServer(master, &priv.PublicKey, time.Minute, silentLogger{}) ls := NewLicenseServer(master, &priv.PublicKey, time.Minute, silentLogger{}, "")
ts := httptest.NewServer(ls.Handler()) ts := httptest.NewServer(ls.Handler())
tok, err := Issue(priv, "cust-fallback", TierPaid, 5, time.Hour) tok, err := Issue(priv, "cust-fallback", TierPaid, 5, time.Hour)
@@ -214,7 +214,7 @@ func TestRemoteSignerStaleFallback(t *testing.T) {
func TestQuotaEnforcement(t *testing.T) { func TestQuotaEnforcement(t *testing.T) {
priv := testKey(t) priv := testKey(t)
master := mustLocal(t, "master-quota-test-xxxxxx") master := mustLocal(t, "master-quota-test-xxxxxx")
ls := NewLicenseServer(master, &priv.PublicKey, time.Minute, silentLogger{}) ls := NewLicenseServer(master, &priv.PublicKey, time.Minute, silentLogger{}, "")
ts := httptest.NewServer(ls.Handler()) ts := httptest.NewServer(ls.Handler())
defer ts.Close() defer ts.Close()
@@ -253,23 +253,102 @@ func TestQuotaEnforcement(t *testing.T) {
} }
} }
// TestAuthRejectsMissingBearer: no token → 401, not 200 / not 500. Belt // TestAnonymousTrialSignsAndCaps: no Authorization header → anonymous trial
// and braces — the auth check sits in front of /sign and /heartbeat. // branch. /sign returns 200 with a real signature up to FreeMaxDevices, then
func TestAuthRejectsMissingBearer(t *testing.T) { // 403 once the per-IP cap is reached. Replaces the older "missing bearer
// 401" test now that anonymous trial is a first-class mode.
func TestAnonymousTrialSignsAndCaps(t *testing.T) {
priv := testKey(t) priv := testKey(t)
master := mustLocal(t, "master-auth-test-xxxxxxx") master := mustLocal(t, "master-trial-test-xxxxxx")
ls := NewLicenseServer(master, &priv.PublicKey, time.Minute, silentLogger{}) ls := NewLicenseServer(master, &priv.PublicKey, time.Minute, silentLogger{}, "")
ts := httptest.NewServer(ls.Handler()) ts := httptest.NewServer(ls.Handler())
defer ts.Close() defer ts.Close()
body := strings.NewReader(`{"client_id":"x","start_time":"y"}`) call := func(clientID string) (int, string) {
resp, err := http.Post(ts.URL+"/license/sign", "application/json", body) body := strings.NewReader(fmt.Sprintf(
`{"client_id":%q,"start_time":"2026-01-01T00:00:00Z"}`, clientID))
resp, err := http.Post(ts.URL+"/license/sign", "application/json", body)
if err != nil {
t.Fatalf("Post: %v", err)
}
defer resp.Body.Close()
var sr signResponse
_ = json.NewDecoder(resp.Body).Decode(&sr)
if sr.Signature != "" {
return resp.StatusCode, sr.Signature
}
return resp.StatusCode, sr.Error
}
// First FreeMaxDevices distinct clientIDs get real signatures.
for i := range FreeMaxDevices {
code, sig := call(fmt.Sprintf("trial-dev-%d", i))
if code != http.StatusOK {
t.Errorf("dev-%d expected 200, got %d (%q)", i, code, sig)
}
if sig == "" {
t.Errorf("dev-%d signature unexpectedly empty", i)
}
}
// Cap+1 → 403 quota exceeded.
code, msg := call("trial-dev-overflow")
if code != http.StatusForbidden {
t.Errorf("overflow expected 403, got %d (%q)", code, msg)
}
}
// TestAnonymousTrialIPRateLimit: anonymous /sign is capped at
// anonRatePerWindow requests per minute per source IP. Hitting the cap
// returns 429 with Retry-After.
func TestAnonymousTrialIPRateLimit(t *testing.T) {
priv := testKey(t)
master := mustLocal(t, "master-rate-test-xxxxxxx")
ls := NewLicenseServer(master, &priv.PublicKey, time.Minute, silentLogger{}, "")
ts := httptest.NewServer(ls.Handler())
defer ts.Close()
// Reuse the same clientID so quota does NOT also reject — we want to
// isolate the rate limiter. quotaTracker.Reserve treats a repeat clientID
// as a refresh (always accepted), so all the 200s here are the same slot.
hit := func() int {
body := strings.NewReader(`{"client_id":"rate-dev","start_time":"t"}`)
resp, err := http.Post(ts.URL+"/license/sign", "application/json", body)
if err != nil {
t.Fatalf("Post: %v", err)
}
resp.Body.Close()
return resp.StatusCode
}
for i := range anonRatePerWindow {
if code := hit(); code != http.StatusOK {
t.Fatalf("req %d expected 200, got %d", i, code)
}
}
if code := hit(); code != http.StatusTooManyRequests {
t.Errorf("expected 429 after %d requests, got %d", anonRatePerWindow, code)
}
}
// TestAuthRejectsBadBearer: invalid JWT still returns 401 (we did NOT widen
// the auth surface; only "no Authorization header at all" enters trial).
func TestAuthRejectsBadBearer(t *testing.T) {
priv := testKey(t)
master := mustLocal(t, "master-bad-bearer-xxxxxx")
ls := NewLicenseServer(master, &priv.PublicKey, time.Minute, silentLogger{}, "")
ts := httptest.NewServer(ls.Handler())
defer ts.Close()
req, _ := http.NewRequest("POST", ts.URL+"/license/sign",
strings.NewReader(`{"client_id":"x","start_time":"y"}`))
req.Header.Set("Authorization", "Bearer not.a.real.jwt")
resp, err := http.DefaultClient.Do(req)
if err != nil { if err != nil {
t.Fatalf("Post: %v", err) t.Fatalf("Do: %v", err)
} }
defer resp.Body.Close() defer resp.Body.Close()
if resp.StatusCode != http.StatusUnauthorized { if resp.StatusCode != http.StatusUnauthorized {
t.Errorf("expected 401, got %d", resp.StatusCode) t.Errorf("expected 401 for malformed bearer, got %d", resp.StatusCode)
} }
} }
@@ -299,7 +378,7 @@ func TestRemoteSignerHardFailNoCacheReturnsError(t *testing.T) {
func TestHeartbeatRefreshOnly(t *testing.T) { func TestHeartbeatRefreshOnly(t *testing.T) {
priv := testKey(t) priv := testKey(t)
master := mustLocal(t, "master-hb-test-xxxxxxxxxx") master := mustLocal(t, "master-hb-test-xxxxxxxxxx")
ls := NewLicenseServer(master, &priv.PublicKey, time.Minute, silentLogger{}) ls := NewLicenseServer(master, &priv.PublicKey, time.Minute, silentLogger{}, "")
ts := httptest.NewServer(ls.Handler()) ts := httptest.NewServer(ls.Handler())
defer ts.Close() defer ts.Close()
@@ -382,7 +461,7 @@ func TestHeartbeatRefreshOnly(t *testing.T) {
func TestQuotaRejectionDoesNotConsumeSlot(t *testing.T) { func TestQuotaRejectionDoesNotConsumeSlot(t *testing.T) {
priv := testKey(t) priv := testKey(t)
master := mustLocal(t, "master-no-leak-xxxxxxxxxxxx") master := mustLocal(t, "master-no-leak-xxxxxxxxxxxx")
ls := NewLicenseServer(master, &priv.PublicKey, time.Minute, silentLogger{}) ls := NewLicenseServer(master, &priv.PublicKey, time.Minute, silentLogger{}, "")
ts := httptest.NewServer(ls.Handler()) ts := httptest.NewServer(ls.Handler())
defer ts.Close() defer ts.Close()
@@ -518,3 +597,36 @@ func TestJWTAlgLockedToRS256(t *testing.T) {
t.Error("VerifyJWT accepted RS384; alg should be locked to RS256") t.Error("VerifyJWT accepted RS384; alg should be locked to RS256")
} }
} }
// TestQuotaTrackerPersistence: after a simulated restart (new tracker loaded
// from the file written by the first), previously-admitted devices re-occupy
// their slots and a new over-quota device is still rejected.
func TestQuotaTrackerPersistence(t *testing.T) {
path := t.TempDir() + "/quota.json"
// First "run": admit dev-1 and dev-2 up to cap=2.
q1 := newQuotaTracker(5 * time.Minute)
q1.statePath = path
if _, ok := q1.Reserve("sub", "dev-1", 2); !ok {
t.Fatal("dev-1 should be admitted")
}
if _, ok := q1.Reserve("sub", "dev-2", 2); !ok {
t.Fatal("dev-2 should be admitted")
}
// Simulate restart: new tracker loads the persisted file.
q2 := newQuotaTracker(5 * time.Minute)
q2.statePath = path
if err := q2.Load(); err != nil {
t.Fatalf("Load: %v", err)
}
// Restored tracker knows about dev-1 and dev-2: quota full.
if count, ok := q2.Reserve("sub", "dev-3", 2); ok {
t.Errorf("dev-3 should be rejected after restore, count=%d", count)
}
// Existing devices re-sign successfully (idempotent refresh).
if _, ok := q2.Reserve("sub", "dev-1", 2); !ok {
t.Error("dev-1 re-sign should succeed after restore")
}
}

View File

@@ -1,6 +1,8 @@
package licensing package licensing
import ( import (
"encoding/json"
"os"
"sync" "sync"
"time" "time"
) )
@@ -22,20 +24,31 @@ const (
TrialMaxDevices = 20 TrialMaxDevices = 20
) )
// persistedQuota is the on-disk snapshot format. V=1 is the current schema.
type persistedQuota struct {
V int `json:"v"` // schema version
Customers map[string][]string `json:"customers"` // sub → []clientID
}
// quotaTracker maintains the active-device set per customer. Customers are // quotaTracker maintains the active-device set per customer. Customers are
// identified by the JWT "sub" claim. The set is keyed by clientID (uint64 // identified by the JWT "sub" claim. The set is keyed by clientID (uint64
// from the device, stringified) — same device coming back through the // from the device, stringified) — same device coming back through the
// same License Server is one slot, not two. // same License Server is one slot, not two.
// //
// Eviction: any clientID not seen in /sign or /license/heartbeat within // Eviction: any clientID not seen in /sign or /license/heartbeat within
// the eviction window is silently dropped from the active set. This stops // the eviction window is silently dropped from the active set. Default
// a never-heartbeating customer from holding slots forever. Default // window is 5 minutes (twice the heartbeat interval).
// window is twice the heartbeat interval the customer reports at (5 min).
// //
// Empty customer entries are reaped at the end of each mutation so the // Persistence: when statePath is set, the sub→clientID map is written
// outer map doesn't accumulate sub claims of expired contracts. // atomically to disk on every structural change (device added or evicted).
// Load() restores the state on startup with fresh timestamps so a License
// Server restart does not open a quota-bypass window.
//
// Empty customer entries are reaped at the end of each mutation.
type quotaTracker struct { type quotaTracker struct {
evictAfter time.Duration evictAfter time.Duration
statePath string // "" = no persistence
log Logger // nil = silent
mu sync.Mutex mu sync.Mutex
customer map[string]*customerState // sub claim → state customer map[string]*customerState // sub claim → state
@@ -52,14 +65,105 @@ func newQuotaTracker(evictAfter time.Duration) *quotaTracker {
} }
} }
// evictLocked drops stale entries from st.devices. Caller must hold q.mu. // Load reads the persisted state from statePath and restores each clientID
func (q *quotaTracker) evictLocked(st *customerState) { // with timestamp time.Now() so restored devices survive the initial eviction
// window long enough to heartbeat or re-sign. A missing or corrupt file is
// silently ignored so the server starts cleanly on first run.
func (q *quotaTracker) Load() error {
if q.statePath == "" {
return nil
}
data, err := os.ReadFile(q.statePath)
if err != nil {
if os.IsNotExist(err) {
return nil
}
return err
}
var p persistedQuota
if err := json.Unmarshal(data, &p); err != nil {
if q.log != nil {
q.log.Warn("quota: corrupt state file %s (starting empty): %v", q.statePath, err)
}
return nil
}
q.mu.Lock()
defer q.mu.Unlock()
now := time.Now()
restored := 0
for sub, ids := range p.Customers {
if len(ids) == 0 {
continue
}
st := &customerState{devices: make(map[string]time.Time, len(ids))}
for _, cid := range ids {
st.devices[cid] = now
restored++
}
q.customer[sub] = st
}
if q.log != nil && restored > 0 {
q.log.Info("quota: restored %d device slot(s) from %s", restored, q.statePath)
}
return nil
}
// snapshotLocked returns a sub→[]clientID map of the current state.
// Caller must hold q.mu.
func (q *quotaTracker) snapshotLocked() map[string][]string {
out := make(map[string][]string, len(q.customer))
for sub, st := range q.customer {
if len(st.devices) == 0 {
continue
}
ids := make([]string, 0, len(st.devices))
for cid := range st.devices {
ids = append(ids, cid)
}
out[sub] = ids
}
return out
}
// save writes snap atomically (temp file + rename). No-op when statePath is
// empty or snap is nil.
func (q *quotaTracker) save(snap map[string][]string) {
if q.statePath == "" || snap == nil {
return
}
data, err := json.Marshal(persistedQuota{V: 1, Customers: snap})
if err != nil {
if q.log != nil {
q.log.Warn("quota: marshal state: %v", err)
}
return
}
tmp := q.statePath + ".tmp"
if err := os.WriteFile(tmp, data, 0600); err != nil {
if q.log != nil {
q.log.Warn("quota: write state to %s: %v", tmp, err)
}
return
}
if err := os.Rename(tmp, q.statePath); err != nil {
if q.log != nil {
q.log.Warn("quota: rename %s → %s: %v", tmp, q.statePath, err)
}
}
}
// evictLocked drops stale entries from st.devices. Returns the number removed.
// Caller must hold q.mu.
func (q *quotaTracker) evictLocked(st *customerState) int {
cutoff := time.Now().Add(-q.evictAfter) cutoff := time.Now().Add(-q.evictAfter)
removed := 0
for cid, last := range st.devices { for cid, last := range st.devices {
if last.Before(cutoff) { if last.Before(cutoff) {
delete(st.devices, cid) delete(st.devices, cid)
removed++
} }
} }
return removed
} }
// reapEmptyLocked deletes sub entries whose device sets are empty. This // reapEmptyLocked deletes sub entries whose device sets are empty. This
@@ -84,7 +188,6 @@ func (q *quotaTracker) reapEmptyLocked(sub string) {
// re-signing is never a quota violation — caps only apply to ADDING). // re-signing is never a quota violation — caps only apply to ADDING).
func (q *quotaTracker) Reserve(sub, clientID string, maxDevices int) (int, bool) { func (q *quotaTracker) Reserve(sub, clientID string, maxDevices int) (int, bool) {
q.mu.Lock() q.mu.Lock()
defer q.mu.Unlock()
st, ok := q.customer[sub] st, ok := q.customer[sub]
if !ok { if !ok {
@@ -92,21 +195,38 @@ func (q *quotaTracker) Reserve(sub, clientID string, maxDevices int) (int, bool)
q.customer[sub] = st q.customer[sub] = st
} }
q.evictLocked(st) evicted := q.evictLocked(st)
if _, already := st.devices[clientID]; already { if _, already := st.devices[clientID]; already {
st.devices[clientID] = time.Now() st.devices[clientID] = time.Now()
return len(st.devices), true count := len(st.devices)
var snap map[string][]string
if evicted > 0 {
snap = q.snapshotLocked()
}
q.mu.Unlock()
q.save(snap)
return count, true
} }
if len(st.devices)+1 > maxDevices { if len(st.devices)+1 > maxDevices {
// Don't reap on rejection — the customer might be at exactly cap count := len(st.devices)
// with valid devices, and an empty map would lose info. var snap map[string][]string
return len(st.devices), false if evicted > 0 {
snap = q.snapshotLocked()
}
q.mu.Unlock()
q.save(snap)
return count, false
} }
// New device admitted: always persist so a restart sees this slot.
st.devices[clientID] = time.Now() st.devices[clientID] = time.Now()
return len(st.devices), true count := len(st.devices)
snap := q.snapshotLocked()
q.mu.Unlock()
q.save(snap)
return count, true
} }
// RefreshExisting bumps the last-activity timestamp for any clientID in // RefreshExisting bumps the last-activity timestamp for any clientID in
@@ -118,14 +238,14 @@ func (q *quotaTracker) Reserve(sub, clientID string, maxDevices int) (int, bool)
// known to us from a prior Reserve). // known to us from a prior Reserve).
func (q *quotaTracker) RefreshExisting(sub string, clientIDs []string) int { func (q *quotaTracker) RefreshExisting(sub string, clientIDs []string) int {
q.mu.Lock() q.mu.Lock()
defer q.mu.Unlock()
st, ok := q.customer[sub] st, ok := q.customer[sub]
if !ok { if !ok {
q.mu.Unlock()
return 0 return 0
} }
q.evictLocked(st) evicted := q.evictLocked(st)
now := time.Now() now := time.Now()
refreshed := 0 refreshed := 0
@@ -137,6 +257,13 @@ func (q *quotaTracker) RefreshExisting(sub string, clientIDs []string) int {
} }
q.reapEmptyLocked(sub) // eviction may have emptied us q.reapEmptyLocked(sub) // eviction may have emptied us
var snap map[string][]string
if evicted > 0 {
snap = q.snapshotLocked()
}
q.mu.Unlock()
q.save(snap)
return refreshed return refreshed
} }
@@ -144,16 +271,25 @@ func (q *quotaTracker) RefreshExisting(sub string, clientIDs []string) int {
// /license/heartbeat to report the server-side view. // /license/heartbeat to report the server-side view.
func (q *quotaTracker) Snapshot(sub string) []string { func (q *quotaTracker) Snapshot(sub string) []string {
q.mu.Lock() q.mu.Lock()
defer q.mu.Unlock()
st, ok := q.customer[sub] st, ok := q.customer[sub]
if !ok { if !ok {
q.mu.Unlock()
return nil return nil
} }
q.evictLocked(st)
evicted := q.evictLocked(st)
out := make([]string, 0, len(st.devices)) out := make([]string, 0, len(st.devices))
for cid := range st.devices { for cid := range st.devices {
out = append(out, cid) out = append(out, cid)
} }
q.reapEmptyLocked(sub) q.reapEmptyLocked(sub)
var snap map[string][]string
if evicted > 0 {
snap = q.snapshotLocked()
}
q.mu.Unlock()
q.save(snap)
return out return out
} }

View File

@@ -16,6 +16,25 @@ import (
"golang.org/x/sync/singleflight" "golang.org/x/sync/singleflight"
) )
// QuotaExceededError is returned by Sign when the License Server explicitly
// rejects the device because the customer's slot quota is full. Unlike
// transient network errors, stale-cache fallback is NOT appropriate — the
// License Server's 403 decision is authoritative, and serving a stale
// signature would silently bypass the operator's cap.
type QuotaExceededError struct {
Message string // raw error field from the License Server JSON body
}
func (e *QuotaExceededError) Error() string { return e.Message }
// IsQuotaExceeded reports whether err is, or wraps, a QuotaExceededError.
// Callers (e.g. handleLogin in cmd/main.go) use this to decide whether to
// close the device connection server-side after sending a zeroed signature.
func IsQuotaExceeded(err error) bool {
var qe *QuotaExceededError
return errors.As(err, &qe)
}
// RemoteSigner fetches per-login signatures from an operator-hosted License // RemoteSigner fetches per-login signatures from an operator-hosted License
// Server. ServerURL and Token (a JWT issued offline by the operator) are // Server. ServerURL and Token (a JWT issued offline by the operator) are
// loaded from YAMA_LICENSE_SERVER / YAMA_LICENSE_TOKEN at startup. // loaded from YAMA_LICENSE_SERVER / YAMA_LICENSE_TOKEN at startup.
@@ -153,8 +172,17 @@ func (r *RemoteSigner) Sign(startTime, clientID string) (string, error) {
return sig, nil return sig, nil
} }
// Hard failure: fall back to stale cache if any. Better to keep an // Quota-exceeded is authoritative — skip stale-cache fallback entirely.
// existing device alive than fail closed during a transient outage. // Serving a cached signature here would bypass the operator's explicit cap
// decision; the caller (handleLogin) should close the connection instead.
if IsQuotaExceeded(err) {
r.logger.Error("RemoteSigner: quota exceeded for clientID=%s (%v); sending zeroed signature",
clientID, err)
return "", err
}
// Transient failure: fall back to stale cache if any. Better to keep an
// existing device alive than fail closed during a momentary outage.
r.mu.Lock() r.mu.Lock()
c, ok := r.cache[key] c, ok := r.cache[key]
r.mu.Unlock() r.mu.Unlock()
@@ -179,7 +207,9 @@ func (r *RemoteSigner) fetch(startTime, clientID string) (string, error) {
if err != nil { if err != nil {
return "", err return "", err
} }
req.Header.Set("Authorization", "Bearer "+r.token) if r.token != "" {
req.Header.Set("Authorization", "Bearer "+r.token)
}
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
resp, err := r.httpClient.Do(req) resp, err := r.httpClient.Do(req)
@@ -194,7 +224,17 @@ func (r *RemoteSigner) fetch(startTime, clientID string) (string, error) {
} }
if resp.StatusCode != http.StatusOK { if resp.StatusCode != http.StatusOK {
// 401/403: token rejected — likely revoked or expired. // 403 with a JSON error body: quota exceeded — this is authoritative,
// not a transient failure. Return a typed error so Sign() can skip
// the stale-cache fallback and callers can close the connection.
if resp.StatusCode == http.StatusForbidden {
var sr signResponse
if jsonErr := json.Unmarshal(respBody, &sr); jsonErr == nil && sr.Error != "" {
return "", &QuotaExceededError{Message: sr.Error}
}
return "", &QuotaExceededError{Message: string(respBody)}
}
// 401 / 5xx: token rejected or server error — treat as transient.
return "", fmt.Errorf("License Server returned %d: %s", return "", fmt.Errorf("License Server returned %d: %s",
resp.StatusCode, string(respBody)) resp.StatusCode, string(respBody))
} }
@@ -265,7 +305,9 @@ func (r *RemoteSigner) sendHeartbeat() {
if err != nil { if err != nil {
return return
} }
req.Header.Set("Authorization", "Bearer "+r.token) if r.token != "" {
req.Header.Set("Authorization", "Bearer "+r.token)
}
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
resp, err := r.httpClient.Do(req) resp, err := r.httpClient.Do(req)
@@ -280,7 +322,15 @@ func (r *RemoteSigner) sendHeartbeat() {
} }
} }
func (r *RemoteSigner) Mode() string { return "remote" } // Mode reports "trial" if this RemoteSigner has no JWT (anonymous
// downstream against the operator's License Server, capped at
// FreeMaxDevices), otherwise "remote" (paid customer with JWT).
func (r *RemoteSigner) Mode() string {
if r.token == "" {
return "trial"
}
return "remote"
}
func (r *RemoteSigner) Close() error { func (r *RemoteSigner) Close() error {
select { select {

View File

@@ -5,13 +5,36 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"net"
"net/http" "net/http"
"strings" "strings"
"sync"
"time" "time"
"github.com/golang-jwt/jwt/v5" "github.com/golang-jwt/jwt/v5"
) )
// Anonymous-trial rate limit: per-source-IP cap on /license/sign +
// /license/heartbeat requests without a Bearer token. Picked at "high enough
// for any legitimate single deployment, low enough to make brute-force /
// signature-probing pointless." Each /sign costs 1, /heartbeat 1, in a 60s
// sliding window. Authenticated requests skip this.
const (
anonRatePerWindow = 10
anonRateWindow = time.Minute
// anonReapInterval throws away stale buckets so the map doesn't grow
// unbounded across IP-cycling attackers. Walk the map every N requests.
anonReapEvery = 200
)
// Log-throttle cooldowns: downstream devices reconnect every few seconds when
// over-quota, so without throttling these two Warn lines flood the operator's
// log file. One log entry per cooldown window per unique key is enough signal.
const (
quotaWarnCooldown = 5 * time.Minute // per (sub, clientID) pair
rlWarnCooldown = anonRateWindow // per IP, matches the rate-limit window
)
// LicenseServer is the HTTP service the operator's LocalSigner exposes for // LicenseServer is the HTTP service the operator's LocalSigner exposes for
// RemoteSigner customer deployments. It uses the same LocalSigner instance // RemoteSigner customer deployments. It uses the same LocalSigner instance
// (same HMAC master key) to produce signatures so customers can issue // (same HMAC master key) to produce signatures so customers can issue
@@ -37,12 +60,32 @@ import (
// //
// Security: serve plain HTTP and put nginx / Caddy in front for TLS. JWT // Security: serve plain HTTP and put nginx / Caddy in front for TLS. JWT
// "alg" is locked to RS256 in token.go; "alg":"none" tampering is blocked. // "alg" is locked to RS256 in token.go; "alg":"none" tampering is blocked.
//
// Anonymous trial: requests without a Bearer token are treated as anonymous
// trial — the client's source IP is used as "sub" (key=`trial:<ip>`) and
// MaxDevices is capped at FreeMaxDevices. This is what lets a zero-config
// downstream binary "just work" for evaluation. Heavily rate-limited per IP
// to make brute-force / signature-probing pointless.
type LicenseServer struct { type LicenseServer struct {
signer *LocalSigner signer *LocalSigner
pubKey *rsa.PublicKey pubKey *rsa.PublicKey
tracker *quotaTracker tracker *quotaTracker
logger Logger logger Logger
mux *http.ServeMux mux *http.ServeMux
trustProxy bool // honor X-Forwarded-For / X-Real-IP — set only behind a trusted reverse proxy
anonMu sync.Mutex
anonBuckets map[string]*anonBucket // ip → bucket
anonReqSeen int // counter for periodic reap
warnMu sync.Mutex
lastWarn map[string]time.Time // dedup key → last log time
}
// anonBucket tracks anonymous request count within a sliding window.
type anonBucket struct {
count int
windowStart time.Time
} }
// Logger is the minimal logging interface we need. The cmd package's // Logger is the minimal logging interface we need. The cmd package's
@@ -57,19 +100,50 @@ type Logger interface {
// quiet device keeps its slot before its quota is reclaimed (recommend // quiet device keeps its slot before its quota is reclaimed (recommend
// 5 min — twice a typical heartbeat interval). // 5 min — twice a typical heartbeat interval).
func NewLicenseServer(signer *LocalSigner, pubKey *rsa.PublicKey, func NewLicenseServer(signer *LocalSigner, pubKey *rsa.PublicKey,
evictAfter time.Duration, lg Logger) *LicenseServer { evictAfter time.Duration, lg Logger, statePath string) *LicenseServer {
qt := newQuotaTracker(evictAfter)
qt.statePath = statePath
qt.log = lg
if err := qt.Load(); err != nil && lg != nil {
lg.Warn("License Server: failed to load quota state from %s: %v", statePath, err)
}
s := &LicenseServer{ s := &LicenseServer{
signer: signer, signer: signer,
pubKey: pubKey, pubKey: pubKey,
tracker: newQuotaTracker(evictAfter), tracker: qt,
logger: lg, logger: lg,
mux: http.NewServeMux(), mux: http.NewServeMux(),
anonBuckets: make(map[string]*anonBucket),
lastWarn: make(map[string]time.Time),
} }
s.mux.HandleFunc("/license/sign", s.handleSign) s.mux.HandleFunc("/license/sign", s.handleSign)
s.mux.HandleFunc("/license/heartbeat", s.handleHeartbeat) s.mux.HandleFunc("/license/heartbeat", s.handleHeartbeat)
return s return s
} }
// warnOnce emits a Warn log at most once per cooldown window for the given
// dedup key. Subsequent identical events within the window are silently
// dropped. This keeps high-frequency but expected conditions (quota exceeded,
// rate limit hit) from flooding the operator's log file while still providing
// one clear signal per event burst.
func (s *LicenseServer) warnOnce(key string, cooldown time.Duration, format string, args ...any) {
s.warnMu.Lock()
if t, ok := s.lastWarn[key]; ok && time.Since(t) < cooldown {
s.warnMu.Unlock()
return
}
s.lastWarn[key] = time.Now()
s.warnMu.Unlock()
s.logger.Warn(format, args...)
}
// SetTrustProxy switches IP extraction to X-Forwarded-For / X-Real-IP for
// the anonymous-trial branch. Only set this when running behind a reverse
// proxy you control (nginx / caddy / cloudflare); direct-exposure
// deployments MUST leave it false or attackers can spoof the header to
// evade the per-IP rate limit and the trial quota.
func (s *LicenseServer) SetTrustProxy(trust bool) { s.trustProxy = trust }
// Handler returns the http.Handler the operator wires into their HTTP // Handler returns the http.Handler the operator wires into their HTTP
// server (or runs standalone via http.ListenAndServe). // server (or runs standalone via http.ListenAndServe).
func (s *LicenseServer) Handler() http.Handler { return s.mux } func (s *LicenseServer) Handler() http.Handler { return s.mux }
@@ -92,13 +166,107 @@ func (s *LicenseServer) authenticate(w http.ResponseWriter, r *http.Request) *Li
return claims return claims
} }
// resolveAuth decides whether the request is paid (Bearer JWT) or anonymous
// trial (no Authorization header). Returns a LicenseClaims structure either
// way:
// - Paid: claims from JWT, untouched.
// - Trial: synthesized claims with Subject="trial:<ip>", Tier=TierFree,
// MaxDevices=FreeMaxDevices, no Bearer required.
//
// Anonymous requests are rate-limited per source IP; if the IP's bucket is
// full we write 429 and return nil. Bad JWTs still 401 as before.
func (s *LicenseServer) resolveAuth(w http.ResponseWriter, r *http.Request) *LicenseClaims {
if r.Header.Get("Authorization") != "" {
return s.authenticate(w, r)
}
// Anonymous trial branch.
ip := s.clientIP(r)
if !s.allowAnon(ip) {
s.warnOnce("rl:"+ip, rlWarnCooldown, "License Server: anonymous rate limit hit for ip=%s", ip)
w.Header().Set("Retry-After", "60")
writeJSONError(w, http.StatusTooManyRequests,
"trial rate limit exceeded; set YAMA_LICENSE_TOKEN for full license")
return nil
}
return &LicenseClaims{
Tier: TierFree,
MaxDevices: FreeMaxDevices,
RegisteredClaims: jwt.RegisteredClaims{
Subject: "trial:" + ip,
},
}
}
// clientIP returns the request's source IP. When trustProxy is set, prefer
// X-Real-IP, then the last entry of X-Forwarded-For. Otherwise fall back to
// r.RemoteAddr (host part only).
func (s *LicenseServer) clientIP(r *http.Request) string {
if s.trustProxy {
if v := strings.TrimSpace(r.Header.Get("X-Real-IP")); v != "" {
return v
}
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
// Last entry is the one appended by the proxy closest to us.
parts := strings.Split(xff, ",")
last := strings.TrimSpace(parts[len(parts)-1])
if last != "" {
return last
}
}
}
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
return r.RemoteAddr
}
return host
}
// allowAnon enforces the anonymous-trial per-IP rate limit. Returns true
// when the call is admitted, false when the IP's bucket is full. Also reaps
// stale buckets opportunistically.
func (s *LicenseServer) allowAnon(ip string) bool {
s.anonMu.Lock()
defer s.anonMu.Unlock()
now := time.Now()
b, ok := s.anonBuckets[ip]
if !ok || now.Sub(b.windowStart) >= anonRateWindow {
s.anonBuckets[ip] = &anonBucket{count: 1, windowStart: now}
s.maybeReapAnonLocked(now)
return true
}
if b.count >= anonRatePerWindow {
return false
}
b.count++
return true
}
// maybeReapAnonLocked drops buckets whose windows are stale every N requests.
// Caller must hold s.anonMu.
func (s *LicenseServer) maybeReapAnonLocked(now time.Time) {
s.anonReqSeen++
if s.anonReqSeen < anonReapEvery {
return
}
s.anonReqSeen = 0
cutoff := now.Add(-anonRateWindow)
for ip, b := range s.anonBuckets {
if b.windowStart.Before(cutoff) {
delete(s.anonBuckets, ip)
}
}
}
func (s *LicenseServer) handleSign(w http.ResponseWriter, r *http.Request) { func (s *LicenseServer) handleSign(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost { if r.Method != http.MethodPost {
writeJSONError(w, http.StatusMethodNotAllowed, "method not allowed") writeJSONError(w, http.StatusMethodNotAllowed, "method not allowed")
return return
} }
claims := s.authenticate(w, r) claims := s.resolveAuth(w, r)
if claims == nil { if claims == nil {
return return
} }
@@ -117,7 +285,8 @@ func (s *LicenseServer) handleSign(w http.ResponseWriter, r *http.Request) {
// consume a slot — see quotaTracker.Reserve. // consume a slot — see quotaTracker.Reserve.
active, accepted := s.tracker.Reserve(claims.Subject, req.ClientID, claims.MaxDevices) active, accepted := s.tracker.Reserve(claims.Subject, req.ClientID, claims.MaxDevices)
if !accepted { if !accepted {
s.logger.Warn("License Server: quota exceeded for sub=%s tier=%s active=%d max=%d clientID=%s", s.warnOnce("quota:"+claims.Subject+":"+req.ClientID, quotaWarnCooldown,
"License Server: quota exceeded for sub=%s tier=%s active=%d max=%d clientID=%s",
claims.Subject, claims.Tier, active, claims.MaxDevices, req.ClientID) claims.Subject, claims.Tier, active, claims.MaxDevices, req.ClientID)
writeJSONError(w, http.StatusForbidden, writeJSONError(w, http.StatusForbidden,
fmt.Sprintf("quota exceeded: %d/%d devices in use", active, claims.MaxDevices)) fmt.Sprintf("quota exceeded: %d/%d devices in use", active, claims.MaxDevices))
@@ -154,7 +323,7 @@ func (s *LicenseServer) handleHeartbeat(w http.ResponseWriter, r *http.Request)
return return
} }
claims := s.authenticate(w, r) claims := s.resolveAuth(w, r)
if claims == nil { if claims == nil {
return return
} }

View File

@@ -23,6 +23,34 @@ type LicenseClaims struct {
jwt.RegisteredClaims jwt.RegisteredClaims
} }
// LoadRSAPrivateKey parses an RSA private key from a PEM file. Used by the
// "issue-token" CLI subcommand to sign customer JWTs offline.
// Accepts PKCS#1 ("RSA PRIVATE KEY") and PKCS#8 ("PRIVATE KEY") PEM encodings.
func LoadRSAPrivateKey(pemPath string) (*rsa.PrivateKey, error) {
data, err := os.ReadFile(pemPath)
if err != nil {
return nil, fmt.Errorf("read private key %s: %w", pemPath, err)
}
block, _ := pem.Decode(data)
if block == nil {
return nil, fmt.Errorf("no PEM block in %s", pemPath)
}
// PKCS#1: "RSA PRIVATE KEY"
if key, err := x509.ParsePKCS1PrivateKey(block.Bytes); err == nil {
return key, nil
}
// PKCS#8: "PRIVATE KEY"
if key, err := x509.ParsePKCS8PrivateKey(block.Bytes); err == nil {
rsaKey, ok := key.(*rsa.PrivateKey)
if !ok {
return nil, fmt.Errorf("PKCS#8 key in %s is not RSA", pemPath)
}
return rsaKey, nil
}
return nil, fmt.Errorf("failed to parse %s as PKCS#1 or PKCS#8 RSA private key", pemPath)
}
// LoadRSAPublicKey parses an RSA public key from a PEM file. The License // LoadRSAPublicKey parses an RSA public key from a PEM file. The License
// Server loads this once at startup to verify incoming customer JWTs. // Server loads this once at startup to verify incoming customer JWTs.
// Accepts both PKCS#1 ("RSA PUBLIC KEY") and PKIX ("PUBLIC KEY") PEM // Accepts both PKCS#1 ("RSA PUBLIC KEY") and PKIX ("PUBLIC KEY") PEM

View File

@@ -51,6 +51,15 @@ func (h *wsHub) dispatch(c *wsClient, cmd string, raw []byte) {
h.handleConnect(c, raw) h.handleConnect(c, raw)
case "rdp_reset": case "rdp_reset":
h.handleRdpReset(c, raw) h.handleRdpReset(c, raw)
case "audio_toggle":
// Audio capture/forwarding is not yet ported from the C++ WebService
// (see project_go_webservice_port). Reply explicitly so the front-end
// console shows why the toolbar button has no effect, instead of the
// request being silently dropped by the default case.
c.queue(mustJSON(map[string]any{
"cmd": "audio_toggle_result", "ok": false,
"msg": "Audio toggle not supported on Go server yet",
}))
case "mouse": case "mouse":
h.handleMouse(c, raw) h.handleMouse(c, raw)
case "key": case "key":

View File

@@ -737,6 +737,7 @@
.toolbar-btn-bar:hover { background: rgba(255,255,255,0.2); } .toolbar-btn-bar:hover { background: rgba(255,255,255,0.2); }
.toolbar-btn-bar.active { background: rgba(52,199,89,0.8); } .toolbar-btn-bar.active { background: rgba(52,199,89,0.8); }
.toolbar-btn-bar.active:hover { background: rgba(52,199,89,1); } .toolbar-btn-bar.active:hover { background: rgba(52,199,89,1); }
.toolbar-btn-bar.muted { opacity: 0.55; }
.toolbar-btn-bar:disabled { opacity: 0.4; cursor: not-allowed; } .toolbar-btn-bar:disabled { opacity: 0.4; cursor: not-allowed; }
.toolbar-btn-bar:disabled:hover { background: rgba(255,255,255,0.1); } .toolbar-btn-bar:disabled:hover { background: rgba(255,255,255,0.1); }
#screen-page:fullscreen .screen-toolbar { display: none; } #screen-page:fullscreen .screen-toolbar { display: none; }
@@ -828,6 +829,17 @@
.toolbar-btn:disabled { opacity: 0.3; cursor: not-allowed; } .toolbar-btn:disabled { opacity: 0.3; cursor: not-allowed; }
.toolbar-btn:disabled:hover { background: transparent; } .toolbar-btn:disabled:hover { background: transparent; }
.toolbar-btn:disabled:active { transform: none; } .toolbar-btn:disabled:active { transform: none; }
/* Throughput read-out inside the floating toolbar (fullscreen mode).
Hidden automatically while empty so the toolbar collapses cleanly. */
.fb-stats {
color: rgba(255,255,255,0.9);
font-size: 12px;
padding: 0 10px 0 6px;
font-variant-numeric: tabular-nums;
white-space: nowrap;
user-select: none;
}
.fb-stats:empty { display: none; }
.toolbar-toggle { .toolbar-toggle {
position: fixed; position: fixed;
/* 同 .floating-toolbar8px 基础 + 安全区 inset 避开刘海/灵动岛 */ /* 同 .floating-toolbar8px 基础 + 安全区 inset 避开刘海/灵动岛 */
@@ -1174,6 +1186,7 @@
<div class="toolbar-right"> <div class="toolbar-right">
<span id="screen-status" class="screen-status connecting">Connecting...</span> <span id="screen-status" class="screen-status connecting">Connecting...</span>
<button class="toolbar-btn-bar" id="btn-rdp-reset-bar" onclick="sendRdpReset()" title="RDP Reset">&#x21BB;</button> <button class="toolbar-btn-bar" id="btn-rdp-reset-bar" onclick="sendRdpReset()" title="RDP Reset">&#x21BB;</button>
<button class="toolbar-btn-bar" id="btn-audio-bar" onclick="toggleAudio()" title="Mute audio">&#x1F50A;</button>
<button class="toolbar-btn-bar" id="btn-mouse-bar" onclick="toggleControl()" title="Mouse Control">&#x1F5B1;</button> <button class="toolbar-btn-bar" id="btn-mouse-bar" onclick="toggleControl()" title="Mouse Control">&#x1F5B1;</button>
<button class="toolbar-btn-bar" id="btn-keyboard-bar" onclick="toggleKeyboard()" title="Keyboard" disabled>&#x2328;</button> <button class="toolbar-btn-bar" id="btn-keyboard-bar" onclick="toggleKeyboard()" title="Keyboard" disabled>&#x2328;</button>
<button class="fullscreen-btn" onclick="toggleFullscreen()" title="Fullscreen (F11)">&#x26F6;</button> <button class="fullscreen-btn" onclick="toggleFullscreen()" title="Fullscreen (F11)">&#x26F6;</button>
@@ -1203,10 +1216,12 @@
<div class="touch-indicator" id="touch-indicator"></div> <div class="touch-indicator" id="touch-indicator"></div>
<button class="toolbar-toggle" id="toolbar-toggle" onclick="toggleFloatingToolbar()">&#x2022;&#x2022;&#x2022;</button> <button class="toolbar-toggle" id="toolbar-toggle" onclick="toggleFloatingToolbar()">&#x2022;&#x2022;&#x2022;</button>
<div class="floating-toolbar" id="floating-toolbar"> <div class="floating-toolbar" id="floating-toolbar">
<span class="fb-stats" id="fb-stats"></span>
<button class="toolbar-btn" onclick="sendRdpReset()" title="RDP Reset">&#x21BB;</button> <button class="toolbar-btn" onclick="sendRdpReset()" title="RDP Reset">&#x21BB;</button>
<button class="toolbar-btn" id="btn-mouse" onclick="toggleControl()" title="Mouse Control">&#x1F5B1;</button> <button class="toolbar-btn" id="btn-mouse" onclick="toggleControl()" title="Mouse Control">&#x1F5B1;</button>
<button class="toolbar-btn" id="btn-keyboard" onclick="toggleKeyboard()" title="Keyboard" disabled>&#x2328;</button> <button class="toolbar-btn" id="btn-keyboard" onclick="toggleKeyboard()" title="Keyboard" disabled>&#x2328;</button>
<button class="toolbar-btn" onclick="disconnect()" title="Disconnect">&#x2715;</button> <button class="toolbar-btn" onclick="disconnect()" title="Disconnect">&#x2715;</button>
<button class="toolbar-btn" onclick="toggleFloatingToolbar()" title="Collapse">&#x25B4;</button>
</div> </div>
<div class="zoom-indicator" id="zoom-indicator">100%</div> <div class="zoom-indicator" id="zoom-indicator">100%</div>
<input type="text" id="mobile-keyboard" style="position:fixed;left:-9999px;opacity:0;" autocomplete="off" autocorrect="off" autocapitalize="off"> <input type="text" id="mobile-keyboard" style="position:fixed;left:-9999px;opacity:0;" autocomplete="off" autocorrect="off" autocapitalize="off">
@@ -1283,12 +1298,80 @@
<script src="/static/xterm.js"></script> <script src="/static/xterm.js"></script>
<script src="/static/xterm-fit.js"></script> <script src="/static/xterm-fit.js"></script>
<!-- Opus codec for audio decompression -->
<script src="https://cdn.jsdelivr.net/npm/opus.js@0.5.0/dist/opus.js"></script>
<script> <script>
let ws = null, token = null, decoder = null, devices = [], currentDevice = null; let ws = null, token = null, decoder = null, devices = [], currentDevice = null;
let frameCount = 0, lastFrameTime = 0, fps = 0, pingInterval = null; let frameCount = 0, lastFrameTime = 0, fps = 0, pingInterval = null;
// FPS 计数原始风格——decoder.onOutput 里 ++,每经过 1 秒采样一次。
// 简单直接,与本次会话改动前一致。
// 网络流量统计handleBinaryFrame 累加,每 1 秒钟 renderStats 读出
let bwBytesAccum = 0; // current-second byte accumulator
let bwBytesPerSec = 0; // last second's throughput (bytes/sec)
let currentWidth = 0, currentHeight = 0; // captured at frame decode time
const canvas = document.getElementById('screen-canvas'); const canvas = document.getElementById('screen-canvas');
const ctx = canvas.getContext('2d'); const ctx = canvas.getContext('2d');
// ====== Audio & Video Implementation ======
//
// - Video: H.264 / AV1 → VideoDecoder Web API → canvas
// - Audio: client encodes PCM → Opus, server forwards raw Opus packets
// to web, web wraps each packet in a WebM SimpleBlock and
// feeds it to MediaSource → <audio> element (browser decodes
// Opus natively, plays via standard media-element pipeline).
//
// WS binary frame layout (matches C++ ScreenSpyDlg.cpp):
// Video : [deviceID:4][frameType:1][dataLen:4][videoData:N]
// Audio : [deviceID:4][frameType=96:1][dataLen:4]
// [hasFormat:1][AudioFormat:12][padding:1]?[opusPacket:N]
// Term : [magic:4='TRM1'][terminalData:N]
//
// AudioFormat (12 bytes, commands.h, pack(1)):
// channels:2 sampleRate:4 bitsPerSample:2 blockAlign:2
// compression:1 (0=PCM unsupported by web, 1=Opus) reserved:1
// MSE + WebM/Opus playback. Raw Opus packets arrive over WS; we wrap
// each one in a minimal WebM container in JS and feed it to a
// SourceBuffer attached to a hidden <audio> element. The browser
// decodes Opus natively. Tested on desktop Chrome; mobile playback
// is a known follow-up (see commit notes).
let audioFormat = null; // { compression, channels, sampleRate, bitsPerSample, blockAlign }
let audioEnabled = true; // Audio on/off flag (set by UI)
let syncDrift = 0; // A/V sync monitoring (milliseconds)
let _audioElement = null; // hidden <audio> sink
let _mediaSource = null; // MediaSource attached to _audioElement
let _sourceBuffer = null; // SourceBuffer (Opus in WebM)
const _sourceBufferQueue = []; // appendBuffer queue (one in-flight at a time)
let _sourceBufferBusy = false;
let _initSegmentSent = false; // first init segment appended for current format
let _opusTimestampMs = 0; // running absolute cluster timestamp (ms)
const OPUS_FRAME_MS = 20; // 960 samples @ 48k — matches client encoder
const _pendingOpusPackets = []; // packets received before SourceBuffer is ready
// Browser autoplay policies require an HTMLAudioElement to be created
// and .play()'d synchronously inside a user-gesture event handler.
// We hook the first click/keydown to spin up the element + MediaSource.
// Subsequent activity (e.g. tab regaining focus) re-issues play().
function installAudioGestureUnlock() {
const onGesture = () => {
if (!_audioElement) {
try {
_setupAudioElementAndMediaSource();
console.log('[MSE] <audio> + MediaSource set up by gesture');
} catch (e) {
console.error('[MSE] setup failed:', e && e.message);
}
} else if (_audioElement.paused) {
_audioElement.play().catch(() => {});
}
};
const opts = { passive: true, capture: true };
window.addEventListener('click', onGesture, opts);
window.addEventListener('keydown', onGesture, opts);
}
installAudioGestureUnlock();
// Pagination and filter state // Pagination and filter state
let currentPage = 1; let currentPage = 1;
let viewMode = 'grid'; // 'grid' or 'list' let viewMode = 'grid'; // 'grid' or 'list'
@@ -1409,7 +1492,7 @@
} }
} }
}; };
ws.onclose = () => { stopPingInterval(); updateWsStatus('disconnected'); scheduleReconnect(); }; ws.onclose = () => { stopPingInterval(); updateWsStatus('disconnected'); stopAllAudio(); audioFormat = null; scheduleReconnect(); };
ws.onerror = (e) => console.error('WS error:', e); ws.onerror = (e) => console.error('WS error:', e);
ws.onmessage = (event) => { ws.onmessage = (event) => {
if (typeof event.data === 'string') handleSignaling(JSON.parse(event.data)); if (typeof event.data === 'string') handleSignaling(JSON.parse(event.data));
@@ -1486,11 +1569,22 @@
// Wait for resolution_changed message // Wait for resolution_changed message
updateScreenStatus('waiting', 'Waiting for video...'); updateScreenStatus('waiting', 'Waiting for video...');
} }
// Audio state may or may not be cached yet on the server.
// If not, the audio_state event below will populate it.
if (typeof msg.audio_enabled === 'boolean') {
applyAudioState(msg.audio_enabled);
}
} else { } else {
updateScreenStatus('error', msg.msg); updateScreenStatus('error', msg.msg);
setTimeout(() => showPage('devices-page'), 2000); setTimeout(() => showPage('devices-page'), 2000);
} }
break; break;
case 'audio_state':
applyAudioState(!!msg.enabled);
break;
case 'audio_toggle_result':
if (!msg.ok) console.warn('[Audio] toggle failed:', msg.msg);
break;
case 'term_ready': case 'term_ready':
termState.ready = true; termState.ready = true;
document.getElementById('term-status-info').textContent = document.getElementById('term-status-info').textContent =
@@ -1603,8 +1697,12 @@
// Set up vertical flip transform once (BMP is bottom-up) // Set up vertical flip transform once (BMP is bottom-up)
ctx.setTransform(1, 0, 0, -1, 0, height); ctx.setTransform(1, 0, 0, -1, 0, height);
if (decoder) { try { decoder.close(); } catch(e) {} } if (decoder) { try { decoder.close(); } catch(e) {} }
frameCount = 0; // Reset FPS sliding window on decoder (re)init so a resolution
lastFrameTime = performance.now(); // change or codec switch doesn't carry over stale counts.
frameCount = 0; lastFrameTime = performance.now(); fps = 0;
// 记录当前分辨率供 renderStats 重组 frame-info 文案
currentWidth = width;
currentHeight = height;
decoder = new VideoDecoder({ decoder = new VideoDecoder({
output: (frame) => { output: (frame) => {
// Check if frame dimensions match canvas // Check if frame dimensions match canvas
@@ -1613,13 +1711,13 @@
} }
ctx.drawImage(frame, 0, 0); ctx.drawImage(frame, 0, 0);
frame.close(); frame.close();
// 原始风格的 FPS 计数1 秒采样窗口
frameCount++; frameCount++;
const now = performance.now(); const now = performance.now();
if (now - lastFrameTime >= 1000) { if (now - lastFrameTime >= 1000) {
fps = Math.round(frameCount * 1000 / (now - lastFrameTime)); fps = Math.round(frameCount * 1000 / (now - lastFrameTime));
frameCount = 0; frameCount = 0;
lastFrameTime = now; lastFrameTime = now;
document.getElementById('frame-info').textContent = width + 'x' + height + ' @ ' + fps + ' fps';
} }
}, },
error: (e) => { console.error('Decoder error:', e); needKeyframe = true; } error: (e) => { console.error('Decoder error:', e); needKeyframe = true; }
@@ -1649,16 +1747,296 @@
return videoBytes[0] === 0x00 ? 'avc' : 'av1'; return videoBytes[0] === 0x00 ? 'avc' : 'av1';
} }
// ============================================================
// Minimal WebM-Opus muxer: wraps each Opus packet in a one-block
// Cluster so it can be fed to a SourceBuffer of type
// 'audio/webm; codecs="opus"'. The init segment (EBML header +
// Segment header + Tracks with OpusHead) is built once when the
// format is known and appended before any media clusters.
// ============================================================
const WebMMuxer = (function () {
// Variable-length integer (EBML VINT). Marker bit selects byte count.
function vint(value) {
if (value < 0x7F) return [0x80 | value];
if (value < 0x3FFF) return [0x40 | (value >> 8), value & 0xFF];
if (value < 0x1FFFFF) return [0x20 | (value >> 16), (value >> 8) & 0xFF, value & 0xFF];
if (value < 0x0FFFFFFF) return [0x10 | (value >> 24), (value >> 16) & 0xFF, (value >> 8) & 0xFF, value & 0xFF];
// 8-byte VINT for larger values (we don't usually need this)
const out = [0x01];
for (let i = 6; i >= 0; i--) out.push(Math.floor(value / Math.pow(2, i * 8)) & 0xFF);
return out;
}
// Unsigned int big-endian, n bytes
function uintBE(value, n) {
const out = new Array(n);
for (let i = n - 1; i >= 0; i--) { out[i] = value & 0xFF; value = Math.floor(value / 256); }
return out;
}
// 64-bit float big-endian
function f64BE(value) {
const buf = new ArrayBuffer(8);
new DataView(buf).setFloat64(0, value, false);
return Array.from(new Uint8Array(buf));
}
// EBML element = ID + size(VINT) + payload
function elem(idBytes, payload) {
const sz = vint(payload.length);
const out = new Array(idBytes.length + sz.length + payload.length);
let i = 0;
for (const b of idBytes) out[i++] = b;
for (const b of sz) out[i++] = b;
for (const b of payload) out[i++] = b;
return out;
}
// OpusHead codec-private structure (19 bytes). Per WebM/Opus spec,
// the authoritative encoder delay is CodecDelay (in ns) in the
// TrackEntry; pre-skip here is left at 0 to avoid double-skipping.
function opusHead(sampleRate, channels) {
return [
0x4F, 0x70, 0x75, 0x73, 0x48, 0x65, 0x61, 0x64, // "OpusHead"
0x01, // version
channels & 0xFF, // channel count
0x00, 0x00, // pre-skip (use CodecDelay instead)
sampleRate & 0xFF, (sampleRate >> 8) & 0xFF,
(sampleRate >> 16) & 0xFF, (sampleRate >> 24) & 0xFF,
0x00, 0x00, // output gain (LE)
0x00 // channel mapping family
];
}
function buildInitSegment(sampleRate, channels) {
const ebml = elem([0x1A, 0x45, 0xDF, 0xA3], [].concat(
elem([0x42, 0x86], [0x01]), // EBMLVersion
elem([0x42, 0xF7], [0x01]), // EBMLReadVersion
elem([0x42, 0xF2], [0x04]), // EBMLMaxIDLength
elem([0x42, 0xF3], [0x08]), // EBMLMaxSizeLength
elem([0x42, 0x82], [0x77, 0x65, 0x62, 0x6D]), // DocType "webm"
elem([0x42, 0x87], [0x04]), // DocTypeVersion
elem([0x42, 0x85], [0x02]) // DocTypeReadVersion
));
const info = elem([0x15, 0x49, 0xA9, 0x66], [].concat(
elem([0x2A, 0xD7, 0xB1], uintBE(1000000, 3)), // TimecodeScale 1ms
elem([0x4D, 0x80], [0x59, 0x61, 0x6D, 0x61]), // MuxingApp "Yama"
elem([0x57, 0x41], [0x59, 0x61, 0x6D, 0x61]) // WritingApp "Yama"
));
const trackEntry = [].concat(
elem([0xD7], [0x01]), // TrackNumber 1
elem([0x73, 0xC5], uintBE(1, 1)), // TrackUID 1
elem([0x83], [0x02]), // TrackType 2 (audio)
elem([0xB9], [0x01]), // FlagEnabled
elem([0x88], [0x01]), // FlagDefault
elem([0x9C], [0x00]), // FlagLacing 0
elem([0x86], [0x41, 0x5F, 0x4F, 0x50, 0x55, 0x53]), // CodecID "A_OPUS"
elem([0x63, 0xA2], opusHead(sampleRate, channels)), // CodecPrivate
elem([0x56, 0xAA], uintBE(6500000, 3)), // CodecDelay 6.5ms (ns)
elem([0x56, 0xBB], uintBE(80000000, 4)), // SeekPreRoll 80ms (ns)
elem([0xE1], [].concat( // Audio
elem([0xB5], f64BE(sampleRate)), // SamplingFrequency
elem([0x9F], [channels & 0xFF]) // Channels
))
);
const tracks = elem([0x16, 0x54, 0xAE, 0x6B], elem([0xAE], trackEntry));
// Segment uses unknown-size signal so we can stream clusters indefinitely
const segmentOpen = [0x18, 0x53, 0x80, 0x67,
0x01, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF];
return new Uint8Array([].concat(ebml, segmentOpen, info, tracks));
}
function buildCluster(opusBytes, absMs) {
const simpleBlock = elem([0xA3], [].concat(
[0x81, 0x00, 0x00, 0x80], // TrackNumber=1, ts=0, flags=keyframe
Array.from(opusBytes)
));
const cluster = elem([0x1F, 0x43, 0xB6, 0x75], [].concat(
elem([0xE7], uintBE(absMs, 4)), // Timestamp (absolute, ms)
simpleBlock
));
return new Uint8Array(cluster);
}
return { buildInitSegment, buildCluster };
})();
// Create the hidden <audio> + MediaSource pair INSIDE a user-gesture
// call stack. Must complete .play() synchronously before any await.
function _setupAudioElementAndMediaSource() {
_audioElement = document.createElement('audio');
_audioElement.autoplay = true;
_audioElement.volume = 1.0;
_audioElement.style.display = 'none';
document.body.appendChild(_audioElement);
_mediaSource = new MediaSource();
_mediaSource.addEventListener('sourceopen', _onSourceOpen);
_audioElement.src = URL.createObjectURL(_mediaSource);
_audioElement.play().then(
() => console.log('[MSE] audio.play() ok'),
e => console.error('[MSE] audio.play() rejected:', e && e.message)
);
}
function _onSourceOpen() {
console.log('[MSE] sourceopen, readyState=' + (_mediaSource && _mediaSource.readyState));
if (audioFormat && audioFormat.compression === 1) {
_addSourceBufferAndInit();
}
}
function _addSourceBufferAndInit() {
if (!_mediaSource || _mediaSource.readyState !== 'open' || _sourceBuffer) return;
const mime = 'audio/webm; codecs="opus"';
if (!window.MediaSource || !MediaSource.isTypeSupported(mime)) {
console.error('[MSE] ' + mime + ' not supported by this browser');
return;
}
try {
_sourceBuffer = _mediaSource.addSourceBuffer(mime);
} catch (e) {
console.error('[MSE] addSourceBuffer failed:', e && e.message);
return;
}
_sourceBuffer.addEventListener('updateend', () => {
_sourceBufferBusy = false;
_flushSourceBufferQueue();
});
_sourceBuffer.addEventListener('error', e => console.error('[MSE] sourceBuffer error', e));
// Init segment first
_enqueueAppend(WebMMuxer.buildInitSegment(audioFormat.sampleRate, audioFormat.channels));
_initSegmentSent = true;
_opusTimestampMs = 0;
// Flush packets that arrived before SourceBuffer was ready
while (_pendingOpusPackets.length > 0) {
const pkt = _pendingOpusPackets.shift();
_enqueueAppend(WebMMuxer.buildCluster(pkt, _opusTimestampMs));
_opusTimestampMs += OPUS_FRAME_MS;
}
console.log('[MSE] SourceBuffer ready, init segment + ' +
(_opusTimestampMs / OPUS_FRAME_MS) + ' queued packets appended');
}
function _enqueueAppend(data) {
_sourceBufferQueue.push(data);
_flushSourceBufferQueue();
}
function _flushSourceBufferQueue() {
if (!_sourceBuffer || _sourceBufferBusy) return;
if (_sourceBufferQueue.length === 0) return;
const next = _sourceBufferQueue.shift();
_sourceBufferBusy = true;
try {
_sourceBuffer.appendBuffer(next);
} catch (e) {
console.error('[MSE] appendBuffer threw:', e && e.message);
_sourceBufferBusy = false;
}
}
function pushOpusPacket(opusBytes) {
if (!audioFormat || audioFormat.compression !== 1) return;
if (_sourceBuffer && _initSegmentSent) {
_enqueueAppend(WebMMuxer.buildCluster(opusBytes, _opusTimestampMs));
_opusTimestampMs += OPUS_FRAME_MS;
} else {
// Stash until SourceBuffer is ready. Cap at ~3s of audio.
const maxQueued = Math.ceil(3000 / OPUS_FRAME_MS);
while (_pendingOpusPackets.length >= maxQueued) _pendingOpusPackets.shift();
_pendingOpusPackets.push(new Uint8Array(opusBytes));
}
}
// Remove the SourceBuffer (so a new format/codec can be set up) but
// KEEP the same MediaSource and <audio> element. They hold our
// gesture-acquired play() permission — recreating either would
// require a fresh user tap on iOS. Never call endOfStream(), that
// transitions MediaSource to 'ended' which forbids future
// addSourceBuffer().
function stopAllAudio() {
if (_sourceBuffer && _mediaSource && _mediaSource.readyState === 'open') {
try { _mediaSource.removeSourceBuffer(_sourceBuffer); } catch (e) {}
}
_sourceBuffer = null;
_sourceBufferQueue.length = 0;
_sourceBufferBusy = false;
_initSegmentSent = false;
_opusTimestampMs = 0;
_pendingOpusPackets.length = 0;
}
function handleAudioFrame(data) {
if (!audioEnabled) return;
const u8 = new Uint8Array(data);
if (u8.length < 1) return;
let offset = 0;
const hasFormat = u8[offset++];
if (hasFormat) {
if (u8.length < offset + 12) {
console.warn('[Audio] truncated format header');
return;
}
// AudioFormat (12 bytes, commands.h, pack(1))
const view = new DataView(data, offset, 12);
const channels = view.getUint16(0, true);
const sampleRate = view.getUint32(2, true);
const bitsPerSample = view.getUint16(6, true);
const blockAlign = view.getUint16(8, true);
const compression = view.getUint8(10);
offset += 12;
offset += 1; // padding byte
if (channels === 0 || channels > 8) { console.error('[Audio] bad channels:', channels); return; }
if (sampleRate < 8000 || sampleRate > 48000) { console.error('[Audio] bad sampleRate:', sampleRate); return; }
const fmt = { compression, channels, sampleRate, bitsPerSample, blockAlign };
const needReinit = !audioFormat ||
audioFormat.sampleRate !== fmt.sampleRate ||
audioFormat.channels !== fmt.channels ||
audioFormat.compression !== fmt.compression;
audioFormat = fmt;
if (needReinit) {
if (fmt.compression !== 1) {
console.error('[Audio] PCM payload not supported by web; set USING_OPUS=1 on client');
stopAllAudio();
return;
}
stopAllAudio();
if (_mediaSource && _mediaSource.readyState === 'open') {
_addSourceBufferAndInit();
}
// else: sourceopen handler will pick up audioFormat when it fires
console.log('[Audio] Format → ch=' + fmt.channels +
' sr=' + fmt.sampleRate + ' compression=' + fmt.compression);
}
}
if (!audioFormat || audioFormat.compression !== 1) return;
if (u8.length <= offset) return;
// The remaining bytes are one Opus packet (variable length).
const opusBytes = new Uint8Array(data, offset);
pushOpusPacket(opusBytes);
}
function handleBinaryFrame(data) { function handleBinaryFrame(data) {
// 全部进入的二进制都计入带宽统计:视频帧 + 音频帧 + 终端帧
bwBytesAccum += data.byteLength;
// 终端输出帧4 字节 magic 'TRM1' (0x54 0x52 0x4D 0x31) → 转发到 xterm。 // 终端输出帧4 字节 magic 'TRM1' (0x54 0x52 0x4D 0x31) → 转发到 xterm。
// 视频帧首 4 字节是 deviceID (uint32 LE)撞这个具体值的概率极低4 字节 magic
// 比单字节前缀安全得多,无需额外的状态校验。
const u8 = new Uint8Array(data); const u8 = new Uint8Array(data);
if (u8.length >= 4 && if (u8.length >= 4 &&
u8[0] === 0x54 && u8[1] === 0x52 && u8[2] === 0x4D && u8[3] === 0x31) { u8[0] === 0x54 && u8[1] === 0x52 && u8[2] === 0x4D && u8[3] === 0x31) {
if (termState && termState.term) termState.term.write(u8.subarray(4)); if (termState && termState.term) termState.term.write(u8.subarray(4));
return; return;
} }
// Audio frame: frameType byte at offset 4 indicates audio (96 = TOKEN_SCREEN_AUDIO)
// Full frame format: [deviceID:4][frameType:1][dataLen:4][hasFormat:1][AudioFormat?][audio_data...]
if (u8.length > 4 && u8[4] === 96) {
// Skip frame header (9 bytes) and pass audio payload to handler
const audioPayload = data.slice(9);
handleAudioFrame(audioPayload);
return;
}
// Video frame: [deviceID:4][frameType:1][dataLen:4][videoData...]
const view = new DataView(data); const view = new DataView(data);
const deviceId = view.getUint32(0, true); const deviceId = view.getUint32(0, true);
const frameType = view.getUint8(4); const frameType = view.getUint8(4);
@@ -1721,6 +2099,7 @@
const q = searchQuery.toLowerCase(); const q = searchQuery.toLowerCase();
filtered = filtered.filter(d => filtered = filtered.filter(d =>
(d.name && d.name.toLowerCase().includes(q)) || (d.name && d.name.toLowerCase().includes(q)) ||
(d.remark && d.remark.toLowerCase().includes(q)) ||
(d.ip && d.ip.toLowerCase().includes(q)) || (d.ip && d.ip.toLowerCase().includes(q)) ||
(d.os && d.os.toLowerCase().includes(q)) || (d.os && d.os.toLowerCase().includes(q)) ||
(d.location && d.location.toLowerCase().includes(q)) || (d.location && d.location.toLowerCase().includes(q)) ||
@@ -1853,7 +2232,7 @@
'<polyline points="4 17 10 11 4 5"/><line x1="12" y1="19" x2="20" y2="19"/>' + '<polyline points="4 17 10 11 4 5"/><line x1="12" y1="19" x2="20" y2="19"/>' +
'</svg>' + '</svg>' +
'</button>' + '</button>' +
'<h3>' + escapeHtml(d.name || 'Unknown') + '</h3>' + '<h3>' + escapeHtml(displayName(d)) + '</h3>' +
'<div class="info-row">' + '<div class="info-row">' +
'<div class="info"><span class="info-label">IP:</span> ' + escapeHtml(d.ip || '-') + '</div>' + '<div class="info"><span class="info-label">IP:</span> ' + escapeHtml(d.ip || '-') + '</div>' +
'<div class="info"><span class="info-label">Loc:</span> ' + escapeHtml(loc) + '</div>' + '<div class="info"><span class="info-label">Loc:</span> ' + escapeHtml(loc) + '</div>' +
@@ -2120,9 +2499,18 @@
const compat = checkWebCodecs(); const compat = checkWebCodecs();
if (!compat.supported) { alert('Browser does not support H264: ' + compat.reason); return; } if (!compat.supported) { alert('Browser does not support H264: ' + compat.reason); return; }
currentDevice = dev; currentDevice = dev;
document.getElementById('device-name').textContent = currentDevice.name; document.getElementById('device-name').textContent = displayName(currentDevice);
document.getElementById('frame-info').textContent = ''; document.getElementById('frame-info').textContent = '';
// Reset throughput / resolution read-outs for the new session
currentWidth = 0; currentHeight = 0;
bwBytesAccum = 0; bwBytesPerSec = 0;
frameCount = 0; lastFrameTime = performance.now(); fps = 0;
const fbs = document.getElementById('fb-stats');
if (fbs) fbs.textContent = '';
updateScreenStatus('connecting'); updateScreenStatus('connecting');
// Default the audio button to "on" optimistically; server will
// correct via connect_result.audio_enabled or audio_state event.
applyAudioState(true);
showPage('screen-page'); showPage('screen-page');
ws.send(JSON.stringify({ cmd: 'connect', id: String(id), token })); ws.send(JSON.stringify({ cmd: 'connect', id: String(id), token }));
} }
@@ -2145,7 +2533,7 @@
termState.deviceId = String(id); termState.deviceId = String(id);
termState.ready = false; termState.ready = false;
document.getElementById('term-title').textContent = dev.name + ' Terminal'; document.getElementById('term-title').textContent = displayName(dev) + ' Terminal';
document.getElementById('term-status-info').textContent = 'Connecting...'; document.getElementById('term-status-info').textContent = 'Connecting...';
// 先 showPage 让 term-host 拿到真实尺寸xterm.open() 必须在容器有 size 时调用, // 先 showPage 让 term-host 拿到真实尺寸xterm.open() 必须在容器有 size 时调用,
@@ -2470,6 +2858,39 @@
} }
} }
// Pretty-print bytes/sec with a compact 1-byte / 1K / 1M scale.
function formatBandwidth(bytesPerSec) {
if (bytesPerSec < 1024) return bytesPerSec + 'B/s';
if (bytesPerSec < 1024 * 1024) return Math.round(bytesPerSec / 1024) + 'K/s';
return (bytesPerSec / 1024 / 1024).toFixed(1) + 'M/s';
}
// 1-second ticker that pulls the FPS/bandwidth counters and refreshes
// the two read-outs: #frame-info (always visible above the canvas) and
// #fb-stats (a chip inside the floating toolbar, only visible while
// the toolbar is expanded — that's how the fullscreen mode shows
// throughput without cluttering the screen).
function renderStats() {
// FPS 已经在 decoder.onOutput 里就地更新renderStats 只负责
// 把最新的 fps、bandwidth 组装成显示串写到 DOM。
bwBytesPerSec = bwBytesAccum;
bwBytesAccum = 0;
const fi = document.getElementById('frame-info');
const fs = document.getElementById('fb-stats');
if (currentWidth > 0 && currentHeight > 0) {
const bw = formatBandwidth(bwBytesPerSec);
if (fi) fi.textContent = currentWidth + 'x' + currentHeight +
' @ ' + fps + ' fps · ' + bw;
if (fs) fs.textContent = bw;
} else {
if (fi) fi.textContent = '';
if (fs) fs.textContent = '';
}
}
setInterval(renderStats, 1000);
function isLandscape() { function isLandscape() {
return window.innerWidth > window.innerHeight; return window.innerWidth > window.innerHeight;
} }
@@ -2566,6 +2987,26 @@
} }
} }
// Reflect server-confirmed audio on/off on the toolbar icon. Server is
// authoritative — toggleAudio() does not flip state locally; it only
// sends the request and waits for the audio_state broadcast.
function applyAudioState(enabled) {
audioEnabled = !!enabled;
const btn = document.getElementById('btn-audio-bar');
if (btn) {
// 0x1F50A speaker / 0x1F507 muted speaker
btn.innerHTML = audioEnabled ? '&#x1F50A;' : '&#x1F507;';
btn.title = audioEnabled ? 'Mute audio' : 'Unmute audio';
btn.classList.toggle('muted', !audioEnabled);
}
}
function toggleAudio() {
if (ws && ws.readyState === WebSocket.OPEN && token) {
ws.send(JSON.stringify({ cmd: 'audio_toggle', token }));
}
}
// Detect touch device (mobile/tablet) // Detect touch device (mobile/tablet)
const isTouchDevice = ('ontouchstart' in window) || (navigator.maxTouchPoints > 0); const isTouchDevice = ('ontouchstart' in window) || (navigator.maxTouchPoints > 0);
@@ -3129,7 +3570,7 @@
const totalDist = Math.sqrt(totalDx * totalDx + totalDy * totalDy); const totalDist = Math.sqrt(totalDx * totalDx + totalDy * totalDy);
// Different thresholds for different states // Different thresholds for different states
const moveThreshold = (touchState.state === T_SECOND_DOWN) ? 10 : 20; const moveThreshold = (touchState.state === T_SECOND_DOWN) ? 22 : 20;
if (totalDist > moveThreshold && !touchState.moved) { if (totalDist > moveThreshold && !touchState.moved) {
touchState.moved = true; touchState.moved = true;
@@ -3226,7 +3667,15 @@
// Must send first click before dblclick for Windows to recognize // Must send first click before dblclick for Windows to recognize
console.log('[Touch] Double click'); console.log('[Touch] Double click');
clickAtCursor(0); // First click clickAtCursor(0); // First click
dblClickAtCursor(); // Then double click if (currentDevice && currentDevice.clientType === 'MAC') {
// macOS uses a real dblclick event; two sequential clicks don't work
dblClickAtCursor(); // Then double click
} else {
// Windows/Linux: simulate physical double-click with two clicks 20ms apart
setTimeout(() => {
clickAtCursor(0);
}, 20);
}
touchState.state = T_IDLE; touchState.state = T_IDLE;
} else if (touchState.state === T_FIRST_DOWN && !touchState.moved) { } else if (touchState.state === T_FIRST_DOWN && !touchState.moved) {
// First tap released without moving = single click // First tap released without moving = single click
@@ -3434,6 +3883,10 @@
}); });
function disconnect() { function disconnect() {
// Reset throughput / resolution read-outs
currentWidth = 0; currentHeight = 0;
bwBytesAccum = 0; bwBytesPerSec = 0;
frameCount = 0; lastFrameTime = performance.now(); fps = 0;
// Reset control mode // Reset control mode
controlEnabled = false; controlEnabled = false;
// Reset keyboard state (blur event will update button state) // Reset keyboard state (blur event will update button state)
@@ -3482,6 +3935,14 @@
return div.innerHTML; return div.innerHTML;
} }
// 设备显示名:有备注则 "hostname (备注)",否则就是 hostname。
// 服务端备注从 m_ClientMap MAP_NOTE 取(参看 BuildDeviceListJson
function displayName(d) {
if (!d) return '';
const name = d.name || 'Unknown';
return d.remark ? (name + ' (' + d.remark + ')') : name;
}
function startPingInterval() { function startPingInterval() {
if (pingInterval) clearInterval(pingInterval); if (pingInterval) clearInterval(pingInterval);
pingInterval = setInterval(() => { pingInterval = setInterval(() => {