Skip to content

Commit 18e0bf0

Browse files
committed
Added Force Quit and modifier settings
1 parent 70f93fd commit 18e0bf0

9 files changed

Lines changed: 261 additions & 140 deletions

File tree

Xcode/MiddleQuit/EventTapManager.swift

Lines changed: 29 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,13 @@ final class EventTapManager {
1414
case middle = 2
1515
}
1616

17+
enum ResolvedAction {
18+
case quit
19+
case forceQuit
20+
}
21+
1722
// Return true to consume the event (prevent Dock from seeing it)
18-
typealias MiddleClickHandler = (_ locationInScreen: CGPoint) -> Bool
23+
typealias MiddleClickHandler = (_ locationInScreen: CGPoint, _ action: ResolvedAction) -> Bool
1924

2025
private var eventTap: CFMachPort?
2126
private var runLoopSource: CFRunLoopSource?
@@ -27,31 +32,33 @@ final class EventTapManager {
2732
// Public read-only: whether this tap can swallow events (depends on creation mode)
2833
private(set) var canSwallow = false
2934

30-
// Accessor for the current activation mode (injected so changes are live)
31-
private var activationModeProvider: (() -> Preferences.ActivationMode)?
35+
// Provider to resolve action based on current modifiers. Return nil for "no action".
36+
private var actionResolver: ((NSEvent.ModifierFlags) -> ResolvedAction?)?
3237

33-
func start(activationMode: @escaping () -> Preferences.ActivationMode,
38+
func start(resolveAction: @escaping (NSEvent.ModifierFlags) -> ResolvedAction?,
3439
handler: @escaping MiddleClickHandler) {
3540
self.handler = handler
36-
self.activationModeProvider = activationMode
41+
self.actionResolver = resolveAction
3742

38-
// Listen only for middle (other) mouse clicks, down and up.
39-
let mask =
40-
(1 << CGEventType.otherMouseDown.rawValue) |
41-
(1 << CGEventType.otherMouseUp.rawValue)
43+
// Build mask in a way that works across SDKs (UInt vs UInt64)
44+
let downShift = CGEventMask(CGEventType.otherMouseDown.rawValue)
45+
let upShift = CGEventMask(CGEventType.otherMouseUp.rawValue)
46+
let downBit: CGEventMask = CGEventMask(1) << downShift
47+
let upBit: CGEventMask = CGEventMask(1) << upShift
48+
let mask: CGEventMask = downBit | upBit
4249

4350
// Try session default (captures synthetic events, can swallow)
44-
if createTap(tap: .cgSessionEventTap, options: .defaultTap, eventsOfInterest: CGEventMask(mask)) {
51+
if createTap(tap: .cgSessionEventTap, options: .defaultTap, eventsOfInterest: mask) {
4552
canSwallow = true
4653
print("EventTap: using cgSessionEventTap (default) — can swallow")
4754
}
4855
// Else try HID default (captures hardware events, can swallow)
49-
else if createTap(tap: .cghidEventTap, options: .defaultTap, eventsOfInterest: CGEventMask(mask)) {
56+
else if createTap(tap: .cghidEventTap, options: .defaultTap, eventsOfInterest: mask) {
5057
canSwallow = true
5158
print("EventTap: using cghidEventTap (default) — can swallow")
5259
}
5360
// Else, try session listen-only (observe only, cannot swallow)
54-
else if createTap(tap: .cgSessionEventTap, options: .listenOnly, eventsOfInterest: CGEventMask(mask)) {
61+
else if createTap(tap: .cgSessionEventTap, options: .listenOnly, eventsOfInterest: mask) {
5562
canSwallow = false
5663
print("EventTap: using cgSessionEventTap (listenOnly) — cannot swallow")
5764
} else {
@@ -88,15 +95,16 @@ final class EventTapManager {
8895
return Unmanaged.passUnretained(event)
8996
}
9097

91-
// Check activation mode against current modifier flags
92-
if !manager.matchesActivationMode(event: event) {
93-
return Unmanaged.passUnretained(event)
94-
}
95-
9698
let loc = event.location
97-
if let shouldConsume = manager.handler?(loc), shouldConsume, manager.canSwallow {
98-
manager.swallowNextOtherMouseUp = true
99-
return nil // swallow down
99+
100+
// CGEvent.flags.rawValue is UInt64; NSEvent.ModifierFlags.RawValue is UInt.
101+
let flags = NSEvent.ModifierFlags(rawValue: UInt(event.flags.rawValue))
102+
103+
if let action = manager.actionResolver?(flags) {
104+
if let shouldConsume = manager.handler?(loc, action), shouldConsume, manager.canSwallow {
105+
manager.swallowNextOtherMouseUp = true
106+
return nil // swallow down
107+
}
100108
}
101109
return Unmanaged.passUnretained(event)
102110
} else {
@@ -123,28 +131,6 @@ final class EventTapManager {
123131
return false
124132
}
125133

126-
private func matchesActivationMode(event: CGEvent) -> Bool {
127-
let flags = event.flags
128-
let mode = activationModeProvider?() ?? .none
129-
130-
switch mode {
131-
case .none:
132-
// Require no primary modifiers (ignore caps lock, numeric pad, function)
133-
return !flags.contains(.maskCommand)
134-
&& !flags.contains(.maskAlternate)
135-
&& !flags.contains(.maskControl)
136-
&& !flags.contains(.maskShift)
137-
case .command:
138-
return flags.contains(.maskCommand)
139-
case .option:
140-
return flags.contains(.maskAlternate)
141-
case .control:
142-
return flags.contains(.maskControl)
143-
case .shift:
144-
return flags.contains(.maskShift)
145-
}
146-
}
147-
148134
func stop() {
149135
if let tap = eventTap {
150136
CGEvent.tapEnable(tap: tap, enable: false)
@@ -157,7 +143,7 @@ final class EventTapManager {
157143
handler = nil
158144
swallowNextOtherMouseUp = false
159145
canSwallow = false
160-
activationModeProvider = nil
146+
actionResolver = nil
161147
}
162148
}
163149

Xcode/MiddleQuit/MiddleQuitApp.swift

Lines changed: 45 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@ struct MiddleQuitApp: App {
1313
@NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
1414

1515
var body: some Scene {
16-
// Provide a real Settings window with a toggle for the menu bar icon
1716
Settings {
1817
SettingsView(
1918
preferences: appDelegate.preferences,
@@ -22,21 +21,19 @@ struct MiddleQuitApp: App {
2221
}
2322
)
2423
}
24+
.windowResizability(.contentSize)
2525
}
2626
}
2727

2828
final class AppDelegate: NSObject, NSApplicationDelegate {
29-
// Make preferences internal so SettingsView can access it via appDelegate
3029
let preferences = Preferences()
3130
private let eventTapManager = EventTapManager()
3231
private let dockHelper = DockAccessibilityHelper()
3332
private let quitController = QuitController()
3433
private let launchAtLogin = LaunchAtLoginManager()
3534
private var statusController: StatusItemController!
3635

37-
// Polling timer to detect when AX trust flips to true after prompting
3836
private var axPollingTimer: Timer?
39-
// Guard to avoid starting the tap twice
4037
private var hasStartedEventTap = false
4138

4239
func applicationDidFinishLaunching(_ notification: Notification) {
@@ -75,14 +72,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
7572
isLaunchAtLoginEnabled: { [weak self] in
7673
return self?.launchAtLogin.isEnabled ?? false
7774
},
78-
getActivationMode: { [weak self] in
79-
self?.preferences.activationMode ?? .none
80-
},
81-
onSetActivationMode: { [weak self] mode in
82-
guard let self else { return }
83-
self.preferences.activationMode = mode
84-
// EventTapManager reads mode dynamically
85-
},
8675
onQuit: {
8776
NSApp.terminate(nil)
8877
}
@@ -94,7 +83,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
9483

9584
// MARK: - Accessibility flow (Option C)
9685

97-
// Show the system-managed AX prompt and auto-start when trust becomes true.
9886
private func promptForAccessibilityAndAutoStart() {
9987
NSApp.activate(ignoringOtherApps: true)
10088

@@ -163,19 +151,52 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
163151
}
164152
hasStartedEventTap = true
165153

166-
// Rebuild menu so the "Open Accessibility Settings" item disappears
167154
statusController.rebuildMenu()
168155

169-
eventTapManager.start(activationMode: { [weak self] in
170-
self?.preferences.activationMode ?? .none
171-
}) { [weak self] point in
172-
guard let self else { return false }
173-
if let pid = self.dockHelper.pidForDockTile(at: point) {
174-
self.quitController.gracefulQuit(pid: pid)
175-
return self.eventTapManager.canSwallow
156+
eventTapManager.start(
157+
resolveAction: { [weak self] flags in
158+
guard let self else { return nil }
159+
160+
// Choose a single effective modifier (priority: ⌘ > ⌥ > ⌃ > ⇧ > none)
161+
func effectiveModifier(from flags: NSEvent.ModifierFlags) -> Preferences.ActivationChoice {
162+
if flags.contains(.command) { return .command }
163+
if flags.contains(.option) { return .option }
164+
if flags.contains(.control) { return .control }
165+
if flags.contains(.shift) { return .shift }
166+
return .none
167+
}
168+
169+
let eff = effectiveModifier(from: flags)
170+
let quitChoice = self.preferences.quitActivation
171+
let forceChoice = self.preferences.forceActivation
172+
173+
// If both are set to the same non-disabled choice, do nothing until resolved.
174+
if quitChoice == forceChoice, quitChoice != .disabled {
175+
return nil
176+
}
177+
178+
if eff == quitChoice, quitChoice != .disabled {
179+
return .quit
180+
}
181+
if eff == forceChoice, forceChoice != .disabled {
182+
return .forceQuit
183+
}
184+
return nil
185+
},
186+
handler: { [weak self] point, action in
187+
guard let self else { return false }
188+
if let pid = self.dockHelper.pidForDockTile(at: point) {
189+
switch action {
190+
case .quit:
191+
self.quitController.gracefulQuit(pid: pid)
192+
case .forceQuit:
193+
self.quitController.forceQuit(pid: pid)
194+
}
195+
return self.eventTapManager.canSwallow
196+
}
197+
return false
176198
}
177-
return false
178-
}
199+
)
179200
}
180201

181202
func applyStatusItemVisibility(show: Bool) {
@@ -193,3 +214,4 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
193214
eventTapManager.stop()
194215
}
195216
}
217+

Xcode/MiddleQuit/Preferences.swift

Lines changed: 34 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -11,31 +11,36 @@ import AppKit
1111
final class Preferences {
1212
private let defaults = UserDefaults.standard
1313
private let showKey = "showStatusItem"
14-
private let activationModeKey = "activationMode"
1514

16-
enum ActivationMode: String, CaseIterable, Identifiable {
17-
case none
15+
// Keys for per-action activations
16+
private let quitActivationKey = "quitActivation"
17+
private let forceActivationKey = "forceActivation"
18+
19+
enum ActivationChoice: String, CaseIterable, Identifiable {
20+
case disabled
21+
case none // Plain middle click
1822
case command
1923
case option
2024
case control
2125
case shift
2226

2327
var id: String { rawValue }
2428

25-
var displayName: String {
29+
// Full descriptive label used in menus
30+
var menuLabel: String {
2631
switch self {
27-
case .none: return "Middle Click"
28-
case .command: return "⌘ Command + Middle Click"
29-
case .option: return "⌥ Option + Middle Click"
30-
case .control: return "⌃ Control + Middle Click"
31-
case .shift: return "⇧ Shift + Middle Click"
32+
case .disabled: return "Disabled"
33+
case .none: return "Middle Click"
34+
case .command: return "⌘ Command + click"
35+
case .option: return "⌥ Option + click"
36+
case .control: return "⌃ Control + click"
37+
case .shift: return "⇧ Shift + click"
3238
}
3339
}
3440
}
3541

3642
var showStatusItem: Bool {
3743
get {
38-
// Default to true (visible)
3944
if defaults.object(forKey: showKey) == nil {
4045
return true
4146
}
@@ -46,17 +51,30 @@ final class Preferences {
4651
}
4752
}
4853

49-
var activationMode: ActivationMode {
54+
// Default: Quit = Middle Click, Force Quit = Option + click
55+
var quitActivation: ActivationChoice {
5056
get {
51-
if let raw = defaults.string(forKey: activationModeKey),
52-
let mode = ActivationMode(rawValue: raw) {
53-
return mode
57+
if let raw = defaults.string(forKey: quitActivationKey),
58+
let v = ActivationChoice(rawValue: raw) {
59+
return v
5460
}
55-
// Default: plain middle click
5661
return .none
5762
}
5863
set {
59-
defaults.set(newValue.rawValue, forKey: activationModeKey)
64+
defaults.set(newValue.rawValue, forKey: quitActivationKey)
65+
}
66+
}
67+
68+
var forceActivation: ActivationChoice {
69+
get {
70+
if let raw = defaults.string(forKey: forceActivationKey),
71+
let v = ActivationChoice(rawValue: raw) {
72+
return v
73+
}
74+
return .option
75+
}
76+
set {
77+
defaults.set(newValue.rawValue, forKey: forceActivationKey)
6078
}
6179
}
6280
}

Xcode/MiddleQuit/QuitController.swift

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,4 +12,13 @@ final class QuitController {
1212
guard let app = NSRunningApplication(processIdentifier: pid) else { return }
1313
app.terminate()
1414
}
15+
16+
func forceQuit(pid: pid_t) {
17+
guard let app = NSRunningApplication(processIdentifier: pid) else { return }
18+
if !app.forceTerminate() {
19+
// Fallback if needed
20+
kill(app.processIdentifier, SIGKILL)
21+
}
22+
}
1523
}
24+

0 commit comments

Comments
 (0)