Fix: Align macOS mouse coordinates with the captured display

The macOS client derived its logical screen size from [NSScreen mainScreen]
while it captured CGMainDisplayID. In clamshell mode with an external display
the former can still report the disabled built-in panel (1470x956) while the
captured frame is 1280x1024, so every injected point was scaled by
logical/encode and drifted linearly with the distance from the top left
corner, independently on each axis. Take the logical size from
CGDisplayBounds of the captured display instead, and report the same bounds
in the login resolution.

For hosts that still run the old client, the remote desktop dialog gains a
per-host mouse calibration in its system menu. The factors are stored by
client ID together with the reported resolution and the captured frame size,
so they stop applying once the client is upgraded or a different display is
captured, and come back automatically when the original display returns.
When no calibration has been saved the dialog pre-fills the factors from
frame/reported, but only for macOS clients, because the other clients derive
their conversion from the captured display itself and a mismatching report
there would be misleading.

Co-Authored-By: deepseek-flash
This commit is contained in:
yuanyuanxianganddeepseek-flash committed 2026-10-22 11:33:39 +02:00
1 parent 38cd207f5d
commit 3ae6298746
7 files changed
+250 -12

No files matched your search

+10 -8
View File
@@ -88,14 +88,16 @@ bool ScreenHandler::init()
m_height = (int)CGDisplayPixelsHigh(m_displayID);
}
// Get logical point dimensions (what CGEvent uses)
// NSScreen provides logical dimensions
NSScreen* mainScreen = [NSScreen mainScreen];
if (mainScreen) {
NSRect frame = [mainScreen frame];
m_logicalWidth = (int)frame.size.width;
m_logicalHeight = (int)frame.size.height;
} else {
// 逻辑尺寸必须取自「实际抓屏的那块屏」(CGMainDisplayID)。CGEvent 的鼠标坐标是
// 全局显示坐标(points),而我们发给服务端的是这块屏的编码像素。
// [NSScreen mainScreen] 取的是菜单栏/焦点窗口所在屏:合盖外接显示器时它仍可能是
// 已停用的内置屏(例如 13" MacBook Air 的 1470x956),与抓屏的外置屏(1280x1024)
// 不是同一块屏。用错尺寸会让每个注入点变成 p * (logical / encode),表现为从左上角
// 起线性增长、且 x/y 各轴独立的鼠标偏移(见 ScreenHandler::OnReceive 的换算)。
CGRect bounds = CGDisplayBounds(m_displayID);
m_logicalWidth = (int)(bounds.size.width + 0.5);
m_logicalHeight = (int)(bounds.size.height + 0.5);
if (m_logicalWidth <= 0 || m_logicalHeight <= 0) {
// Fallback: use physical dimensions
m_logicalWidth = m_width;
m_logicalHeight = m_height;
+7 -4
View File
@@ -271,11 +271,14 @@ static std::string normalizeExePathLower(const std::string& path)
// Get screen resolution
static std::string getScreenResolution()
{
NSScreen* mainScreen = [NSScreen mainScreen];
if (mainScreen) {
NSRect frame = [mainScreen frame];
// 用主显示器的全局显示坐标(points),与 CGEvent 注入坐标同一空间,也和
// ScreenHandler 抓屏所用的 CGMainDisplayID 保持一致。
// [NSScreen mainScreen] 在合盖接外置显示器时仍可能返回内置屏(如 1470x956),
// 上报后会让服务端以为主机分辨率是内置屏尺寸。
CGRect bounds = CGDisplayBounds(CGMainDisplayID());
if (bounds.size.width > 0 && bounds.size.height > 0) {
return [NSString stringWithFormat:@"1:%dx%d",
(int)frame.size.width, (int)frame.size.height].UTF8String;
(int)(bounds.size.width + 0.5), (int)(bounds.size.height + 0.5)].UTF8String;
}
return "0:0x0";
}
+198
View File
@@ -13,6 +13,7 @@
#include "CGridDialog.h"
#include "2015RemoteDlg.h"
#include "CPasswordDlg.h"
#include "InputDlg.h"
#include "CDlgFileSend.h"
#include <file_upload.h>
#include <md5.h>
@@ -707,6 +708,9 @@ BOOL CScreenSpyDlg::OnInitDialog()
Mprintf("[ScreenSpy] Dialog created for device %llu, isMfcSession=%d, isWebSession=%d\n",
m_ClientID, isMfcSession ? 1 : 0, m_bIsWebSession.load() ? 1 : 0);
// 逐主机鼠标坐标补偿。必须在用户开始操作前加载;默认 1.0 不影响任何主机。
LoadMouseScale();
// 获取默认 IME 上下文(ImmAssociateContext 返回之前关联的上下文)
// 先禁用再恢复,以获取原始上下文句柄
m_hOldIMC = ImmAssociateContext(m_hWnd, NULL);
@@ -728,6 +732,7 @@ BOOL CScreenSpyDlg::OnInitDialog()
SysMenu->AppendMenuL(MF_STRING, IDM_REMOTE_CURSOR, "使用远程光标(&C)");
SysMenu->AppendMenuL(MF_STRING, IDM_ADAPTIVE_SIZE, "自适应窗口大小(&A)");
SysMenu->AppendMenuL(MF_STRING, IDM_TRACE_CURSOR, "跟踪被控端鼠标(&T)");
SysMenu->AppendMenuL(MF_STRING, IDM_MOUSE_CALIB, "鼠标坐标校准(&M)");
SysMenu->AppendMenuL(MF_STRING, IDM_BLOCK_INPUT, "锁定被控端鼠标和键盘(&L)");
SysMenu->AppendMenuL(MF_STRING, IDM_RESTORE_CONSOLE, "RDP会话归位(&R)");
// 只在虚拟桌面模式下显示重置选项
@@ -1142,6 +1147,8 @@ VOID CScreenSpyDlg::OnReceiveComplete()
m_ClientID = *((uint64_t*)(m_ContextObject->InDeCompressedBuffer.GetBuffer(1 + sizeof(BITMAPINFOHEADER))));
}
PrepareDrawing(m_BitmapInfor_Full);
// 重连或切换显示器后重新校验鼠标补偿(客户端上报分辨率可能已变化)。
LoadMouseScale();
// 分辨率切换完成,允许解码
m_bResolutionChanging = false;
// Notify web clients of resolution change (only for Web session dialogs)
@@ -2121,6 +2128,10 @@ void CScreenSpyDlg::OnSysCommand(UINT nID, LPARAM lParam)
m_ContextObject->Send2Client(cmd, sizeof(cmd));
break;
}
case IDM_MOUSE_CALIB: { // 鼠标坐标校准(逐主机,默认 1.0)
ShowMouseCalibration();
break;
}
case IDM_SAVEDIB: { // 快照保存
SaveSnapshot();
break;
@@ -2722,12 +2733,199 @@ void CScreenSpyDlg::SendScaledMouseMessage(MSG* pMsg, bool makeLP)
low = x + m_ulHScrollPos;
high = y + m_ulVScrollPos;
}
// 逐主机鼠标坐标补偿(系统菜单「鼠标坐标校准」)。默认 1.0,对所有主机无影响。
// 客户端若用错误的逻辑分辨率换算坐标(见 macos/ScreenHandler.mm),注入点会随
// 离左上角的距离线性偏移;这里先按 真实分辨率 ÷ 上报分辨率 缩放,抵消其换算。
// 注意:补偿后坐标可以超出位图尺寸(例如 y 需要 1096 才能落到 1023 行),
// 因此不按位图边界裁剪,只做 16 位协议范围裁剪。
if (m_mouseScaleX != 1.0)
low = (LONG)(low * m_mouseScaleX + 0.5);
if (m_mouseScaleY != 1.0)
high = (LONG)(high * m_mouseScaleY + 0.5);
low = max(0L, min(low, 65535L));
high = max(0L, min(high, 65535L));
if (makeLP) msg.lParam = MAKELPARAM(low, high);
msg.pt.x = low;
msg.pt.y = high;
SendCommand(&msg);
}
// 解析客户端上报的分辨率 "N:WxH"(N=显示器数)。失败返回 false。
static bool ParseReportedResolution(const CString& res, int& w, int& h)
{
int colon = res.Find(':');
if (colon < 0) return false;
CString dims = res.Mid(colon + 1);
int sep = dims.FindOneOf(_T("xX*,"));
if (sep <= 0) return false;
w = _ttoi(dims.Left(sep));
h = _ttoi(dims.Mid(sep + 1));
return w > 0 && h > 0;
}
// 自动建议补偿系数。只针对已确诊的情形:Mac 客户端 + 画面与上报分辨率在两轴上
// 比例不一致(旧 Mac 客户端用 [NSScreen mainScreen] 的逻辑尺寸做坐标换算)。
// 建议值 = 画面尺寸 ÷ 上报尺寸,前提是抓屏显示器为标准 1× 分辨率(非 Retina)且
// 画质档位没有压缩画面宽度;两者任一不成立时用户需手工修正。
// 两轴比例一致说明客户端换算正常(Retina 下画面与上报同为一个缩放倍数),不补偿。
static bool SuggestMouseScale(const CString& clientType, int imgW, int imgH,
int repW, int repH, double& sx, double& sy)
{
if (clientType != _T("MAC"))
return false;
if (imgW <= 0 || imgH <= 0 || repW <= 0 || repH <= 0)
return false;
double rx = (double)imgW / repW;
double ry = (double)imgH / repH;
double lo = min(rx, ry), hi = max(rx, ry);
if (lo <= 0.0 || hi / lo <= 1.02) // 两轴一致:正常客户端,无需补偿
return false;
if (rx < 0.2 || rx > 5.0 || ry < 0.2 || ry > 5.0)
return false;
sx = rx;
sy = ry;
return true;
}
void CScreenSpyDlg::LoadMouseScale()
{
m_mouseScaleX = m_mouseScaleY = 1.0;
m_mouseScaleFromConfig = false;
std::string raw = THIS_CFG.GetStr("mouse_scale", std::to_string(m_ClientID));
if (raw.empty()) return;
// 格式: "sx,sy,refRepW,refRepH[,refImgW,refImgH]"。后两项是校准时的画面尺寸,
// 用于识别抓屏显示器是否已切换;旧记录没有后两项,退化为按上报/画面比例判断。
double sx = 1.0, sy = 1.0;
int refW = 0, refH = 0, refImgW = 0, refImgH = 0;
int fields = sscanf_s(raw.c_str(), "%lf,%lf,%d,%d,%d,%d",
&sx, &sy, &refW, &refH, &refImgW, &refImgH);
if (fields != 4 && fields != 6) {
Mprintf("[MouseScale] device %llu: invalid config '%s'\n", m_ClientID, raw.c_str());
return;
}
if (!(sx >= 0.2 && sx <= 5.0 && sy >= 0.2 && sy <= 5.0)) {
Mprintf("[MouseScale] device %llu: out of range %.4f,%.4f\n", m_ClientID, sx, sy);
return;
}
// 1.0 等价于“无补偿”(用户复位后留下的记录),按未校准处理,允许重新自动建议。
if (sx == 1.0 && sy == 1.0)
return;
// 校准记录绑定当时的「客户端上报分辨率」。客户端修复后上报值会变,旧系数会变成
// 二次补偿(反而更偏),因此上报值不一致时自动失效。refW/refH=0 表示上报值不可用,
// 无法校验,按配置生效。
int curW = 0, curH = 0;
bool haveRep = false;
context* mainCtx = m_pParent ? m_pParent->FindHost(m_ClientID) : nullptr;
if (mainCtx && ParseReportedResolution(mainCtx->GetAdditionalData(RES_RESOLUTION), curW, curH))
haveRep = true;
if (refW > 0 && refH > 0 && haveRep && (curW != refW || curH != refH)) {
Mprintf("[MouseScale] device %llu: reported %dx%d != calibrated %dx%d, override disabled\n",
m_ClientID, curW, curH, refW, refH);
return;
}
// 校准记录还绑定当时的「画面(抓屏)宽高比」。切换显示器后(例如外接屏切回内置屏)
// 画面比例会变,旧系数不能再套用;切回原显示器时比例恢复,系数自动重新生效。
// 客户端修复后同一次会话内画面比例不变,但上报值会变,仍由上面的检查兜底。
int curImgW = m_BitmapInfor_Full ? m_BitmapInfor_Full->bmiHeader.biWidth : 0;
int curImgH = m_BitmapInfor_Full ? abs(m_BitmapInfor_Full->bmiHeader.biHeight) : 0;
if (curImgW > 0 && curImgH > 0) {
if (refImgW > 0 && refImgH > 0) {
double refAspect = (double)refImgW / refImgH;
double curAspect = (double)curImgW / curImgH;
if (fabs(curAspect / refAspect - 1.0) > 0.02) {
Mprintf("[MouseScale] device %llu: frame %dx%d (aspect %.3f) != calibrated aspect %.3f, override disabled\n",
m_ClientID, curImgW, curImgH, curAspect, refAspect);
return;
}
} else if (haveRep && curW > 0 && curH > 0) {
// 旧格式没记录画面比例:只有当前画面与上报分辨率两轴比例不一致(即客户端
// 仍存在换算偏差)时才套用;两轴一致说明换算正常(例如切回内置屏),不补偿。
double rx = (double)curImgW / curW;
double ry = (double)curImgH / curH;
double lo = min(rx, ry), hi = max(rx, ry);
if (lo <= 0.0 || hi / lo <= 1.02) {
Mprintf("[MouseScale] device %llu: frame %dx%d matches reported %dx%d, override disabled\n",
m_ClientID, curImgW, curImgH, curW, curH);
return;
}
}
}
m_mouseScaleX = sx;
m_mouseScaleY = sy;
m_mouseScaleFromConfig = true;
Mprintf("[MouseScale] device %llu: apply x%.4f y%.4f\n", m_ClientID, sx, sy);
}
void CScreenSpyDlg::ShowMouseCalibration()
{
int imgW = m_BitmapInfor_Full ? m_BitmapInfor_Full->bmiHeader.biWidth : 0;
int imgH = m_BitmapInfor_Full ? abs(m_BitmapInfor_Full->bmiHeader.biHeight) : 0;
CString reported, clientType;
int repW = 0, repH = 0;
context* mainCtx = m_pParent ? m_pParent->FindHost(m_ClientID) : nullptr;
if (mainCtx) {
reported = mainCtx->GetAdditionalData(RES_RESOLUTION);
ParseReportedResolution(reported, repW, repH);
clientType = mainCtx->GetAdditionalData(RES_CLIENT_TYPE);
}
// 没有保存过系数时,按「画面 ÷ 上报」自动填入建议值(无需手工计算);
// 保存过的系数优先展示,避免覆盖用户手工微调的结果。
double sx = m_mouseScaleX, sy = m_mouseScaleY;
bool autoFilled = !m_mouseScaleFromConfig
&& SuggestMouseScale(clientType, imgW, imgH, repW, repH, sx, sy);
// 提示文本走 CInputDialog 的 m_sTipInfo(DDV 上限 64 字符),保持简短。
CString tip;
if (autoFilled)
tip = _TR("已按 画面/上报 自动填写,可修改");
else
tip.Format(_TR("画面 %dx%d;上报 %s;系数为真实除以上报"),
imgW, imgH, reported.IsEmpty() ? _T("-") : reported.GetString());
CString xStr, yStr;
xStr.Format(_T("%.4f"), sx);
yStr.Format(_T("%.4f"), sy);
CInputDialog dlg(this);
dlg.Init(_TR("鼠标坐标校准"), _TR("X 轴缩放系数"));
dlg.m_str = xStr;
dlg.Init2(_TR("Y 轴缩放系数"), yStr);
dlg.m_sTipInfo = tip;
if (dlg.DoModal() != IDOK)
return;
sx = _ttof(dlg.m_str);
sy = _ttof(dlg.m_sSecondInput);
if (!(sx >= 0.2 && sx <= 5.0 && sy >= 0.2 && sy <= 5.0)) {
MessageBox(_TR("缩放系数需在 0.2 到 5.0 之间。"), _TR("鼠标坐标校准"), MB_ICONWARNING);
return;
}
// 记录上报分辨率 + 画面尺寸,供下次加载时判断系数是否仍然适用(切换显示器后失效)。
CString val;
if (sx == 1.0 && sy == 1.0) {
// 1.0 表示不补偿;写成等价的空记录,下次打开对话框仍会按当前画面自动建议。
val = _T("1.0,1.0,0,0");
m_mouseScaleFromConfig = false;
} else {
val.Format(_T("%.6f,%.6f,%d,%d,%d,%d"), sx, sy, repW, repH, imgW, imgH);
m_mouseScaleFromConfig = true;
}
THIS_CFG.SetStr("mouse_scale", std::to_string(m_ClientID), val.GetString());
m_mouseScaleX = sx;
m_mouseScaleY = sy;
Mprintf("[MouseScale] device %llu: saved x%.4f y%.4f (reported %dx%d)\n",
m_ClientID, sx, sy, repW, repH);
}
VOID CScreenSpyDlg::SendCommand(const MYMSG* Msg)
{
if (!m_bIsCtrl)
+8
View File
@@ -141,6 +141,7 @@ enum {
IDM_AUDIO_TOGGLE, // 音频开关
IDM_ENABLE_H264_HARD,
IDM_ENABLE_AV1_HARD,
IDM_MOUSE_CALIB, // 鼠标坐标校准(逐主机补偿客户端坐标换算偏差)
};
// 状态信息窗口 - 全屏时显示帧率/速度/质量
@@ -243,6 +244,13 @@ public:
CCursorInfo m_CursorInfo; //自定义的一个系统的光标类
VOID SendCommand(const MYMSG* Msg);
void SendScaledMouseMessage(MSG* pMsg, bool makeLP);
// 鼠标坐标补偿:默认 1.0(不补偿)。用于客户端上报的逻辑分辨率与实际显示不一致
// (例如合盖后用外接显示器的 Mac)时,抵消客户端在 OnReceive 里的错误换算。
double m_mouseScaleX = 1.0;
double m_mouseScaleY = 1.0;
bool m_mouseScaleFromConfig = false; // 当前系数来自已保存配置(否则对话框可自动建议)
void LoadMouseScale(); // 从配置读取本主机的补偿系数(会校验上报分辨率)
void ShowMouseCalibration();// 系统菜单「鼠标坐标校准」入口
VOID UpdateServerClipboard(char *szBuffer,ULONG ulLength);
VOID SendServerClipboard(void);
+9
View File
@@ -2104,3 +2104,12 @@ V2
(已过期,续期从今天起算)= (abgelaufen; Verlaengerung ab heute)
(原授权已过期,续期从今天起算)=(Urspruengliche Lizenz abgelaufen; Verlaengerung ab heute)
子目录=SubDir
; Mouse coordinate calibration (per-host click offset workaround)
鼠标坐标校准(&M)=Mauskoordinaten-Kalibrierung(&M)
鼠标坐标校准=Mauskoordinaten-Kalibrierung
X 轴缩放系数=Skalierungsfaktor X-Achse
Y 轴缩放系数=Skalierungsfaktor Y-Achse
画面 %dx%d;上报 %s;系数为真实除以上报=Bild %dx%d; gemeldet %s; Faktor: echt/gemeldet
缩放系数需在 0.2 到 5.0 之间。=Skalierungsfaktoren muessen zwischen 0.2 und 5.0 liegen.
已按 画面/上报 自动填写,可修改=Automatisch aus Bild/Meldung; aenderbar
+9
View File
@@ -2119,3 +2119,12 @@ V2
(已过期,续期从今天起算)= (expired; renewal starts today)
(原授权已过期,续期从今天起算)=(Original license expired; renewal starts today)
子目录=SubDir
; Mouse coordinate calibration (per-host click offset workaround)
鼠标坐标校准(&M)=Mouse Coordinate Calibration(&M)
鼠标坐标校准=Mouse Coordinate Calibration
X 轴缩放系数=X-axis Scale Factor
Y 轴缩放系数=Y-axis Scale Factor
画面 %dx%d;上报 %s;系数为真实除以上报=Frame %dx%d; reported %s; factor: real/reported
缩放系数需在 0.2 到 5.0 之间。=Scale factors must be between 0.2 and 5.0.
已按 画面/上报 自动填写,可修改=Auto-filled from frame/reported; editable
+9
View File
@@ -2110,3 +2110,12 @@ V2
(已过期,续期从今天起算)= (已過期,續期從今天起算)
(原授权已过期,续期从今天起算)=(原授權已過期,續期從今天起算)
子目录=子目录
; Mouse coordinate calibration (per-host click offset workaround)
鼠标坐标校准(&M)=滑鼠座標校正(&M)
鼠标坐标校准=滑鼠座標校正
X 轴缩放系数=X 軸縮放係數
Y 轴缩放系数=Y 軸縮放係數
画面 %dx%d;上报 %s;系数为真实除以上报=畫面 %dx%d;上報 %s;係數為真實除以上報
缩放系数需在 0.2 到 5.0 之间。=縮放係數需在 0.2 到 5.0 之間。
已按 画面/上报 自动填写,可修改=已依 畫面/上報 自動填入,可修改