From 82229b394b655beb9b76e38bba9d48d7cc3afa62 Mon Sep 17 00:00:00 2001 From: ZHOUSJ <1710062486@qq.com> Date: Sun, 3 May 2026 12:24:14 +0800 Subject: [PATCH] fix: bypass smooth scrolling for ToDesk remote sessions --- Mos/ScrollCore/ScrollCore.swift | 18 +++-- Mos/ScrollCore/ScrollUtils.swift | 113 +++++++++++++++++++++++++------ Mos/Utils/Constants.swift | 14 ++++ MosTests/ScrollEventTests.swift | 34 ++++++++++ 4 files changed, 153 insertions(+), 26 deletions(-) diff --git a/Mos/ScrollCore/ScrollCore.swift b/Mos/ScrollCore/ScrollCore.swift index 3a86e319..2517af14 100644 --- a/Mos/ScrollCore/ScrollCore.swift +++ b/Mos/ScrollCore/ScrollCore.swift @@ -82,13 +82,18 @@ class ScrollCore { if scrollEvent.isTrackpad() { return Unmanaged.passUnretained(event) } - // 当事件来自远程桌面,且其发送的事件 isContinuous=1.0,此时跳过本地平滑 - if ScrollUtils.shared.isRemoteSmoothedEvent(event) { - return Unmanaged.passUnretained(event) - } // 当鼠标输入, 根据需要执行翻转方向/平滑滚动 // 获取事件目标 let targetRunningApplication = ScrollUtils.shared.getRunningApplication(from: event) + // ToDesk 等远程控制链路无法稳定处理 Mos 合成的连续滚轮事件,只禁用平滑,保留翻转等原始事件处理。 + let disableSmoothForRemoteControl = ScrollUtils.shared.shouldDisableSmoothForRemoteControl( + event, + targetRunningApplication: targetRunningApplication + ) + let isRemoteSmoothedEvent = ScrollUtils.shared.isRemoteSmoothedEvent(event) + if isRemoteSmoothedEvent && !disableSmoothForRemoteControl { + return Unmanaged.passUnretained(event) + } // 获取列表中应用程序的列外设置信息 ScrollCore.shared.application = ScrollUtils.shared.getTargetApplication(from: targetRunningApplication) // 平滑/翻转 @@ -117,6 +122,11 @@ class ScrollCore { enableReverseVertical = allowReverse && Options.shared.scroll.reverseVertical enableReverseHorizontal = allowReverse && Options.shared.scroll.reverseHorizontal } + if disableSmoothForRemoteControl || isRemoteSmoothedEvent { + enableSmooth = false + enableSmoothVertical = false + enableSmoothHorizontal = false + } // Launchpad 激活则强制屏蔽平滑 if ScrollUtils.shared.getLaunchpadActivity(withRunningApplication: targetRunningApplication) { enableSmooth = false diff --git a/Mos/ScrollCore/ScrollUtils.swift b/Mos/ScrollCore/ScrollUtils.swift index 2ed9ca1c..08879fe6 100644 --- a/Mos/ScrollCore/ScrollUtils.swift +++ b/Mos/ScrollCore/ScrollUtils.swift @@ -121,33 +121,21 @@ class ScrollUtils { // 远程桌面事件检测缓存 private var lastSourcePID: pid_t = 0 private var lastSourceIsRemoteControl: Bool = false + private var lastSourceNeedsRawScrollPassthrough: Bool = false /// 检测事件来源是否为远程桌面应用 func isFromRemoteApplication(_ event: CGEvent) -> Bool { - let sourcePID = pid_t(event.getIntegerValueField(.eventSourceUnixProcessID)) - if sourcePID == 0 { return false } - - if sourcePID != lastSourcePID { - lastSourcePID = sourcePID - lastSourceIsRemoteControl = false + refreshRemoteSourceCacheIfNeeded(event) + return lastSourceIsRemoteControl + } - if let app = NSRunningApplication(processIdentifier: sourcePID) { - // 检查可执行文件路径(系统守护进程) - if let path = app.executableURL?.path { - for keyword in REMOTE_CONTROL_APPLICATION.executableKeywords { - if path.contains(keyword) { - lastSourceIsRemoteControl = true - break - } - } - } - // 检查 Bundle Identifier(第三方应用) - if !lastSourceIsRemoteControl, let bundleId = app.bundleIdentifier { - lastSourceIsRemoteControl = REMOTE_CONTROL_APPLICATION.bundleIdentifiers.contains(bundleId) - } - } + /// ToDesk 等远程控制应用对 Mos 合成的连续滚轮事件兼容性差,需禁用平滑但保留方向翻转等原始事件处理。 + func shouldDisableSmoothForRemoteControl(_ event: CGEvent, targetRunningApplication: NSRunningApplication?) -> Bool { + if needsRawScrollPassthrough(from: targetRunningApplication) { + return true } - return lastSourceIsRemoteControl + refreshRemoteSourceCacheIfNeeded(event) + return lastSourceNeedsRawScrollPassthrough } /// 检测事件是否来自已被平滑的远程源 @@ -159,6 +147,87 @@ class ScrollUtils { return isContinuous == 1.0 // 1.0 表示主控端已平滑 } + func isKnownRemoteControlApplication(executablePath: String?, bundleIdentifier: String?) -> Bool { + if containsAnyKeyword(in: executablePath, keywords: REMOTE_CONTROL_APPLICATION.executableKeywords) { + return true + } + if let bundleIdentifier = bundleIdentifier, + REMOTE_CONTROL_APPLICATION.bundleIdentifiers.contains(bundleIdentifier) { + return true + } + return containsAnyKeyword( + in: bundleIdentifier, + keywords: REMOTE_CONTROL_APPLICATION.bundleIdentifierKeywords + ) + } + + func needsRawScrollPassthrough(executablePath: String?, bundleIdentifier: String?) -> Bool { + return containsAnyKeyword( + in: executablePath, + keywords: REMOTE_CONTROL_APPLICATION.rawScrollPassthroughExecutableKeywords + ) || containsAnyKeyword( + in: bundleIdentifier, + keywords: REMOTE_CONTROL_APPLICATION.rawScrollPassthroughBundleIdentifierKeywords + ) + } + + private func needsRawScrollPassthrough(from runningApplication: NSRunningApplication?) -> Bool { + guard let runningApplication = runningApplication else { return false } + return needsRawScrollPassthrough( + executablePath: runningApplication.executableURL?.path, + bundleIdentifier: runningApplication.bundleIdentifier + ) || needsRawScrollPassthrough( + executablePath: runningApplication.bundleURL?.path, + bundleIdentifier: runningApplication.bundleIdentifier + ) + } + + private func refreshRemoteSourceCacheIfNeeded(_ event: CGEvent) { + let sourcePID = pid_t(event.getIntegerValueField(.eventSourceUnixProcessID)) + if sourcePID == 0 { + lastSourcePID = 0 + lastSourceIsRemoteControl = false + lastSourceNeedsRawScrollPassthrough = false + return + } + + if sourcePID != lastSourcePID { + lastSourcePID = sourcePID + lastSourceIsRemoteControl = false + lastSourceNeedsRawScrollPassthrough = false + + guard let app = NSRunningApplication(processIdentifier: sourcePID) else { return } + let executablePath = app.executableURL?.path + let bundlePath = app.bundleURL?.path + let bundleIdentifier = app.bundleIdentifier + + lastSourceIsRemoteControl = isKnownRemoteControlApplication( + executablePath: executablePath, + bundleIdentifier: bundleIdentifier + ) || isKnownRemoteControlApplication( + executablePath: bundlePath, + bundleIdentifier: bundleIdentifier + ) + lastSourceNeedsRawScrollPassthrough = needsRawScrollPassthrough( + executablePath: executablePath, + bundleIdentifier: bundleIdentifier + ) || needsRawScrollPassthrough( + executablePath: bundlePath, + bundleIdentifier: bundleIdentifier + ) + } + } + + private func containsAnyKeyword(in value: String?, keywords: [String]) -> Bool { + guard let lowercasedValue = value?.lowercased() else { return false } + for keyword in keywords { + if lowercasedValue.contains(keyword.lowercased()) { + return true + } + } + return false + } + // MARK: - 滚动参数: 热键 // 返回 ScrollHotkey? 供 ScrollCore 使用 func optionsDashKey(application: Application?) -> ScrollHotkey? { diff --git a/Mos/Utils/Constants.swift b/Mos/Utils/Constants.swift index d44cbe42..56eec877 100644 --- a/Mos/Utils/Constants.swift +++ b/Mos/Utils/Constants.swift @@ -70,6 +70,8 @@ struct REMOTE_CONTROL_APPLICATION { "screensharingd", // macOS 屏幕共享守护进程 "ScreensharingAgent", // macOS 屏幕共享用户会话代理 "ARDAgent", // Apple Remote Desktop + "ToDesk", // ToDesk 远程控制相关进程 + "todesk", // ToDesk helper/daemon 可能使用小写路径 ] // Bundle Identifier(用于第三方应用) static let bundleIdentifiers = [ @@ -83,6 +85,18 @@ struct REMOTE_CONTROL_APPLICATION { "com.tigervnc.vncviewer", "com.netease.uuremote", // UU 远程桌面 ] + // Bundle Identifier 关键字(用于 ToDesk 这类版本间可能变化的 ID) + static let bundleIdentifierKeywords = [ + "todesk", + ] + // 这些远程控制应用无法稳定处理 Mos 合成的连续滚轮事件,需直接使用原始滚轮。 + static let rawScrollPassthroughExecutableKeywords = [ + "ToDesk", + "todesk", + ] + static let rawScrollPassthroughBundleIdentifierKeywords = [ + "todesk", + ] } enum ScrollDurationLimits { diff --git a/MosTests/ScrollEventTests.swift b/MosTests/ScrollEventTests.swift index be472617..bba687e7 100644 --- a/MosTests/ScrollEventTests.swift +++ b/MosTests/ScrollEventTests.swift @@ -29,6 +29,40 @@ final class ScrollEventTests: XCTestCase { return event } + // MARK: - 远程控制应用识别 + + func testRemoteControlApplicationDetectsToDeskExecutableKeyword() { + XCTAssertTrue(ScrollUtils.shared.isKnownRemoteControlApplication( + executablePath: "/Applications/ToDesk.app/Contents/MacOS/ToDesk", + bundleIdentifier: nil + )) + } + + func testRemoteControlApplicationDetectsToDeskBundleIdentifierKeyword() { + XCTAssertTrue(ScrollUtils.shared.isKnownRemoteControlApplication( + executablePath: nil, + bundleIdentifier: "com.youqu.todesk" + )) + } + + func testRemoteControlApplicationRequiresRawPassthroughForToDesk() { + XCTAssertTrue(ScrollUtils.shared.needsRawScrollPassthrough( + executablePath: "/Library/Application Support/ToDesk/ToDesk_Service", + bundleIdentifier: nil + )) + } + + func testRemoteControlApplicationDoesNotRequireRawPassthroughForOtherRemoteApps() { + XCTAssertTrue(ScrollUtils.shared.isKnownRemoteControlApplication( + executablePath: nil, + bundleIdentifier: "com.teamviewer.TeamViewer" + )) + XCTAssertFalse(ScrollUtils.shared.needsRawScrollPassthrough( + executablePath: nil, + bundleIdentifier: "com.teamviewer.TeamViewer" + )) + } + // MARK: - initEvent: 优先级 (scrollPt > scrollFixPt > scrollFix) func testInitEvent_prefersPtOverFixPt() throws {