Created
August 13, 2026 11:37
-
-
Save kyleneideck/9a8421dfdfda25eb27b705d386d6197c to your computer and use it in GitHub Desktop.
UTM - Workaround for macOS guest hangs when scrolling with trackpad
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // Permission to use, copy, modify, and/or distribute this software for | |
| // any purpose with or without fee is hereby granted. | |
| // | |
| // THE SOFTWARE IS PROVIDED “AS IS” AND THE AUTHOR DISCLAIMS ALL | |
| // WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES | |
| // OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE | |
| // FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY | |
| // DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN | |
| // AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT | |
| // OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. | |
| // scrolladaptive | |
| // | |
| // Workaround for UTM/Virtualization.framework trackpad stalls. | |
| // Filters out empty scroll events and animates a hidden window while you're | |
| // scrolling to keep the compositor alive. | |
| // | |
| // Build: | |
| // swiftc -O -target arm64-apple-macosx13.0 scrolladaptive.swift \ | |
| // -o build/scrolladaptive | |
| // | |
| // This needs Accessibility permission because its session event tap can drop | |
| // events. The tap runs on its own run-loop thread. AppKit/Core Animation stay | |
| // on the main thread. | |
| // | |
| // Context: https://github.com/utmapp/UTM/issues/7531 | |
| import AppKit | |
| import CoreGraphics | |
| import Foundation | |
| import QuartzCore | |
| private let surfaceUpdateRate = 30.0 | |
| private let inactivityDelaySeconds = 0.5 | |
| private let inactivityDelayNanoseconds = | |
| UInt64((inactivityDelaySeconds * 1_000_000_000).rounded()) | |
| private let deltaFields: [CGEventField] = [ | |
| .scrollWheelEventDeltaAxis1, | |
| .scrollWheelEventDeltaAxis2, | |
| .scrollWheelEventDeltaAxis3, | |
| .scrollWheelEventPointDeltaAxis1, | |
| .scrollWheelEventPointDeltaAxis2, | |
| .scrollWheelEventPointDeltaAxis3, | |
| .scrollWheelEventFixedPtDeltaAxis1, | |
| .scrollWheelEventFixedPtDeltaAxis2, | |
| .scrollWheelEventFixedPtDeltaAxis3, | |
| ] | |
| private final class NonKeyWindow: NSWindow { | |
| override var canBecomeKey: Bool { false } | |
| override var canBecomeMain: Bool { false } | |
| } | |
| private final class ColorView: NSView { | |
| let colorLayer = CALayer() | |
| override init(frame frameRect: NSRect) { | |
| super.init(frame: frameRect) | |
| colorLayer.isOpaque = true | |
| colorLayer.backgroundColor = NSColor.black.cgColor | |
| wantsLayer = true | |
| } | |
| required init?(coder: NSCoder) { | |
| nil | |
| } | |
| override func makeBackingLayer() -> CALayer { | |
| colorLayer | |
| } | |
| } | |
| private struct CompositorStats { | |
| var starts: UInt64 = 0 | |
| var updates: UInt64 = 0 | |
| var activeNanoseconds: UInt64 = 0 | |
| } | |
| private final class CompositorKeepalive { | |
| private let view: ColorView | |
| private let window: NSWindow | |
| private var surfaceUpdateTimer: DispatchSourceTimer? | |
| private var isActive = false | |
| private var useAlternateColor = false | |
| private var activeStartedAtNanoseconds: UInt64 = 0 | |
| private var stats = CompositorStats() | |
| init() { | |
| let size: CGFloat = 1 | |
| let frame = NSRect(x: 0, y: 0, width: size, height: size) | |
| let view = ColorView(frame: frame) | |
| let window = NonKeyWindow( | |
| contentRect: frame, | |
| styleMask: [.borderless], | |
| backing: .buffered, | |
| defer: false | |
| ) | |
| window.contentView = view | |
| window.backgroundColor = .black | |
| window.isOpaque = true | |
| window.hasShadow = false | |
| window.ignoresMouseEvents = true | |
| window.level = .floating | |
| window.collectionBehavior = [.canJoinAllSpaces, .stationary, .ignoresCycle] | |
| if let screen = NSScreen.main { | |
| // Put it behind the menu bar. | |
| window.setFrameOrigin( | |
| NSPoint(x: screen.frame.maxX - size - 1, y: screen.frame.maxY - size - 1) | |
| ) | |
| } | |
| window.orderOut(nil) | |
| self.view = view | |
| self.window = window | |
| } | |
| func start() { | |
| dispatchPrecondition(condition: .onQueue(.main)) | |
| guard !isActive else { return } | |
| isActive = true | |
| stats.starts &+= 1 | |
| activeStartedAtNanoseconds = DispatchTime.now().uptimeNanoseconds | |
| window.orderFrontRegardless() | |
| updateSurface() | |
| let interval = max(1, Int((1_000_000_000 / surfaceUpdateRate).rounded())) | |
| let surfaceUpdateTimer = DispatchSource.makeTimerSource(queue: .main) | |
| surfaceUpdateTimer.schedule( | |
| deadline: .now() + .nanoseconds(interval), | |
| repeating: .nanoseconds(interval), | |
| leeway: .milliseconds(3) | |
| ) | |
| surfaceUpdateTimer.setEventHandler { [weak self] in | |
| guard let self, self.isActive else { return } | |
| self.updateSurface() | |
| } | |
| self.surfaceUpdateTimer = surfaceUpdateTimer | |
| surfaceUpdateTimer.resume() | |
| } | |
| private func updateSurface() { | |
| stats.updates &+= 1 | |
| useAlternateColor.toggle() | |
| let color = | |
| useAlternateColor | |
| ? NSColor(calibratedRed: 0.05, green: 0.10, blue: 0.18, alpha: 1) | |
| : NSColor(calibratedRed: 0.16, green: 0.06, blue: 0.10, alpha: 1) | |
| CATransaction.begin() | |
| CATransaction.setDisableActions(true) | |
| view.colorLayer.backgroundColor = color.cgColor | |
| view.colorLayer.setNeedsDisplay() | |
| CATransaction.commit() | |
| } | |
| func stop() { | |
| dispatchPrecondition(condition: .onQueue(.main)) | |
| guard isActive else { return } | |
| surfaceUpdateTimer?.cancel() | |
| surfaceUpdateTimer = nil | |
| window.orderOut(nil) | |
| let now = DispatchTime.now().uptimeNanoseconds | |
| stats.activeNanoseconds &+= now &- activeStartedAtNanoseconds | |
| activeStartedAtNanoseconds = 0 | |
| isActive = false | |
| } | |
| func stopAndSnapshot() -> CompositorStats { | |
| dispatchPrecondition(condition: .onQueue(.main)) | |
| stop() | |
| return stats | |
| } | |
| } | |
| private struct TapStats { | |
| var events: UInt64 = 0 | |
| var passed: UInt64 = 0 | |
| var dropped: UInt64 = 0 | |
| var disableCount: UInt64 = 0 | |
| } | |
| // The event tap and its inactivity timer run on one private thread. Startup waits | |
| // until that thread is ready, and shutdown waits until it has stopped. Swift | |
| // cannot verify those handoffs, so this conformance must be unchecked. | |
| private final class TapController: @unchecked Sendable { | |
| private let startKeepalive: @Sendable () -> Void | |
| private let stopKeepalive: @Sendable () -> Void | |
| private let ready = DispatchSemaphore(value: 0) | |
| private let stopped = DispatchSemaphore(value: 0) | |
| private var runLoop: CFRunLoop? | |
| private var tap: CFMachPort? | |
| private var inactivityTimer: Timer? | |
| private var lastContinuousScrollEventNanoseconds: UInt64 = 0 | |
| private var startupError: String? | |
| private var stats = TapStats() | |
| init( | |
| startKeepalive: @escaping @Sendable () -> Void, | |
| stopKeepalive: @escaping @Sendable () -> Void | |
| ) { | |
| self.startKeepalive = startKeepalive | |
| self.stopKeepalive = stopKeepalive | |
| } | |
| func start() -> String? { | |
| let thread = Thread { [weak self] in | |
| self?.runTap() | |
| } | |
| thread.name = "scrolladaptive.event-tap" | |
| thread.qualityOfService = .userInitiated | |
| thread.start() | |
| ready.wait() | |
| return startupError | |
| } | |
| private func runTap() { | |
| autoreleasepool { | |
| let mask = CGEventMask(1) << CGEventType.scrollWheel.rawValue | |
| guard | |
| let tap = CGEvent.tapCreate( | |
| tap: .cgSessionEventTap, | |
| place: .headInsertEventTap, | |
| options: .defaultTap, | |
| eventsOfInterest: mask, | |
| callback: tapCallback, | |
| userInfo: Unmanaged.passUnretained(self).toOpaque() | |
| ) | |
| else { | |
| startupError = | |
| "Could not create an active session event tap. Grant Accessibility permission." | |
| ready.signal() | |
| return | |
| } | |
| let runLoop = CFRunLoopGetCurrent() | |
| let source = CFMachPortCreateRunLoopSource(kCFAllocatorDefault, tap, 0) | |
| self.tap = tap | |
| self.runLoop = runLoop | |
| CFRunLoopAddSource(runLoop, source, .commonModes) | |
| CGEvent.tapEnable(tap: tap, enable: true) | |
| CFRunLoopPerformBlock(runLoop, CFRunLoopMode.commonModes.rawValue) { [ready] in | |
| ready.signal() | |
| } | |
| CFRunLoopRun() | |
| inactivityTimer?.invalidate() | |
| inactivityTimer = nil | |
| CGEvent.tapEnable(tap: tap, enable: false) | |
| CFRunLoopRemoveSource(runLoop, source, .commonModes) | |
| self.tap = nil | |
| stopped.signal() | |
| } | |
| } | |
| func stopAndSnapshot() -> TapStats { | |
| guard let runLoop else { return stats } | |
| CFRunLoopStop(runLoop) | |
| CFRunLoopWakeUp(runLoop) | |
| stopped.wait() | |
| self.runLoop = nil | |
| return stats | |
| } | |
| fileprivate func handle(type: CGEventType, event: CGEvent) -> Unmanaged<CGEvent>? { | |
| if type == .tapDisabledByTimeout || type == .tapDisabledByUserInput { | |
| stats.disableCount &+= 1 | |
| if let tap { CGEvent.tapEnable(tap: tap, enable: true) } | |
| return Unmanaged.passUnretained(event) | |
| } | |
| guard type == .scrollWheel else { return Unmanaged.passUnretained(event) } | |
| stats.events &+= 1 | |
| let isContinuous = event.getIntegerValueField(.scrollWheelEventIsContinuous) != 0 | |
| if isContinuous { recordContinuousScrollEvent() } | |
| if isEmptyScrollEvent(event) { | |
| stats.dropped &+= 1 | |
| return nil | |
| } | |
| stats.passed &+= 1 | |
| return Unmanaged.passUnretained(event) | |
| } | |
| private func recordContinuousScrollEvent() { | |
| lastContinuousScrollEventNanoseconds = DispatchTime.now().uptimeNanoseconds | |
| guard inactivityTimer == nil else { | |
| // The existing timer will use the new timestamp when it fires. | |
| return | |
| } | |
| startKeepalive() | |
| scheduleInactivityCheck(afterNanoseconds: inactivityDelayNanoseconds) | |
| } | |
| private func scheduleInactivityCheck(afterNanoseconds delayNanoseconds: UInt64) { | |
| let delay = TimeInterval(delayNanoseconds) / 1_000_000_000 | |
| let timer = Timer(timeInterval: delay, repeats: false) { [weak self] timer in | |
| self?.inactivityTimerFired(timer) | |
| } | |
| inactivityTimer = timer | |
| RunLoop.current.add(timer, forMode: .common) | |
| } | |
| private func inactivityTimerFired(_ timer: Timer) { | |
| guard timer === inactivityTimer else { return } | |
| inactivityTimer = nil | |
| let now = DispatchTime.now().uptimeNanoseconds | |
| let elapsed = now &- lastContinuousScrollEventNanoseconds | |
| if elapsed < inactivityDelayNanoseconds { | |
| scheduleInactivityCheck(afterNanoseconds: inactivityDelayNanoseconds - elapsed) | |
| } else { | |
| stopKeepalive() | |
| } | |
| } | |
| private func isEmptyScrollEvent(_ event: CGEvent) -> Bool { | |
| guard event.getIntegerValueField(.scrollWheelEventIsContinuous) != 0, | |
| event.getIntegerValueField(.scrollWheelEventScrollPhase) | |
| == CGScrollPhase.changed.rawValue, | |
| event.getIntegerValueField(.scrollWheelEventMomentumPhase) | |
| == CGMomentumScrollPhase.none.rawValue | |
| else { | |
| return false | |
| } | |
| return deltaFields.allSatisfy { event.getDoubleValueField($0) == 0 } | |
| } | |
| } | |
| private let tapCallback: CGEventTapCallBack = { _, type, event, userInfo in | |
| guard let userInfo else { return Unmanaged.passUnretained(event) } | |
| let controller = Unmanaged<TapController>.fromOpaque(userInfo).takeUnretainedValue() | |
| return controller.handle(type: type, event: event) | |
| } | |
| private let application = NSApplication.shared | |
| application.setActivationPolicy(.accessory) | |
| private let compositor = CompositorKeepalive() | |
| private let tapController = TapController( | |
| startKeepalive: { | |
| DispatchQueue.main.async { | |
| compositor.start() | |
| } | |
| }, | |
| stopKeepalive: { | |
| DispatchQueue.main.async { | |
| compositor.stop() | |
| } | |
| } | |
| ) | |
| if let error = tapController.start() { | |
| FileHandle.standardError.write(Data("scrolladaptive: \(error)\n".utf8)) | |
| exit(1) | |
| } | |
| private func finish(signalName: String) -> Never { | |
| let tapStats = tapController.stopAndSnapshot() | |
| let compositorStats = compositor.stopAndSnapshot() | |
| let activeSeconds = Double(compositorStats.activeNanoseconds) / 1_000_000_000 | |
| let dropPercent = | |
| tapStats.events == 0 | |
| ? 0 | |
| : 100 * Double(tapStats.dropped) / Double(tapStats.events) | |
| FileHandle.standardError.write( | |
| Data( | |
| String( | |
| format: | |
| "scrolladaptive: %@; tap=%llu passed=%llu dropped=%llu (%.2f%%) disabled=%llu; CA starts=%llu updates=%llu active=%.2fs\n", | |
| signalName, | |
| tapStats.events, | |
| tapStats.passed, | |
| tapStats.dropped, | |
| dropPercent, | |
| tapStats.disableCount, | |
| compositorStats.starts, | |
| compositorStats.updates, | |
| activeSeconds | |
| ).utf8 | |
| ) | |
| ) | |
| exit(0) | |
| } | |
| signal(SIGINT, SIG_IGN) | |
| signal(SIGTERM, SIG_IGN) | |
| private let interruptSource = DispatchSource.makeSignalSource(signal: SIGINT, queue: .main) | |
| interruptSource.setEventHandler { | |
| finish(signalName: "SIGINT") | |
| } | |
| interruptSource.resume() | |
| private let terminateSource = DispatchSource.makeSignalSource(signal: SIGTERM, queue: .main) | |
| terminateSource.setEventHandler { | |
| finish(signalName: "SIGTERM") | |
| } | |
| terminateSource.resume() | |
| FileHandle.standardError.write( | |
| Data("scrolladaptive armed. Press Ctrl-C for statistics.\n".utf8) | |
| ) | |
| application.run() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment