-
Notifications
You must be signed in to change notification settings - Fork 29
fix: recover CGEventTap after system events — fixes #167 system-wide input degradation #169
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
pablopunk
merged 1 commit into
main
from
fractal-Swift-Shift-https-github-com-pablopunk-swiftshift-issues-167-foll-62dcab
Jun 19, 2026
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
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
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -7,3 +7,4 @@ build/ | |
| www/node_modules/ | ||
| www/dist/ | ||
| www/.astro/ | ||
| Swift Shift/packages/CGEventSupervisor/.build/ | ||
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
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,62 @@ | ||
| # Fixes for Issue #167 — System-wide input degradation | ||
|
|
||
| ## Problem | ||
| SwiftShift's mouse-click shortcuts (Command+click for Move, Command+right-click for Resize) use a CGEventTap to intercept mouse events. macOS silently disables event taps on sleep/wake, display changes, session lock, and slow callbacks — but SwiftShift had no recovery mechanism. A dead tap causes system-wide input lag because CGEventTaps are synchronous (WindowServer pauses all input until the callback returns). | ||
|
|
||
| ## Fixes applied | ||
|
|
||
| ### 1. CGEventSupervisor: Proper teardown (run loop source leak) | ||
| **Files:** `CGEventSupervisor.swift`, `CGEventSupervisor+Setup.swift` | ||
|
|
||
| - Added `eventTapRunLoopSource` stored property to track the run loop source | ||
| - `teardown()` now removes the source from the run loop and invalidates the CFMachPort, not just disables the tap | ||
|
|
||
| ### 2. CGEventSupervisor: Handle disabled-tap events | ||
| **File:** `CGEventSupervisor+Callback.swift` | ||
|
|
||
| - The callback now checks for `.tapDisabledByTimeout` and `.tapDisabledByUserInput` | ||
| - When detected, re-enables the tap via `CGEvent.tapEnable(tap:enable: true)` | ||
| - Prevents silent tap death from slow callbacks | ||
|
|
||
| ### 3. ShortcutsManager: System event recovery | ||
| **File:** `Swift Shift/src/Manager/ShortcutsManager.swift` | ||
|
|
||
| Added observers for three system events that kill taps: | ||
| - `NSWorkspace.didWakeNotification` — sleep/wake | ||
| - `NSWorkspace.sessionDidBecomeActiveNotification` — screen lock/unlock, fast user switch | ||
| - `NSApplication.didChangeScreenParametersNotification` — display connect/disconnect, resolution change | ||
|
|
||
| `rebuildAllInputHooks()` now: | ||
| 1. Stops any active tracking and clears `activeShortcuts` state | ||
| 2. Tears down the current tap via `CGEventSupervisor.shared.cancelAll()` | ||
| 3. Rebuilds keyboard monitors via `updateGlobalShortcuts()` | ||
| 4. Force-rebuilds mouse chord subscriptions via `MouseChordActionManager.shared.forceRebuild()` | ||
|
|
||
| ### 4. MouseChordActionManager: forceRebuild() | ||
| **File:** `Swift Shift/src/Manager/ShortcutsManager.swift` | ||
|
|
||
| Added `forceRebuild()` which properly resets the internal `isSubscribed` flag before | ||
| re-subscribing. This fixes a state corruption issue where `cancelAll()` could leave | ||
| the `isSubscribed` flag true, causing `subscribeIfNeeded()` to return early without | ||
| actually re-creating the CGEventSupervisor subscriber. | ||
|
|
||
| `ShortcutsManager.rebuildAllInputHooks()` (not `forceRebuild()`) clears | ||
| `activeShortcuts` and stops any in-progress tracking before rebuild, | ||
| preventing stale modifier-key state from blocking future shortcut activations. | ||
|
|
||
| ### 5. MouseChordActionManager: Periodic tap health check | ||
| **File:** `Swift Shift/src/Manager/ShortcutsManager.swift` | ||
|
|
||
| Added a 60-second repeating timer that calls `forceRebuild()` when subscribed. | ||
| This catches tap death from Secure Input (password prompts, sudo) and any other | ||
| no-notification tap-killing scenarios by fully tearing down and rebuilding the | ||
| subscription. | ||
|
|
||
| ### 6. CGEventSupervisor vendored into project | ||
| **Files:** `Swift Shift/packages/CGEventSupervisor/` | ||
|
|
||
| The CGEventSupervisor dependency (previously `stephancasas/CGEventSupervisor`) | ||
| is now a local package. This allows us to ship the teardown and callback fixes | ||
| without waiting for upstream. The package is unchanged except for the fixes | ||
| documented in items 1 and 2 above. | ||
| The reporter uses Command+click for Move and Command+right-click for Resize. Each shortcut press creates CGEventSupervisor subscriptions which create a CGEventTap. The tap dies silently on system events, and without recovery the tap stays dead — causing the system-wide input delay described in the bug. Restarting the app works because it creates a fresh tap. |
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
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
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,21 @@ | ||
| MIT License | ||
|
|
||
| Copyright (c) 2023 Stephan Casas | ||
|
|
||
| Permission is hereby granted, free of charge, to any person obtaining a copy | ||
| of this software and associated documentation files (the "Software"), to deal | ||
| in the Software without restriction, including without limitation the rights | ||
| to use, copy, modify, merge, publish, distribute, sublicense, and/or sell | ||
| copies of the Software, and to permit persons to whom the Software is | ||
| furnished to do so, subject to the following conditions: | ||
|
|
||
| The above copyright notice and this permission notice shall be included in all | ||
| copies or substantial portions of the Software. | ||
|
|
||
| THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR | ||
| IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, | ||
| FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE | ||
| AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER | ||
| LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, | ||
| OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE | ||
| SOFTWARE. |
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
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,18 @@ | ||
| // swift-tools-version: 5.8 | ||
| // The swift-tools-version declares the minimum version of Swift required to build this package. | ||
|
|
||
| import PackageDescription | ||
|
|
||
| let package = Package( | ||
| name: "CGEventSupervisor", | ||
| products: [ | ||
| .library( | ||
| name: "CGEventSupervisor", | ||
| targets: ["CGEventSupervisor"]), | ||
| ], | ||
| targets: [ | ||
| .target( | ||
| name: "CGEventSupervisor", | ||
| dependencies: []), | ||
| ] | ||
| ) |
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
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,9 @@ | ||
| # CGEventSupervisor | ||
|
|
||
| Originally by [Stephan Casas](https://github.com/stephancasas) — [CGEventSupervisor](https://github.com/stephancasas/CGEventSupervisor) | ||
|
|
||
| Vendored into SwiftShift with the following fixes: | ||
| - Proper Mach port teardown (run loop source removal + `CFMachPortInvalidate`) | ||
| - Re-enable tap on `kCGEventTapDisabledByTimeout` / `kCGEventTapDisabledByUserInput` | ||
|
|
||
| MIT License. | ||
76 changes: 76 additions & 0 deletions
76
...ift/packages/CGEventSupervisor/Sources/CGEventSupervisor/CGEventSupervisor+Callback.swift
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
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,76 @@ | ||
| // | ||
| // CGEventSupervisor+Callback.swift | ||
| // | ||
| // | ||
| // Created by Stephan Casas on 8/6/23. | ||
| // | ||
|
|
||
| import Cocoa; | ||
| import Foundation; | ||
|
|
||
| /// The main event callback which will perform casting | ||
| /// for `CGEvent` to `NSEvent` and resolution of the | ||
| /// given opaque pointer into `CGEventSupervisor`. | ||
| /// | ||
| internal func CGEventSupervisorCallback( | ||
| _ eventTap: CGEventTapProxy, | ||
| _ eventType: CGEventType, | ||
| _ event: CGEvent, | ||
| _ userData: UnsafeMutableRawPointer? | ||
| ) -> Unmanaged<CGEvent>? { | ||
| guard | ||
| let supervisorRef = userData | ||
| else { | ||
| return Unmanaged.passUnretained(event); | ||
| } | ||
|
|
||
| let supervisor = Unmanaged<CGEventSupervisor> | ||
| .fromOpaque(supervisorRef) | ||
| .takeUnretainedValue(); | ||
|
|
||
| /// Handle disabled-tap events from the WindowServer. | ||
| /// These are sent when macOS kills the tap due to timeout, user input, | ||
| /// or system state changes (sleep, display reconfiguration, etc.). | ||
| /// Without this handler, the tap dies silently and stays dead until | ||
| /// the app is restarted. | ||
| if eventType == .tapDisabledByTimeout || eventType == .tapDisabledByUserInput { | ||
| guard let tap = supervisor.eventTapMachPort else { | ||
| return nil | ||
| } | ||
| CGEvent.tapEnable(tap: tap, enable: true) | ||
| return nil | ||
| } | ||
|
|
||
| var bubbles = true; | ||
| for subscriber in supervisor.cgEventSubscribers.values { | ||
| if subscriber.events.contains(event.type){ | ||
| subscriber.callback(event); | ||
| bubbles = !event.supervisorShouldCancel; | ||
| } | ||
| if !bubbles { return nil } | ||
| } | ||
|
|
||
| /// Is the event eligible for casting to `NSEvent`? | ||
| /// | ||
| if event.type.rawValue > 0, | ||
| event.type.rawValue <= kCGEventLastUniversalType.rawValue, | ||
| supervisor.nsEventSubscribers.count > 0, | ||
| let nsEvent = NSEvent(cgEvent: event) { | ||
|
|
||
| for subscriber in supervisor.nsEventSubscribers.values { | ||
| if subscriber.events.contains(event.type) { | ||
| subscriber.callback(nsEvent); | ||
| bubbles = !nsEvent.supervisorShouldCancel; | ||
| } | ||
| if !bubbles { return nil } | ||
| } | ||
|
|
||
| } | ||
|
|
||
| return Unmanaged.passUnretained(event); | ||
| } | ||
|
|
||
| /// The last-supported universal event type in the enumerations | ||
| /// of `NSEvent.EventType` and `CGEventType`. | ||
| /// | ||
| fileprivate let kCGEventLastUniversalType: CGEventType = .otherMouseDragged; |
64 changes: 64 additions & 0 deletions
64
... Shift/packages/CGEventSupervisor/Sources/CGEventSupervisor/CGEventSupervisor+Setup.swift
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
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,64 @@ | ||
| // | ||
| // CGEventSupervisor+Setup.swift | ||
| // | ||
| // | ||
| // Created by Stephan Casas on 8/6/23. | ||
| // | ||
|
|
||
| import Cocoa; | ||
| import Foundation; | ||
|
|
||
| extension CGEventSupervisor { | ||
|
|
||
| /// Setup the event tap and receiving mach port for subscription | ||
| /// to the currently-subscribed event types. | ||
| /// | ||
| internal func setup() { | ||
| self.teardown(); | ||
|
|
||
| if self.totalSubscribers == 0 { return } | ||
|
|
||
| guard let eventTapMachPort = CGEvent.tapCreate( | ||
| tap: .cgSessionEventTap, | ||
| place: .headInsertEventTap, | ||
| options: .defaultTap, | ||
| eventsOfInterest: self.eventMask, | ||
| callback: CGEventSupervisorCallback, | ||
| userInfo: Unmanaged.passUnretained(self).toOpaque() | ||
| ) else { | ||
| NSLog("[CGEventSupervisor]: Could not allocate the required mach port for CGEventTap.") | ||
| return; | ||
| } | ||
|
|
||
| let runLoop = CFRunLoopGetCurrent(); | ||
| let runLoopSrc = CFMachPortCreateRunLoopSource(kCFAllocatorDefault, eventTapMachPort, 0); | ||
|
|
||
| CFRunLoopAddSource(runLoop, runLoopSrc, .commonModes); | ||
| CGEvent.tapEnable(tap: eventTapMachPort, enable: true); | ||
|
|
||
| self.eventTapMachPort = eventTapMachPort; | ||
| self.eventTapRunLoopSource = runLoopSrc; | ||
| } | ||
|
|
||
| /// Disable the event tap, and dispose of the receiving mach port. | ||
| /// | ||
| internal func teardown() { | ||
| if let src = self.eventTapRunLoopSource { | ||
| CFRunLoopRemoveSource(CFRunLoopGetCurrent(), src, .commonModes) | ||
| self.eventTapRunLoopSource = nil | ||
| } | ||
| guard let eventTapMachPort = self.eventTapMachPort else { return } | ||
| CGEvent.tapEnable(tap: eventTapMachPort, enable: false); | ||
| CFMachPortInvalidate(eventTapMachPort) | ||
| self.eventTapMachPort = nil; | ||
| } | ||
|
|
||
| /// The `CGEventTap` mask/filter. | ||
| /// | ||
| var eventMask: CGEventMask { | ||
| CGEventMask(self.subscribedEvents.reduce(0, { | ||
| $0 | (1 << $1.rawValue) | ||
| })) | ||
|
pablopunk marked this conversation as resolved.
|
||
| } | ||
|
|
||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.