Skip to content

Commit 007e61c

Browse files
committed
Added Force Quit and modifier settings
1 parent 18e0bf0 commit 007e61c

File tree

7 files changed

+239
-207
lines changed

7 files changed

+239
-207
lines changed

Xcode/MiddleQuit/AppDelegate.swift

Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
import Cocoa
2+
import AppKit
3+
4+
final class AppDelegate: NSObject, NSApplicationDelegate {
5+
let preferences = Preferences()
6+
private let eventTapManager = EventTapManager()
7+
private let dockHelper = DockAccessibilityHelper()
8+
private let quitController = QuitController()
9+
let launchAtLogin = LaunchAtLoginManager()
10+
private var statusController: StatusItemController!
11+
12+
private var axPollingTimer: Timer?
13+
private var hasStartedEventTap = false
14+
15+
func applicationDidFinishLaunching(_ notification: Notification) {
16+
statusController = StatusItemController(
17+
preferences: preferences,
18+
onToggleShowIcon: { [weak self] show in
19+
self?.applyStatusItemVisibility(show: show)
20+
},
21+
onOpenAccessibility: { [weak self] in
22+
self?.promptForAccessibilityAndAutoStart()
23+
},
24+
onToggleLaunchAtLogin: { [weak self] in
25+
guard let self else { return }
26+
let result = self.launchAtLogin.toggle()
27+
switch result {
28+
case .requiresApproval:
29+
self.launchAtLogin.openLoginItemsSettingsIfAvailable()
30+
case .failed(let error):
31+
let alert = NSAlert()
32+
alert.messageText = "Couldn’t Update Login Item"
33+
alert.informativeText = error.localizedDescription
34+
alert.alertStyle = .warning
35+
alert.addButton(withTitle: "OK")
36+
alert.runModal()
37+
case .unavailable:
38+
let alert = NSAlert()
39+
alert.messageText = "Not Supported on This macOS Version"
40+
alert.informativeText = "Enabling launch at login without a helper app requires macOS 13 or later."
41+
alert.alertStyle = .informational
42+
alert.addButton(withTitle: "OK")
43+
alert.runModal()
44+
default:
45+
break
46+
}
47+
},
48+
isLaunchAtLoginEnabled: { [weak self] in
49+
return self?.launchAtLogin.isEnabled ?? false
50+
},
51+
onQuit: {
52+
NSApp.terminate(nil)
53+
}
54+
)
55+
56+
applyStatusItemVisibility(show: preferences.showStatusItem)
57+
ensureAccessibilityAndStart()
58+
}
59+
60+
// MARK: - Accessibility flow
61+
62+
private func promptForAccessibilityAndAutoStart() {
63+
NSApp.activate(ignoringOtherApps: true)
64+
65+
let alert = NSAlert()
66+
alert.messageText = "Accessibility Permission"
67+
alert.informativeText = "MiddleQuit needs Accessibility permission to handle mouse clicks. macOS will show a prompt. After allowing, MiddleQuit will become active automatically."
68+
alert.alertStyle = .informational
69+
alert.addButton(withTitle: "Continue")
70+
alert.addButton(withTitle: "Cancel")
71+
let response = alert.runModal()
72+
guard response == .alertFirstButtonReturn else { return }
73+
74+
DockAccessibilityHelper.requestAXPermissionIfNeeded()
75+
beginAXTrustPolling(timeout: 30.0, interval: 0.5)
76+
}
77+
78+
private func beginAXTrustPolling(timeout: TimeInterval, interval: TimeInterval) {
79+
axPollingTimer?.invalidate()
80+
81+
if DockAccessibilityHelper.isAXEnabled() {
82+
ensureAccessibilityAndStart()
83+
return
84+
}
85+
86+
let deadline = Date().addingTimeInterval(timeout)
87+
88+
axPollingTimer = Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { [weak self] timer in
89+
guard let self else { return }
90+
if DockAccessibilityHelper.isAXEnabled() {
91+
timer.invalidate()
92+
self.axPollingTimer = nil
93+
self.ensureAccessibilityAndStart()
94+
} else if Date() >= deadline {
95+
timer.invalidate()
96+
self.axPollingTimer = nil
97+
self.offerAccessibilitySettingsFallback()
98+
}
99+
}
100+
101+
RunLoop.main.add(axPollingTimer!, forMode: .common)
102+
}
103+
104+
private func offerAccessibilitySettingsFallback() {
105+
let fallback = NSAlert()
106+
fallback.messageText = "Accessibility Not Enabled"
107+
fallback.informativeText = "You can enable Accessibility for MiddleQuit in System Settings. Would you like to open it now?"
108+
fallback.alertStyle = .warning
109+
fallback.addButton(withTitle: "Open Settings")
110+
fallback.addButton(withTitle: "Cancel")
111+
let result = fallback.runModal()
112+
guard result == .alertFirstButtonReturn else { return }
113+
114+
if let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility") {
115+
NSWorkspace.shared.open(url)
116+
}
117+
}
118+
119+
// MARK: - Event tap lifecycle
120+
121+
private func ensureAccessibilityAndStart() {
122+
guard DockAccessibilityHelper.isAXEnabled() else {
123+
return
124+
}
125+
guard !hasStartedEventTap else {
126+
return
127+
}
128+
hasStartedEventTap = true
129+
130+
statusController.rebuildMenu()
131+
132+
eventTapManager.start(
133+
resolveAction: { [weak self] flags in
134+
guard let self else { return nil }
135+
136+
func effectiveModifier(from flags: NSEvent.ModifierFlags) -> Preferences.ActivationChoice {
137+
if flags.contains(.command) { return .command }
138+
if flags.contains(.option) { return .option }
139+
if flags.contains(.control) { return .control }
140+
if flags.contains(.shift) { return .shift }
141+
return .none
142+
}
143+
144+
let eff = effectiveModifier(from: flags)
145+
let quitChoice = self.preferences.quitActivation
146+
let forceChoice = self.preferences.forceActivation
147+
148+
if quitChoice == forceChoice, quitChoice != .disabled {
149+
return nil
150+
}
151+
152+
if eff == quitChoice, quitChoice != .disabled {
153+
return .quit
154+
}
155+
if eff == forceChoice, forceChoice != .disabled {
156+
return .forceQuit
157+
}
158+
return nil
159+
},
160+
handler: { [weak self] point, action in
161+
guard let self else { return false }
162+
if let pid = self.dockHelper.pidForDockTile(at: point) {
163+
switch action {
164+
case .quit:
165+
self.quitController.gracefulQuit(pid: pid)
166+
case .forceQuit:
167+
self.quitController.forceQuit(pid: pid)
168+
}
169+
return self.eventTapManager.canSwallow
170+
}
171+
return false
172+
}
173+
)
174+
}
175+
176+
func applyStatusItemVisibility(show: Bool) {
177+
if show {
178+
statusController.show()
179+
NSApp.setActivationPolicy(.accessory)
180+
} else {
181+
statusController.hide()
182+
NSApp.setActivationPolicy(.prohibited)
183+
}
184+
}
185+
186+
func applicationWillTerminate(_ notification: Notification) {
187+
axPollingTimer?.invalidate()
188+
eventTapManager.stop()
189+
}
190+
}

Xcode/MiddleQuit/MiddleQuitApp.swift

Lines changed: 7 additions & 190 deletions
Original file line numberDiff line numberDiff line change
@@ -18,200 +18,17 @@ struct MiddleQuitApp: App {
1818
preferences: appDelegate.preferences,
1919
onToggleShowIcon: { show in
2020
appDelegate.applyStatusItemVisibility(show: show)
21+
},
22+
isLaunchAtLoginEnabled: {
23+
appDelegate.launchAtLogin.isEnabled
24+
},
25+
onToggleLaunchAtLogin: {
26+
_ = appDelegate.launchAtLogin.toggle()
2127
}
2228
)
2329
}
2430
.windowResizability(.contentSize)
2531
}
2632
}
2733

28-
final class AppDelegate: NSObject, NSApplicationDelegate {
29-
let preferences = Preferences()
30-
private let eventTapManager = EventTapManager()
31-
private let dockHelper = DockAccessibilityHelper()
32-
private let quitController = QuitController()
33-
private let launchAtLogin = LaunchAtLoginManager()
34-
private var statusController: StatusItemController!
35-
36-
private var axPollingTimer: Timer?
37-
private var hasStartedEventTap = false
38-
39-
func applicationDidFinishLaunching(_ notification: Notification) {
40-
statusController = StatusItemController(
41-
preferences: preferences,
42-
onToggleShowIcon: { [weak self] show in
43-
self?.applyStatusItemVisibility(show: show)
44-
},
45-
onOpenAccessibility: { [weak self] in
46-
self?.promptForAccessibilityAndAutoStart()
47-
},
48-
onToggleLaunchAtLogin: { [weak self] in
49-
guard let self else { return }
50-
let result = self.launchAtLogin.toggle()
51-
switch result {
52-
case .requiresApproval:
53-
self.launchAtLogin.openLoginItemsSettingsIfAvailable()
54-
case .failed(let error):
55-
let alert = NSAlert()
56-
alert.messageText = "Couldn’t Update Login Item"
57-
alert.informativeText = error.localizedDescription
58-
alert.alertStyle = .warning
59-
alert.addButton(withTitle: "OK")
60-
alert.runModal()
61-
case .unavailable:
62-
let alert = NSAlert()
63-
alert.messageText = "Not Supported on This macOS Version"
64-
alert.informativeText = "Enabling launch at login without a helper app requires macOS 13 or later."
65-
alert.alertStyle = .informational
66-
alert.addButton(withTitle: "OK")
67-
alert.runModal()
68-
default:
69-
break
70-
}
71-
},
72-
isLaunchAtLoginEnabled: { [weak self] in
73-
return self?.launchAtLogin.isEnabled ?? false
74-
},
75-
onQuit: {
76-
NSApp.terminate(nil)
77-
}
78-
)
79-
80-
applyStatusItemVisibility(show: preferences.showStatusItem)
81-
ensureAccessibilityAndStart()
82-
}
83-
84-
// MARK: - Accessibility flow (Option C)
85-
86-
private func promptForAccessibilityAndAutoStart() {
87-
NSApp.activate(ignoringOtherApps: true)
88-
89-
let alert = NSAlert()
90-
alert.messageText = "Accessibility Permission"
91-
alert.informativeText = "MiddleQuit needs Accessibility permission to handle mouse clicks. macOS will show a prompt. After allowing, MiddleQuit will become active automatically."
92-
alert.alertStyle = .informational
93-
alert.addButton(withTitle: "Continue")
94-
alert.addButton(withTitle: "Cancel")
95-
let response = alert.runModal()
96-
guard response == .alertFirstButtonReturn else { return }
97-
98-
DockAccessibilityHelper.requestAXPermissionIfNeeded()
99-
beginAXTrustPolling(timeout: 30.0, interval: 0.5)
100-
}
101-
102-
private func beginAXTrustPolling(timeout: TimeInterval, interval: TimeInterval) {
103-
axPollingTimer?.invalidate()
104-
105-
if DockAccessibilityHelper.isAXEnabled() {
106-
ensureAccessibilityAndStart()
107-
return
108-
}
109-
110-
let deadline = Date().addingTimeInterval(timeout)
111-
112-
axPollingTimer = Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { [weak self] timer in
113-
guard let self else { return }
114-
if DockAccessibilityHelper.isAXEnabled() {
115-
timer.invalidate()
116-
self.axPollingTimer = nil
117-
self.ensureAccessibilityAndStart()
118-
} else if Date() >= deadline {
119-
timer.invalidate()
120-
self.axPollingTimer = nil
121-
self.offerAccessibilitySettingsFallback()
122-
}
123-
}
124-
125-
RunLoop.main.add(axPollingTimer!, forMode: .common)
126-
}
127-
128-
private func offerAccessibilitySettingsFallback() {
129-
let fallback = NSAlert()
130-
fallback.messageText = "Accessibility Not Enabled"
131-
fallback.informativeText = "You can enable Accessibility for MiddleQuit in System Settings. Would you like to open it now?"
132-
fallback.alertStyle = .warning
133-
fallback.addButton(withTitle: "Open Settings")
134-
fallback.addButton(withTitle: "Cancel")
135-
let result = fallback.runModal()
136-
guard result == .alertFirstButtonReturn else { return }
137-
138-
if let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility") {
139-
NSWorkspace.shared.open(url)
140-
}
141-
}
142-
143-
// MARK: - Event tap lifecycle
144-
145-
private func ensureAccessibilityAndStart() {
146-
guard DockAccessibilityHelper.isAXEnabled() else {
147-
return
148-
}
149-
guard !hasStartedEventTap else {
150-
return
151-
}
152-
hasStartedEventTap = true
153-
154-
statusController.rebuildMenu()
155-
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
198-
}
199-
)
200-
}
201-
202-
func applyStatusItemVisibility(show: Bool) {
203-
if show {
204-
statusController.show()
205-
NSApp.setActivationPolicy(.accessory) // menu bar only
206-
} else {
207-
statusController.hide()
208-
NSApp.setActivationPolicy(.prohibited) // fully background
209-
}
210-
}
211-
212-
func applicationWillTerminate(_ notification: Notification) {
213-
axPollingTimer?.invalidate()
214-
eventTapManager.stop()
215-
}
216-
}
217-
34+
// ... rest unchanged ...

0 commit comments

Comments
 (0)