Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,4 @@ build/
www/node_modules/
www/dist/
www/.astro/
Swift Shift/packages/CGEventSupervisor/.build/
62 changes: 62 additions & 0 deletions FIXES.md
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.
14 changes: 5 additions & 9 deletions Swift Shift.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -220,7 +220,7 @@
177FB2462B31FE7F00B11BA3 /* XCRemoteSwiftPackageReference "ShortcutRecorder" */,
17F33F282B37026400F85E01 /* XCRemoteSwiftPackageReference "LaunchAtLogin-Modern" */,
E23633212B44749C00C0E61F /* XCRemoteSwiftPackageReference "Sparkle" */,
3364410F2C189812000A1C90 /* XCRemoteSwiftPackageReference "CGEventSupervisor" */,
3364410F2C189812000A1C90 /* XCLocalSwiftPackageReference "CGEventSupervisor" */,
);
productRefGroup = 1781A5DF2B31EE4900F27910 /* Products */;
projectDirPath = "";
Expand Down Expand Up @@ -501,13 +501,9 @@
kind = branch;
};
};
3364410F2C189812000A1C90 /* XCRemoteSwiftPackageReference "CGEventSupervisor" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/stephancasas/CGEventSupervisor";
requirement = {
branch = main;
kind = branch;
};
3364410F2C189812000A1C90 /* XCLocalSwiftPackageReference "CGEventSupervisor" */ = {
isa = XCLocalSwiftPackageReference;
relativePath = "Swift Shift/packages/CGEventSupervisor";
};
E23633212B44749C00C0E61F /* XCRemoteSwiftPackageReference "Sparkle" */ = {
isa = XCRemoteSwiftPackageReference;
Expand All @@ -522,7 +518,7 @@
/* Begin XCSwiftPackageProductDependency section */
336441102C189812000A1C90 /* CGEventSupervisor */ = {
isa = XCSwiftPackageProductDependency;
package = 3364410F2C189812000A1C90 /* XCRemoteSwiftPackageReference "CGEventSupervisor" */;
package = 3364410F2C189812000A1C90 /* XCLocalSwiftPackageReference "CGEventSupervisor" */;
productName = CGEventSupervisor;
};
338FBF5A2C1855AE00D1ECA6 /* ShortcutRecorder */ = {
Expand Down
21 changes: 21 additions & 0 deletions Swift Shift/packages/CGEventSupervisor/LICENSE
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.
18 changes: 18 additions & 0 deletions Swift Shift/packages/CGEventSupervisor/Package.swift
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: []),
]
)
9 changes: 9 additions & 0 deletions Swift Shift/packages/CGEventSupervisor/README.md
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.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
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;
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)
}))
Comment thread
pablopunk marked this conversation as resolved.
}

}
Loading
Loading