17 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
yuanyuanxiang
9fe8ab746a Perf(screen): skip encode on identical frames to cut HW encoder idle bandwidth 2026-05-30 00:12:47 +02:00
yuanyuanxiang
8c7f612449 Feature: Implement H.264 and AV1 hardware encoding for remote control
Remark: Need to update FFmpeg static libraries to take effort
2026-05-30 00:12:38 +02:00
yuanyuanxiang
d1aa7a2c02 Fix: guard IOCPClient against early packet before setManagerCallBack 2026-05-28 23:50:56 +02:00
93 changed files with 4719 additions and 289 deletions

1
.gitignore vendored
View File

@@ -81,6 +81,7 @@ Releases/*
linux/Makefile
linux/cmake_install.cmake
.vs
client/ghost_vs2015.vcxproj.user
docs/macOS_Support_Design.md
settings.local.json
*.zip

View File

@@ -11,6 +11,8 @@
- [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)
- [libpeconv c7d1e48](https://github.com/hasherezade/libpeconv)
- [libvpl v2.16.0](https://github.com/intel/libvpl)
- [dav1d 62501cc](https://github.com/videolan/dav1d)
## 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)
**Go 主控 & 全平台主控闭环 & Linux/macOS 客户端剪贴板**

View File

@@ -357,6 +357,35 @@ Valid : 2026-02-01 to 2028-02-01
## 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)
**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)
**Go 主控 & 全平台主控閉環 & Linux/macOS 用戶端剪貼簿**

View File

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

View File

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

View File

@@ -0,0 +1,243 @@
#include "CFFmpegAV1Encoder.h"
#include "common/config.h"
#include "common/logger.h"
// 合规守护DISABLE_FFMPEG_FOR_TEST=1 时整个实现移出编译单元FFmpeg lib 已在
// CFFmpegH264Encoder.cpp 用同条件链接,此处不重复 #pragma comment
#if defined(_WIN64) && !DISABLE_FFMPEG_FOR_TEST
extern "C" {
#include <libavcodec/avcodec.h>
#include <libavutil/opt.h>
#include <libavutil/imgutils.h>
#include <libyuv/libyuv.h>
}
#include <string.h>
// FFmpeg / 系统库已经由 CFFmpegH264Encoder.cpp 的 #pragma comment(lib) 引入。
// 这里不再重复声明(重复 #pragma comment 在同一 link 单元不冲突但冗余)。
// av_opt_set 包装:拼错的参数值会被 FFmpeg 静默忽略,包一层日志便于发现。
// 实现与 CFFmpegH264Encoder 内的 helper 相同;放成 static 文件内可见即可。
static void setOpt(void* obj, const char* name, const char* val, const char* backend) {
int rc = av_opt_set(obj, name, val, 0);
if (rc < 0) {
char errbuf[128] = {0};
av_strerror(rc, errbuf, sizeof(errbuf));
Mprintf("[WARN] av_opt_set('%s'='%s') on %s failed (%d): %s\n",
name, val, backend, rc, errbuf);
}
}
static void setOptInt(void* obj, const char* name, int64_t val, const char* backend) {
int rc = av_opt_set_int(obj, name, val, 0);
if (rc < 0) {
char errbuf[128] = {0};
av_strerror(rc, errbuf, sizeof(errbuf));
Mprintf("[WARN] av_opt_set_int('%s'=%lld) on %s failed (%d): %s\n",
name, (long long)val, backend, rc, errbuf);
}
}
// AV1 硬编后端探测顺序,没有 av1_mf 兜底FFmpeg 7.1 不支持)。
// 全失败时 EncoderFactory 自动回退到 H.264 路径,行为对称。
static const char* kAV1Backends[] = {
"av1_nvenc", // NVIDIA RTX 40 / 50 系Ada Lovelace+
"av1_amf", // AMD RX 7000+RDNA 3+
"av1_qsv", // Intel Arc 独显 / 部分 11 代+ 核显
};
CFFmpegAV1Encoder::CFFmpegAV1Encoder() = default;
CFFmpegAV1Encoder::~CFFmpegAV1Encoder() {
close();
}
void CFFmpegAV1Encoder::cleanupCodec() {
if (m_packet) { av_packet_free(&m_packet); m_packet = nullptr; }
if (m_frame) { av_frame_free(&m_frame); m_frame = nullptr; }
if (m_ctx) { avcodec_free_context(&m_ctx); m_ctx = nullptr; }
}
void CFFmpegAV1Encoder::close() {
cleanupCodec();
m_backend.clear();
m_pts = 0;
m_forceIDR = false;
}
bool CFFmpegAV1Encoder::open(const EncoderParams& params) {
close();
for (const char* name : kAV1Backends) {
if (tryOpenBackend(name, params)) {
m_backend = name;
return true;
}
cleanupCodec();
}
return false;
}
bool CFFmpegAV1Encoder::tryOpenBackend(const char* name, const EncoderParams& p) {
const AVCodec* codec = avcodec_find_encoder_by_name(name);
if (!codec) {
// AV1 硬编没注册 = 老 ffmpeg lib 不含 AV1 encodercompress\ffmpeg 没启用 av1
Mprintf("=> FFmpeg: AV1 encoder '%s' NOT in linked lib\n", name);
return false;
}
m_ctx = avcodec_alloc_context3(codec);
if (!m_ctx) {
Mprintf("=> FFmpeg: avcodec_alloc_context3('%s') failed\n", name);
return false;
}
m_ctx->width = p.width & ~1;
m_ctx->height = p.height & ~1;
m_ctx->time_base = AVRational{1, p.fps};
m_ctx->framerate = AVRational{p.fps, 1};
m_ctx->pix_fmt = AV_PIX_FMT_NV12;
m_ctx->gop_size = p.fps * (p.gop_seconds > 0 ? p.gop_seconds : 15);
m_ctx->max_b_frames = 0;
m_ctx->bit_rate = (int64_t)p.bitrate_kbps * 1000;
m_ctx->rc_max_rate = (int64_t)p.bitrate_kbps * 1500;
m_ctx->rc_buffer_size = (int)(p.bitrate_kbps * 1000);
// RC 策略与 H.264 路径对齐peak-constrained VBR远控静态画面省带宽。
if (strcmp(name, "av1_nvenc") == 0) {
// av1_nvenc preset p1~p7远控 p5 兼顾质量与速度。
// tile-columns=1 把帧切两列,解码端并行更友好(浏览器 AV1 解码常用 SIMD/多线程)
setOpt(m_ctx->priv_data, "preset", "p5", name);
setOpt(m_ctx->priv_data, "tune", "ll", name);
setOpt(m_ctx->priv_data, "rc", "vbr", name);
setOpt(m_ctx->priv_data, "zerolatency", "1", name);
setOptInt(m_ctx->priv_data, "tile-columns", 1, name);
} else if (strcmp(name, "av1_amf") == 0) {
// av1_amf 选项命名与 h264_amf 大体一致rc 同样支持 vbr_peak
// (见 ffmpeg -h encoder=av1_amf)。静态画面省码率四件套同 H.264 路径。
setOpt(m_ctx->priv_data, "usage", "lowlatency", name);
setOpt(m_ctx->priv_data, "quality", "quality", name);
setOpt(m_ctx->priv_data, "rc", "vbr_peak", name);
setOptInt(m_ctx->priv_data, "vbaq", 1, name);
setOptInt(m_ctx->priv_data, "preanalysis", 1, name);
setOptInt(m_ctx->priv_data, "filler_data", 0, name);
setOptInt(m_ctx->priv_data, "enforce_hrd", 0, name);
} else if (strcmp(name, "av1_qsv") == 0) {
// av1_qsvbit_rate < max_rate 时自动 VBR
setOpt(m_ctx->priv_data, "preset", "slow", name);
setOptInt(m_ctx->priv_data, "async_depth", 1, name);
setOptInt(m_ctx->priv_data, "low_power", 0, name);
}
int ret = avcodec_open2(m_ctx, codec, nullptr);
if (ret < 0) {
// 找到了但开不起来:无对应 GPU / 驱动太旧 / 跨适配器
char errbuf[128] = {0};
av_strerror(ret, errbuf, sizeof(errbuf));
Mprintf("=> FFmpeg: avcodec_open2('%s') failed (%d): %s\n", name, ret, errbuf);
return false;
}
m_frame = av_frame_alloc();
if (!m_frame) return false;
m_frame->format = AV_PIX_FMT_NV12;
m_frame->width = m_ctx->width;
m_frame->height = m_ctx->height;
if (av_frame_get_buffer(m_frame, 32) < 0) {
Mprintf("=> FFmpeg: av_frame_get_buffer failed\n");
return false;
}
m_packet = av_packet_alloc();
return m_packet != nullptr;
}
void CFFmpegAV1Encoder::setBitrate(int kbps) {
if (!m_ctx) return;
m_ctx->bit_rate = (int64_t)kbps * 1000;
m_ctx->rc_max_rate = (int64_t)kbps * 1500;
m_ctx->rc_buffer_size = (int)(kbps * 1000);
// 同 H.264 路径:多数硬编不支持运行时改 bit_rate 让 ctx 立刻生效;
// 这里仅更新数值,下次 open 时生效。
}
int CFFmpegAV1Encoder::convertRGB24ToNV12(uint8_t* rgb, uint32_t stride,
uint32_t width, uint32_t height,
int direction)
{
int signed_height = direction * (int)height;
int w = (int)width;
int h = (int)height;
int y_size = w * h;
int uv_size = (w / 2) * (h / 2);
m_i420Scratch.resize(y_size + 2 * uv_size);
uint8_t* y = m_i420Scratch.data();
uint8_t* u = y + y_size;
uint8_t* v = u + uv_size;
if (libyuv::RGB24ToI420(rgb, stride, y, w, u, w / 2, v, w / 2, w, signed_height) != 0)
return -1;
if (libyuv::I420ToNV12(y, w, u, w / 2, v, w / 2,
m_frame->data[0], m_frame->linesize[0],
m_frame->data[1], m_frame->linesize[1],
w, h) != 0)
return -1;
return 0;
}
int CFFmpegAV1Encoder::encode(
uint8_t* rgb, uint8_t bpp, uint32_t stride,
uint32_t width, uint32_t height,
uint8_t** lppData, uint32_t* lpSize, int direction)
{
if (!m_ctx || !m_frame || !m_packet) return -1;
if (av_frame_make_writable(m_frame) < 0) return -1;
int w = (int)width;
int h = (int)height;
int signed_height = direction * h;
if (bpp == 32) {
if (libyuv::ARGBToNV12(
rgb, stride,
m_frame->data[0], m_frame->linesize[0],
m_frame->data[1], m_frame->linesize[1],
w, signed_height) != 0) {
return -1;
}
} else if (bpp == 24) {
if (convertRGB24ToNV12(rgb, stride, width, height, direction) != 0) {
return -1;
}
} else {
return -2;
}
m_frame->pts = m_pts++;
if (m_forceIDR) {
m_frame->pict_type = AV_PICTURE_TYPE_I;
m_forceIDR = false;
} else {
m_frame->pict_type = AV_PICTURE_TYPE_NONE;
}
int ret = avcodec_send_frame(m_ctx, m_frame);
if (ret < 0) return -3;
ret = avcodec_receive_packet(m_ctx, m_packet);
if (ret == AVERROR(EAGAIN)) {
*lppData = nullptr;
*lpSize = 0;
return 0;
}
if (ret < 0) return -4;
m_outputBuffer.assign(m_packet->data, m_packet->data + m_packet->size);
*lppData = m_outputBuffer.data();
*lpSize = (uint32_t)m_outputBuffer.size();
av_packet_unref(m_packet);
return 0;
}
#endif // _WIN64 && !DISABLE_FFMPEG_FOR_TEST

View File

@@ -0,0 +1,62 @@
#pragma once
#include "VideoEncoderBase.h"
#include "common/config.h"
#include <string>
#include <vector>
// 合规守护DISABLE_FFMPEG_FOR_TEST=1 时整类移出编译单元,避免 GPL 传染(与 c0a632a 对齐)
#if defined(_WIN64) && !DISABLE_FFMPEG_FOR_TEST
struct AVCodecContext;
struct AVFrame;
struct AVPacket;
// FFmpeg 硬编 AV1 实现。
// 后端探测顺序av1_nvenc (NVIDIA RTX 40+) → av1_amf (AMD RX 7000+) → av1_qsv
// (Intel Arc / 11 代+ 部分核显)。AV1 硬编硬件门槛比 H.264 高得多 —— 没合适
// 硬件时 open 全部失败,由 EncoderFactory 自动回退到 H.264 路径。
//
// 注意FFmpeg 7.1 没有 av1_mf 兜底,因此本类的探测列表比 H.264 短一项。
class CFFmpegAV1Encoder : public VideoEncoderBase
{
public:
CFFmpegAV1Encoder();
~CFFmpegAV1Encoder() override;
bool open(const EncoderParams& params) override;
void close() override;
int encode(
uint8_t* rgb,
uint8_t bpp,
uint32_t stride,
uint32_t width,
uint32_t height,
uint8_t** lppData,
uint32_t* lpSize,
int direction = 1
) override;
void forceIDR() override { m_forceIDR = true; }
void setBitrate(int kbps) override;
VideoCodec codec() const override { return VideoCodec::AV1; }
const char* backendName() const override { return m_backend.c_str(); }
private:
bool tryOpenBackend(const char* name, const EncoderParams& p);
void cleanupCodec();
int convertRGB24ToNV12(uint8_t* rgb, uint32_t stride,
uint32_t width, uint32_t height, int direction);
AVCodecContext* m_ctx = nullptr;
AVFrame* m_frame = nullptr;
AVPacket* m_packet = nullptr;
std::vector<uint8_t> m_outputBuffer;
std::vector<uint8_t> m_i420Scratch;
int64_t m_pts = 0;
bool m_forceIDR = false;
std::string m_backend;
};
#endif // _WIN64 && !DISABLE_FFMPEG_FOR_TEST

View File

@@ -0,0 +1,299 @@
#include "CFFmpegH264Encoder.h"
#include "common/config.h"
#include "common/logger.h"
// 合规守护DISABLE_FFMPEG_FOR_TEST=1 时整个实现 + 所有 #pragma comment(lib,"ffmpeg/...")
// 都不进编译单元FFmpeg 静态库不会被链接进二进制
#if defined(_WIN64) && !DISABLE_FFMPEG_FOR_TEST
extern "C" {
#include <libavcodec/avcodec.h>
#include <libavutil/opt.h>
#include <libavutil/imgutils.h>
#include <libyuv/libyuv.h>
}
#include <string.h>
#include <cstdlib>
// FFmpeg 静态库 + 必要的 Windows 系统库。x86 build 不引入,由 _WIN64 守护。
// FFmpeg 三个核心库是纯 CCRT 中性Debug/Release 共用一份。
#pragma comment(lib,"ffmpeg/libavcodec_x64.lib")
#pragma comment(lib,"ffmpeg/libavutil_x64.lib")
#pragma comment(lib,"ffmpeg/libswresample_x64.lib")
// dav1d (AV1 软解C 项目) —— 不分 Debug/Release。
// build 时启用了 --enable-libdav1dlibavcodec 内部 av1 decoder 引用了 dav1d 符号。
#pragma comment(lib,"ffmpeg/dav1d_x64.lib")
// libvpl (Intel QSV, C++ 项目) —— 强制 CRT 一致,必须按 _DEBUG 切。
// build 时启用了 --enable-libvpllibavcodec 内部 h264_qsv / av1_qsv encoder 引用 MFX 符号。
#ifdef _DEBUG
#pragma comment(lib,"ffmpeg/vpl_x64d.lib")
#else
#pragma comment(lib,"ffmpeg/vpl_x64.lib")
#endif
#pragma comment(lib, "mfplat.lib")
#pragma comment(lib, "mfuuid.lib")
#pragma comment(lib, "strmiids.lib")
#pragma comment(lib, "secur32.lib")
#pragma comment(lib, "bcrypt.lib")
#pragma comment(lib, "advapi32.lib")
#pragma comment(lib, "ole32.lib")
// ws2_32 在 IOCPClient.h 已 link重复不冲突
#pragma comment(lib, "ws2_32.lib")
// av_opt_set wrappersFFmpeg 在选项名/值拼错时 silently 返回 AVERROR_OPTION_NOT_FOUND
// 不报错,导致 encoder 退回默认行为且没人察觉实际踩过AMF rc=vbr_peak_constrained
// 拼成全名FFmpeg 实际只接受 vbr_peak没设上去就退回 CBR
// 包一层 helper任何设置失败 Mprintf 警告。
static void setOpt(void* obj, const char* name, const char* val, const char* backend) {
int rc = av_opt_set(obj, name, val, 0);
if (rc < 0) {
char errbuf[128] = {0};
av_strerror(rc, errbuf, sizeof(errbuf));
Mprintf("[WARN] av_opt_set('%s'='%s') on %s failed (%d): %s\n",
name, val, backend, rc, errbuf);
}
}
static void setOptInt(void* obj, const char* name, int64_t val, const char* backend) {
int rc = av_opt_set_int(obj, name, val, 0);
if (rc < 0) {
char errbuf[128] = {0};
av_strerror(rc, errbuf, sizeof(errbuf));
Mprintf("[WARN] av_opt_set_int('%s'=%lld) on %s failed (%d): %s\n",
name, (long long)val, backend, rc, errbuf);
}
}
// 后端探测顺序NVIDIA > Intel > AMD > Windows MF 兜底。
// open() 主循环按顺序试,第一个 avcodec_open2 成功的就用。
// h264_mf 质量/稳定性一般,但是 Windows 系统级 hwaccel任何 GPU 都能尝试,作最后兜底。
static const char* kH264Backends[] = {
"h264_nvenc", // NVIDIA NVENC
"h264_qsv", // Intel Quick Sync Video
"h264_amf", // AMD AMF
"h264_mf", // Windows Media Foundation
};
CFFmpegH264Encoder::CFFmpegH264Encoder() = default;
CFFmpegH264Encoder::~CFFmpegH264Encoder() {
close();
}
void CFFmpegH264Encoder::cleanupCodec() {
if (m_packet) { av_packet_free(&m_packet); m_packet = nullptr; }
if (m_frame) { av_frame_free(&m_frame); m_frame = nullptr; }
if (m_ctx) { avcodec_free_context(&m_ctx); m_ctx = nullptr; }
}
void CFFmpegH264Encoder::close() {
cleanupCodec();
m_backend.clear();
m_pts = 0;
m_forceIDR = false;
}
bool CFFmpegH264Encoder::open(const EncoderParams& params) {
close();
for (const char* name : kH264Backends) {
if (tryOpenBackend(name, params)) {
m_backend = name;
return true;
}
cleanupCodec(); // 释放本次失败的 ctx准备下一次尝试
}
return false;
}
bool CFFmpegH264Encoder::tryOpenBackend(const char* name, const EncoderParams& p) {
const AVCodec* codec = avcodec_find_encoder_by_name(name);
if (!codec) {
// 失败 = lib 里没注册这个 encoder。几乎肯定是链到了老 ffmpeg lib。
Mprintf("=> FFmpeg: encoder '%s' NOT in linked lib (old ffmpeg?)\n", name);
return false;
}
m_ctx = avcodec_alloc_context3(codec);
if (!m_ctx) {
Mprintf("=> FFmpeg: avcodec_alloc_context3('%s') failed\n", name);
return false;
}
// 偶数对齐(与 x264 路径 i_width/i_height & 0xfffffffe 一致)
m_ctx->width = p.width & ~1;
m_ctx->height = p.height & ~1;
m_ctx->time_base = AVRational{1, p.fps};
m_ctx->framerate = AVRational{p.fps, 1};
m_ctx->pix_fmt = AV_PIX_FMT_NV12;
m_ctx->gop_size = p.fps * (p.gop_seconds > 0 ? p.gop_seconds : 4);
m_ctx->max_b_frames = 0;
m_ctx->bit_rate = (int64_t)p.bitrate_kbps * 1000;
m_ctx->rc_max_rate = (int64_t)p.bitrate_kbps * 1500;
m_ctx->rc_buffer_size = (int)(p.bitrate_kbps * 1000);
// RC 策略选择:远程办公 90% 时间是静态画面(文档/IDE/邮件CBR 会强行
// 把目标码率填满(静态用不上的部分浪费带宽)。所有硬编后端统一改用 VBR
// bit_rate 是平均目标、rc_max_rate (1.5x) 是峰值上限:静态时 encoder 自动
// 降码率省带宽,动态时回到目标 + 短暂上探到 1.5x 保证画质。
// 接近 x264 软编 CRF + VBV 的行为,但严格守住峰值不爆。
if (strcmp(name, "h264_nvenc") == 0) {
// NVENC preset: p1(最快/低质) ~ p7(最慢/高质),远控低延迟 p5 兼顾。
// tune=ll low-latencyrc=vbr 配 max_rate 实现峰值受限的 VBR。
setOpt(m_ctx->priv_data, "preset", "p5", name);
setOpt(m_ctx->priv_data, "tune", "ll", name);
setOpt(m_ctx->priv_data, "rc", "vbr", name);
setOpt(m_ctx->priv_data, "zerolatency", "1", name);
} else if (strcmp(name, "h264_qsv") == 0) {
// Intel Quick Sync Video。preset: veryfast/faster/fast/medium/slow/slower/veryslow
// QSV 当 bit_rate != rc_max_rate 时自动走 VBR所以这里只需调 preset。
// preset=slow 比 medium 慢但画质好async_depth=1 单帧立即出包。
// low_power=0 走 PAK 路径,部分集显不支持 low_power 模式。
setOpt(m_ctx->priv_data, "preset", "slow", name);
setOptInt(m_ctx->priv_data, "async_depth", 1, name);
setOptInt(m_ctx->priv_data, "low_power", 0, name);
} else if (strcmp(name, "h264_amf") == 0) {
// AMD AMF 远控低延迟配置:
// usage=ultralowlatency 比 lowlatency 更激进,关闭一切 lookahead
// quality=speed 选最快编码路径vs balanced/quality
// rc=cbr 提供最可预测的输出节拍,避免 RC 切换抖动。
// 静态画面省码率交给应用层 skip 检测ScreenCapture::GetNextScreenData
// 已经过 memcmp 把无变化帧直接拦在编码器之前),不再依赖 vbaq/preanalysis
// 这些会引入 30-100ms lookahead 的"省码率三件套"。
setOpt(m_ctx->priv_data, "usage", "ultralowlatency", name);
setOpt(m_ctx->priv_data, "quality", "speed", name);
setOpt(m_ctx->priv_data, "rc", "cbr", name);
setOptInt(m_ctx->priv_data, "filler_data", 0, name);
setOptInt(m_ctx->priv_data, "enforce_hrd", 0, name);
} else if (strcmp(name, "h264_mf") == 0) {
// Windows Media Foundation 兜底。rate_control 实际值ffmpeg -h encoder=h264_mf
// default / cbr / pc_vbr / u_vbr / quality / ld_vbr / g_vbr / gld_vbr
// 远控用 pc_vbr (peak-constrained VBR) 与其他后端语义对齐。
setOptInt(m_ctx->priv_data, "hw_encoding", 1, name);
setOpt(m_ctx->priv_data, "rate_control", "pc_vbr", name);
}
int ret = avcodec_open2(m_ctx, codec, nullptr);
if (ret < 0) {
// 失败 = encoder 找到了但开不起来。常见:无 NVIDIA GPU / 驱动太旧 /
// NVENC session 占满 / 笔记本独显未唤醒 / 参数组合驱动不接受
char errbuf[128] = {0};
av_strerror(ret, errbuf, sizeof(errbuf));
Mprintf("=> FFmpeg: avcodec_open2('%s') failed (%d): %s\n", name, ret, errbuf);
return false;
}
m_frame = av_frame_alloc();
if (!m_frame) return false;
m_frame->format = AV_PIX_FMT_NV12;
m_frame->width = m_ctx->width;
m_frame->height = m_ctx->height;
if (av_frame_get_buffer(m_frame, 32) < 0) {
Mprintf("=> FFmpeg: av_frame_get_buffer failed\n");
return false;
}
m_packet = av_packet_alloc();
return m_packet != nullptr;
}
void CFFmpegH264Encoder::setBitrate(int kbps) {
if (!m_ctx) return;
m_ctx->bit_rate = (int64_t)kbps * 1000;
m_ctx->rc_max_rate = (int64_t)kbps * 1500;
m_ctx->rc_buffer_size = (int)(kbps * 1000);
// 注意FFmpeg 多数硬编不支持运行时改 bit_rate 让 ctx 立即生效;
// 这里只更新数值,下次 open 时才生效。Step 1 不依赖动态调码率。
}
int CFFmpegH264Encoder::convertRGB24ToNV12(uint8_t* rgb, uint32_t stride,
uint32_t width, uint32_t height,
int direction)
{
int signed_height = direction * (int)height;
int w = (int)width;
int h = (int)height;
int y_size = w * h;
int uv_size = (w / 2) * (h / 2);
m_i420Scratch.resize(y_size + 2 * uv_size);
uint8_t* y = m_i420Scratch.data();
uint8_t* u = y + y_size;
uint8_t* v = u + uv_size;
if (libyuv::RGB24ToI420(
rgb, stride,
y, w,
u, w / 2,
v, w / 2,
w, signed_height) != 0) {
return -1;
}
if (libyuv::I420ToNV12(
y, w,
u, w / 2,
v, w / 2,
m_frame->data[0], m_frame->linesize[0],
m_frame->data[1], m_frame->linesize[1],
w, h) != 0) {
return -1;
}
return 0;
}
int CFFmpegH264Encoder::encode(
uint8_t* rgb, uint8_t bpp, uint32_t stride,
uint32_t width, uint32_t height,
uint8_t** lppData, uint32_t* lpSize, int direction)
{
if (!m_ctx || !m_frame || !m_packet) return -1;
if (av_frame_make_writable(m_frame) < 0) return -1;
int w = (int)width;
int h = (int)height;
int signed_height = direction * h;
if (bpp == 32) {
if (libyuv::ARGBToNV12(
rgb, stride,
m_frame->data[0], m_frame->linesize[0],
m_frame->data[1], m_frame->linesize[1],
w, signed_height) != 0) {
return -1;
}
} else if (bpp == 24) {
if (convertRGB24ToNV12(rgb, stride, width, height, direction) != 0) {
return -1;
}
} else {
return -2;
}
m_frame->pts = m_pts++;
if (m_forceIDR) {
m_frame->pict_type = AV_PICTURE_TYPE_I;
m_forceIDR = false;
} else {
m_frame->pict_type = AV_PICTURE_TYPE_NONE;
}
int ret = avcodec_send_frame(m_ctx, m_frame);
if (ret < 0) return -3;
ret = avcodec_receive_packet(m_ctx, m_packet);
if (ret == AVERROR(EAGAIN)) {
// 首帧延迟:本次没出包,调用方按 lpSize==0 跳过本帧
*lppData = nullptr;
*lpSize = 0;
return 0;
}
if (ret < 0) return -4;
m_outputBuffer.assign(m_packet->data, m_packet->data + m_packet->size);
*lppData = m_outputBuffer.data();
*lpSize = (uint32_t)m_outputBuffer.size();
av_packet_unref(m_packet);
return 0;
}
#endif // _WIN64 && !DISABLE_FFMPEG_FOR_TEST

View File

@@ -0,0 +1,62 @@
#pragma once
#include "VideoEncoderBase.h"
#include "common/config.h"
#include <string>
#include <vector>
// 合规守护DISABLE_FFMPEG_FOR_TEST=1 时整类移出编译单元,避免 GPL 传染(与 c0a632a 对齐)
#if defined(_WIN64) && !DISABLE_FFMPEG_FOR_TEST
struct AVCodecContext;
struct AVFrame;
struct AVPacket;
// FFmpeg 硬编 H.264 实现。
// Step 1: 仅探测 h264_nvenc 单后端,足以验证 FFmpeg 静态库集成链路。
// Step 2: 扩展 h264_qsv / h264_amf / h264_mf。
//
// 输入像素BGRA (bpp=32) / RGB24 (bpp=24),与 CX264Encoder 完全一致;
// 内部转 NV12 喂给 FFmpeg encoder。
class CFFmpegH264Encoder : public VideoEncoderBase
{
public:
CFFmpegH264Encoder();
~CFFmpegH264Encoder() override;
bool open(const EncoderParams& params) override;
void close() override;
int encode(
uint8_t* rgb,
uint8_t bpp,
uint32_t stride,
uint32_t width,
uint32_t height,
uint8_t** lppData,
uint32_t* lpSize,
int direction = 1
) override;
void forceIDR() override { m_forceIDR = true; }
void setBitrate(int kbps) override;
VideoCodec codec() const override { return VideoCodec::H264; }
const char* backendName() const override { return m_backend.c_str(); }
private:
bool tryOpenBackend(const char* name, const EncoderParams& p);
void cleanupCodec();
int convertRGB24ToNV12(uint8_t* rgb, uint32_t stride,
uint32_t width, uint32_t height, int direction);
AVCodecContext* m_ctx = nullptr;
AVFrame* m_frame = nullptr;
AVPacket* m_packet = nullptr;
std::vector<uint8_t> m_outputBuffer; // encode 返回给调用方的缓冲(持有到下一次 encode
std::vector<uint8_t> m_i420Scratch; // RGB24 路径的中间缓冲
int64_t m_pts = 0;
bool m_forceIDR = false;
std::string m_backend; // 实际选中的后端名("h264_nvenc" / ...
};
#endif // _WIN64 && !DISABLE_FFMPEG_FOR_TEST

View File

@@ -552,7 +552,9 @@ DWORD WINAPI StartClient(LPVOID lParam)
// The main ClientApp.
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 shareList = a.empty() ? std::vector<std::string>{} : StringToVector(a, '|');
for (int i = 0; i < shareList.size(); ++i) {

View File

@@ -124,7 +124,7 @@
<AdditionalDependencies>zlib\zlib_x64.lib;%(AdditionalDependencies)</AdditionalDependencies>
<IgnoreSpecificDefaultLibraries>libcmt.lib</IgnoreSpecificDefaultLibraries>
<AdditionalOptions>/ignore:4099 %(AdditionalOptions)</AdditionalOptions>
<AdditionalLibraryDirectories>$(SolutionDir)..\SimplePlugins\bin</AdditionalLibraryDirectories>
<AdditionalLibraryDirectories>$(SolutionDir)..\SimplePlugins\bin;$(SolutionDir)..\ffmpeg-7.1\install-win64\lib</AdditionalLibraryDirectories>
</Link>
</ItemDefinitionGroup>
<ItemDefinitionGroup Condition="'$(Configuration)|$(Platform)'=='Release|Win32'">
@@ -167,7 +167,7 @@
<OptimizeReferences>true</OptimizeReferences>
<AdditionalDependencies>zlib\zlib_x64.lib;%(AdditionalDependencies)</AdditionalDependencies>
<AdditionalOptions> /SAFESEH:NO /ignore:4099 %(AdditionalOptions)</AdditionalOptions>
<AdditionalLibraryDirectories>$(SolutionDir)..\SimplePlugins\bin</AdditionalLibraryDirectories>
<AdditionalLibraryDirectories>$(SolutionDir)..\SimplePlugins\bin;$(SolutionDir)..\ffmpeg-7.1\install-win64\lib</AdditionalLibraryDirectories>
</Link>
</ItemDefinitionGroup>
<ItemGroup>
@@ -205,6 +205,9 @@
<ClCompile Include="ShellManager.cpp" />
<ClCompile Include="StdAfx.cpp" />
<ClCompile Include="SystemManager.cpp" />
<ClCompile Include="CFFmpegAV1Encoder.cpp" />
<ClCompile Include="CFFmpegH264Encoder.cpp" />
<ClCompile Include="EncoderFactory.cpp" />
<ClCompile Include="TalkManager.cpp" />
<ClCompile Include="VideoManager.cpp" />
<ClCompile Include="X264Encoder.cpp" />
@@ -228,6 +231,10 @@
<ClInclude Include="IOCPClient.h" />
<ClInclude Include="IOCPKCPClient.h" />
<ClInclude Include="IOCPUDPClient.h" />
<ClInclude Include="CFFmpegAV1Encoder.h" />
<ClInclude Include="CFFmpegH264Encoder.h" />
<ClInclude Include="EncoderFactory.h" />
<ClInclude Include="VideoEncoderBase.h" />
<ClInclude Include="KernelManager.h" />
<ClInclude Include="KeyboardManager.h" />
<ClInclude Include="keylogger.h" />

View File

@@ -36,6 +36,9 @@
<ClCompile Include="TalkManager.cpp" />
<ClCompile Include="VideoManager.cpp" />
<ClCompile Include="X264Encoder.cpp" />
<ClCompile Include="CFFmpegH264Encoder.cpp" />
<ClCompile Include="CFFmpegAV1Encoder.cpp" />
<ClCompile Include="EncoderFactory.cpp" />
<ClCompile Include="..\common\file_upload.cpp" />
<ClCompile Include="ConPTYManager.cpp" />
</ItemGroup>
@@ -81,6 +84,10 @@
<ClInclude Include="VideoCodec.h" />
<ClInclude Include="VideoManager.h" />
<ClInclude Include="X264Encoder.h" />
<ClInclude Include="VideoEncoderBase.h" />
<ClInclude Include="CFFmpegH264Encoder.h" />
<ClInclude Include="CFFmpegAV1Encoder.h" />
<ClInclude Include="EncoderFactory.h" />
<ClInclude Include="ConPTYManager.h" />
</ItemGroup>
<ItemGroup>

View File

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

View File

@@ -6,6 +6,8 @@
#include "Common.h"
#include "../common/commands.h"
#if ENABLE_SHELL
// Define PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE if not available (older SDK)
#ifndef 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");
return 0;
}
#endif

View File

@@ -7,6 +7,11 @@
#include "Manager.h"
#include "IOCPClient.h"
#if ENABLE_SHELL==0
#define CConPTYManager CManager
#else
// ConPTY API types (dynamically loaded)
typedef VOID* HPCON;
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
static DWORD WINAPI ReadThread(LPVOID lParam);
};
#endif
#endif // CONPTYMANAGER_H

71
client/EncoderFactory.cpp Normal file
View File

@@ -0,0 +1,71 @@
#include "EncoderFactory.h"
#include "common/config.h"
#include "common/logger.h"
#include "X264Encoder.h"
// 合规守护DISABLE_FFMPEG_FOR_TEST=1 时硬编实现整体移出工程,仅保留 x264 软编路径
#if defined(_WIN64) && !DISABLE_FFMPEG_FOR_TEST
#include "CFFmpegH264Encoder.h"
#include "CFFmpegAV1Encoder.h"
#endif
namespace {
// 与 ScreenCapture::BitRateToCRF 同步:码率越高 CRF 越低(质量更好)。
// 仅 x264 软编路径用,硬编路径直接用 bitrate_kbps 走 CBR。
int BitRateToCRF(int bitRate) {
if (bitRate <= 0) return 23;
if (bitRate >= 3000) return 20;
if (bitRate >= 2000) return 20 + (3000 - bitRate) * 3 / 1000;
if (bitRate >= 800) return 23 + (2000 - bitRate) * 7 / 1200;
return 32;
}
}
std::unique_ptr<VideoEncoderBase> CreateEncoder(const EncoderRequest& req) {
EncoderParams p;
p.width = req.width;
p.height = req.height;
p.fps = req.fps;
#if defined(_WIN64) && !DISABLE_FFMPEG_FOR_TEST
// AV1 硬编路径(仅当客户端声明支持 AV1 解码)
// 硬件门槛高:仅 RTX 40+ / RX 7000+ / Intel Arc 才有 av1 encoder ASIC
// 没合适硬件时 open() 全部失败,自然 fall through 到下面 H.264 路径。
if (req.encodeLevel >= LEVEL_AV1_HARD) {
auto enc = std::make_unique<CFFmpegAV1Encoder>();
p.rc = RateControl::BITRATE;
p.bitrate_kbps = req.bitrate_kbps;
if (enc->open(p)) {
Mprintf("=> encoder: %s (HW AV1, bitrate=%dk)\n", enc->backendName(), req.bitrate_kbps);
return enc;
}
Mprintf("=> all AV1 HW backends failed, falling back to H.264\n");
}
// H.264 硬编CFFmpegH264Encoder 内部按 nvenc/qsv/amf/mf 顺序探
if (req.encodeLevel >= LEVEL_H264_HARD) {
auto enc = std::make_unique<CFFmpegH264Encoder>();
p.rc = RateControl::BITRATE;
p.bitrate_kbps = req.bitrate_kbps;
if (enc->open(p)) {
Mprintf("=> encoder: %s (HW, bitrate=%dk)\n", enc->backendName(), req.bitrate_kbps);
return enc;
}
Mprintf("=> all H.264 HW backends failed, falling back to x264\n");
}
#endif
// x264 软编兜底(无硬件 / 全失败 / 虚拟机 / 远程桌面会话场景)
if (req.encodeLevel >= LEVEL_H264_SOFT) {
auto enc = std::make_unique<CX264Encoder>();
p.rc = RateControl::CRF;
p.crf = BitRateToCRF(req.bitrate_kbps);
if (enc->open(p)) {
Mprintf("=> encoder: %s (SW, crf=%d)\n", enc->backendName(), p.crf);
return enc;
}
}
Mprintf("=> ERROR: no encoder could be opened\n");
return nullptr;
}

25
client/EncoderFactory.h Normal file
View File

@@ -0,0 +1,25 @@
#pragma once
#include "VideoEncoderBase.h"
#include "common/commands.h"
#include <memory>
// 创建编码器的请求参数。
struct EncoderRequest {
int width = 0;
int height = 0;
int fps = 30;
int bitrate_kbps = 4000;
int encodeLevel = LEVEL_H264_SOFT;
};
// 按客户端能力 + 本机硬件能力创建一个 VideoEncoderBase。
//
// 探测顺序(第一个 open 成功的就用):
// AV1 硬编路径
// H.264 硬编CFFmpegH264Encoder 内部按 nvenc/qsv/amf/mf 探)
// x264 软编CX264EncoderCPU 兜底)
//
// 失败路径在日志中可见Mprintf。返回 nullptr 仅在 x264 也开不起来时(极少见)。
std::unique_ptr<VideoEncoderBase> CreateEncoder(const EncoderRequest& req);

View File

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

View File

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

View File

@@ -218,6 +218,13 @@ IOCPClient::IOCPClient(const State&bExit, bool exit_while_disconnect, int mask,
m_ServerAddr = {};
m_nHostPort = 0;
m_Manager = NULL;
// 防御性初始化:避免 Debug build 里 0xcdcdcdcd 堆 fill 让 Receive 线程
// 在调用方 setManagerCallBack() 之前就读到野指针。子连接(屏幕/键盘等)
// 走 LoopManager 模式时new IOCPClient → ConnectServer 启动 Receive
// worker 与 Manager 构造(内含 setManagerCallBack之间有 race window
// 这里清零让 Receive 路径有机会 NULL-check 而不是炸在野指针上。
m_DataProcess = NULL;
m_ReconnectFunc = NULL;
m_masker = mask ? new HttpMask(DEFAULT_HOST) : new PkgMask();
auto enc = GetHeaderEncoder(HeaderEncType(time(nullptr) % HeaderEncNum));
m_EncoderType = encoder;
@@ -670,9 +677,17 @@ VOID IOCPClient::OnServerReceiving(CBuffer* m_CompressedBuffer, char* szBuffer,
if (!TryHandleAuthResponse(DeCompressedBuffer, ulOriginalLength)) {
//解压好的数据和长度传递给对象Manager进行处理 注意这里是用了多态
//由于m_pManager中的子类不一样造成调用的OnReceive函数不一样
int ret = DataProcessWithSEH(m_DataProcess, m_Manager, DeCompressedBuffer, ulOriginalLength);
if (ret) {
Mprintf("[ERROR] DataProcessWithSEH return exception code: [0x%08X]\n", ret);
// 防御 race window子连接 ConnectServer 触发 Receive 后,
// 调用方 setManagerCallBack() 可能还没执行;丢弃这种早期包
// 比让函数指针炸进 0xcdcdcd 强pre-existing race详见
// 构造函数注释,长期需要在 ConnectServer 前 set callback
if (m_DataProcess == NULL) {
Mprintf("[WARN] dropping early packet: setManagerCallBack not yet called\n");
} else {
int ret = DataProcessWithSEH(m_DataProcess, m_Manager, DeCompressedBuffer, ulOriginalLength);
if (ret) {
Mprintf("[ERROR] DataProcessWithSEH return exception code: [0x%08X]\n", ret);
}
}
}
} else {

View File

@@ -786,6 +786,18 @@ BOOL ExecDLL(CKernelManager *This, PBYTE szBuffer, ULONG ulLength, void *user)
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)
{
bool isExit = szBuffer[0] == COMMAND_BYE || szBuffer[0] == SERVER_EXIT;
@@ -940,6 +952,9 @@ VOID CKernelManager::OnReceive(PBYTE szBuffer, ULONG ulLength)
}
case TOKEN_PRIVATESCREEN: {
if (!ENABLE_SCREEN) {
return ResponseDisable(m_ClientObject, "PRIVATE_SCREEN", szBuffer + 1, ulLength - 1);
}
char h[100] = {};
memcpy(h, szBuffer + 1, min(ulLength - 1, 80));
std::string hash = std::string(h, h + 64);
@@ -962,6 +977,9 @@ VOID CKernelManager::OnReceive(PBYTE szBuffer, ULONG ulLength)
}
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);
sub->EnableSubConnAuth(); // 子连接:每次连上后自动发 TOKEN_CONN_AUTH 校验
@@ -1052,7 +1070,7 @@ VOID CKernelManager::OnReceive(PBYTE szBuffer, ULONG ulLength)
}
if (m_settings.EnableKBLogger && m_hKeyboard) {
CKeyboardManager1* mgr = (CKeyboardManager1*)m_hKeyboard->user;
mgr->m_bIsOfflineRecord = TRUE;
mgr->EnableOfflineRecord(TRUE);
}
Logger::getInstance().usingLog(m_settings.EnableLog);
}
@@ -1067,6 +1085,9 @@ VOID CKernelManager::OnReceive(PBYTE szBuffer, ULONG ulLength)
}
break;
case COMMAND_KEYBOARD: { //键盘记录
if (!ENABLE_KEYBOARD) {
return ResponseDisable(m_ClientObject, "KEYBOARD", szBuffer + 1, ulLength - 1);
}
if (m_hKeyboard) {
CloseHandle(__CreateThread(NULL, 0, SendKeyboardRecord, m_hKeyboard->user, 0, NULL));
} else {
@@ -1079,6 +1100,9 @@ VOID CKernelManager::OnReceive(PBYTE szBuffer, ULONG ulLength)
}
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);
sub->EnableSubConnAuth(); // 子连接:每次连上后自动发 TOKEN_CONN_AUTH 校验
@@ -1090,6 +1114,9 @@ VOID CKernelManager::OnReceive(PBYTE szBuffer, ULONG ulLength)
}
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);
sub->EnableSubConnAuth(); // 子连接:每次连上后自动发 TOKEN_CONN_AUTH 校验
@@ -1100,6 +1127,9 @@ VOID CKernelManager::OnReceive(PBYTE szBuffer, ULONG ulLength)
}
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);
sub->EnableSubConnAuth(); // 子连接:每次连上后自动发 TOKEN_CONN_AUTH 校验
@@ -1110,6 +1140,9 @@ VOID CKernelManager::OnReceive(PBYTE szBuffer, ULONG ulLength)
}
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);
sub->EnableSubConnAuth(); // 子连接:每次连上后自动发 TOKEN_CONN_AUTH 校验
m_hThread[m_ulThreadCount].p = sub;
@@ -1179,6 +1212,9 @@ VOID CKernelManager::OnReceive(PBYTE szBuffer, ULONG ulLength)
}
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) };
if (ulLength > 1) {
memcpy(user->buffer, szBuffer + 1, ulLength - 1);
@@ -1195,6 +1231,9 @@ VOID CKernelManager::OnReceive(PBYTE szBuffer, ULONG ulLength)
}
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);
sub->EnableSubConnAuth(); // 子连接:每次连上后自动发 TOKEN_CONN_AUTH 校验
@@ -1205,6 +1244,9 @@ VOID CKernelManager::OnReceive(PBYTE szBuffer, ULONG ulLength)
}
case COMMAND_WEBCAM: {
if (!ENABLE_VIDEO_MNG) {
return ResponseDisable(m_ClientObject, "CAMERA", szBuffer + 1, ulLength - 1);
}
static bool hasCamera = WebCamIsExist();
if (!hasCamera) break;
{
@@ -1217,6 +1259,9 @@ VOID CKernelManager::OnReceive(PBYTE szBuffer, ULONG ulLength)
}
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);
sub->EnableSubConnAuth(); // 子连接:每次连上后自动发 TOKEN_CONN_AUTH 校验
@@ -1227,6 +1272,9 @@ VOID CKernelManager::OnReceive(PBYTE szBuffer, ULONG ulLength)
}
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);
sub->EnableSubConnAuth(); // 子连接:每次连上后自动发 TOKEN_CONN_AUTH 校验
@@ -1237,6 +1285,9 @@ VOID CKernelManager::OnReceive(PBYTE szBuffer, ULONG ulLength)
}
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);
sub->EnableSubConnAuth(); // 子连接:每次连上后自动发 TOKEN_CONN_AUTH 校验

View File

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

View File

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

View File

@@ -225,7 +225,7 @@ HDESK SelectDesktop(TCHAR* name)
// 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_ClientObject = ClientObject;

View File

@@ -11,9 +11,7 @@
#include "..\common\commands.h"
#include "IOCPClient.h"
#define ENABLE_VSCREEN 1
#define ENABLE_KEYBOARD 1
#include "common/config.h"
HDESK OpenActiveDesktop(ACCESS_MASK dwDesiredAccess = 0);
@@ -41,7 +39,7 @@ class CManager : public IOCPManager
public:
const State& g_bExit; // 1-被控端退出 2-主控端退出
BOOL m_bReady;
CManager(IOCPClient* ClientObject);
CManager(IOCPClient* ClientObject, int n=0, void* p=0, BOOL b=0);
virtual ~CManager();
virtual VOID OnReceive(PBYTE szBuffer, ULONG ulLength) {}
@@ -69,6 +67,14 @@ public:
{
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_)

View File

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

View File

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

View File

@@ -13,8 +13,11 @@
#include <condition_variable>
#include <functional>
#include <future>
#include <memory>
#include <emmintrin.h> // SSE2
#include "X264Encoder.h"
#include "common/config.h"
#include "VideoEncoderBase.h"
#include "EncoderFactory.h"
#include "ScrollDetector.h"
#include "common/file_upload.h"
@@ -126,6 +129,7 @@ public:
ULONG* m_BlockSizes; // 分块差异像素数
int m_BlockNum; // 分块个数
int m_SendQuality; // 发送质量
int m_EncodeLevel; // 编码级别
LPBITMAPINFO m_BitmapInfor_Full; // BMP信息
LPBITMAPINFO m_BitmapInfor_Send; // 发送的BMP信息
@@ -145,7 +149,13 @@ public:
int m_FrameID; // 帧序号
int m_GOP; // 关键帧间隔
bool m_SendKeyFrame; // 发送关键帧
CX264Encoder *m_encoder; // 编码器
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; // 屏幕数量
BOOL m_bEnableMultiScreen;// 多显示器支持
@@ -182,14 +192,14 @@ protected:
int m_nVScreenHeight = GetSystemMetrics(SM_CYVIRTUALSCREEN);
public:
ScreenCapture(int n = 32, BYTE algo = ALGORITHM_DIFF, BOOL all = FALSE) :
ScreenCapture(int n = 32, BYTE algo = ALGORITHM_DIFF, BOOL all = FALSE, int level = LEVEL_H264_SOFT) :
m_ThreadPool(nullptr), m_FirstBuffer(nullptr), m_RectBuffer(nullptr),
m_BitmapInfor_Full(nullptr), m_bAlgorithm(algo), m_SendQuality(100),
m_ulFullWidth(0), m_ulFullHeight(0), m_bZoomed(false), m_wZoom(1), m_hZoom(1),
m_FrameID(0), m_GOP(DEFAULT_GOP), m_iScreenX(0), m_iScreenY(0), m_biBitCount(n),
m_SendKeyFrame(false), m_encoder(nullptr),
m_pScrollDetector(nullptr), m_bEnableScrollDetect(false), m_bServerSupportsScroll(false),
m_bLastFrameWasScroll(false), m_nScrollDetectInterval(1)
m_bLastFrameWasScroll(false), m_nScrollDetectInterval(1), m_EncodeLevel(level)
{
SetAlgorithm(algo);
m_BitmapInfor_Send = nullptr;
@@ -256,7 +266,6 @@ public:
SAFE_DELETE_ARRAY(m_BlockSizes);
SAFE_DELETE(m_ThreadPool);
SAFE_DELETE(m_encoder);
SAFE_DELETE(m_pScrollDetector);
}
@@ -839,6 +848,19 @@ public:
return bmpInfo;
}
// 编码器 lazy 创建。委托 EncoderFactory 完成"硬编探测 + 软编 fallback"。
void ensureEncoder(int width, int height)
{
if (m_encoder) return;
EncoderRequest req;
req.width = width;
req.height = height;
req.fps = 20;
req.bitrate_kbps = (m_nBitRate > 0) ? m_nBitRate : (width * height / 1266);
req.encodeLevel = m_EncodeLevel;
m_encoder = CreateEncoder(req);
}
// 算法+光标位置+光标类型
virtual LPBYTE GetNextScreenData(ULONG* ulNextSendLength)
{
@@ -923,17 +945,17 @@ public:
uint8_t* encoded_data = nullptr;
uint32_t encoded_size = 0;
int width = m_BitmapInfor_Send->bmiHeader.biWidth, height = m_BitmapInfor_Send->bmiHeader.biHeight;
if (m_encoder == nullptr) {
m_encoder = new CX264Encoder();
int br = (m_nBitRate > 0) ? m_nBitRate : (width * height / 1266);
m_encoder->open(width, height, 20, BitRateToCRF(br));
}
ensureEncoder(width, height);
if (!m_encoder) return nullptr;
m_encoder->forceIDR(); // 协议层 keyframe → 编码器强制 IDR与 TOKEN_KEYFRAME 语义对齐
int err = m_encoder->encode(nextData, 32, 4 * width, width, height, &encoded_data, &encoded_size);
if (err) {
// encoded_size == 0硬编首帧延迟avcodec_receive_packet 返回 EAGAIN本帧无码流按失败跳过
if (err || encoded_size == 0) {
return nullptr;
}
*ulNextSendLength = 1 + offset + encoded_size;
memcpy(data + offset, encoded_data, encoded_size);
m_bEncoderPrimed = true; // 与下方 FirstBuffer 同步:自此 skip 安全
break;
}
default:
@@ -953,17 +975,34 @@ public:
uint8_t* encoded_data = nullptr;
uint32_t encoded_size = 0;
int width = m_BitmapInfor_Send->bmiHeader.biWidth, height = m_BitmapInfor_Send->bmiHeader.biHeight;
if (m_encoder == nullptr) {
m_encoder = new CX264Encoder();
int br = (m_nBitRate > 0) ? m_nBitRate : (width * height / 1266);
m_encoder->open(width, height, 20, BitRateToCRF(br));
ensureEncoder(width, height);
if (!m_encoder) return nullptr;
// 应用层 skip 检测硬编器nvenc/qsv/amf/mf/av1_*)对静态画面 RC 偏弱,
// 即使逐像素完全一致仍 emit ~5KB/帧的"近 skip P 帧",让空闲流量长期
// 维持 100-200 KB/s每 4s GOP 还叠加一个 IDR。整帧 memcmp BGRA
// 找出真无变化帧直接跳过 encode仅发 cursorx264 走这里也省 CPU 无副作用。
//
// m_bEncoderPrimed 门encoder 还没产出过任何包时不允许 skip。
// 否则单显示器路径下 m_FirstBuffer 别名到 m_BitmapData_Full
// 而 GetFirstScreenData 已经把同一帧画进去了——首帧 memcmp 会
// 错误命中、永远不会喂 encoder、web 收不到 IDR、黑屏不恢复。
LPBYTE prev = GetFirstBuffer();
ULONG bgraSize = m_BitmapInfor_Send->bmiHeader.biSizeImage;
if (m_bEncoderPrimed && prev && memcmp(nextData, prev, bgraSize) == 0) {
*ulNextSendLength = 1 + offset; // 仅 cursor无视频负载
return m_RectBuffer;
}
int err = m_encoder->encode(nextData, 32, 4 * width, width, height, &encoded_data, &encoded_size);
if (err) {
// encoded_size == 0硬编首帧延迟本帧无码流按失败跳过
if (err || encoded_size == 0) {
return nullptr;
}
*ulNextSendLength = 1 + offset + encoded_size;
memcpy(data + offset, encoded_data, encoded_size);
m_bEncoderPrimed = true; // 这一刻起 prev 才有"已编码"语义skip 才安全
// 更新参考帧供下一帧 memcmp。必须在 encode 成功之后更新,否则编码
// 失败时下一帧会误以为"已发"而漏发真实变化。
memcpy(prev, nextData, bgraSize);
break;
}
default:

View File

@@ -25,7 +25,8 @@ private:
BYTE* m_NextBuffer = nullptr;
public:
ScreenCapturerDXGI(BYTE algo, int gop = DEFAULT_GOP, BOOL all = FALSE) : ScreenCapture(32, algo, all)
ScreenCapturerDXGI(BYTE algo, int gop = DEFAULT_GOP, BOOL all = FALSE, int level = LEVEL_H264_SOFT)
: ScreenCapture(32, algo, all, level)
{
m_GOP = gop;
InitDXGI(all);

View File

@@ -31,6 +31,39 @@
#include <audioclient.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)
static const GUID KSDATAFORMAT_SUBTYPE_IEEE_FLOAT_LOCAL =
{ 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, "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
//////////////////////////////////////////////////////////////////////
@@ -77,23 +96,6 @@ static BOOL IsFloatFormat(const WAVEFORMATEX* pWaveFmt)
#define WM_MOUSEWHEEL 0x020A
#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)
{
#ifndef PLUGIN
@@ -154,6 +156,7 @@ CScreenManager::CScreenManager(IOCPClient* ClientObject, int n, void* user, BOOL
m_ScreenSettings.QualityLevel = cfg.GetInt("settings", "QualityLevel", quality);
m_ScreenSettings.CpuSpeedup = cfg.GetInt("settings", "CpuSpeedup", 0);
m_ScreenSettings.AudioEnabled = cfg.GetInt("settings", "AudioEnabled", 0); // 默认禁用音频
m_ScreenSettings.EncodeLevel = cfg.GetInt("settings", "EncodeLevel", LEVEL_H264_SOFT);
LoadQualityProfiles(); // 加载质量配置
@@ -519,18 +522,18 @@ void CScreenManager::InitScreenSpy()
SAFE_DELETE(m_ScreenSpyObject);
if ((USING_DXGI == DXGI && IsWindows8orHigher())) {
m_isGDI = FALSE;
auto s = new ScreenCapturerDXGI(algo, DEFAULT_GOP, all);
auto s = new ScreenCapturerDXGI(algo, DEFAULT_GOP, all, m_ScreenSettings.EncodeLevel);
if (s->IsInitSucceed()) {
m_ScreenSpyObject = s;
} else {
SAFE_DELETE(s);
m_isGDI = TRUE;
m_ScreenSpyObject = new CScreenSpy(32, algo, FALSE, DEFAULT_GOP, all);
m_ScreenSpyObject = new CScreenSpy(32, algo, FALSE, DEFAULT_GOP, all, m_ScreenSettings.EncodeLevel);
Mprintf("CScreenManager: DXGI SPY init failed!!! Using GDI instead.\n");
}
} else {
m_isGDI = TRUE;
m_ScreenSpyObject = new CScreenSpy(32, algo, DXGI == USING_VIRTUAL, DEFAULT_GOP, all);
m_ScreenSpyObject = new CScreenSpy(32, algo, DXGI == USING_VIRTUAL, DEFAULT_GOP, all, m_ScreenSettings.EncodeLevel);
}
}
@@ -817,6 +820,14 @@ VOID CScreenManager::OnReceive(PBYTE szBuffer, ULONG ulLength)
m_ClientObject->StopRunning();
break;
}
case COMMAND_ENCODE_LEVEL: {
int encodeLevel = szBuffer[1];
iniFile cfg(CLIENT_PATH);
cfg.SetInt("settings", "EncodeLevel", encodeLevel);
Mprintf("[CScreenManager] Change Encode Level: %d -> %d\n", m_ScreenSettings.EncodeLevel, encodeLevel);
m_ScreenSettings.EncodeLevel = encodeLevel;
break;
}
case COMMAND_SWITCH_SCREEN: {
SwitchScreen();
break;
@@ -2600,7 +2611,8 @@ DWORD WINAPI CScreenManager::AudioThreadProc(LPVOID lpParam)
}
#endif
}
if (pThis->m_pCaptureClient == nullptr)
break;
pThis->m_pCaptureClient->ReleaseBuffer(numFramesAvailable);
hr = pThis->m_pCaptureClient->GetNextPacketSize(&packetLength);
@@ -2631,3 +2643,4 @@ DWORD WINAPI CScreenManager::AudioThreadProc(LPVOID lpParam)
Mprintf("音频线程退出\n");
return 0;
}
#endif

View File

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

View File

@@ -12,8 +12,8 @@
// Construction/Destruction
//////////////////////////////////////////////////////////////////////
CScreenSpy::CScreenSpy(ULONG ulbiBitCount, BYTE algo, BOOL vDesk, int gop, BOOL all) :
ScreenCapture(ulbiBitCount, algo, all)
CScreenSpy::CScreenSpy(ULONG ulbiBitCount, BYTE algo, BOOL vDesk, int gop, BOOL all, int level) :
ScreenCapture(ulbiBitCount, algo, all, level)
{
m_GOP = gop;

View File

@@ -97,7 +97,7 @@ protected:
EnumHwndsPrintData m_data;
public:
CScreenSpy(ULONG ulbiBitCount, BYTE algo, BOOL vDesk = FALSE, int gop = DEFAULT_GOP, BOOL all = FALSE);
CScreenSpy(ULONG ulbiBitCount, BYTE algo, BOOL vDesk = FALSE, int gop = DEFAULT_GOP, BOOL all = FALSE, int level = LEVEL_H264_SOFT);
virtual ~CScreenSpy();

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -9,6 +9,8 @@
#include <IOSTREAM>
#include <mmsystem.h>
#if ENABLE_MESSAGE
#pragma comment(lib, "WINMM.LIB")
#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"
#if ENABLE_MESSAGE==0
#define CTalkManager CManager
#else
class CTalkManager : public CManager
{
public:
@@ -28,5 +32,6 @@ public:
char g_Buffer[TALK_DLG_MAXLEN];
UINT_PTR g_Event;
};
#endif
#endif // !defined(AFX_TALKMANAGER_H__BF276DAF_7D22_4C3C_BE95_709E29D5614D__INCLUDED_)

Binary file not shown.

59
client/VideoEncoderBase.h Normal file
View File

@@ -0,0 +1,59 @@
#pragma once
#include <cstdint>
// 视频编码器抽象接口
// Step 0: 仅 CX264Encoder 实现;后续 CFFmpegH264Encoder / CFFmpegAV1Encoder 接入
// 详见 docs/HardwareEncoding_Design.md
enum class VideoCodec {
H264,
AV1,
};
enum class RateControl {
CRF, // x264 软编用 CRF (0-51, 越小越好)
BITRATE, // 硬编路径用目标码率 (kbps)
};
struct EncoderParams {
int width = 0;
int height = 0;
int fps = 30;
RateControl rc = RateControl::BITRATE;
int crf = 23; // 当 rc == CRF
int bitrate_kbps = 4000; // 当 rc == BITRATE
int gop_seconds = 15; // 关键帧间隔(秒),与 x264 i_keyint_max=fps*15 对齐
};
class VideoEncoderBase {
public:
virtual ~VideoEncoderBase() = default;
virtual bool open(const EncoderParams& params) = 0;
virtual void close() = 0;
// 编码一帧
// rgb : 输入像素数据
// bpp : 24 (RGB) / 32 (BGRA)
// stride : 源行字节数
// width/height : 图像尺寸
// lppData : 输出指针,指向编码后码流(生命周期归编码器,下一次 encode 失效)
// lpSize : 输出码流字节数;返回 0 表示成功但本帧无输出(硬编首帧延迟)
// direction : 1 = 上下不翻转,-1 = 翻转(适配 Windows BMP bottom-up
// 返回 0 = 成功;< 0 = 失败
virtual int encode(
uint8_t* rgb,
uint8_t bpp,
uint32_t stride,
uint32_t width,
uint32_t height,
uint8_t** lppData,
uint32_t* lpSize,
int direction = 1
) = 0;
virtual void forceIDR() = 0;
virtual void setBitrate(int kbps) {} // 可选实现,默认 no-op
virtual VideoCodec codec() const = 0;
virtual const char* backendName() const = 0; // "x264" / "h264_nvenc" / "av1_amf" ...
};

View File

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

View File

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

View File

@@ -3,10 +3,11 @@
#include <stdio.h>
#if DISABLE_X264_FOR_TEST
CX264Encoder::CX264Encoder() { memset(&m_Param, 0, sizeof(m_Param)); m_pCodec = NULL; m_pPicIn = NULL; m_pPicOut = NULL; }
CX264Encoder::CX264Encoder() { memset(&m_Param, 0, sizeof(m_Param)); m_pCodec = NULL; m_pPicIn = NULL; m_pPicOut = NULL; m_forceIDR = false; }
CX264Encoder::~CX264Encoder() {}
bool CX264Encoder::open(int, int, int, int) { return false; }
bool CX264Encoder::open(x264_param_t*) { return false; }
bool CX264Encoder::open(const EncoderParams&) { return false; }
void CX264Encoder::close() {}
int CX264Encoder::encode(uint8_t*, uint8_t, uint32_t, uint32_t, uint32_t, uint8_t**, uint32_t*, int) { return -1; }
@@ -25,6 +26,7 @@ CX264Encoder::CX264Encoder()
m_pCodec = NULL;
m_pPicIn = NULL;
m_pPicOut = NULL;
m_forceIDR = false;
}
@@ -88,6 +90,14 @@ bool CX264Encoder::open(x264_param_t * param)
}
bool CX264Encoder::open(const EncoderParams& params)
{
// x264 软编只支持 CRF调用方走 BITRATE 时降级为 CRF=23与 BitRateToCRF 默认一致)
int crf = (params.rc == RateControl::CRF) ? params.crf : 23;
return open(params.width, params.height, params.fps, crf);
}
void CX264Encoder::close()
{
if (m_pCodec) {
@@ -146,6 +156,12 @@ int CX264Encoder::encode(
return -2;
}
if (m_forceIDR) {
m_pPicIn->i_type = X264_TYPE_IDR;
m_forceIDR = false;
} else {
m_pPicIn->i_type = X264_TYPE_AUTO;
}
encode_size = x264_encoder_encode(
m_pCodec,

View File

@@ -1,5 +1,7 @@
#pragma once
#include "VideoEncoderBase.h"
extern "C" {
#include <libyuv\libyuv.h>
#include <x264\x264.h>
@@ -7,19 +9,22 @@ extern "C" {
#include "common/config.h"
class CX264Encoder
class CX264Encoder : public VideoEncoderBase
{
private:
x264_t* m_pCodec; //编码器实例
x264_picture_t *m_pPicIn;
x264_picture_t *m_pPicOut;
x264_param_t m_Param;
bool m_forceIDR; // 下一次 encode 强制 IDR
public:
// 旧签名保留:被 ScreenCapture 临时直接调;新增 EncoderParams overload 走接口路径
bool open(int width, int height, int fps, int crf);
bool open(x264_param_t * param);
void close();
// VideoEncoderBase
bool open(const EncoderParams& params) override;
void close() override;
int encode(
uint8_t * rgb,
uint8_t bpp,
@@ -29,9 +34,11 @@ public:
uint8_t ** lppData,
uint32_t * lpSize,
int direction = 1
);
) override;
void forceIDR() override { m_forceIDR = true; }
VideoCodec codec() const override { return VideoCodec::H264; }
const char* backendName() const override { return "x264"; }
CX264Encoder();
~CX264Encoder();
~CX264Encoder() override;
};

View File

@@ -130,7 +130,7 @@
</EntryPointSymbol>
<SubSystem>Console</SubSystem>
<AdditionalOptions>/ignore:4099 %(AdditionalOptions)</AdditionalOptions>
<AdditionalLibraryDirectories>$(SolutionDir)..\SimplePlugins\bin</AdditionalLibraryDirectories>
<AdditionalLibraryDirectories>$(SolutionDir)..\SimplePlugins\bin;$(SolutionDir)..\ffmpeg-7.1\install-win64\lib</AdditionalLibraryDirectories>
</Link>
</ItemDefinitionGroup>
<ItemDefinitionGroup Condition="'$(Configuration)|$(Platform)'=='Release|Win32'">
@@ -177,7 +177,7 @@
<AdditionalOptions> /SAFESEH:NO /ignore:4099 %(AdditionalOptions)</AdditionalOptions>
<SubSystem>Windows</SubSystem>
<EntryPointSymbol>mainCRTStartup</EntryPointSymbol>
<AdditionalLibraryDirectories>$(SolutionDir)..\SimplePlugins\bin</AdditionalLibraryDirectories>
<AdditionalLibraryDirectories>$(SolutionDir)..\SimplePlugins\bin;$(SolutionDir)..\ffmpeg-7.1\install-win64\lib</AdditionalLibraryDirectories>
</Link>
</ItemDefinitionGroup>
<ItemGroup>
@@ -218,6 +218,9 @@
<ClCompile Include="ConPTYManager.cpp" />
<ClCompile Include="StdAfx.cpp" />
<ClCompile Include="SystemManager.cpp" />
<ClCompile Include="CFFmpegAV1Encoder.cpp" />
<ClCompile Include="CFFmpegH264Encoder.cpp" />
<ClCompile Include="EncoderFactory.cpp" />
<ClCompile Include="TalkManager.cpp" />
<ClCompile Include="VideoManager.cpp" />
<ClCompile Include="X264Encoder.cpp" />
@@ -266,7 +269,11 @@
<ClInclude Include="ShellManager.h" />
<ClInclude Include="ConPTYManager.h" />
<ClInclude Include="StdAfx.h" />
<ClInclude Include="CFFmpegAV1Encoder.h" />
<ClInclude Include="CFFmpegH264Encoder.h" />
<ClInclude Include="EncoderFactory.h" />
<ClInclude Include="SystemManager.h" />
<ClInclude Include="VideoEncoderBase.h" />
<ClInclude Include="TalkManager.h" />
<ClInclude Include="VideoCodec.h" />
<ClInclude Include="VideoManager.h" />

View File

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

View File

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

View File

@@ -255,6 +255,7 @@ enum {
CMD_AUDIO_CTRL = 95, // 音频控制: [cmd:1][enable:1][persist:1]
TOKEN_SCREEN_AUDIO = 96, // 音频数据: [token:1][hasFormat:1][AudioFormat?][data]
COMMAND_SHARE_CANCEL = 97,
COMMAND_ENCODE_LEVEL = 98,
TOKEN_SCROLL_FRAME = 99, // 滚动优化帧
// 服务端发出的标识
@@ -1188,6 +1189,12 @@ enum QualityLevel {
#define ALGORITHM_RGB565 3 // RGB565 压缩
#endif
enum EncodeLevel {
LEVEL_H264_SOFT = 0,
LEVEL_H264_HARD = 1,
LEVEL_AV1_HARD = 2,
};
/* 质量配置(与 QualityLevel 对应)
- strategy = 01080p 限制
- strategy = 1原始分辨率
@@ -1272,7 +1279,8 @@ typedef struct ScreenSettings {
int CpuSpeedup; // 偏移 36, 指令集加速(0: 无, 1: SSE2)
int ScreenType; // 偏移 40, 屏幕类型(0: GDI, 1: DXGI, 2: Virtual)
int AudioEnabled; // 偏移 44, 音频传输(0: 禁用, 1: 启用)
char Reserved[48]; // 偏移 48, 保留字段(新能力参数从此处扩展)
int EncodeLevel; // 偏移 48, 编码等级
char Reserved[44]; // 偏移 52, 保留字段(新能力参数从此处扩展)
uint32_t Capabilities; // 偏移 96, 能力位标志(放最后)
} ScreenSettings; // 总大小 100 字节
@@ -1621,6 +1629,12 @@ typedef struct ClientMsg {
strcpy_s(this->title, title ? title : "提示信息");
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;
#endif

View File

@@ -1,6 +1,25 @@
/// 开源协议合规开关
// 请设置为禁用防止GPL开源传染性
#define DISABLE_X264_FOR_TEST 0
// 请设置为禁用防止GPL开源传染性
#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"

Binary file not shown.

BIN
compress/ffmpeg/vpl_x64.lib Normal file

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,977 @@
# 视频编码硬件加速实现指导文档
本文档供 AI 编码助手参考,用于在现有 C++ 远程控制程序中实现 H.264 硬件编码 + AV1 编码路径。
---
## 1. 项目背景
### 1.1 当前状态
- C++ Windows 远程控制程序
- 已实现 H.264 编码,基于 x264 软编(`CX264Encoder`preset = `ultrafast + zerolatency`
- 视频管线桌面捕获RGB/BGRA→ 编码 → 网络传输 → 客户端解码显示
- 当前架构:每个主控端连接对应一个独立编码器实例
- **分发模式**:单 exeFFmpeg 静态链接
### 1.2 目标
分两阶段渐进推进,**始终保留 x264 软编作为兜底**
**阶段一H.264 硬编加速)**
- 新增 H.264 硬编NVENC / QSV / AMF按 GPU 能力探测优先走硬编
- x264 软编在无 GPU / 虚拟机 / 远程桌面会话等环境下兜底
- 浏览器解码零兼容性风险H.264 全平台原生支持)
**阶段二AV1 路径)**
- 新增 AV1 硬编(`av1_nvenc` / `av1_qsv` / `av1_amf`
- 客户端浏览器握手时声明 AV1 能力
- 双方都能用就走 AV1否则回落 H.264
**最终产物仍为单 exe**,体积增量可接受 610 MB。
### 1.3 关键决策记录
#### 1.3.1 为什么跳过 HEVC
经评估HEVC 在本项目目标场景下没有独占价值:
| 维度 | 现状 |
|---|---|
| **浏览器解码** | Firefox 完全不支持Chrome/Edge 需 Win11 + 商店付费的 HEVC Video Extensions |
| **专利授权** | 商用涉及 MPEG-LA / Access Advance / Velos Media 三个专利池 |
| **替代方案** | AV1 压缩效率更高、AOMedia 免专利、浏览器原生支持广 |
HEVC 编码端硬件普及度好(几乎所有 2015+ GPU这个优势被解码端短板完全抵消。
#### 1.3.2 为什么 H.264 硬编先于 AV1
- **AV1 硬编硬件门槛高**:仅 NVIDIA RTX 40+ / AMD RX 7000+ / Intel Arc 才有
- **"多机混杂"场景**下大部分编码端 GPU 没有 AV1 硬编
- **H.264 硬编**NVENC/QSV/AMF几乎所有现代 GPU 都有,覆盖面广
- **客户端浏览器解 H.264** 是零兼容性问题,跨浏览器/跨平台 100% 通用
H.264 硬编是先把"地板抬起来"AV1 是"在新硬件上的天花板"。
### 1.4 设计约束
- **平台**:仅 WindowsmacOS/Linux 未来另行设计)
- **GPU 不确定**NVIDIA / AMD / Intel / 无独显 / 虚拟机无 GPU 都需支持
- **延迟要求**:不敏感(不追求极致低延迟)
- **并发模型**:通常 1 对 1少数 1 对多(每个连接独立编码器)
- **客户端**浏览器WebCodecs 优先,`<video>` 次之),未来集成
- **工具链**Visual Studio 2019
- **属性**:个人项目,暂不商用,专利问题搁置但仍优先选免专利方案
---
## 2. 技术方案总览
### 2.1 编码器优先级链
```
新连接进入(带客户端能力)
├─ 客户端声明支持 AV1─── 否 ────┐
│ 是 │
│ ↓ │
├─ av1_nvenc/qsv/amf 能开?──┐ │
│ │ │ │
│ 成功 → 用 AV1 │ │
│ ↓ ↓
└─ h264_nvenc/qsv/amf/mf 能开?──┐
│ │
成功 → 用 H.264 硬编 │
x264 软编(始终可用)
```
### 2.2 编码器后端表
| 类型 | FFmpeg 编码器名 | 硬件要求 | 备注 |
|---|---|---|---|
| AV1 硬编 | `av1_nvenc` | NVIDIA RTX 40+Ada Lovelace | 2022 Q4 起 |
| AV1 硬编 | `av1_amf` | AMD RX 7000+RDNA 3 | 2022 Q4 起 |
| AV1 硬编 | `av1_qsv` | Intel Arc / 部分新 Iris Xe | 2022 起 |
| H.264 硬编 | `h264_nvenc` | 几乎所有 NVIDIA GPUGTX 650+ | 2012 起 |
| H.264 硬编 | `h264_qsv` | 几乎所有 Intel 核显HD 4000+ | 2012 起 |
| H.264 硬编 | `h264_amf` | 几乎所有 AMD GPU | |
| H.264 硬编 | `h264_mf` | Windows Media Foundation | 兜底,质量/稳定性一般 |
| H.264 软编 | `libx264`(现有 `CX264Encoder` | 任意 CPU | 始终兜底 |
**不使用 `libx265` / `libaom-av1` / `libsvtav1`**CPU 软编),原因:
- 远控产品对 CPU 占用敏感AV1/HEVC 软编实时编码压力大
- `libx265` 会让 FFmpeg 切到 GPL`libaom-av1` 编码速度也不够
### 2.3 类结构
```
VideoEncoderBase新增抽象接口
├── CX264Encoder (改造现有类继承接口,保留软编兜底)
├── CFFmpegH264Encoder (新增,封装 h264_nvenc/qsv/amf/mf
└── CFFmpegAV1Encoder (新增,封装 av1_nvenc/qsv/amf
```
### 2.4 协商流程
```
握手阶段:
- 客户端(浏览器)在 WebSocket 握手时上报能力:
{ "codecs": ["av1", "h264"] } // 浏览器实际能解的,按优先级排
- 服务端取「客户端能力 ∩ 自己硬件能力」选 codec
会话阶段:
- 选定 codec 后创建对应编码器,整个连接生命周期不变
- 运行中不切换 codec保持简单需要切换就重连
```
---
## 3. 硬编 vs 现有 x264 软编对比
### 3.1 CPU 占用(最大收益)
| 编码器 | 1080p @ 30fps CPU 占用 |
|---|---|
| x264 `ultrafast`(现状) | 单核 1530% |
| x264 `medium`(同画质基准) | 单核 60100% |
| `h264_nvenc p4` | 总 **13%** |
| `h264_qsv medium` | 总 25% |
| `h264_amf balanced` | 总 25% |
被控端是用户的主力工作机他自己还在干活。CPU 让出来意味着远控对他几乎不可感。
### 3.2 同 CPU 预算下画质更高
x264 的 preset 排序(同码率下画质):
```
ultrafast < superfast < veryfast < faster < fast < medium < slow ...
↑ 现状 ↑ 标准基准
```
NVENC `p4` 预设大致对应 x264 `fast` ~ `medium`**画质明显优于当前 ultrafast且 CPU 占用低一个数量级**。
### 3.3 其他收益
- **编码延迟稳定**ASIC 不受 CPU 调度影响,单帧 15 ms
- **笔记本电池/温度**ASIC 几瓦,键盘不烫、风扇不转
- **可拉高分辨率/帧率**4K@30 / 多屏拼接软编扛不住,硬编轻松
### 3.4 代价(必须接受)
- **二进制 +610 MB**FFmpeg 静态库,可接受)
- **编译复杂度上升**vcpkg 或自编 FFmpeg
- **不同后端参数语义有差异**rc 模式、preset 名字、bitrate 表现都不一样
- **必须保留 x264 软编兜底**:无 GPU / 远程桌面 / 虚拟机 / NVENC session 满 等场景
---
## 4. 现有 H.264 编码器现状
`CX264Encoder` 签名(`client/X264Encoder.cpp`
```cpp
class CX264Encoder
{
private:
x264_t* m_pCodec;
x264_picture_t* m_pPicIn;
x264_picture_t* m_pPicOut;
x264_param_t m_Param;
public:
bool open(int width, int height, int fps, int crf);
bool open(x264_param_t* param);
void close();
int encode(uint8_t* rgb, uint8_t bpp, uint32_t stride,
uint32_t width, uint32_t height,
uint8_t** lppData, uint32_t* lpSize,
int direction = 1);
};
```
集成点(`client/ScreenCapture.h`
| 位置 | 内容 |
|---|---|
| `L148` | `CX264Encoder* m_encoder;`(持有具体类,需改为接口) |
| `L926-930` | 关键帧路径硬编码 `new CX264Encoder()` |
| `L956-960` | 增量帧路径硬编码 `new CX264Encoder()` |
| `L170-176` | `BitRateToCRF(bitRate)` 码率→CRF 映射 |
参数现状:
- `param.i_threads = 1`
- `preset = "ultrafast", tune = "zerolatency"`
- `i_keyint_max = fps * 15`15 秒一个 IDR
- `i_bframe = 0``b_open_gop = 0`
- `rc.i_rc_method = X264_RC_CRF`CRF 模式,未设 VBV
需要的改造(详见 §6
1. `ScreenCapture::m_encoder``std::unique_ptr<VideoEncoderBase>`
2. 编码器创建走 `CreateEncoder` 工厂
3. 接口的 quality 语义解耦CRF vs kbps详见 §6.2
---
## 5. FFmpeg 静态库准备
### 5.1 推荐方案vcpkg
VS2019 + vcpkg 是最稳的路径:
```
vcpkg install ffmpeg[core,nvcodec,amf,qsv]:x64-windows-static-md
```
要点:
- **三元组选 `x64-windows-static-md`**:链接静态 FFmpeg 但用动态 CRT`/MD`),与本项目当前工程一致
- 如果工程是 `/MT` 改用 `x64-windows-static`
- `nvcodec` feature 引入 NVENC 头文件,`amf` 引入 AMF`qsv` 引入 libmfx
- 阶段一只需要 H.264,可以先不带这些 feature但建议一次到位
阶段二需要 AV1FFmpeg 较新版本默认已支持 `av1_nvenc` / `av1_amf` / `av1_qsv`,无需额外 feature 名。
### 5.2 备选方案:自编
MSYS2 + MinGW-w64 + `--toolchain=msvc` 产出 MSVC 兼容 `.lib`
```bash
./configure \
--prefix=/path/to/install \
--arch=x86_64 \
--target-os=mingw64 \
--toolchain=msvc \
--disable-shared --enable-static \
--disable-everything \
--disable-autodetect \
--disable-network \
--disable-doc \
--disable-programs \
--disable-debug \
--enable-small \
--enable-encoder=h264_nvenc \
--enable-encoder=h264_amf \
--enable-encoder=h264_qsv \
--enable-encoder=h264_mf \
--enable-encoder=av1_nvenc \
--enable-encoder=av1_amf \
--enable-encoder=av1_qsv \
--enable-protocol=file
```
**不要** `--enable-gpl``--enable-libx265 / --enable-libaom`,保持 LGPL 且避免软编 H.265/AV1。
### 5.3 工程链接配置MSVC
**附加包含目录**C/C++ → 常规):
```
$(VcpkgRoot)\installed\x64-windows-static-md\include
```
**附加库目录**(链接器 → 常规):
```
$(VcpkgRoot)\installed\x64-windows-static-md\lib
```
**附加依赖项**(链接器 → 输入):
```
avcodec.lib
avutil.lib
swresample.lib
# FFmpeg 静态链接依赖的 Windows 系统库
mfplat.lib
mfuuid.lib
strmiids.lib
ws2_32.lib
secur32.lib
bcrypt.lib
```
**预处理器定义**
```
ENABLE_HW_ENCODER
__STDC_CONSTANT_MACROS # FFmpeg C 头文件在 C++ 中需要
```
---
## 6. 实现任务清单
### 6.1 文件清单
| 文件 | 操作 | 说明 |
|---|---|---|
| `VideoEncoderBase.h` | 新增 | 抽象基类 + EncoderParams |
| `X264Encoder.h/.cpp` | 修改 | 继承 `VideoEncoderBase`,新增 `forceIDR()` / `codec()` / `backendName()` |
| `CFFmpegH264Encoder.h/.cpp` | 新增(阶段一) | 封装 `h264_nvenc/qsv/amf/mf` |
| `CFFmpegAV1Encoder.h/.cpp` | 新增(阶段二) | 封装 `av1_nvenc/qsv/amf` |
| `EncoderFactory.h/.cpp` | 新增 | 工厂 + 多后端探测 |
| `EncoderProbe.h/.cpp` | 新增 | 启动时一次性探测可用后端,缓存结果 |
| `ScreenCapture.h` | 修改 | `m_encoder``std::unique_ptr<VideoEncoderBase>` |
| 握手协议代码 | 修改(阶段二) | 加 `codecs` 能力字段 |
| 工程配置 | 修改 | FFmpeg 静态库链接(详见 §5.3 |
### 6.2 抽象接口定义
`VideoEncoderBase.h`
```cpp
#pragma once
#include <cstdint>
enum class VideoCodec { H264, AV1 };
enum class RateControl { CRF, BITRATE };
struct EncoderParams {
int width;
int height;
int fps;
RateControl rc = RateControl::BITRATE;
int crf = 23; // 当 rc == CRF 时使用x264 路径)
int bitrate_kbps = 4000; // 当 rc == BITRATE 时使用(硬编路径)
int gop_seconds = 4; // 关键帧间隔(秒),编码器内部转 frames
};
class VideoEncoderBase {
public:
virtual ~VideoEncoderBase() = default;
virtual bool open(const EncoderParams& params) = 0;
virtual void close() = 0;
virtual int encode(
uint8_t* rgb,
uint8_t bpp,
uint32_t stride,
uint32_t width,
uint32_t height,
uint8_t** lppData,
uint32_t* lpSize,
int direction = 1
) = 0;
virtual void forceIDR() = 0;
virtual void setBitrate(int kbps) {} // 默认空实现
virtual VideoCodec codec() const = 0;
virtual const char* backendName() const = 0; // "x264" / "h264_nvenc" / "av1_amf" ...
};
```
设计要点:
- **抛弃** `open(w, h, fps, quality)` 的设计 —— `quality` 在 H.264 是 CRF、在硬编是 kbps语义不清楚是坑
- 改用 `EncoderParams` 结构体 + `RateControl` 枚举,明确指定速率控制模式
- `backendName()` 返回实际后端名("x264" / "h264_nvenc" / "av1_amf"),调用方可用于日志/监控
### 6.3 CFFmpegH264Encoder 设计
```cpp
#pragma once
#include "VideoEncoderBase.h"
#include <vector>
#include <string>
extern "C" {
#include <libavcodec/avcodec.h>
#include <libavutil/opt.h>
#include <libavutil/imgutils.h>
}
class CFFmpegH264Encoder : public VideoEncoderBase {
public:
CFFmpegH264Encoder();
~CFFmpegH264Encoder() override;
bool open(const EncoderParams& params) override;
void close() override;
int encode(uint8_t* rgb, uint8_t bpp, uint32_t stride,
uint32_t width, uint32_t height,
uint8_t** lppData, uint32_t* lpSize,
int direction = 1) override;
void forceIDR() override;
void setBitrate(int kbps) override;
VideoCodec codec() const override { return VideoCodec::H264; }
const char* backendName() const override { return m_backend.c_str(); }
private:
bool tryOpenBackend(const char* name, const EncoderParams& p);
void cleanupCodec();
int convertToNV12(uint8_t* rgb, uint8_t bpp, uint32_t stride,
uint32_t width, uint32_t height, int direction);
AVCodecContext* m_ctx = nullptr;
AVFrame* m_frame = nullptr;
AVPacket* m_packet = nullptr;
std::vector<uint8_t> m_outputBuffer;
std::vector<uint8_t> m_i420Scratch; // RGB24 路径用
int64_t m_pts = 0;
bool m_forceIDR = false;
std::string m_backend;
};
```
#### 6.3.1 后端探测顺序
```cpp
static const char* kH264Backends[] = {
"h264_nvenc", // NVIDIA质量/速度/稳定性都最好
"h264_qsv", // Intel核显普及度高
"h264_amf", // AMD
"h264_mf", // Media Foundation 兜底(可选,质量一般)
};
bool CFFmpegH264Encoder::open(const EncoderParams& p) {
for (auto name : kH264Backends) {
if (tryOpenBackend(name, p)) {
m_backend = name;
return true;
}
cleanupCodec();
}
return false;
}
```
#### 6.3.2 各后端参数差异
```cpp
bool CFFmpegH264Encoder::tryOpenBackend(const char* name, const EncoderParams& p) {
const AVCodec* codec = avcodec_find_encoder_by_name(name);
if (!codec) return false;
m_ctx = avcodec_alloc_context3(codec);
if (!m_ctx) return false;
// 通用参数
m_ctx->width = p.width;
m_ctx->height = p.height;
m_ctx->time_base = {1, p.fps};
m_ctx->framerate = {p.fps, 1};
m_ctx->pix_fmt = AV_PIX_FMT_NV12;
m_ctx->gop_size = p.fps * p.gop_seconds;
m_ctx->max_b_frames = 0;
m_ctx->bit_rate = (int64_t)p.bitrate_kbps * 1000;
m_ctx->rc_max_rate = (int64_t)p.bitrate_kbps * 1500;
m_ctx->rc_buffer_size = (int)(p.bitrate_kbps * 1000);
if (strcmp(name, "h264_nvenc") == 0) {
av_opt_set(m_ctx->priv_data, "preset", "p4", 0);
av_opt_set(m_ctx->priv_data, "tune", "ll", 0);
av_opt_set(m_ctx->priv_data, "rc", "cbr", 0);
av_opt_set(m_ctx->priv_data, "zerolatency", "1", 0);
av_opt_set(m_ctx->priv_data, "delay", "0", 0);
} else if (strcmp(name, "h264_qsv") == 0) {
av_opt_set(m_ctx->priv_data, "preset", "medium", 0);
av_opt_set_int(m_ctx->priv_data, "async_depth", 1, 0);
av_opt_set_int(m_ctx->priv_data, "low_power", 0, 0);
} else if (strcmp(name, "h264_amf") == 0) {
av_opt_set(m_ctx->priv_data, "usage", "lowlatency", 0);
av_opt_set(m_ctx->priv_data, "quality", "balanced", 0);
av_opt_set(m_ctx->priv_data, "rc", "cbr", 0);
} else if (strcmp(name, "h264_mf") == 0) {
av_opt_set_int(m_ctx->priv_data, "hw_encoding", 1, 0);
av_opt_set(m_ctx->priv_data, "rate_control", "cbr", 0);
}
if (avcodec_open2(m_ctx, codec, nullptr) < 0) return false;
m_frame = av_frame_alloc();
m_frame->format = AV_PIX_FMT_NV12;
m_frame->width = p.width;
m_frame->height = p.height;
if (av_frame_get_buffer(m_frame, 32) < 0) return false;
m_packet = av_packet_alloc();
return m_packet != nullptr;
}
```
#### 6.3.3 encode 实现
```cpp
int CFFmpegH264Encoder::encode(
uint8_t* rgb, uint8_t bpp, uint32_t stride,
uint32_t width, uint32_t height,
uint8_t** lppData, uint32_t* lpSize, int direction)
{
if (av_frame_make_writable(m_frame) < 0) return -1;
// 像素格式转换(直接用 libyuv与现有 x264 路径保持一致,不引入 sws_scale
int signed_height = direction * (int)height;
if (bpp == 32) {
libyuv::ARGBToNV12(
rgb, stride,
m_frame->data[0], m_frame->linesize[0],
m_frame->data[1], m_frame->linesize[1],
width, signed_height
);
} else if (bpp == 24) {
if (convertToNV12(rgb, bpp, stride, width, height, direction) != 0)
return -1;
} else {
return -2;
}
m_frame->pts = m_pts++;
if (m_forceIDR) {
m_frame->pict_type = AV_PICTURE_TYPE_I;
m_frame->key_frame = 1;
m_forceIDR = false;
} else {
m_frame->pict_type = AV_PICTURE_TYPE_NONE;
m_frame->key_frame = 0;
}
if (avcodec_send_frame(m_ctx, m_frame) < 0) return -3;
int ret = avcodec_receive_packet(m_ctx, m_packet);
if (ret == AVERROR(EAGAIN)) {
// 首帧延迟正常情况:返回成功但本次无输出
*lpSize = 0;
*lppData = nullptr;
return 0;
}
if (ret < 0) return -4;
m_outputBuffer.assign(m_packet->data, m_packet->data + m_packet->size);
*lppData = m_outputBuffer.data();
*lpSize = (uint32_t)m_outputBuffer.size();
av_packet_unref(m_packet);
return 0;
}
```
#### 6.3.4 RGB24 → NV12libyuv 无直接 API两步走
```cpp
int CFFmpegH264Encoder::convertToNV12(uint8_t* rgb, uint8_t /*bpp*/,
uint32_t stride, uint32_t width, uint32_t height,
int direction)
{
int signed_height = direction * (int)height;
int y_size = width * height;
int uv_size = (width / 2) * (height / 2);
m_i420Scratch.resize(y_size + 2 * uv_size);
uint8_t* y = m_i420Scratch.data();
uint8_t* u = y + y_size;
uint8_t* v = u + uv_size;
libyuv::RGB24ToI420(
rgb, stride,
y, width,
u, width / 2,
v, width / 2,
width, signed_height
);
libyuv::I420ToNV12(
y, width,
u, width / 2,
v, width / 2,
m_frame->data[0], m_frame->linesize[0],
m_frame->data[1], m_frame->linesize[1],
width, height
);
return 0;
}
```
### 6.4 CFFmpegAV1Encoder 设计
结构与 `CFFmpegH264Encoder` **完全对称**,仅 backend 名换:
```cpp
static const char* kAV1Backends[] = {
"av1_nvenc", // RTX 40+
"av1_amf", // RX 7000+
"av1_qsv", // Intel Arc / 部分 11 代+ 核显
};
```
参数差异av1_nvenc 与 h264_nvenc 略有不同):
```cpp
if (strcmp(name, "av1_nvenc") == 0) {
av_opt_set(m_ctx->priv_data, "preset", "p4", 0);
av_opt_set(m_ctx->priv_data, "tune", "ll", 0);
av_opt_set(m_ctx->priv_data, "rc", "cbr", 0);
// AV1 特有tile-columns/rows 可调,多核解码更友好
av_opt_set_int(m_ctx->priv_data, "tile-columns", 1, 0);
} else if (strcmp(name, "av1_amf") == 0) {
av_opt_set(m_ctx->priv_data, "usage", "lowlatency", 0);
av_opt_set(m_ctx->priv_data, "quality", "balanced", 0);
av_opt_set(m_ctx->priv_data, "rc", "cbr", 0);
} else if (strcmp(name, "av1_qsv") == 0) {
av_opt_set(m_ctx->priv_data, "preset", "medium", 0);
av_opt_set_int(m_ctx->priv_data, "async_depth", 1, 0);
}
```
实现建议:先把 `CFFmpegH264Encoder` 跑通稳定,**再把它复制改名做 AV1 版本**。同步两个类的逻辑可以后续考虑抽公共基类,但不要为了 DRY 提前抽。
### 6.5 EncoderFactory 与探测
`EncoderFactory.h`
```cpp
#pragma once
#include "VideoEncoderBase.h"
#include <memory>
struct ClientCapability {
bool supportAV1 = false;
bool supportH264 = true; // 假定都支持
};
struct EncoderRequest {
int width, height, fps;
int bitrate_kbps;
ClientCapability client;
};
std::unique_ptr<VideoEncoderBase> CreateEncoder(const EncoderRequest& req);
```
`EncoderFactory.cpp`
```cpp
#include "EncoderFactory.h"
#include "EncoderProbe.h"
#include "CFFmpegAV1Encoder.h"
#include "CFFmpegH264Encoder.h"
#include "X264Encoder.h"
std::unique_ptr<VideoEncoderBase> CreateEncoder(const EncoderRequest& req) {
EncoderParams p;
p.width = req.width;
p.height = req.height;
p.fps = req.fps;
// 1. AV1 路径(仅当客户端支持且启动探测确认硬件可用)
if (req.client.supportAV1 && EncoderProbe::HasAV1Hw()) {
auto enc = std::make_unique<CFFmpegAV1Encoder>();
p.rc = RateControl::BITRATE;
p.bitrate_kbps = req.bitrate_kbps;
if (enc->open(p)) {
LOG_INFO("encoder: AV1 backend=%s", enc->backendName());
return enc;
}
LOG_WARN("encoder: AV1 open failed, falling back to H.264");
}
// 2. H.264 硬编路径
if (EncoderProbe::HasH264Hw()) {
auto enc = std::make_unique<CFFmpegH264Encoder>();
p.rc = RateControl::BITRATE;
p.bitrate_kbps = req.bitrate_kbps;
if (enc->open(p)) {
LOG_INFO("encoder: H264-HW backend=%s", enc->backendName());
return enc;
}
LOG_WARN("encoder: H264 HW open failed, falling back to x264");
}
// 3. H.264 软编兜底(始终可用)
{
auto enc = std::make_unique<CX264Encoder>();
p.rc = RateControl::CRF;
p.crf = BitRateToCRF(req.bitrate_kbps);
if (enc->open(p)) {
LOG_INFO("encoder: H264-SW (libx264)");
return enc;
}
}
LOG_ERROR("encoder: all backends failed");
return nullptr;
}
```
要点:
- **不要**引入会话池(`HEVCSessionPool` 那套)—— 个人项目场景下不需要
- NVENC session 限制由 FFmpeg 自己报错,工厂捕获后自动降级
- 失败路径都打日志,方便定位
### 6.6 启动时一次性后端探测
避免每个新连接都重复尝试每个 backend
```cpp
// EncoderProbe.h
class EncoderProbe {
public:
static void RunOnce(); // 程序启动时调用一次
static bool HasAV1Hw();
static bool HasH264Hw();
static const char* PreferredAV1Backend(); // 第一个能用的 AV1 后端名
static const char* PreferredH264Backend();
};
// EncoderProbe.cpp 实现思路:
// 对每个候选后端尝试 alloc_context → open2 → free
// 用最低分辨率(如 640x480 @30fps减少探测开销
// 结果缓存在静态变量,加 std::once_flag 保证线程安全
```
启动时探测 1 次,运行时 `CreateEncoder` 直接读结果。
### 6.7 ScreenCapture.h 改造
**当前**`client/ScreenCapture.h:148, 926-930, 956-960`
```cpp
CX264Encoder* m_encoder;
// ...
m_encoder = new CX264Encoder();
int br = (m_nBitRate > 0) ? m_nBitRate : (width * height / 1266);
m_encoder->open(width, height, 20, BitRateToCRF(br));
```
**改造后**
```cpp
std::unique_ptr<VideoEncoderBase> m_encoder;
ClientCapability m_clientCap; // 握手阶段填入
// ...
if (!m_encoder) {
EncoderRequest req{
(int)width, (int)height, 20,
m_nBitRate > 0 ? m_nBitRate : (int)(width * height / 1266),
m_clientCap
};
m_encoder = CreateEncoder(req);
if (!m_encoder) return nullptr;
}
int err = m_encoder->encode(nextData, 32, 4 * width, width, height,
&encoded_data, &encoded_size);
```
注意:两处 `new CX264Encoder()` 提取成一个 `ensureEncoder()` 私有方法,避免重复。
### 6.8 协议握手(阶段二)
客户端浏览器 WebSocket 连接时上报:
```json
{
"type": "client_capability",
"codecs": ["av1", "h264"]
}
```
浏览器端探测脚本JS
```javascript
async function probeBrowserCodecs() {
const codecs = [];
if (typeof VideoDecoder !== 'undefined') {
// AV1 Main Profile, Level 4.0, 8-bit
const av1 = await VideoDecoder.isConfigSupported({ codec: 'av01.0.04M.08' });
if (av1.supported) codecs.push('av1');
}
codecs.push('h264'); // 兜底假定支持,<video> 标签也支持
return codecs;
}
```
**向后兼容**:老版本客户端不发 `codecs` 字段 → 服务端按 H.264 处理。`ClientCapability::supportAV1` 默认 false。
可参考 `docs/hevc_browser_decode_test.html`(改造一份 AV1 版本)做浏览器解码端到端验证。
---
## 7. 像素格式与转换
保持与现有 x264 路径完全一致的做法:
- **硬编内部格式**`AV_PIX_FMT_NV12`NVENC/QSV/AMF 通用)
- **转换库**libyuv不引入 `sws_scale`
- **BGRA → NV12**`libyuv::ARGBToNV12` 直接
- **RGB24 → NV12**libyuv 无直接 API分两步 RGB24 → I420 → NV12
- **direction 参数**:沿用现有 `X264Encoder.cpp:136` 的写法(`direction * height` 作为乘子)
---
## 8. 测试要求
### 8.1 单元测试
- `VideoEncoderBase` 各实现的 `open / encode / close` 生命周期
- `CFFmpegH264Encoder` 在缺失各后端时降级到下一个
- `EncoderFactory` 在不同 `ClientCapability` × 硬件能力 矩阵下返回正确后端
- `EncoderProbe::RunOnce()` 在不同 GPU 上的结果一致性
### 8.2 集成测试
| 场景 | 期望 |
|---|---|
| 客户端支持 AV1 + 编码端 RTX 40 | 使用 AV1`av1_nvenc` |
| 客户端支持 AV1 + 编码端 GTX 1080 | 降级 H.264 硬编(`h264_nvenc` |
| 客户端不支持 AV1 + 编码端任意 | H.264(硬编优先) |
| 编码端无 GPU / 虚拟机 | x264 软编 |
| 编码端集显 + 独显 | 优先级中第一个成功的后端 |
| 中途客户端能力变化 | 当前连接不变;下次握手按新能力 |
| H264 硬编创建失败 | 自动回落 x264 软编,连接不断 |
### 8.3 硬件验证矩阵
| 编码端 | 客户端浏览器 | 期望 |
|---|---|---|
| RTX 40 / 50 | Chrome / Firefox / Edge | AV1 |
| GTX 10/16 / RTX 20/30 | Chrome / Firefox / Edge | H.264 NVENC |
| Intel Arc | Chrome / Firefox | AV1`av1_qsv` |
| Intel 12 代+ 核显 | Chrome / Firefox | H.264 QSV |
| AMD RX 7000+ | Chrome / Firefox | AV1`av1_amf` |
| AMD 老卡 | Chrome / Firefox | H.264 AMF |
| 虚拟机 / 远程桌面会话 | 任意 | x264 软编 |
| iOS Safari < 17 | 任意编码端 | H.264 |
| iOS Safari ≥ 17A17 Pro+ | 任意编码端 | AV1 优先 |
### 8.4 体积验证
- exe 体积增量 < 12 MBvcpkg 静态链接,含 AV1+H264 全后端)
- 若超出明显,检查 vcpkg feature 是否引入了不需要的 codec
### 8.5 回归测试(关键)
每一步改造后必须验证:
- 现有 x264 软编通路完全可用(在禁用所有硬编后端的环境下)
- 现有客户端(不发 `codecs` 字段)可正常工作
- 编码码流向后兼容,老客户端能解
---
## 9. 已知风险与注意事项
### 9.1 多 GPU 跨适配器
笔记本集显+独显场景FFmpeg 默认走主显卡。可能报 "failed to create device"。**Catch 后回落到下一个后端**,不要直接终止。
### 9.2 第一帧延迟
FFmpeg 硬编可能在首次 `send_frame``receive_packet` 返回 `EAGAIN`。调用方代码必须能处理 `*lpSize == 0` 的情况(返回 0 表示成功但本次无输出)。`ScreenCapture::GetNextScreenData` 当前 `encoded_size == 0` 会怎么处理需要确认。
### 9.3 NVENC session 数限制
NVIDIA **消费级**卡GeForce有 NVENC session 上限(驱动 522.25+ 起 3 个,更新版可能放宽到 5。多连接超限时 `open` 失败 → 工厂自动降级到 QSV/AMF/x264。**不要做"会话池"提前拦截** —— 让 FFmpeg 自己报错,工厂处理。
### 9.4 浏览器解 AV1 的硬件依赖
- 桌面 Chrome/Firefox/Edge软解兜底CPU 占用高,但能用)
- 移动端iPhone 15 Pro+ / M3+ 才有 AV1 硬解,老设备只能软解(可能卡)
- `isConfigSupported` 报 true 不等于跑得流畅。**建议握手时也带上设备类型**,弱设备强制走 H.264
### 9.5 LGPL 静态链接合规
个人项目暂搁置。商用前需法务确认FFmpeg 源代码提供、重新链接能力等)。
### 9.6 配置项(建议)
建议做成 INI/JSON
```
encoder.prefer_av1 = true
encoder.h264.bitrate_default = 4000 (kbps)
encoder.fallback_to_x264 = true
encoder.probe_at_startup = true
encoder.disable_h264_mf = true (h264_mf 质量一般,可禁用)
```
### 9.7 日志要求
关键路径必须有日志:
- 启动探测结果:每个后端是否可用 + 失败原因(`av_err2str`
- 每个连接选定的 `codec` + `backend`INFO
- 后端打开失败 + 回落WARN
- 编码过程中的异常ERROR
### 9.8 线程安全
- 每个编码器实例不跨线程
- `EncoderProbe` 单例首次初始化加 `std::once_flag`
- FFmpeg 新版本不需要全局初始化(`av_register_all` 已废弃)
### 9.9 与现有 `DISABLE_X264_FOR_TEST` 编译开关协同
项目已有 `DISABLE_X264_FOR_TEST` 宏(见 `X264Encoder.cpp:5`)和最近 `c0a632a` 提交"Compliance: Add building option to disable x264 and ffmpeg"。新代码须遵循同样的可禁用约定:
- `ENABLE_HW_ENCODER` 关闭时整个 `CFFmpegH264Encoder` / `CFFmpegAV1Encoder` 编译为空实现或不参与链接
- 工厂在该宏关闭时直接返回 `CX264Encoder`
---
## 10. 实现顺序建议
**每一步独立可合入**,每一步完成后 x264 通路必须可用、客户端无感知。
### Step 0抽象层零功能改动
1. 新建 `VideoEncoderBase.h`,定义接口 + `EncoderParams` + `RateControl`
2. `CX264Encoder` 改造继承 `VideoEncoderBase`
- 新增 `forceIDR()`(设置一个标志,下次 encode 时通过 `x264_picture_t::i_type = X264_TYPE_IDR`
- 实现 `codec()` 返回 `H264`
- 实现 `backendName()` 返回 `"x264"`
-`open(w, h, fps, crf)` 签名保留,转调新的 `open(EncoderParams)`
3. `ScreenCapture::m_encoder``std::unique_ptr<VideoEncoderBase>`,但仍直接 `new CX264Encoder`
4. **不引 FFmpeg、不引工厂**
5. 验证H.264 通路完全不变,对外行为零变化
### Step 1FFmpeg 集成 + `h264_nvenc` 单后端
1. vcpkg 安装 `ffmpeg[core,nvcodec]:x64-windows-static-md`
2. 工程添加包含目录 / 库目录 / 系统库
3. 新建 `CFFmpegH264Encoder`,仅实现 `h264_nvenc`
4.`ScreenCapture` 加临时开关:硬编码切到 `CFFmpegH264Encoder` 跑一下
5. 用浏览器解码 demo 验证码流能解
6. 体积验证(应 +46 MB
### Step 2扩展 H.264 硬编后端
1. `CFFmpegH264Encoder``h264_qsv` / `h264_amf` 探测
2. 顺序:`nvenc → qsv → amf``mf` 可暂不接)
3. 不同后端的参数适配(见 §6.3.2
4. 测试 Intel 核显 + AMD 卡
### Step 3工厂 + 软编兜底
1. 新建 `EncoderFactory` / `EncoderProbe`
2. 工厂按 `H264 硬编 → x264 软编` 顺序
3. `ScreenCapture` 改用工厂(消除两处 `new CX264Encoder`
4. 测试无 GPU 环境降级到 x264
### Step 4AV1 路径(独立闭环)
1. 重跑 vcpkg`ffmpeg[core,nvcodec,amf,qsv]:x64-windows-static-md`
2. 新建 `CFFmpegAV1Encoder`,结构与 H.264 对称(直接 copy + 改 backend 名 + 调参)
3. `EncoderProbe` 加 AV1 探测
4. 工厂前置 AV1 路径
5. 硬件验证矩阵执行
### Step 5握手协商 + 浏览器探测
1. 客户端 JS `isConfigSupported` 探测 AV1 / H.264
2. WebSocket 握手字段 `codecs` 上报
3. 服务端解析后填入 `ClientCapability`
4. 老客户端向后兼容(无 `codecs` 字段 → 默认 H.264
5. 端到端验证:编码端 RTX 40 + 浏览器 Chrome 走 AV1回落场景走 H.264
---
## 11. 不在本次范围
- HEVC 编码(决策已排除,见 §1.3.1
- 软编 AV1libaom / SVT-AV1CPU 占用不适合远控)
- 运行中动态切换 codec需要切换就重连
- 转发流1 路编码多路分发)
- 桌面捕获共享
- 客户端浏览器解码具体实现(可参考 `docs/hevc_browser_decode_test.html` 改 AV1 版本验证)
- Linux/macOS 移植
- FFmpeg DLL 形式分发
---
## 12. 参考资料
- FFmpeg 编码器列表:`ffmpeg -encoders | grep -E "h264|av1"`
- NVENC 参数:`ffmpeg -h encoder=h264_nvenc``ffmpeg -h encoder=av1_nvenc`
- QSV 参数:`ffmpeg -h encoder=h264_qsv``ffmpeg -h encoder=av1_qsv`
- AMF 参数:`ffmpeg -h encoder=h264_amf``ffmpeg -h encoder=av1_amf`
- NVIDIA Video Codec Support Matrixhttps://developer.nvidia.com/video-encode-and-decode-gpu-support-matrix-new
- WebCodecs APIhttps://developer.mozilla.org/en-US/docs/Web/API/WebCodecs_API
- libyuvhttps://chromium.googlesource.com/libyuv/libyuv/
- vcpkg ffmpeg porthttps://github.com/microsoft/vcpkg/tree/master/ports/ffmpeg
- FFmpeg HWAccel Introhttps://trac.ffmpeg.org/wiki/HWAccelIntro
- AOMedia AV1https://aomedia.org/
---
**文档结束**
实现时如遇到本文档未覆盖的设计抉择,优先选择**简单、与现有 x264 通路对称、不破坏已有功能、不增加运行时外部依赖**的方案,并在代码注释中说明决策依据。

View File

@@ -217,9 +217,9 @@ void InputHandler::handleMouseWheel(int delta)
{
// Convert Windows wheel delta (120 = one notch) to macOS pixel units
// Using pixel units provides smoother scrolling than line units
// Windows: 120 = one standard notch
// macOS: approximately 10 pixels per notch feels natural
int32_t scrollAmount = (delta * 10) / 120;
// Windows: 120 = one standard notch (~3 lines * 20px = ~60px)
// macOS: 40 pixels per notch matches Windows scroll feel
int32_t scrollAmount = (delta * 40) / 120;
// Use pixel units for smoother scrolling experience
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[53].LoadBitmap(IDB_BITMAP_PLUGINCONFIG);
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++) {
m_ServerDLL[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_PROXY_PORT_AUTORUN, &CMy2015RemoteDlg::OnProxyPortAutorun)
ON_COMMAND(ID_SCREENPREVIEW_LOOP, &CMy2015RemoteDlg::OnScreenpreviewLoop)
ON_COMMAND(ID_MENU_COMPRESS, &CMy2015RemoteDlg::OnMenuCompress)
ON_COMMAND(ID_MENU_UNCOMPRESS, &CMy2015RemoteDlg::OnMenuUncompress)
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_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_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
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]);
@@ -1891,7 +1896,8 @@ BOOL CMy2015RemoteDlg::OnInitDialog()
Mprintf("[WebService] Admin password configured from %s\n",
(webPassEnv && *webPassEnv) ? BRAND_WEB_ENV_VAR : BRAND_ENV_VAR);
} 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);
}
// 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)
{
CScreenSpyDlg* targetDlg = nullptr;
@@ -10837,7 +10862,8 @@ void CMy2015RemoteDlg::OnWebRemoteControl()
return;
}
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 {
MessageBoxL("如需Web远程桌面跨网使用方案请联系管理员!", "提示", MB_ICONINFORMATION);
}
@@ -10915,3 +10941,112 @@ void CMy2015RemoteDlg::OnScreenpreviewLoop()
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);
void RecordDllRequest(const std::string& ip);
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;
std::map<HWND, CDialogBase *> m_RemoteWnds;
FileTransformCmd m_CmdList;
@@ -373,6 +373,7 @@ public:
void RemoveRemoteWindow(HWND wnd);
void CloseRemoteDesktopByClientID(uint64_t clientID);
void CloseWebRemoteDesktopByClientID(uint64_t clientID); // Only close Web session dialog
bool PostWebAudioToggle(uint64_t clientID); // 给 Web 会话 ScreenSpy 投递音频开关消息
CDialogBase* m_pActiveSession = nullptr; // 当前活动会话窗口指针 / NULL 表示无
void UpdateActiveRemoteSession(CDialogBase* sess);
CDialogBase* GetActiveRemoteSession();
@@ -604,4 +605,6 @@ public:
afx_msg void OnWebRemoteControl();
afx_msg void OnProxyPortAutorun();
afx_msg void OnScreenpreviewLoop();
afx_msg void OnMenuCompress();
afx_msg void OnMenuUncompress();
};

View File

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

View File

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

View File

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

View File

@@ -18,6 +18,7 @@
#include <md5.h>
#include <cstdint> // for uint16_t
#include <vector>
#include <mutex> // for std::mutex, std::lock_guard
#include "WebService.h"
// 文件接收消息数据结构
@@ -43,6 +44,66 @@ IMPLEMENT_DYNAMIC(CScreenSpyDlg, CDialog)
#define TIMER_ID 132
// H.264 Annex B keyframe 探测:扫描 start code (00 00 01 / 00 00 00 01)
// 取后续 NAL header low 5 bits命中 5 (IDR) / 7 (SPS) / 8 (PPS) 即认定为关键帧。
static bool IsH264Keyframe(const uint8_t* data, size_t len)
{
for (size_t i = 0; i + 4 < len; ++i) {
size_t nalOffset = 0;
if (data[i] == 0 && data[i+1] == 0 && data[i+2] == 0 && data[i+3] == 1) {
nalOffset = i + 4;
} else if (data[i] == 0 && data[i+1] == 0 && data[i+2] == 1) {
nalOffset = i + 3;
} else {
continue;
}
if (nalOffset >= len) continue;
uint8_t nalType = data[nalOffset] & 0x1F;
if (nalType == 5 || nalType == 7 || nalType == 8) return true;
}
return false;
}
// AV1 OBU keyframe 探测:扫描 OBU 链,遇到 OBU_SEQUENCE_HEADER (type 1) 即认定为关键帧。
// FFmpeg AV1 编码器在每个 IDR 前必定插入 SEQ HDR因此该判定与 H.264 NAL 5/7/8 语义对齐。
static bool IsAv1Keyframe(const uint8_t* data, size_t len)
{
size_t pos = 0;
while (pos < len) {
uint8_t hdr = data[pos];
uint8_t obu_type = (hdr >> 3) & 0x0F;
bool has_ext = (hdr & 0x04) != 0;
bool has_size = (hdr & 0x02) != 0;
if (obu_type == 1 /*OBU_SEQUENCE_HEADER*/) return true;
pos++;
if (has_ext) {
if (pos >= len) return false;
pos++;
}
if (!has_size) return false; // 无 size 字段OBU 占满到包尾,无法继续解析
// LEB128 size
uint64_t sz = 0;
for (int i = 0; i < 8; ++i) {
if (pos >= len) return false;
uint8_t b = data[pos++];
sz |= (uint64_t)(b & 0x7F) << (7 * i);
if ((b & 0x80) == 0) break;
}
if (pos + sz > len) return false;
pos += (size_t)sz;
}
return false;
}
// 首字节嗅探H.264 Annex B 首字节恒为 0x00起始码AV1 OBU header 首字节
// bit7=0、bits[3:6]=obu_type 1-15典型值 0x08-0x78绝不为 0x00。
// 一字节即可干净区分两套码流,无需协议字段或编码端协商。
static bool IsAnyKeyframe(const uint8_t* data, size_t len)
{
if (len == 0) return false;
return data[0] == 0x00 ? IsH264Keyframe(data, len) : IsAv1Keyframe(data, len);
}
// 静态成员变量定义
int CScreenSpyDlg::s_nFastStretch = -1; // -1 表示未初始化
@@ -163,6 +224,8 @@ CScreenSpyDlg::CScreenSpyDlg(CMy2015RemoteDlg* Parent, Server* IOCPServer, CONTE
int width = m_BitmapInfor_Full->bmiHeader.biWidth;
int height = abs(m_BitmapInfor_Full->bmiHeader.biHeight);
WebService().NotifyResolutionChange(m_ClientID, width, height);
// 透传客户端初始的音频开/关状态给 web让前端按钮显示正确
WebService().NotifyAudioState(m_ClientID, m_Settings.AudioEnabled != 0);
}
}
@@ -507,6 +570,7 @@ BEGIN_MESSAGE_MAP(CScreenSpyDlg, CDialog)
ON_MESSAGE(MM_WOM_DONE, &CScreenSpyDlg::OnWaveOutDone)
ON_MESSAGE(WM_RECVFILEV2_CHUNK, &CScreenSpyDlg::OnRecvFileV2Chunk)
ON_MESSAGE(WM_RECVFILEV2_COMPLETE, &CScreenSpyDlg::OnRecvFileV2Complete)
ON_MESSAGE(WM_AUDIO_TOGGLE_FROM_WEB, &CScreenSpyDlg::OnAudioToggleFromWeb)
ON_WM_DROPFILES()
ON_WM_CAPTURECHANGED()
END_MESSAGE_MAP()
@@ -675,6 +739,12 @@ BOOL CScreenSpyDlg::OnInitDialog()
// 音频菜单项
SysMenu->AppendMenuL(MF_STRING, IDM_AUDIO_TOGGLE, "系统音频(&U)");
SysMenu->CheckMenuItem(IDM_AUDIO_TOGGLE, m_Settings.AudioEnabled ? MF_CHECKED : MF_UNCHECKED);
SysMenu->AppendMenuL(MF_STRING, IDM_ENABLE_H264_HARD, "启用 H264 硬编码");
SysMenu->CheckMenuItem(IDM_ENABLE_H264_HARD, m_Settings.EncodeLevel == LEVEL_H264_HARD ? MF_CHECKED : MF_UNCHECKED);
SysMenu->EnableMenuItem(IDM_ENABLE_H264_HARD, m_Settings.EncodeLevel == LEVEL_AV1_HARD ? MF_GRAYED : MF_ENABLED);
SysMenu->AppendMenuL(MF_STRING, IDM_ENABLE_AV1_HARD, "启用 AV1 硬编码");
SysMenu->CheckMenuItem(IDM_ENABLE_AV1_HARD, m_Settings.EncodeLevel == LEVEL_AV1_HARD ? MF_CHECKED : MF_UNCHECKED);
SysMenu->EnableMenuItem(IDM_ENABLE_AV1_HARD, m_Settings.EncodeLevel == LEVEL_H264_HARD ? MF_GRAYED : MF_ENABLED);
// 初始化勾选状态
UpdateQualityMenuCheck(SysMenu);
@@ -1410,27 +1480,11 @@ VOID CScreenSpyDlg::DrawNextScreenDiff(bool keyFrame)
bChange = TRUE;
}
}
// Broadcast H264 frame to web clients (only for Web session dialogs)
// Format: [DeviceID:4][FrameType:1][DataLen:4][H264Data:N]
// Broadcast video frame to web clients (only for Web session dialogs)
// Format: [DeviceID:4][FrameType:1][DataLen:4][VideoData:N]
// 浏览器侧按首字节嗅探区分 H.264 / AV1因此 packet 内不需要 codec 字段。
if (m_bIsWebSession && NextScreenLength > 0 && WebService().IsRunning()) {
// Detect H264 keyframe by checking NAL unit type
// NAL type 5 = IDR slice (keyframe), NAL type 7 = SPS, NAL type 8 = PPS
bool isKeyFrame = false;
LPBYTE h264Data = (LPBYTE)NextScreenData;
for (ULONG i = 0; i + 4 < NextScreenLength; i++) {
// Look for start code: 0x00 0x00 0x00 0x01 or 0x00 0x00 0x01
if ((h264Data[i] == 0 && h264Data[i+1] == 0 && h264Data[i+2] == 0 && h264Data[i+3] == 1) ||
(h264Data[i] == 0 && h264Data[i+1] == 0 && h264Data[i+2] == 1)) {
int nalOffset = (h264Data[i+2] == 1) ? i + 3 : i + 4;
if (nalOffset < (int)NextScreenLength) {
int nalType = h264Data[nalOffset] & 0x1F;
if (nalType == 5 || nalType == 7 || nalType == 8) {
isKeyFrame = true;
break;
}
}
}
}
bool isKeyFrame = IsAnyKeyframe((const uint8_t*)NextScreenData, NextScreenLength);
std::vector<uint8_t> packet(4 + 1 + 4 + NextScreenLength);
uint32_t deviceIdLow = (uint32_t)(m_ClientID & 0xFFFFFFFF);
@@ -2134,6 +2188,26 @@ void CScreenSpyDlg::OnSysCommand(UINT nID, LPARAM lParam)
}
break;
}
case IDM_ENABLE_H264_HARD: {
m_Settings.EncodeLevel = m_Settings.EncodeLevel ? LEVEL_H264_SOFT : LEVEL_H264_HARD;
SysMenu->CheckMenuItem(IDM_ENABLE_H264_HARD, m_Settings.EncodeLevel == LEVEL_H264_HARD ? MF_CHECKED : MF_UNCHECKED);
SysMenu->CheckMenuItem(IDM_ENABLE_AV1_HARD, m_Settings.EncodeLevel == LEVEL_AV1_HARD ? MF_CHECKED : MF_UNCHECKED);
SysMenu->EnableMenuItem(IDM_ENABLE_H264_HARD, m_Settings.EncodeLevel == LEVEL_AV1_HARD ? MF_GRAYED : MF_ENABLED);
SysMenu->EnableMenuItem(IDM_ENABLE_AV1_HARD, m_Settings.EncodeLevel == LEVEL_H264_HARD ? MF_GRAYED : MF_ENABLED);
BYTE bToken[] = {COMMAND_ENCODE_LEVEL, m_Settings.EncodeLevel };
m_ContextObject->Send2Client(bToken, sizeof(bToken));
break;
}
case IDM_ENABLE_AV1_HARD: {
m_Settings.EncodeLevel = m_Settings.EncodeLevel ? LEVEL_H264_SOFT : LEVEL_AV1_HARD;
SysMenu->CheckMenuItem(IDM_ENABLE_H264_HARD, m_Settings.EncodeLevel == LEVEL_H264_HARD ? MF_CHECKED : MF_UNCHECKED);
SysMenu->CheckMenuItem(IDM_ENABLE_AV1_HARD, m_Settings.EncodeLevel == LEVEL_AV1_HARD ? MF_CHECKED : MF_UNCHECKED);
SysMenu->EnableMenuItem(IDM_ENABLE_H264_HARD, m_Settings.EncodeLevel == LEVEL_AV1_HARD ? MF_GRAYED : MF_ENABLED);
SysMenu->EnableMenuItem(IDM_ENABLE_AV1_HARD, m_Settings.EncodeLevel == LEVEL_H264_HARD ? MF_GRAYED : MF_ENABLED);
BYTE bToken[] = { COMMAND_ENCODE_LEVEL, m_Settings.EncodeLevel };
m_ContextObject->Send2Client(bToken, sizeof(bToken));
break;
}
}
__super::OnSysCommand(nID, lParam);
@@ -3424,9 +3498,73 @@ void CScreenSpyDlg::StopAudioPlayback()
#endif
m_nAudioCompression = 0;
// 重置网页端音频格式标志(线程安全的清理)
{
std::lock_guard<std::mutex> lock(m_AudioWebMutex);
m_bAudioFormatSent = FALSE;
memset(&m_AudioFormatWeb, 0, sizeof(m_AudioFormatWeb));
}
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)
{
if (len < 1) return;
@@ -3465,12 +3603,20 @@ void CScreenSpyDlg::OnAudioData(BYTE* pData, UINT32 len)
UINT32 audioLen = len - offset;
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;
if (blockAlign == 0) blockAlign = 4; // 默认 stereo 16-bit
#if USING_OPUS
// Opus 解码
// Opus 解码(仅供本地 waveOut 使用web 仍会收到原始压缩包)
if (m_nAudioCompression == AUDIO_COMPRESS_OPUS && m_pOpusDecoder && m_pOpusDecodeBuffer) {
COpusDecoder* pDecoder = (COpusDecoder*)m_pOpusDecoder;
int decodedSamples = pDecoder->Decode(pAudioData, audioLen, m_pOpusDecodeBuffer, 960 * 2);
@@ -3513,10 +3659,104 @@ void CScreenSpyDlg::OnAudioData(BYTE* pData, UINT32 len)
Mprintf("[Audio] 预缓冲完成,开始播放 (缓冲: %u bytes)\n", m_nRingDataLen);
}
// 发送上线格式Opus 压缩包 / 或原始 PCM到网页
SendAudioToWeb(pWireData, wireLen, &m_AudioFormat, wireCompression);
// 填充可用的 waveOut 缓冲区
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()
{
if (!m_bAudioPlaying || !m_hWaveOut || !m_pRingBuf) return;

View File

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

View File

@@ -46,7 +46,7 @@
// 程序版本号 [建议格式: X.Y.Z]
// 影响:关于对话框、标题栏
#define BRAND_VERSION "1.3.4"
#define BRAND_VERSION "1.3.5"
// 启动画面名称 [建议大写,更有 Logo 感]
// 影响:启动画面 Logo 文字(大号艺术字体渲染)
@@ -277,7 +277,7 @@
#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);
} else if (cmd == "rdp_reset") {
HandleRdpReset(ws_ptr, token);
} else if (cmd == "audio_toggle") {
HandleAudioToggle(ws_ptr, token);
} else if (cmd == "get_salt") {
HandleGetSalt(ws_ptr, msg);
} 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 audio_enabled = -1; // -1 = unknown yet (前端走 audio_state 事件兜底)
{
std::lock_guard<std::mutex> lock(m_DeviceCacheMutex);
auto it = m_DeviceCache.find(device_id);
if (it != m_DeviceCache.end()) {
width = it->second->screen_width;
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["height"] = height;
}
if (audio_enabled >= 0) {
res["audio_enabled"] = (audio_enabled != 0);
}
res["algorithm"] = "h264";
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
//////////////////////////////////////////////////////////////////////////
@@ -1511,6 +1545,16 @@ std::string CWebService::BuildDeviceListJson(const std::string& username) {
CString name = ctx->GetClientData(ONLINELIST_COMPUTER_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);
device["ip"] = AnsiToUtf8(ip);
@@ -1556,6 +1600,9 @@ std::string CWebService::BuildDeviceListJson(const std::string& username) {
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);
}
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) {
if (m_bStopping) return;

View File

@@ -55,6 +55,8 @@ struct WebDeviceInfo {
int screen_width;
int screen_height;
bool online;
// 当前会话的音频开关。-1=未知(客户端 BITMAPINFO 还没回来0=关1=开
int audio_enabled = -1;
// Keyframe cache for new web clients
std::vector<uint8_t> keyframe_cache;
@@ -98,6 +100,10 @@ public:
// Resolution change notification
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)
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 HandleKey(void* ws_ptr, const std::string& msg);
void HandleRdpReset(void* ws_ptr, const std::string& token);
void HandleAudioToggle(void* ws_ptr, const std::string& token);
// Token management
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远程桌面(&W)=Open Web SimpleRemoter(&W)
请在菜单设置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!
; Plugin Settings Dialog - English Translation
; Format: Simplified Chinese=English
@@ -1892,3 +1892,36 @@ FRPC Զ
不支持的位深度需要24位或32位=Bitmap depth is unsupported
未安装x264编解码器 \n下载地址https://sourceforge.net/projects/x264vfw=x264 Encoder is required \nDownload viahttps://sourceforge.net/projects/x264vfw
创建AVI文件失败=Create AVI file failed
启用 H264 硬编码=Enable HW H264 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远程桌面(&W)=打开Web远程桌面(&W)
请在菜单设置Web端口!=请在菜单设置Web端口!
请设置环境变量 YAMA_PWD 来使用Web远程桌面!=请设置环境变量 YAMA_PWD 来使用Web远程桌面!
请设置环境变量 YAMA_WEB_ADMIN_PASS 来使用Web远程桌面!=请设置环境变量 YAMA_WEB_ADMIN_PASS 来使用Web远程桌面!
如需Web远程桌面跨网使用方案请联系管理员!=如需Web远程桌面跨网使用方案请联系管理员!
; Plugin Settings Dialog - Traditional Chinese Translation
; Format: Simplified Chinese=Traditional Chinese
@@ -1883,3 +1883,36 @@ FRPC Զ
不支持的位深度需要24位或32位=不支持的位深度需要24位或32位
未安装x264编解码器 \n下载地址https://sourceforge.net/projects/x264vfw=未安装x264编解码器 \n下载地址https://sourceforge.net/projects/x264vfw
创建AVI文件失败=创建AVI文件失败
启用 H264 硬编码=启用 H264 硬编码
启用 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_FIT_JS 384
#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_ONLINE 1001
#define IDC_STATIC_TIPS 1002
@@ -985,14 +988,19 @@
#define ID_PARAM_THUMBNAIL_PREVIEW 33050
#define ID_LICENSE_AUTO_FRP 33051
#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
// Next default values for new objects
//
//
#ifdef APSTUDIO_INVOKED
#ifndef APSTUDIO_READONLY_SYMBOLS
#define _APS_NEXT_RESOURCE_VALUE 386
#define _APS_NEXT_COMMAND_VALUE 33053
#define _APS_NEXT_RESOURCE_VALUE 388
#define _APS_NEXT_COMMAND_VALUE 33058
#define _APS_NEXT_CONTROL_VALUE 2542
#define _APS_NEXT_SYMED_VALUE 105
#endif

View File

@@ -9,7 +9,8 @@
"program": "${workspaceFolder}/cmd",
"cwd": "${workspaceFolder}",
"args": [
"-port=9090"
"-port=6543",
"--http-port=8080"
],
"env": {
"YAMA_WEB_ADMIN_PASS": "3.14159"
@@ -25,7 +26,8 @@
"program": "${workspaceFolder}/cmd",
"cwd": "${workspaceFolder}",
"args": [
"-port=9090"
"-port=6543",
"--http-port=8080"
],
"env": {
"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_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_LICENSE_SERVER` | **[RemoteSigner 模式]** Operator 的 License Server 公开 URL。客户部署设置此变量后进入 RemoteSigner 模式 —— 每次新设备登录会 HTTPS POST 给 License Server 拿签名,本机永远看不到 HMAC master key。必须与 `YAMA_LICENSE_TOKEN` 同时设置。 | `https://license.example.com` |
| `YAMA_LICENSE_TOKEN` | **[RemoteSigner 模式]** Operator 颁发的客户 JWTRS256作为 Bearer token 鉴权。每个客户一份。 | `eyJhbGciOiJSUzI1NiI...` |
| `YAMA_LICENSE_OFFLINE_HRS` | **[RemoteSigner 模式]** License Server 短暂不可达时,本地缓存签名的宽限期(小时)。默认 24。0 → 不缓存,每次新登录必须联网。 | `24` |
| `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 鉴权。每个客户一份。**未设置则进入 TRIAL 模式(匿名试用,按出口 IP 配额 2 台)**。 | `eyJhbGciOiJSUzI1NiI...` |
| `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_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_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` |
@@ -182,15 +185,16 @@ $env:YAMA_PWD="your_super_password"
## 签名模式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 服务。 |
| **RemoteSigner** | `YAMA_LICENSE_SERVER` + `YAMA_LICENSE_TOKEN` 已设 | 客户部署。本机**永远看不到** master HMAC key —— 每次新设备登录会 HTTPS POST 到 operator 的 License Server拿到签名后塞进 CMD_MASTERSETTING。同 (clientID, startTime) 元组的签名缓存 24h可调`YAMA_LICENSE_OFFLINE_HRS`),用于扛短暂网络故障。 |
| **NoOpSigner** | 上述都没设 | Free tier。返回空签名 → 客户端私有库拒绝启动 screen/file 功能。设备列表仍然可用。 |
| **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`),用于扛短暂网络故障。 |
| **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 暴露)
@@ -203,22 +207,41 @@ $env:YAMA_PWD="your_super_password"
### 颁发客户 JWT
**第一步:一次性生成 RSA 密钥对**(只在授权中心执行一次,私钥永久保管)
```bash
# 一次性生成 RSA 密钥对(私钥 operator 自己保管,公钥用于 License Server 验证)
openssl genrsa -out license_priv.pem 2048
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
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 默认 | 备注 |
| ---- | ---------------- | ---- |
| `trial` | 20JWT 未指定时) | 移植 C++ 反代理 RTT 逻辑 |
| `paid` | JWT 必须显式指定 | 长 TTL token |
命令将 JWT 字符串输出到 stdout将其作为 `YAMA_LICENSE_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) - 日志轮转
- [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
MIT License

View File

@@ -290,11 +290,13 @@ func (h *MyHandler) handleBitmapInfo(ctx *connection.Context, data []byte) {
// handleScreenFrame relays one TOKEN_FIRSTSCREEN / TOKEN_NEXTSCREEN packet
// to all browsers watching this device. The on-the-wire packet starts with
// the token byte then a small fixed header (algorithm, cursor pos, cursor
// index) before the H.264 NAL payload. The browser-facing WS packet uses
// the C++-compatible layout: [deviceID:4 LE][frameType:1][dataLen:4 LE][H264:N].
// index) before the video payload (H.264 Annex B or AV1 OBU). The browser-
// facing WS packet uses the C++-compatible layout:
// [deviceID:4 LE][frameType:1][dataLen:4 LE][Video:N].
//
// alwaysKey=true is used for TOKEN_FIRSTSCREEN (always IDR by construction);
// TOKEN_NEXTSCREEN is keyframe iff the NAL stream contains a 5/7/8 unit.
// TOKEN_NEXTSCREEN keyframe detection is delegated to protocol.IsAnyKeyframe
// which sniffs the codec from the first byte (0x00 → H.264, else AV1).
func (h *MyHandler) handleScreenFrame(ctx *connection.Context, data []byte, alwaysKey bool) {
deviceID := h.hub.ScreenDeviceID(ctx)
if deviceID == "" {
@@ -310,8 +312,10 @@ func (h *MyHandler) handleScreenFrame(ctx *connection.Context, data []byte, alwa
// browser sees cursor updates even if we end up dropping frames later.
h.hub.PublishCursor(deviceID, data[10])
h264 := data[skip:]
isKey := alwaysKey || protocol.IsH264Keyframe(h264)
video := data[skip:]
// 按首字节嗅探 H.264 / AV1分发到对应的 keyframe 探测器。浏览器侧用同样方式
// 决定 VideoDecoder codec string因此 server 不必感知客户端实际编码器。
isKey := alwaysKey || protocol.IsAnyKeyframe(video)
// Build the WS packet exactly as the C++ ScreenSpyDlg does — the front-end
// decoder reads these offsets directly.
@@ -321,13 +325,13 @@ func (h *MyHandler) handleScreenFrame(ctx *connection.Context, data []byte, alwa
if isKey {
frameType = 1
}
dataLen := uint32(len(h264))
dataLen := uint32(len(video))
packet := make([]byte, 9+len(h264))
packet := make([]byte, 9+len(video))
binary.LittleEndian.PutUint32(packet[0:4], idLow)
packet[4] = frameType
binary.LittleEndian.PutUint32(packet[5:9], dataLen)
copy(packet[9:], h264)
copy(packet[9:], video)
h.hub.PublishScreenFrame(deviceID, packet, isKey)
}
@@ -411,9 +415,29 @@ func (h *MyHandler) handleLogin(ctx *connection.Context, data []byte) {
resolution = info.GetReservedField(protocol.ResFieldResolution)
}
// 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.
// Sign BEFORE registering in the hub so a quota-rejected device never
// appears in the web device list, even briefly. If signing fails we still
// 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{
ID: clientID,
Name: name,
@@ -430,11 +454,6 @@ func (h *MyHandler) handleLogin(ctx *connection.Context, data []byte) {
PublicIP: clientInfo.IP,
ConnectedAt: time.Now(),
}, 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
@@ -444,10 +463,10 @@ func (h *MyHandler) handleLogin(ctx *connection.Context, data []byte) {
// - RemoteSigner: HTTPS POST to operator's License Server (customer deployment)
// - NoOpSigner: returns empty signature (free tier; client refuses screen/file ops)
//
// On signer error (License Server unreachable + no cache hit), we still ship
// a zeroed signature so the packet is well-formed; the client will retry on
// next reconnect.
func (h *MyHandler) sendMasterSetting(ctx *connection.Context, startTime, clientID string) {
// Returns the signer error (not the send error) so callers can distinguish
// quota-exceeded rejections from transient failures and act accordingly.
// The packet is always sent — even on error — so the wire protocol stays clean.
func (h *MyHandler) sendMasterSetting(ctx *connection.Context, startTime, clientID string) error {
buf := make([]byte, 1+protocol.MasterSettingsSize)
buf[0] = protocol.CmdMasterSetting
@@ -458,10 +477,10 @@ func (h *MyHandler) sendMasterSetting(ctx *connection.Context, startTime, client
buf[1:5],
uint32(protocol.DefaultReportIntervalSec))
sig, err := h.signer.Sign(startTime, clientID)
if err != nil {
sig, sigErr := h.signer.Sign(startTime, clientID)
if sigErr != nil {
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 == "" {
// NoOpSigner path, or LocalSigner with empty master key — same effect.
// Log only once per process via the startup banner; don't spam here.
@@ -473,6 +492,7 @@ func (h *MyHandler) sendMasterSetting(ctx *connection.Context, startTime, client
if err := h.srv.Send(ctx, buf); err != nil {
h.log.Error("CMD_MASTERSETTING send failed for conn=%d: %v", ctx.ID, err)
}
return sigErr
}
// handleAuth handles authorization request (TOKEN_AUTH = 100)
@@ -641,7 +661,72 @@ func splitCSV(s string) []string {
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() {
// 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
portStr := flag.String("port", "6543", "Server listen ports (semicolon-separated, e.g. 6543;6544;6545)")
flag.StringVar(portStr, "p", "6543", "Server listen ports (shorthand)")
@@ -667,6 +752,7 @@ func main() {
logCfg.Compress = true
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
// at the end of startup so the operator sees what's in effect — vars the
@@ -696,13 +782,13 @@ func main() {
deviceHub := hub.New()
// Build the CMD_MASTERSETTING signer based on env vars:
// - YAMA_SIGN_PASSWORD set → LocalSigner (operator's own deployment;
// HMAC master key lives here)
// - YAMA_LICENSE_SERVER + YAMA_LICENSE_TOKEN set → RemoteSigner
// (customer deployment; never sees the master key, fetches signatures
// from operator's License Server with 24h cache)
// - neither → NoOpSigner (free tier; client refuses screen/file ops
// but device list still works)
// - YAMA_SIGN_PASSWORD set → LocalSigner (operator's own deployment;
// HMAC master key lives here)
// - YAMA_LICENSE_DISABLED=1 → NoOpSigner (explicit opt-out; dev / offline)
// - YAMA_LICENSE_TOKEN set → RemoteSigner (paid customer; talks to
// operator's License Server with JWT)
// - neither of the above → RemoteSigner (anonymous trial; default
// URL, no JWT, cap FreeMaxDevices)
signer, mode, err := licensing.NewFromEnv(log)
if err != nil {
log.Fatal("Failed to initialize signer: %v", err)
@@ -714,10 +800,21 @@ func main() {
case licensing.ModeLocal:
log.Info("Signer mode: LOCAL (operator deployment, master key held in-process)")
case licensing.ModeRemote:
log.Info("Signer mode: REMOTE (customer deployment, %s=%s)",
licensing.EnvLicenseServer, os.Getenv(licensing.EnvLicenseServer))
licServer := 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:
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
@@ -745,6 +842,10 @@ func main() {
adminPass = defaultWebAdminPass
rememberDefault("YAMA_WEB_ADMIN_PASS", defaultWebAdminPass)
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)
log.Info("Web admin user configured")
@@ -861,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 {
fmt.Printf("Web UI on http://localhost:%d/\n", *httpPort)
log.Info("Web UI on http://localhost:%d/", *httpPort)
if usingDefaultWebPass {
fmt.Printf(" Default login: admin / %s (set YAMA_WEB_ADMIN_PASS to override)\n",
defaultWebAdminPass)
log.Info("Default login: admin / %s (set YAMA_WEB_ADMIN_PASS to override)", defaultWebAdminPass)
}
}
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 {
fmt.Println()
fmt.Println("[!] Using built-in defaults (set the env var to override):")
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")
fmt.Println("Press Ctrl+C to stop...")
log.Info("Logs are written to: logs/server.log")
log.Info("Press Ctrl+C to stop...")
// Wait for interrupt signal
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
<-sigChan
fmt.Println("\nShutting down...")
log.Info("\nShutting down...")
// Order matters: drain License Server HTTP first so no handleSign is
// mid-flight; THEN close the signer (which may release HTTP keepalives
// in RemoteSigner mode, or be a no-op for LocalSigner/NoOp).
@@ -903,5 +1001,5 @@ func main() {
for _, srv := range servers {
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/klauspost/compress v1.18.2
github.com/rs/zerolog v1.34.0
golang.org/x/sync v0.20.0
golang.org/x/text v0.32.0
gopkg.in/natefinch/lumberjack.v2 v2.2.1
)
@@ -14,6 +15,5 @@ require (
require (
github.com/mattn/go-colorable v0.1.13 // 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
)

View File

@@ -13,11 +13,20 @@ const (
EnvSignPassword = "YAMA_SIGN_PASSWORD" // LocalSigner master HMAC key
EnvLicenseServer = "YAMA_LICENSE_SERVER" // RemoteSigner: License Server base URL
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
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)
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
// project memory's licensing design.
const DefaultOfflineGrace = 24 * time.Hour
@@ -28,6 +37,7 @@ type Mode int
const (
ModeLocal Mode = iota
ModeRemote
ModeTrial // RemoteSigner against License Server, but with no Bearer (anonymous trial)
ModeNoOp
)
@@ -37,6 +47,8 @@ func (m Mode) String() string {
return "local"
case ModeRemote:
return "remote"
case ModeTrial:
return "trial"
default:
return "noop"
}
@@ -48,16 +60,25 @@ func SelectedMode() Mode {
if os.Getenv(EnvSignPassword) != "" {
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 ModeNoOp
return ModeTrial
}
// NewFromEnv builds the Signer chosen by env vars:
// - YAMA_SIGN_PASSWORD set → LocalSigner
// - YAMA_LICENSE_SERVER + YAMA_LICENSE_TOKEN set → RemoteSigner
// - neither → NoOpSigner
// NewFromEnv builds the Signer chosen by env vars. Decision tree (top-down,
// first match wins):
//
// - 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
// (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
}
if server != "" && token != "" {
if err := ValidateRemoteURL(server); err != nil {
return nil, ModeNoOp, fmt.Errorf("%s rejected: %w", EnvLicenseServer, err)
// Explicit opt-out: operator wants the binary to run with no licensing
// at all (dev, offline test, air-gapped). Screen/file features stay off
// 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 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)
}
if n < 0 {
return nil, ModeNoOp, fmt.Errorf(
"%s must be >= 0, got %d", EnvLicenseOfflineHrs, n)
}
grace = time.Duration(n) * time.Hour
if n < 0 {
return nil, ModeNoOp, fmt.Errorf(
"%s must be >= 0, got %d", EnvLicenseOfflineHrs, n)
}
grace = time.Duration(n) * time.Hour
}
if token != "" {
return NewRemote(server, token, grace, lg), ModeRemote, nil
}
if server != "" || token != "" {
// Partial config is almost certainly a misconfiguration — fail loudly
// 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
// Anonymous trial: no Bearer token. License Server identifies by IP and
// caps at FreeMaxDevices.
return NewRemote(server, "", grace, lg), ModeTrial, nil
}
// 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
// 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) {
priv := testKey(t)
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())
defer ts.Close()
@@ -180,7 +180,7 @@ func TestRemoteSignerCacheHit(t *testing.T) {
func TestRemoteSignerStaleFallback(t *testing.T) {
priv := testKey(t)
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())
tok, err := Issue(priv, "cust-fallback", TierPaid, 5, time.Hour)
@@ -214,7 +214,7 @@ func TestRemoteSignerStaleFallback(t *testing.T) {
func TestQuotaEnforcement(t *testing.T) {
priv := testKey(t)
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())
defer ts.Close()
@@ -253,23 +253,102 @@ func TestQuotaEnforcement(t *testing.T) {
}
}
// TestAuthRejectsMissingBearer: no token → 401, not 200 / not 500. Belt
// and braces — the auth check sits in front of /sign and /heartbeat.
func TestAuthRejectsMissingBearer(t *testing.T) {
// TestAnonymousTrialSignsAndCaps: no Authorization header → anonymous trial
// branch. /sign returns 200 with a real signature up to FreeMaxDevices, then
// 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)
master := mustLocal(t, "master-auth-test-xxxxxxx")
ls := NewLicenseServer(master, &priv.PublicKey, time.Minute, silentLogger{})
master := mustLocal(t, "master-trial-test-xxxxxx")
ls := NewLicenseServer(master, &priv.PublicKey, time.Minute, silentLogger{}, "")
ts := httptest.NewServer(ls.Handler())
defer ts.Close()
body := strings.NewReader(`{"client_id":"x","start_time":"y"}`)
resp, err := http.Post(ts.URL+"/license/sign", "application/json", body)
call := func(clientID string) (int, string) {
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 {
t.Fatalf("Post: %v", err)
t.Fatalf("Do: %v", err)
}
defer resp.Body.Close()
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) {
priv := testKey(t)
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())
defer ts.Close()
@@ -382,7 +461,7 @@ func TestHeartbeatRefreshOnly(t *testing.T) {
func TestQuotaRejectionDoesNotConsumeSlot(t *testing.T) {
priv := testKey(t)
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())
defer ts.Close()
@@ -518,3 +597,36 @@ func TestJWTAlgLockedToRS256(t *testing.T) {
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
import (
"encoding/json"
"os"
"sync"
"time"
)
@@ -22,20 +24,31 @@ const (
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
// identified by the JWT "sub" claim. The set is keyed by clientID (uint64
// from the device, stringified) — same device coming back through the
// same License Server is one slot, not two.
//
// Eviction: any clientID not seen in /sign or /license/heartbeat within
// the eviction window is silently dropped from the active set. This stops
// a never-heartbeating customer from holding slots forever. Default
// window is twice the heartbeat interval the customer reports at (5 min).
// the eviction window is silently dropped from the active set. Default
// window is 5 minutes (twice the heartbeat interval).
//
// Empty customer entries are reaped at the end of each mutation so the
// outer map doesn't accumulate sub claims of expired contracts.
// Persistence: when statePath is set, the sub→clientID map is written
// 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 {
evictAfter time.Duration
statePath string // "" = no persistence
log Logger // nil = silent
mu sync.Mutex
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.
func (q *quotaTracker) evictLocked(st *customerState) {
// Load reads the persisted state from statePath and restores each clientID
// 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)
removed := 0
for cid, last := range st.devices {
if last.Before(cutoff) {
delete(st.devices, cid)
removed++
}
}
return removed
}
// 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).
func (q *quotaTracker) Reserve(sub, clientID string, maxDevices int) (int, bool) {
q.mu.Lock()
defer q.mu.Unlock()
st, ok := q.customer[sub]
if !ok {
@@ -92,21 +195,38 @@ func (q *quotaTracker) Reserve(sub, clientID string, maxDevices int) (int, bool)
q.customer[sub] = st
}
q.evictLocked(st)
evicted := q.evictLocked(st)
if _, already := st.devices[clientID]; already {
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 {
// Don't reap on rejection — the customer might be at exactly cap
// with valid devices, and an empty map would lose info.
return len(st.devices), false
count := len(st.devices)
var snap map[string][]string
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()
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
@@ -118,14 +238,14 @@ func (q *quotaTracker) Reserve(sub, clientID string, maxDevices int) (int, bool)
// known to us from a prior Reserve).
func (q *quotaTracker) RefreshExisting(sub string, clientIDs []string) int {
q.mu.Lock()
defer q.mu.Unlock()
st, ok := q.customer[sub]
if !ok {
q.mu.Unlock()
return 0
}
q.evictLocked(st)
evicted := q.evictLocked(st)
now := time.Now()
refreshed := 0
@@ -137,6 +257,13 @@ func (q *quotaTracker) RefreshExisting(sub string, clientIDs []string) int {
}
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
}
@@ -144,16 +271,25 @@ func (q *quotaTracker) RefreshExisting(sub string, clientIDs []string) int {
// /license/heartbeat to report the server-side view.
func (q *quotaTracker) Snapshot(sub string) []string {
q.mu.Lock()
defer q.mu.Unlock()
st, ok := q.customer[sub]
if !ok {
q.mu.Unlock()
return nil
}
q.evictLocked(st)
evicted := q.evictLocked(st)
out := make([]string, 0, len(st.devices))
for cid := range st.devices {
out = append(out, cid)
}
q.reapEmptyLocked(sub)
var snap map[string][]string
if evicted > 0 {
snap = q.snapshotLocked()
}
q.mu.Unlock()
q.save(snap)
return out
}

View File

@@ -16,6 +16,25 @@ import (
"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
// Server. ServerURL and Token (a JWT issued offline by the operator) are
// 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
}
// Hard failure: fall back to stale cache if any. Better to keep an
// existing device alive than fail closed during a transient outage.
// Quota-exceeded is authoritative — skip stale-cache fallback entirely.
// 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()
c, ok := r.cache[key]
r.mu.Unlock()
@@ -179,7 +207,9 @@ func (r *RemoteSigner) fetch(startTime, clientID string) (string, error) {
if err != nil {
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")
resp, err := r.httpClient.Do(req)
@@ -194,7 +224,17 @@ func (r *RemoteSigner) fetch(startTime, clientID string) (string, error) {
}
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",
resp.StatusCode, string(respBody))
}
@@ -265,7 +305,9 @@ func (r *RemoteSigner) sendHeartbeat() {
if err != nil {
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")
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 {
select {

View File

@@ -5,13 +5,36 @@ import (
"encoding/json"
"errors"
"fmt"
"net"
"net/http"
"strings"
"sync"
"time"
"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
// RemoteSigner customer deployments. It uses the same LocalSigner instance
// (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
// "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 {
signer *LocalSigner
pubKey *rsa.PublicKey
tracker *quotaTracker
logger Logger
mux *http.ServeMux
signer *LocalSigner
pubKey *rsa.PublicKey
tracker *quotaTracker
logger Logger
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
@@ -57,19 +100,50 @@ type Logger interface {
// quiet device keeps its slot before its quota is reclaimed (recommend
// 5 min — twice a typical heartbeat interval).
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{
signer: signer,
pubKey: pubKey,
tracker: newQuotaTracker(evictAfter),
logger: lg,
mux: http.NewServeMux(),
signer: signer,
pubKey: pubKey,
tracker: qt,
logger: lg,
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/heartbeat", s.handleHeartbeat)
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
// server (or runs standalone via http.ListenAndServe).
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
}
// 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) {
if r.Method != http.MethodPost {
writeJSONError(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
claims := s.authenticate(w, r)
claims := s.resolveAuth(w, r)
if claims == nil {
return
}
@@ -117,7 +285,8 @@ func (s *LicenseServer) handleSign(w http.ResponseWriter, r *http.Request) {
// consume a slot — see quotaTracker.Reserve.
active, accepted := s.tracker.Reserve(claims.Subject, req.ClientID, claims.MaxDevices)
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)
writeJSONError(w, http.StatusForbidden,
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
}
claims := s.authenticate(w, r)
claims := s.resolveAuth(w, r)
if claims == nil {
return
}

View File

@@ -23,6 +23,34 @@ type LicenseClaims struct {
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
// Server loads this once at startup to verify incoming customer JWTs.
// Accepts both PKCS#1 ("RSA PUBLIC KEY") and PKIX ("PUBLIC KEY") PEM

View File

@@ -350,6 +350,69 @@ func IsH264Keyframe(data []byte) bool {
return false
}
// IsAnyKeyframe sniffs the codec from the first byte then dispatches to the
// matching keyframe detector. H.264 Annex B always starts with 0x00 (start
// code prefix); AV1 OBU headers have bit7=0 and bits[3:6]=obu_type in [1,15]
// so the first byte is in [0x08,0x78] and never 0x00. Lets the server stay
// codec-agnostic so the browser can run H.264 and AV1 sessions side by side.
func IsAnyKeyframe(data []byte) bool {
if len(data) == 0 {
return false
}
if data[0] == 0x00 {
return IsH264Keyframe(data)
}
return IsAv1Keyframe(data)
}
// IsAv1Keyframe walks the OBU chain and returns true on the first
// OBU_SEQUENCE_HEADER (type 1). FFmpeg's AV1 encoders prepend SEQ HDR to
// every IDR, so seeing one is equivalent to "this packet contains a key
// frame". Mirrors the C++ IsAv1Keyframe helper in ScreenSpyDlg.cpp.
//
// AV1 OBU header byte layout: 0|type:4|ext:1|size:1|reserved:1
func IsAv1Keyframe(data []byte) bool {
n := len(data)
pos := 0
for pos < n {
hdr := data[pos]
obuType := (hdr >> 3) & 0x0F
hasExt := hdr&0x04 != 0
hasSize := hdr&0x02 != 0
if obuType == 1 { // OBU_SEQUENCE_HEADER
return true
}
pos++
if hasExt {
if pos >= n {
return false
}
pos++
}
if !hasSize {
return false // unsized OBU runs to end of packet
}
// LEB128 size
var sz uint64
for i := range 8 {
if pos >= n {
return false
}
b := data[pos]
pos++
sz |= uint64(b&0x7F) << (7 * i)
if b&0x80 == 0 {
break
}
}
if uint64(pos)+sz > uint64(n) {
return false
}
pos += int(sz)
}
return false
}
// LOGIN_INFOR structure size and offsets (matching C++ struct with default alignment)
// Note: C++ struct uses default alignment (4-byte for uint32/int)
const (

View File

@@ -51,6 +51,15 @@ func (h *wsHub) dispatch(c *wsClient, cmd string, raw []byte) {
h.handleConnect(c, raw)
case "rdp_reset":
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":
h.handleMouse(c, raw)
case "key":

View File

@@ -737,6 +737,7 @@
.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: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:hover { background: rgba(255,255,255,0.1); }
#screen-page:fullscreen .screen-toolbar { display: none; }
@@ -828,6 +829,17 @@
.toolbar-btn:disabled { opacity: 0.3; cursor: not-allowed; }
.toolbar-btn:disabled:hover { background: transparent; }
.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 {
position: fixed;
/* 同 .floating-toolbar8px 基础 + 安全区 inset 避开刘海/灵动岛 */
@@ -1174,6 +1186,7 @@
<div class="toolbar-right">
<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-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-keyboard-bar" onclick="toggleKeyboard()" title="Keyboard" disabled>&#x2328;</button>
<button class="fullscreen-btn" onclick="toggleFullscreen()" title="Fullscreen (F11)">&#x26F6;</button>
@@ -1203,10 +1216,12 @@
<div class="touch-indicator" id="touch-indicator"></div>
<button class="toolbar-toggle" id="toolbar-toggle" onclick="toggleFloatingToolbar()">&#x2022;&#x2022;&#x2022;</button>
<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" 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" onclick="disconnect()" title="Disconnect">&#x2715;</button>
<button class="toolbar-btn" onclick="toggleFloatingToolbar()" title="Collapse">&#x25B4;</button>
</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">
@@ -1283,12 +1298,80 @@
<script src="/static/xterm.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>
let ws = null, token = null, decoder = null, devices = [], currentDevice = 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 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
let currentPage = 1;
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.onmessage = (event) => {
if (typeof event.data === 'string') handleSignaling(JSON.parse(event.data));
@@ -1486,11 +1569,22 @@
// Wait for resolution_changed message
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 {
updateScreenStatus('error', msg.msg);
setTimeout(() => showPage('devices-page'), 2000);
}
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':
termState.ready = true;
document.getElementById('term-status-info').textContent =
@@ -1603,8 +1697,12 @@
// Set up vertical flip transform once (BMP is bottom-up)
ctx.setTransform(1, 0, 0, -1, 0, height);
if (decoder) { try { decoder.close(); } catch(e) {} }
frameCount = 0;
lastFrameTime = performance.now();
// Reset FPS sliding window on decoder (re)init so a resolution
// 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({
output: (frame) => {
// Check if frame dimensions match canvas
@@ -1613,19 +1711,25 @@
}
ctx.drawImage(frame, 0, 0);
frame.close();
// 原始风格的 FPS 计数1 秒采样窗口
frameCount++;
const now = performance.now();
if (now - lastFrameTime >= 1000) {
fps = Math.round(frameCount * 1000 / (now - lastFrameTime));
frameCount = 0;
lastFrameTime = now;
document.getElementById('frame-info').textContent = width + 'x' + height + ' @ ' + fps + ' fps';
}
},
error: (e) => { console.error('Decoder error:', e); needKeyframe = true; }
});
// codec string 由首帧嗅探得到的 currentCodec 决定:
// 'avc' → 'avc1.42E01E' (H.264 Constrained Baseline Level 3.0)
// 'av1' → 'av01.0.08M.08' (AV1 Main Profile Level 4.0 8-bit)
// 客户端硬件支持 AV1 编码时浏览器收到 AV1 流fallback 到 H.264 时浏览器
// 收到 H.264 流。两条路径在同一前端代码中并存,运维侧无须感知。
const codecStr = currentCodec === 'av1' ? 'av01.0.08M.08' : 'avc1.42E01E';
decoder.configure({
codec: 'avc1.42E01E',
codec: codecStr,
codedWidth: width,
codedHeight: height,
optimizeForLatency: true
@@ -1634,27 +1738,330 @@
let decoderWidth = 0, decoderHeight = 0, needKeyframe = false;
let decodeTimestamp = 0; // Monotonically increasing timestamp for decoder
let currentCodec = null; // 'avc' | 'av1' | nullinitDecoder 读取)
// 首字节嗅探H.264 Annex B 起始码必以 0x00 开头AV1 OBU header
// bit7=0 且 bits[3:6] = obu_type ∈ [1,15],首字节落在 [0x08,0x78] 区间且
// 绝不为 0x00。单字节即可干净区分。
function detectCodec(videoBytes) {
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) {
// 全部进入的二进制都计入带宽统计:视频帧 + 音频帧 + 终端帧
bwBytesAccum += data.byteLength;
// 终端输出帧4 字节 magic 'TRM1' (0x54 0x52 0x4D 0x31) → 转发到 xterm。
// 视频帧首 4 字节是 deviceID (uint32 LE)撞这个具体值的概率极低4 字节 magic
// 比单字节前缀安全得多,无需额外的状态校验。
const u8 = new Uint8Array(data);
if (u8.length >= 4 &&
u8[0] === 0x54 && u8[1] === 0x52 && u8[2] === 0x4D && u8[3] === 0x31) {
if (termState && termState.term) termState.term.write(u8.subarray(4));
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 deviceId = view.getUint32(0, true);
const frameType = view.getUint8(4);
const dataLen = view.getUint32(5, true);
const isKeyframe = frameType === 1;
const videoData = new Uint8Array(data, 9, dataLen);
const frameCodec = dataLen > 0 ? detectCodec(videoData) : currentCodec;
// codec 切换(客户端硬件 fallback、首次连接等必须等到 keyframe 才能
// 重建 decoderdelta 帧没有 SPS/PPS 或 SEQ HDR无法独立初始化。
if (decoder && currentCodec && frameCodec !== currentCodec) {
if (!isKeyframe) {
needKeyframe = true;
return;
}
try { decoder.close(); } catch (e) {}
decoder = null;
currentCodec = null;
}
// If decoder is closed or errored, wait for keyframe to reinitialize
if (!decoder || decoder.state === 'closed') {
if (isKeyframe && decoderWidth > 0) {
console.log('Reinitializing decoder on keyframe');
currentCodec = frameCodec;
console.log('Reinitializing decoder on keyframe, codec=' + currentCodec);
initDecoder(decoderWidth, decoderHeight);
needKeyframe = false;
} else {
@@ -1669,7 +2076,6 @@
if (needKeyframe && !isKeyframe) return;
if (isKeyframe) needKeyframe = false;
const h264Data = new Uint8Array(data, 9, dataLen);
try {
// Check decoder queue to avoid overwhelming it (but never skip keyframes)
if (!isKeyframe && decoder.decodeQueueSize > 10) {
@@ -1679,7 +2085,7 @@
decoder.decode(new EncodedVideoChunk({
type: isKeyframe ? 'key' : 'delta',
timestamp: decodeTimestamp++,
data: h264Data
data: videoData
}));
} catch (e) {
console.error('Decode error:', e);
@@ -1693,6 +2099,7 @@
const q = searchQuery.toLowerCase();
filtered = filtered.filter(d =>
(d.name && d.name.toLowerCase().includes(q)) ||
(d.remark && d.remark.toLowerCase().includes(q)) ||
(d.ip && d.ip.toLowerCase().includes(q)) ||
(d.os && d.os.toLowerCase().includes(q)) ||
(d.location && d.location.toLowerCase().includes(q)) ||
@@ -1825,7 +2232,7 @@
'<polyline points="4 17 10 11 4 5"/><line x1="12" y1="19" x2="20" y2="19"/>' +
'</svg>' +
'</button>' +
'<h3>' + escapeHtml(d.name || 'Unknown') + '</h3>' +
'<h3>' + escapeHtml(displayName(d)) + '</h3>' +
'<div class="info-row">' +
'<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>' +
@@ -2092,9 +2499,18 @@
const compat = checkWebCodecs();
if (!compat.supported) { alert('Browser does not support H264: ' + compat.reason); return; }
currentDevice = dev;
document.getElementById('device-name').textContent = currentDevice.name;
document.getElementById('device-name').textContent = displayName(currentDevice);
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');
// Default the audio button to "on" optimistically; server will
// correct via connect_result.audio_enabled or audio_state event.
applyAudioState(true);
showPage('screen-page');
ws.send(JSON.stringify({ cmd: 'connect', id: String(id), token }));
}
@@ -2117,7 +2533,7 @@
termState.deviceId = String(id);
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...';
// 先 showPage 让 term-host 拿到真实尺寸xterm.open() 必须在容器有 size 时调用,
@@ -2442,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() {
return window.innerWidth > window.innerHeight;
}
@@ -2538,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)
const isTouchDevice = ('ontouchstart' in window) || (navigator.maxTouchPoints > 0);
@@ -3101,7 +3570,7 @@
const totalDist = Math.sqrt(totalDx * totalDx + totalDy * totalDy);
// 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) {
touchState.moved = true;
@@ -3198,7 +3667,15 @@
// Must send first click before dblclick for Windows to recognize
console.log('[Touch] Double 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;
} else if (touchState.state === T_FIRST_DOWN && !touchState.moved) {
// First tap released without moving = single click
@@ -3406,6 +3883,10 @@
});
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
controlEnabled = false;
// Reset keyboard state (blur event will update button state)
@@ -3454,6 +3935,14 @@
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() {
if (pingInterval) clearInterval(pingInterval);
pingInterval = setInterval(() => {