Skip to content

Instantly share code, notes, and snippets.

@kyleneideck
Created August 13, 2026 11:37
Show Gist options
  • Select an option

  • Save kyleneideck/9a8421dfdfda25eb27b705d386d6197c to your computer and use it in GitHub Desktop.

Select an option

Save kyleneideck/9a8421dfdfda25eb27b705d386d6197c to your computer and use it in GitHub Desktop.
UTM - Workaround for macOS guest hangs when scrolling with trackpad
// 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()
@mesonoxianvlad

Copy link
Copy Markdown

been working good for me, thanks for this!

@mahdix

mahdix commented Aug 27, 2026

Copy link
Copy Markdown

wow! Unbelievable but this really works! 26.6.2 guest

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment