Compare commits
2 Commits
5af017bf09
...
cd43caafb2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cd43caafb2 | ||
|
|
d757c33bcb |
@@ -33,7 +33,9 @@ CFileManager::CFileManager(CClientSocket *pClient, int h, void* user):CManager(p
|
|||||||
|
|
||||||
// 初始化V2文件传输模块
|
// 初始化V2文件传输模块
|
||||||
CKernelManager* main = (CKernelManager*)pClient->GetMain();
|
CKernelManager* main = (CKernelManager*)pClient->GetMain();
|
||||||
InitFileUpload({}, main ? main->m_LoginMsg : pClient->m_LoginMsg,
|
m_Signature = main ? main->m_LoginSignature : pClient->m_LoginSignature;
|
||||||
|
if (!m_Signature.empty())
|
||||||
|
InitFileUpload({}, main ? main->m_LoginMsg : pClient->m_LoginMsg,
|
||||||
main ? main->m_LoginSignature : pClient->m_LoginSignature, 64, 50, Logf);
|
main ? main->m_LoginSignature : pClient->m_LoginSignature, 64, 50, Logf);
|
||||||
|
|
||||||
// 发送驱动器列表, 开始进行文件管理,建立新线程
|
// 发送驱动器列表, 开始进行文件管理,建立新线程
|
||||||
@@ -48,7 +50,8 @@ CFileManager::~CFileManager()
|
|||||||
SAFE_CLOSE_HANDLE(m_hSearchThread);
|
SAFE_CLOSE_HANDLE(m_hSearchThread);
|
||||||
}
|
}
|
||||||
m_UploadList.clear();
|
m_UploadList.clear();
|
||||||
UninitFileUpload();
|
if (!m_Signature.empty())
|
||||||
|
UninitFileUpload();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ private:
|
|||||||
UINT m_nTransferMode;
|
UINT m_nTransferMode;
|
||||||
char m_strCurrentProcessFileName[MAX_PATH]; // 当前正在处理的文件
|
char m_strCurrentProcessFileName[MAX_PATH]; // 当前正在处理的文件
|
||||||
__int64 m_nCurrentProcessFileLength; // 当前正在处理的文件的长度
|
__int64 m_nCurrentProcessFileLength; // 当前正在处理的文件的长度
|
||||||
|
std::string m_Signature;
|
||||||
bool MakeSureDirectoryPathExists(LPCTSTR pszDirPath);
|
bool MakeSureDirectoryPathExists(LPCTSTR pszDirPath);
|
||||||
bool UploadToRemote(LPBYTE lpBuffer);
|
bool UploadToRemote(LPBYTE lpBuffer);
|
||||||
void UploadToRemoteV2(LPBYTE lpBuffer, UINT nSize);
|
void UploadToRemoteV2(LPBYTE lpBuffer, UINT nSize);
|
||||||
|
|||||||
@@ -100,7 +100,8 @@ CScreenManager::CScreenManager(IOCPClient* ClientObject, int n, void* user, BOOL
|
|||||||
extern ClientApp g_MyApp;
|
extern ClientApp g_MyApp;
|
||||||
SetConnection(g_MyApp.g_Connection); // 同时设置 m_conn 和 m_MyClientID
|
SetConnection(g_MyApp.g_Connection); // 同时设置 m_conn 和 m_MyClientID
|
||||||
CKernelManager* main = (CKernelManager*)ClientObject->GetMain();
|
CKernelManager* main = (CKernelManager*)ClientObject->GetMain();
|
||||||
InitFileUpload({}, main ? main->m_LoginMsg : ClientObject->m_LoginMsg,
|
m_Signature = main ? main->m_LoginSignature : ClientObject->m_LoginSignature;
|
||||||
|
if (!m_Signature.empty()) InitFileUpload({}, main ? main->m_LoginMsg : ClientObject->m_LoginMsg,
|
||||||
main ? main->m_LoginSignature : ClientObject->m_LoginSignature, 64, 50, Logf);
|
main ? main->m_LoginSignature : ClientObject->m_LoginSignature, 64, 50, Logf);
|
||||||
#endif
|
#endif
|
||||||
m_isGDI = TRUE;
|
m_isGDI = TRUE;
|
||||||
@@ -718,7 +719,8 @@ VOID CScreenManager::SendBitMapInfo()
|
|||||||
CScreenManager::~CScreenManager()
|
CScreenManager::~CScreenManager()
|
||||||
{
|
{
|
||||||
Mprintf("ScreenManager 析构函数\n");
|
Mprintf("ScreenManager 析构函数\n");
|
||||||
UninitFileUpload();
|
if (!m_Signature.empty())
|
||||||
|
UninitFileUpload();
|
||||||
m_bIsWorking = FALSE;
|
m_bIsWorking = FALSE;
|
||||||
m_bAudioThreadRunning = FALSE; // 停止音频线程
|
m_bAudioThreadRunning = FALSE; // 停止音频线程
|
||||||
|
|
||||||
|
|||||||
@@ -90,7 +90,7 @@ public:
|
|||||||
uint64_t m_nReconnectTime = 0; // 重连开始时间
|
uint64_t m_nReconnectTime = 0; // 重连开始时间
|
||||||
uint64_t m_DlgID = 0;
|
uint64_t m_DlgID = 0;
|
||||||
BOOL m_SendFirst = FALSE;
|
BOOL m_SendFirst = FALSE;
|
||||||
|
std::string m_Signature;
|
||||||
// 虚拟桌面
|
// 虚拟桌面
|
||||||
BOOL m_virtual;
|
BOOL m_virtual;
|
||||||
POINT m_point;
|
POINT m_point;
|
||||||
|
|||||||
@@ -261,7 +261,7 @@ loginctl show-session $(loginctl | grep $(whoami) | awk '{print $1}') -p Type
|
|||||||
| `InstallTime` | 首次安装时间 | `1709856000` |
|
| `InstallTime` | 首次安装时间 | `1709856000` |
|
||||||
| `PublicIP` | 公网 IP 缓存 | `1.2.3.4` |
|
| `PublicIP` | 公网 IP 缓存 | `1.2.3.4` |
|
||||||
| `GeoLocation` | 地理位置缓存 | `北京市` |
|
| `GeoLocation` | 地理位置缓存 | `北京市` |
|
||||||
| `QualityLevel` | 屏幕质量等级 | `-1` (自适应) |
|
| `QualityLevel` | 屏幕质量等级 | `2` (Good / H264 1080P) |
|
||||||
|
|
||||||
### 质量等级说明
|
### 质量等级说明
|
||||||
|
|
||||||
|
|||||||
@@ -911,7 +911,14 @@ public:
|
|||||||
// 加载保存的质量设置
|
// 加载保存的质量设置
|
||||||
void LoadQualitySettings()
|
void LoadQualitySettings()
|
||||||
{
|
{
|
||||||
m_qualityLevel = (int8_t)m_config.GetInt("QualityLevel", QUALITY_ADAPTIVE);
|
// Default to QUALITY_GOOD (H264 1080P) — same as Windows ScreenManager.cpp
|
||||||
|
// and the explicit hardcoded default in macos/ScreenHandler.mm. Using
|
||||||
|
// QUALITY_ADAPTIVE here told the controller's EvaluateQuality to auto-
|
||||||
|
// upgrade to Ultra/High (DIFF/RGB565), breaking web sessions whose
|
||||||
|
// browser decoder only handles H264. Web sessions also get a clamp on
|
||||||
|
// the server side as defense in depth; this default change ensures
|
||||||
|
// Linux clients don't request adaptive in the first place.
|
||||||
|
m_qualityLevel = (int8_t)m_config.GetInt("QualityLevel", QUALITY_GOOD);
|
||||||
Mprintf(">>> LoadQualitySettings: level=%d\n", m_qualityLevel);
|
Mprintf(">>> LoadQualitySettings: level=%d\n", m_qualityLevel);
|
||||||
|
|
||||||
// 如果有保存的具体等级,立即应用
|
// 如果有保存的具体等级,立即应用
|
||||||
|
|||||||
@@ -567,6 +567,26 @@ BOOL CScreenSpyDlg::OnInitDialog()
|
|||||||
__super::OnInitDialog();
|
__super::OnInitDialog();
|
||||||
SetIcon(m_hIcon,FALSE);
|
SetIcon(m_hIcon,FALSE);
|
||||||
|
|
||||||
|
// Determine session type FIRST so the window-show machinery below
|
||||||
|
// (SetWindowPlacement with showCmd=SW_MAXIMIZE, EnterFullScreen) can be
|
||||||
|
// skipped for hidden web sessions. Without this, the dialog briefly
|
||||||
|
// flashes on-screen — the hide used to happen ~200 lines later, after
|
||||||
|
// a chain of init work that the user could see in the meantime.
|
||||||
|
bool isMfcSession = WebService().IsMfcTriggered(m_ClientID);
|
||||||
|
if (isMfcSession) {
|
||||||
|
// MFC-triggered: clear the flag, don't register with WebService.
|
||||||
|
WebService().ClearMfcTriggered(m_ClientID);
|
||||||
|
// m_bIsWebSession remains false (default)
|
||||||
|
} else if (WebService().IsWebTriggered(m_ClientID) && WebService().GetHideWebSessions()) {
|
||||||
|
// Web-triggered: register and hide upfront.
|
||||||
|
WebService().RegisterScreenContext(m_ClientID, m_ContextObject);
|
||||||
|
m_bHide = true;
|
||||||
|
m_bIsWebSession = true;
|
||||||
|
ShowWindow(SW_HIDE);
|
||||||
|
}
|
||||||
|
Mprintf("[ScreenSpy] Dialog created for device %llu, isMfcSession=%d, isWebSession=%d\n",
|
||||||
|
m_ClientID, isMfcSession ? 1 : 0, m_bIsWebSession.load() ? 1 : 0);
|
||||||
|
|
||||||
// 获取默认 IME 上下文(ImmAssociateContext 返回之前关联的上下文)
|
// 获取默认 IME 上下文(ImmAssociateContext 返回之前关联的上下文)
|
||||||
// 先禁用再恢复,以获取原始上下文句柄
|
// 先禁用再恢复,以获取原始上下文句柄
|
||||||
m_hOldIMC = ImmAssociateContext(m_hWnd, NULL);
|
m_hOldIMC = ImmAssociateContext(m_hWnd, NULL);
|
||||||
@@ -730,11 +750,19 @@ BOOL CScreenSpyDlg::OnInitDialog()
|
|||||||
wp.rcNormalPosition.right = normalX + normalWidth;
|
wp.rcNormalPosition.right = normalX + normalWidth;
|
||||||
wp.rcNormalPosition.bottom = normalY + normalHeight;
|
wp.rcNormalPosition.bottom = normalY + normalHeight;
|
||||||
wp.showCmd = SW_MAXIMIZE;
|
wp.showCmd = SW_MAXIMIZE;
|
||||||
SetWindowPlacement(&wp);
|
|
||||||
|
|
||||||
// 同时初始化 m_struOldWndpl,供全屏退出时使用
|
// Skip the placement + fullscreen machinery for hidden web sessions —
|
||||||
m_struOldWndpl = wp;
|
// those calls would briefly flash the dialog on the user's desktop
|
||||||
m_Settings.FullScreen ? EnterFullScreen() : LeaveFullScreen();
|
// before the early ShowWindow(SW_HIDE) above takes effect at WM_PAINT
|
||||||
|
// time. m_struOldWndpl still gets a sane snapshot so any defensive
|
||||||
|
// LeaveFullScreen path later has something to restore against.
|
||||||
|
if (!m_bIsWebSession) {
|
||||||
|
SetWindowPlacement(&wp);
|
||||||
|
m_struOldWndpl = wp;
|
||||||
|
m_Settings.FullScreen ? EnterFullScreen() : LeaveFullScreen();
|
||||||
|
} else {
|
||||||
|
m_struOldWndpl = wp;
|
||||||
|
}
|
||||||
|
|
||||||
// 启动传输速率更新定时器 (1秒)
|
// 启动传输速率更新定时器 (1秒)
|
||||||
SetTimer(4, 1000, NULL);
|
SetTimer(4, 1000, NULL);
|
||||||
@@ -771,32 +799,9 @@ BOOL CScreenSpyDlg::OnInitDialog()
|
|||||||
if (pMain)
|
if (pMain)
|
||||||
::PostMessage(pMain->GetSafeHwnd(), WM_SESSION_ACTIVATED, (WPARAM)this, 0);
|
::PostMessage(pMain->GetSafeHwnd(), WM_SESSION_ACTIVATED, (WPARAM)this, 0);
|
||||||
|
|
||||||
// Determine session type: MFC or Web
|
// Session type detection and visibility moved to the top of this function
|
||||||
// Must check MfcTriggered FIRST - if MFC triggered this dialog, it's NOT a web session
|
// (right after __super::OnInitDialog) so SetWindowPlacement / EnterFullScreen
|
||||||
// even if WebTriggered is also true (happens when Web is already open for same device)
|
// above can be skipped for web sessions and avoid a visible flash.
|
||||||
bool isMfcSession = WebService().IsMfcTriggered(m_ClientID);
|
|
||||||
bool isWebSession = false;
|
|
||||||
if (isMfcSession) {
|
|
||||||
// MFC session: clear the flag, don't register with WebService
|
|
||||||
WebService().ClearMfcTriggered(m_ClientID);
|
|
||||||
// m_bIsWebSession remains false (default)
|
|
||||||
} else {
|
|
||||||
// Check if this is a Web session
|
|
||||||
isWebSession = WebService().IsWebTriggered(m_ClientID) && WebService().GetHideWebSessions();
|
|
||||||
|
|
||||||
// Only register screen context for Web sessions
|
|
||||||
// MFC dialogs handle input directly via m_ContextObject, don't need WebService registry
|
|
||||||
// This prevents MFC close from deleting Web's context (they share same device_id key)
|
|
||||||
if (isWebSession) {
|
|
||||||
WebService().RegisterScreenContext(m_ClientID, m_ContextObject);
|
|
||||||
m_bHide = true;
|
|
||||||
m_bIsWebSession = true;
|
|
||||||
ShowWindow(SW_HIDE);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Mprintf("[ScreenSpy] Dialog created for device %llu, isMfcSession=%d, isWebSession=%d\n",
|
|
||||||
m_ClientID, isMfcSession ? 1 : 0, isWebSession ? 1 : 0);
|
|
||||||
|
|
||||||
return TRUE;
|
return TRUE;
|
||||||
}
|
}
|
||||||
@@ -2277,6 +2282,16 @@ void CScreenSpyDlg::EvaluateQuality()
|
|||||||
|
|
||||||
// 2. 计算目标等级
|
// 2. 计算目标等级
|
||||||
int targetLevel = GetTargetQualityLevel(rtt, m_bUsingFRP);
|
int targetLevel = GetTargetQualityLevel(rtt, m_bUsingFRP);
|
||||||
|
|
||||||
|
// Web 会话只解码 H264。levels 0 (Ultra/DIFF) 和 1 (High/RGB565) 在网络好的时候
|
||||||
|
// 会被自适应控制器选中,但 device 切到那两个算法后浏览器的 WebCodecs decoder
|
||||||
|
// 就吃不下了,画面冻在最后一个 H264 帧上 (用户感受是 ~1 分钟后卡死,
|
||||||
|
// 因为 EvaluateQuality 启动 1 分钟才开始评估)。Clamp 到最高质量的 H264 等级
|
||||||
|
// 让自适应在 H264 区间内继续工作.
|
||||||
|
if (m_bIsWebSession && targetLevel < QUALITY_GOOD) {
|
||||||
|
targetLevel = QUALITY_GOOD;
|
||||||
|
}
|
||||||
|
|
||||||
int currentLevel = m_AdaptiveQuality.currentLevel;
|
int currentLevel = m_AdaptiveQuality.currentLevel;
|
||||||
|
|
||||||
if (targetLevel == currentLevel) {
|
if (targetLevel == currentLevel) {
|
||||||
@@ -2322,6 +2337,14 @@ void CScreenSpyDlg::ApplyQualityLevel(int level, bool persist)
|
|||||||
{
|
{
|
||||||
if (level < 0 || level >= QUALITY_COUNT) return;
|
if (level < 0 || level >= QUALITY_COUNT) return;
|
||||||
|
|
||||||
|
// Defense in depth: ALL paths that set quality level (adaptive controller,
|
||||||
|
// manual menu pick, restore from config, …) flow through here. Web
|
||||||
|
// sessions cannot use non-H264 levels — see EvaluateQuality for the
|
||||||
|
// failure mode explanation.
|
||||||
|
if (m_bIsWebSession && level < QUALITY_GOOD) {
|
||||||
|
level = QUALITY_GOOD;
|
||||||
|
}
|
||||||
|
|
||||||
const QualityProfile& profile = GetQualityProfile(level);
|
const QualityProfile& profile = GetQualityProfile(level);
|
||||||
int oldMaxWidth = m_AdaptiveQuality.currentMaxWidth;
|
int oldMaxWidth = m_AdaptiveQuality.currentMaxWidth;
|
||||||
int newMaxWidth = profile.maxWidth;
|
int newMaxWidth = profile.maxWidth;
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ package hub
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
|
"sort"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -280,6 +281,14 @@ func (h *Hub) SendToScreen(id string, data []byte) error {
|
|||||||
// just sent TOKEN_BITMAPINFO) with the device identified by clientID.
|
// just sent TOKEN_BITMAPINFO) with the device identified by clientID.
|
||||||
// Returns false if the device is not registered — callers should drop the
|
// Returns false if the device is not registered — callers should drop the
|
||||||
// orphan connection in that case.
|
// orphan connection in that case.
|
||||||
|
//
|
||||||
|
// If the device already has a screen sub-conn bound (typically because the
|
||||||
|
// client's monitor-poll logic opened a new one without the previous viewer
|
||||||
|
// going through CloseScreen — e.g. multiple open/close cycles where each
|
||||||
|
// cycle picks a different display), the previous sub-conn is retired via
|
||||||
|
// retireScreenConn. Without this, both sub-conns keep streaming under the
|
||||||
|
// same device ID and the browser sees H.264 frames from two encoders
|
||||||
|
// interleaved, rendering as a picture that jumps between monitors.
|
||||||
func (h *Hub) BindScreenConn(deviceID string, ctx *connection.Context) bool {
|
func (h *Hub) BindScreenConn(deviceID string, ctx *connection.Context) bool {
|
||||||
if deviceID == "" || ctx == nil {
|
if deviceID == "" || ctx == nil {
|
||||||
return false
|
return false
|
||||||
@@ -290,12 +299,29 @@ func (h *Hub) BindScreenConn(deviceID string, ctx *connection.Context) bool {
|
|||||||
h.mu.Unlock()
|
h.mu.Unlock()
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
old := d.screenConn
|
||||||
d.screenConn = ctx
|
d.screenConn = ctx
|
||||||
|
// The cached resolution and last IDR belong to the old encoder's
|
||||||
|
// stream. A viewer joining now must wait for the new sub-conn's
|
||||||
|
// TOKEN_BITMAPINFO + first IDR; serving the old monitor's keyframe
|
||||||
|
// to a decoder that's about to receive a different SPS/PPS would
|
||||||
|
// produce the same mixed-stream corruption retireScreenConn exists
|
||||||
|
// to prevent.
|
||||||
|
replacing := old != nil && old != ctx
|
||||||
|
if replacing {
|
||||||
|
d.screenWidth = 0
|
||||||
|
d.screenHeight = 0
|
||||||
|
d.lastKeyframe = nil
|
||||||
|
}
|
||||||
h.mu.Unlock()
|
h.mu.Unlock()
|
||||||
|
|
||||||
h.screenIndexMu.Lock()
|
h.screenIndexMu.Lock()
|
||||||
h.screenIndex[ctx] = deviceID
|
h.screenIndex[ctx] = deviceID
|
||||||
h.screenIndexMu.Unlock()
|
h.screenIndexMu.Unlock()
|
||||||
|
|
||||||
|
if replacing {
|
||||||
|
h.retireScreenConn(old)
|
||||||
|
}
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -332,6 +358,49 @@ func (h *Hub) UnbindScreenConn(ctx *connection.Context) {
|
|||||||
h.mu.Unlock()
|
h.mu.Unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// retireScreenConn tears down a screen sub-conn that's being replaced or
|
||||||
|
// closed. Shared by CloseScreen (last viewer left) and BindScreenConn (device
|
||||||
|
// opened a new sub-conn that's superseding this one, e.g. when the client's
|
||||||
|
// monitor-poll logic switches to a different display).
|
||||||
|
//
|
||||||
|
// Steps and ordering matter:
|
||||||
|
//
|
||||||
|
// 1. screenIndex entry is dropped FIRST so any in-flight frames still in the
|
||||||
|
// device's TCP send buffer arrive at handleScreenFrame with deviceID=""
|
||||||
|
// and are silently dropped. Without this they'd be relayed under the
|
||||||
|
// same deviceID as the new sub-conn — that's the "frames from two
|
||||||
|
// monitors interleaved in one stream" symptom: the browser decoder sees
|
||||||
|
// a mix of two independent x264 SPS/PPS sequences and renders an
|
||||||
|
// alternating / glitchy picture.
|
||||||
|
//
|
||||||
|
// 2. COMMAND_BYE is sent so the C++ client's IOCPClient exits via the
|
||||||
|
// clean StopRunning() path. Without it the client treats FIN as a
|
||||||
|
// network blip and fires m_ReconnectFunc, which opens a fresh sub-conn
|
||||||
|
// that never sends BITMAPINFO again, keeps the encoder thread alive,
|
||||||
|
// and ultimately wedges the device for ~10 s — see the original
|
||||||
|
// CloseScreen comment for the full failure mode.
|
||||||
|
//
|
||||||
|
// 3. The actual TCP Close happens 500 ms later on a goroutine, mirroring
|
||||||
|
// the C++ ScreenSpyDlg.cpp:842 (Sleep(500); CancelIO()) sequence so
|
||||||
|
// COMMAND_BYE has time to reach the device read loop before FIN does.
|
||||||
|
//
|
||||||
|
// No-op for a nil sc so callers can pass d.screenConn unconditionally.
|
||||||
|
func (h *Hub) retireScreenConn(sc *connection.Context) {
|
||||||
|
if sc == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.screenIndexMu.Lock()
|
||||||
|
delete(h.screenIndex, sc)
|
||||||
|
h.screenIndexMu.Unlock()
|
||||||
|
if h.sender != nil {
|
||||||
|
_ = h.sender(sc, []byte{protocol.CommandBye})
|
||||||
|
}
|
||||||
|
go func(c *connection.Context) {
|
||||||
|
time.Sleep(500 * time.Millisecond)
|
||||||
|
c.Close()
|
||||||
|
}(sc)
|
||||||
|
}
|
||||||
|
|
||||||
// Subscribe registers an EventHandler. The returned func removes it.
|
// Subscribe registers an EventHandler. The returned func removes it.
|
||||||
// Multiple handlers are supported; each receives every event.
|
// Multiple handlers are supported; each receives every event.
|
||||||
func (h *Hub) Subscribe(eh EventHandler) (unsubscribe func()) {
|
func (h *Hub) Subscribe(eh EventHandler) (unsubscribe func()) {
|
||||||
@@ -397,6 +466,10 @@ func (h *Hub) Unregister(id string) {
|
|||||||
|
|
||||||
// ListDevices returns a fresh snapshot slice. The caller may mutate it freely;
|
// ListDevices returns a fresh snapshot slice. The caller may mutate it freely;
|
||||||
// it shares no state with the hub.
|
// it shares no state with the hub.
|
||||||
|
//
|
||||||
|
// Sort by ConnectedAt asc (ID as tiebreaker) so the order stays stable across
|
||||||
|
// REST polls and WS pushes — Go's map iteration is intentionally randomized,
|
||||||
|
// which would otherwise reshuffle the UI list on every refresh.
|
||||||
func (h *Hub) ListDevices() []DeviceInfo {
|
func (h *Hub) ListDevices() []DeviceInfo {
|
||||||
h.mu.RLock()
|
h.mu.RLock()
|
||||||
defer h.mu.RUnlock()
|
defer h.mu.RUnlock()
|
||||||
@@ -404,6 +477,12 @@ func (h *Hub) ListDevices() []DeviceInfo {
|
|||||||
for _, d := range h.devices {
|
for _, d := range h.devices {
|
||||||
out = append(out, deviceToInfo(d))
|
out = append(out, deviceToInfo(d))
|
||||||
}
|
}
|
||||||
|
sort.Slice(out, func(i, j int) bool {
|
||||||
|
if out[i].ConnectedAt != out[j].ConnectedAt {
|
||||||
|
return out[i].ConnectedAt < out[j].ConnectedAt
|
||||||
|
}
|
||||||
|
return out[i].ID < out[j].ID
|
||||||
|
})
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -460,6 +539,9 @@ func (h *Hub) PublishCursor(deviceID string, index byte) {
|
|||||||
// the disconnect callback would see Active=true with stale dimensions/IDR
|
// the disconnect callback would see Active=true with stale dimensions/IDR
|
||||||
// and skip the COMMAND_SCREEN_SPY kick, leaving the page stuck on a "connected"
|
// and skip the COMMAND_SCREEN_SPY kick, leaving the page stuck on a "connected"
|
||||||
// status with no frames ever arriving.
|
// status with no frames ever arriving.
|
||||||
|
//
|
||||||
|
// The actual sub-conn teardown is delegated to retireScreenConn, which is
|
||||||
|
// shared with BindScreenConn's replacement path.
|
||||||
func (h *Hub) CloseScreen(deviceID string) {
|
func (h *Hub) CloseScreen(deviceID string) {
|
||||||
h.mu.Lock()
|
h.mu.Lock()
|
||||||
d, ok := h.devices[deviceID]
|
d, ok := h.devices[deviceID]
|
||||||
@@ -473,52 +555,7 @@ func (h *Hub) CloseScreen(deviceID string) {
|
|||||||
d.screenHeight = 0
|
d.screenHeight = 0
|
||||||
d.lastKeyframe = nil
|
d.lastKeyframe = nil
|
||||||
h.mu.Unlock()
|
h.mu.Unlock()
|
||||||
if sc != nil {
|
h.retireScreenConn(sc)
|
||||||
// Drop the screenIndex entry SYNCHRONOUSLY so any in-flight frames
|
|
||||||
// still draining out of the device on this sub-conn (between our
|
|
||||||
// FIN and the device's clean-up) are silently dropped instead of
|
|
||||||
// being relayed to the freshly initialized browser decoder. Mixing
|
|
||||||
// frames from the old x264 SPS/PPS sequence with the new session's
|
|
||||||
// decoder produces the classic "every other quick reconnect goes
|
|
||||||
// black" symptom — old NAL units come in via the old ctx after we
|
|
||||||
// nulled d.screenConn but before OnDisconnect fires.
|
|
||||||
h.screenIndexMu.Lock()
|
|
||||||
delete(h.screenIndex, sc)
|
|
||||||
h.screenIndexMu.Unlock()
|
|
||||||
|
|
||||||
// Tell the client to shut its screen pipeline down gracefully.
|
|
||||||
// Without this, the client's IOCPClient sees recv()==0 as a network
|
|
||||||
// blip and fires m_ReconnectFunc, which:
|
|
||||||
// 1. Reconnects the sub-conn (~100 ms)
|
|
||||||
// 2. Re-sends ConnAuthPacket (no BITMAPINFO!)
|
|
||||||
// 3. Keeps the capture thread alive for ~10 s holding DXGI handles
|
|
||||||
// 4. ConnAuth eventually times out, ScreenManager exits
|
|
||||||
// Net effect: a second viewer arriving within ~10 s of leaving lands
|
|
||||||
// in the dead window where the device is still capturing for the old
|
|
||||||
// (now unrouted) sub-conn — page sits on "Waiting for video".
|
|
||||||
//
|
|
||||||
// COMMAND_BYE is what the C++ server sends via
|
|
||||||
// CDialogBase::SayByeBye (server/2015Remote/IOCPServer.h:248) before
|
|
||||||
// it tears down a sub-conn for the same reason. Client-side handler:
|
|
||||||
// CScreenManager::OnReceive case COMMAND_BYE
|
|
||||||
// (client/ScreenManager.cpp:812) sets m_bIsWorking=FALSE and calls
|
|
||||||
// StopRunning() — the clean exit path that does NOT trigger reconnect.
|
|
||||||
if h.sender != nil {
|
|
||||||
_ = h.sender(sc, []byte{protocol.CommandBye})
|
|
||||||
}
|
|
||||||
// Mirror the C++ flow (ScreenSpyDlg.cpp:842 — Sleep(500); CancelIO()).
|
|
||||||
// Give the device's read loop a moment to pull COMMAND_BYE off the
|
|
||||||
// wire before our FIN arrives; otherwise on a fast LAN the BYE byte
|
|
||||||
// can be coalesced with the FIN and the client's IOCPClient may
|
|
||||||
// observe recv()==0 first and trigger reconnect anyway.
|
|
||||||
// Run the close on a goroutine so the caller (web handler) isn't
|
|
||||||
// blocked for 500 ms. screenIndex is already cleared above, so
|
|
||||||
// in-flight frames during the grace window are silently dropped.
|
|
||||||
go func(c *connection.Context) {
|
|
||||||
time.Sleep(500 * time.Millisecond)
|
|
||||||
c.Close()
|
|
||||||
}(sc)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// PublishResolution announces a new (or first-ever) screen geometry for a
|
// PublishResolution announces a new (or first-ever) screen geometry for a
|
||||||
|
|||||||
@@ -109,6 +109,7 @@ const (
|
|||||||
CommandNext byte = 30 // COMMAND_NEXT - "control-side dialog is open, you may stream"
|
CommandNext byte = 30 // COMMAND_NEXT - "control-side dialog is open, you may stream"
|
||||||
CommandShell byte = 40 // COMMAND_SHELL - ask device to open a shell sub-connection
|
CommandShell byte = 40 // COMMAND_SHELL - ask device to open a shell sub-connection
|
||||||
CommandTerminalRsize byte = 81 // CMD_TERMINAL_RESIZE - [cmd:1][cols:2 LE][rows:2 LE]
|
CommandTerminalRsize byte = 81 // CMD_TERMINAL_RESIZE - [cmd:1][cols:2 LE][rows:2 LE]
|
||||||
|
CmdRestoreConsole byte = 82 // CMD_RESTORE_CONSOLE - RDP session "归位": switch back to the console session and restart capture
|
||||||
CommandBye byte = 204 // COMMAND_BYE - disconnect
|
CommandBye byte = 204 // COMMAND_BYE - disconnect
|
||||||
CommandHeartbeat byte = 216 // CMD_HEARTBEAT_ACK
|
CommandHeartbeat byte = 216 // CMD_HEARTBEAT_ACK
|
||||||
|
|
||||||
@@ -382,7 +383,22 @@ type LoginInfo struct {
|
|||||||
Reserved string // Contains additional info separated by |
|
Reserved string // Contains additional info separated by |
|
||||||
}
|
}
|
||||||
|
|
||||||
// ParseLoginInfo parses LOGIN_INFOR from data
|
// ParseLoginInfo parses LOGIN_INFOR from data.
|
||||||
|
//
|
||||||
|
// Encoding: text fields are GBK on legacy Windows clients and UTF-8 on modern
|
||||||
|
// clients that set CLIENT_CAP_UTF8 (always on for LNX / MAC). Picking the
|
||||||
|
// wrong codec mangles non-ASCII characters — e.g. a German location string
|
||||||
|
// "Nürnberg" sent as UTF-8 (4E C3 BC 72 ...) and force-decoded as GBK turns
|
||||||
|
// into mojibake. The heartbeat path already honors this via DecodeClientString
|
||||||
|
// (see cmd/main.go handleHeartbeat); ParseLoginInfo previously did not, so
|
||||||
|
// every login string from a UTF-8 client was being misread.
|
||||||
|
//
|
||||||
|
// To get encoding right we have a chicken-and-egg problem: capability lives
|
||||||
|
// in ModuleVersion (offset 164) and clientType lives in Reserved field 0
|
||||||
|
// (offset 476) — but Reserved itself needs that information to decode. Both
|
||||||
|
// "discriminator" values are pure ASCII (hex digits, "Windows"/"LNX"/"MAC"),
|
||||||
|
// so we can extract them with a UTF-8 read and then re-decode the actual
|
||||||
|
// user-text fields with the correct codec.
|
||||||
func ParseLoginInfo(data []byte) (*LoginInfo, error) {
|
func ParseLoginInfo(data []byte) (*LoginInfo, error) {
|
||||||
if len(data) < 100 { // Minimum size check
|
if len(data) < 100 { // Minimum size check
|
||||||
return nil, ErrInvalidData
|
return nil, ErrInvalidData
|
||||||
@@ -392,64 +408,61 @@ func ParseLoginInfo(data []byte) (*LoginInfo, error) {
|
|||||||
Token: data[0],
|
Token: data[0],
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse OS version info (offset 1, 156 bytes)
|
// CPU MHz, WebCam, Speed — fixed-width binary, encoding-independent.
|
||||||
// The C++ client fills this with a readable string like "Windows 10" via getSystemName()
|
|
||||||
if len(data) >= OffsetOsVerInfoEx+156 {
|
|
||||||
info.OsVerInfo = parseOsVersionInfo(data[OffsetOsVerInfoEx : OffsetOsVerInfoEx+156])
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse CPU MHz (offset 160, 4 bytes)
|
|
||||||
if len(data) >= OffsetCPUMHz+4 {
|
if len(data) >= OffsetCPUMHz+4 {
|
||||||
info.CPUMHz = binary.LittleEndian.Uint32(data[OffsetCPUMHz:])
|
info.CPUMHz = binary.LittleEndian.Uint32(data[OffsetCPUMHz:])
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse module version (offset 164, 24 bytes)
|
|
||||||
// This contains date string like "Dec 19 2025"
|
|
||||||
if len(data) >= OffsetModuleVersion+24 {
|
|
||||||
info.ModuleVersion = GbkToUTF8(data[OffsetModuleVersion : OffsetModuleVersion+24])
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse PC name (offset 188, 240 bytes)
|
|
||||||
if len(data) >= OffsetPCName+240 {
|
|
||||||
info.PCName = GbkToUTF8(data[OffsetPCName : OffsetPCName+240])
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse Master ID (offset 428, 20 bytes)
|
|
||||||
if len(data) >= OffsetMasterID+20 {
|
|
||||||
info.MasterID = GbkToUTF8(data[OffsetMasterID : OffsetMasterID+20])
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse WebCam exist (offset 448, 4 bytes)
|
|
||||||
if len(data) >= OffsetWebCamExist+4 {
|
if len(data) >= OffsetWebCamExist+4 {
|
||||||
info.WebCamExist = binary.LittleEndian.Uint32(data[OffsetWebCamExist:]) != 0
|
info.WebCamExist = binary.LittleEndian.Uint32(data[OffsetWebCamExist:]) != 0
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse Speed (offset 452, 4 bytes)
|
|
||||||
if len(data) >= OffsetSpeed+4 {
|
if len(data) >= OffsetSpeed+4 {
|
||||||
info.Speed = binary.LittleEndian.Uint32(data[OffsetSpeed:])
|
info.Speed = binary.LittleEndian.Uint32(data[OffsetSpeed:])
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse Start time (offset 456, 20 bytes)
|
// ModuleVersion is "version-capabilityHex" — pure ASCII (e.g. "Dec 19
|
||||||
if len(data) >= OffsetStartTime+20 {
|
// 2025-0006"). Safe to read as UTF-8 regardless of client codec.
|
||||||
info.StartTime = GbkToUTF8(data[OffsetStartTime : OffsetStartTime+20])
|
if len(data) >= OffsetModuleVersion+24 {
|
||||||
|
info.ModuleVersion = Utf8CleanString(data[OffsetModuleVersion : OffsetModuleVersion+24])
|
||||||
|
}
|
||||||
|
_, capability, _ := strings.Cut(info.ModuleVersion, "-")
|
||||||
|
|
||||||
|
// Peek at Reserved field 0 (RES_CLIENT_TYPE: "Windows" / "LNX" / "MAC")
|
||||||
|
// — pure ASCII, so we can read raw bytes without knowing the codec.
|
||||||
|
// LNX / MAC clients are implicitly UTF-8 even when capability is absent.
|
||||||
|
clientType := ""
|
||||||
|
if len(data) > OffsetReserved {
|
||||||
|
raw := data[OffsetReserved:min(OffsetReserved+512, len(data))]
|
||||||
|
if nul := bytes.IndexByte(raw, 0); nul >= 0 {
|
||||||
|
raw = raw[:nul]
|
||||||
|
}
|
||||||
|
head, _, _ := bytes.Cut(raw, []byte("|"))
|
||||||
|
clientType = string(head)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse Reserved (offset 476, 512 bytes) - contains additional info
|
// Now decode every user-text field with the client's actual codec.
|
||||||
|
decode := func(b []byte) string { return DecodeClientString(b, capability, clientType) }
|
||||||
|
|
||||||
|
if len(data) >= OffsetOsVerInfoEx+156 {
|
||||||
|
info.OsVerInfo = decode(data[OffsetOsVerInfoEx : OffsetOsVerInfoEx+156])
|
||||||
|
}
|
||||||
|
if len(data) >= OffsetPCName+240 {
|
||||||
|
info.PCName = decode(data[OffsetPCName : OffsetPCName+240])
|
||||||
|
}
|
||||||
|
if len(data) >= OffsetMasterID+20 {
|
||||||
|
info.MasterID = decode(data[OffsetMasterID : OffsetMasterID+20])
|
||||||
|
}
|
||||||
|
if len(data) >= OffsetStartTime+20 {
|
||||||
|
info.StartTime = decode(data[OffsetStartTime : OffsetStartTime+20])
|
||||||
|
}
|
||||||
if len(data) >= OffsetReserved+512 {
|
if len(data) >= OffsetReserved+512 {
|
||||||
info.Reserved = GbkToUTF8(data[OffsetReserved : OffsetReserved+512])
|
info.Reserved = decode(data[OffsetReserved : OffsetReserved+512])
|
||||||
} else if len(data) > OffsetReserved {
|
} else if len(data) > OffsetReserved {
|
||||||
info.Reserved = GbkToUTF8(data[OffsetReserved:])
|
info.Reserved = decode(data[OffsetReserved:])
|
||||||
}
|
}
|
||||||
|
|
||||||
return info, nil
|
return info, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// parseOsVersionInfo parses the OS version info field
|
|
||||||
// The C++ client fills this with a readable string like "Windows 10" via getSystemName()
|
|
||||||
func parseOsVersionInfo(data []byte) string {
|
|
||||||
return GbkToUTF8(data)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ParseReserved parses the reserved field into a slice of strings
|
// ParseReserved parses the reserved field into a slice of strings
|
||||||
func (info *LoginInfo) ParseReserved() []string {
|
func (info *LoginInfo) ParseReserved() []string {
|
||||||
if info.Reserved == "" {
|
if info.Reserved == "" {
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ func (h *wsHub) dispatch(c *wsClient, cmd string, raw []byte) {
|
|||||||
case "connect":
|
case "connect":
|
||||||
h.handleConnect(c, raw)
|
h.handleConnect(c, raw)
|
||||||
case "rdp_reset":
|
case "rdp_reset":
|
||||||
// silently ignored — UI uses this as a fire-and-forget
|
h.handleRdpReset(c, raw)
|
||||||
case "mouse":
|
case "mouse":
|
||||||
h.handleMouse(c, raw)
|
h.handleMouse(c, raw)
|
||||||
case "key":
|
case "key":
|
||||||
@@ -311,6 +311,36 @@ func (h *wsHub) handleConnect(c *wsClient, raw []byte) {
|
|||||||
c.queue(mustJSON(map[string]any{"cmd": "connect_result", "ok": true}))
|
c.queue(mustJSON(map[string]any{"cmd": "connect_result", "ok": true}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handleRdpReset asks the device to switch its screen capture back to the
|
||||||
|
// physical console session ("RDP 会话归位"). Useful when someone has RDP'd into
|
||||||
|
// the box: the device's screen thread is by default attached to whatever WTS
|
||||||
|
// session is currently active, so the operator may otherwise see a login
|
||||||
|
// screen or a different user's desktop instead of the local console.
|
||||||
|
//
|
||||||
|
// Fire-and-forget on purpose, matching the C++ server and the browser UI —
|
||||||
|
// front-end ignores any reply, so we don't send one. Failures (device offline,
|
||||||
|
// no active screen session, browser hasn't called `connect` yet) are warn-
|
||||||
|
// logged server-side only.
|
||||||
|
func (h *wsHub) handleRdpReset(c *wsClient, raw []byte) {
|
||||||
|
if !h.requireAuth(c, raw, "rdp_reset_result") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
deviceID := c.watching
|
||||||
|
if deviceID == "" {
|
||||||
|
h.log.Warn("rdp_reset: no device watched (addr=%s role=%s)", c.addr, c.role)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// CMD_RESTORE_CONSOLE must go through the screen sub-conn — the client
|
||||||
|
// dispatches it from CScreenManager::OnReceive, which only reads from
|
||||||
|
// the screen sub-conn (see client/ScreenManager.cpp:996). Sending on the
|
||||||
|
// main conn would silently no-op.
|
||||||
|
if err := h.devices.SendToScreen(deviceID, []byte{protocol.CmdRestoreConsole}); err != nil {
|
||||||
|
h.log.Warn("rdp_reset: device=%s: %v", deviceID, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.log.Info("rdp_reset sent: device=%s", deviceID)
|
||||||
|
}
|
||||||
|
|
||||||
func (h *wsHub) handleGetDevices(c *wsClient, raw []byte) {
|
func (h *wsHub) handleGetDevices(c *wsClient, raw []byte) {
|
||||||
if !h.requireAuth(c, raw, "device_list") {
|
if !h.requireAuth(c, raw, "device_list") {
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -2290,10 +2290,14 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
document.addEventListener('keydown', function(e) {
|
document.addEventListener('keydown', function(e) {
|
||||||
if (e.key === 'F11' && document.getElementById('screen-page').classList.contains('active')) {
|
if (e.key !== 'F11') return;
|
||||||
e.preventDefault();
|
if (!document.getElementById('screen-page').classList.contains('active')) return;
|
||||||
toggleFullscreen();
|
// In control mode F11 is a remote keystroke — let it fall through
|
||||||
}
|
// to the desktop key handler (which calls sendKey). Outside control
|
||||||
|
// mode F11 toggles our page fullscreen as the local convenience.
|
||||||
|
if (controlEnabled) return;
|
||||||
|
e.preventDefault();
|
||||||
|
toggleFullscreen();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Control mode state (mouse/keyboard control)
|
// Control mode state (mouse/keyboard control)
|
||||||
@@ -2365,6 +2369,40 @@
|
|||||||
document.getElementById('screen-page')?.classList.contains('pseudo-fullscreen'));
|
document.getElementById('screen-page')?.classList.contains('pseudo-fullscreen'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Keyboard Lock keeps F11 / Esc from triggering their browser-default
|
||||||
|
// behaviour (F11 → toggle browser fullscreen, Esc → exit native page
|
||||||
|
// fullscreen) while we're actively controlling a remote desktop —
|
||||||
|
// those keys belong to the remote OS in that mode, not the local
|
||||||
|
// browser. Lock is no-op on browsers without the API.
|
||||||
|
function updateKeyboardLock() {
|
||||||
|
if (!navigator.keyboard || !navigator.keyboard.lock) return;
|
||||||
|
if (isFullscreen() && controlEnabled) {
|
||||||
|
navigator.keyboard.lock(['Escape', 'F11']).catch(() => {});
|
||||||
|
} else {
|
||||||
|
navigator.keyboard.unlock();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unified fullscreen-change handler. Always clears the floating
|
||||||
|
// quick-action toolbar (its three buttons duplicate the top-right
|
||||||
|
// toolbar that becomes visible again on exit — leaving them pinned
|
||||||
|
// to the top of the page is just noise). Then re-evaluates the
|
||||||
|
// keyboard lock and, for touch devices, refreshes the orientation-
|
||||||
|
// dependent layout.
|
||||||
|
function onFullscreenChange() {
|
||||||
|
if (!isFullscreen()) {
|
||||||
|
const fb = document.getElementById('floating-toolbar');
|
||||||
|
if (fb) fb.classList.remove('visible');
|
||||||
|
toolbarVisible = false;
|
||||||
|
if (toolbarHideTimer) {
|
||||||
|
clearTimeout(toolbarHideTimer);
|
||||||
|
toolbarHideTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
updateKeyboardLock();
|
||||||
|
if (isTouchDevice) updateUIForOrientation();
|
||||||
|
}
|
||||||
|
|
||||||
function toggleFloatingToolbar() {
|
function toggleFloatingToolbar() {
|
||||||
const toolbar = document.getElementById('floating-toolbar');
|
const toolbar = document.getElementById('floating-toolbar');
|
||||||
toolbarVisible = !toolbarVisible;
|
toolbarVisible = !toolbarVisible;
|
||||||
@@ -2517,6 +2555,12 @@
|
|||||||
if (controlEnabled) {
|
if (controlEnabled) {
|
||||||
applyRemoteCursor(currentCursorIndex);
|
applyRemoteCursor(currentCursorIndex);
|
||||||
}
|
}
|
||||||
|
// Sync the keyboard lock: in fullscreen + control mode, ESC and F11
|
||||||
|
// must be passed through to the remote OS instead of triggering the
|
||||||
|
// browser's exit-fullscreen / toggle-fullscreen behavior. The
|
||||||
|
// updateKeyboardLock helper no-ops if either condition is missing
|
||||||
|
// or the API is unavailable.
|
||||||
|
updateKeyboardLock();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update cursor overlay position (accounting for zoom/pan transform)
|
// Update cursor overlay position (accounting for zoom/pan transform)
|
||||||
@@ -3279,7 +3323,10 @@
|
|||||||
document.addEventListener('keydown', function(e) {
|
document.addEventListener('keydown', function(e) {
|
||||||
if (!document.getElementById('screen-page').classList.contains('active')) return;
|
if (!document.getElementById('screen-page').classList.contains('active')) return;
|
||||||
if (e.target.tagName === 'INPUT') return;
|
if (e.target.tagName === 'INPUT') return;
|
||||||
if (e.key === 'F11') return;
|
// F11 belongs to the local fullscreen toggle UNLESS we're actively
|
||||||
|
// controlling the remote — see the F11 handler above. Skip here
|
||||||
|
// to let that one handle it.
|
||||||
|
if (e.key === 'F11' && !controlEnabled) return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
sendKey(e.keyCode, true, e.altKey);
|
sendKey(e.keyCode, true, e.altKey);
|
||||||
});
|
});
|
||||||
@@ -3287,7 +3334,7 @@
|
|||||||
document.addEventListener('keyup', function(e) {
|
document.addEventListener('keyup', function(e) {
|
||||||
if (!document.getElementById('screen-page').classList.contains('active')) return;
|
if (!document.getElementById('screen-page').classList.contains('active')) return;
|
||||||
if (e.target.tagName === 'INPUT') return;
|
if (e.target.tagName === 'INPUT') return;
|
||||||
if (e.key === 'F11') return;
|
if (e.key === 'F11' && !controlEnabled) return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
sendKey(e.keyCode, false, e.altKey);
|
sendKey(e.keyCode, false, e.altKey);
|
||||||
});
|
});
|
||||||
@@ -3480,9 +3527,10 @@
|
|||||||
resizeTimer = setTimeout(updateUIForOrientation, 100);
|
resizeTimer = setTimeout(updateUIForOrientation, 100);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Listen for fullscreen changes
|
// Touch-device-only handlers continue here; the unified
|
||||||
document.addEventListener('fullscreenchange', updateUIForOrientation);
|
// fullscreen change handler (onFullscreenChange) is registered
|
||||||
document.addEventListener('webkitfullscreenchange', updateUIForOrientation);
|
// below outside this branch so desktop browsers also get the
|
||||||
|
// floating-toolbar cleanup + keyboard lock sync.
|
||||||
|
|
||||||
// Input shortcut buttons event handlers
|
// Input shortcut buttons event handlers
|
||||||
// Shortcuts only visible when keyboard is open, so no extra check needed
|
// Shortcuts only visible when keyboard is open, so no extra check needed
|
||||||
@@ -3528,6 +3576,11 @@
|
|||||||
if (btnKeyboard) btnKeyboard.classList.add('ui-hidden');
|
if (btnKeyboard) btnKeyboard.classList.add('ui-hidden');
|
||||||
if (btnKeyboardBar) btnKeyboardBar.classList.add('ui-hidden');
|
if (btnKeyboardBar) btnKeyboardBar.classList.add('ui-hidden');
|
||||||
}
|
}
|
||||||
|
// Unified fullscreen change handler — registered for all devices.
|
||||||
|
// Touch devices need updateUIForOrientation; desktop devices need
|
||||||
|
// the floating-toolbar cleanup. Both need the keyboard lock sync.
|
||||||
|
document.addEventListener('fullscreenchange', onFullscreenChange);
|
||||||
|
document.addEventListener('webkitfullscreenchange', onFullscreenChange);
|
||||||
// Keyboard buttons: capture focus state and prevent blur from updating button state
|
// Keyboard buttons: capture focus state and prevent blur from updating button state
|
||||||
function bindKeyboardBtnEvents(btn) {
|
function bindKeyboardBtnEvents(btn) {
|
||||||
if (!btn) return;
|
if (!btn) return;
|
||||||
|
|||||||
Reference in New Issue
Block a user