263 lines
9.4 KiB
Swift
263 lines
9.4 KiB
Swift
import AppKit
|
|
import ApplicationServices
|
|
import CoreGraphics
|
|
import MacToolsPluginKit
|
|
|
|
// MARK: - Errors
|
|
|
|
enum MenuBarHiddenEventError: Error {
|
|
case notTrusted
|
|
case eventCreationFailed
|
|
case pidDead(pid_t)
|
|
|
|
func localizedDescription(localization: PluginLocalization) -> String {
|
|
switch self {
|
|
case .notTrusted:
|
|
localization.string("eventError.notTrusted", defaultValue: "辅助功能权限未授予")
|
|
case .eventCreationFailed:
|
|
localization.string("eventError.eventCreationFailed", defaultValue: "无法创建鼠标事件")
|
|
case .pidDead(let pid):
|
|
localization.format("eventError.pidDead", defaultValue: "目标进程 %d 已退出", pid)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - MenuBarHiddenEventSynthesis
|
|
//
|
|
// Synthesises the CGEvents required to:
|
|
// * Drag a menu bar item (Cmd+drag is the gesture macOS recognises).
|
|
// * Click a menu bar item (left or right) so its menu opens.
|
|
//
|
|
// Accessibility permission (`AXIsProcessTrusted`) is mandatory for both.
|
|
|
|
@MainActor
|
|
final class MenuBarHiddenEventSynthesis {
|
|
/// Cmd+drag as Thaw does it: send a targeted mouseDown to the destination
|
|
/// edge, then a targeted mouseUp. Window Server treats this as moving the
|
|
/// menu-bar item's window next to that edge.
|
|
func move(item: MenuBarItem, to target: MenuBarHiddenResolvedMoveTarget) async throws {
|
|
guard AXIsProcessTrusted() else { throw MenuBarHiddenEventError.notTrusted }
|
|
let pid = eventPID(for: item)
|
|
guard kill(pid, 0) == 0 else { throw MenuBarHiddenEventError.pidDead(pid) }
|
|
|
|
let source = try makeEventSource()
|
|
|
|
guard
|
|
let down = CGEvent(
|
|
mouseEventSource: source,
|
|
mouseType: .leftMouseDown,
|
|
mouseCursorPosition: target.point,
|
|
mouseButton: .left
|
|
),
|
|
let up = CGEvent(
|
|
mouseEventSource: source,
|
|
mouseType: .leftMouseUp,
|
|
mouseCursorPosition: target.point,
|
|
mouseButton: .left
|
|
)
|
|
else { throw MenuBarHiddenEventError.eventCreationFailed }
|
|
|
|
down.flags = .maskCommand
|
|
down.setMenuBarItemWindowID(item.windowID, includeWindowID: true)
|
|
up.setMenuBarItemWindowID(target.windowID ?? item.windowID, includeWindowID: true)
|
|
|
|
let savedCursor = MouseCursorHelper.locationCoreGraphics
|
|
let shouldWarpToTarget = MouseCursorHelper.isOnActiveDisplay(target.point)
|
|
if shouldWarpToTarget {
|
|
MouseCursorHelper.warpCursor(to: target.point)
|
|
}
|
|
MouseCursorHelper.hideCursor()
|
|
defer {
|
|
if let savedCursor {
|
|
MouseCursorHelper.warpCursor(to: savedCursor)
|
|
}
|
|
MouseCursorHelper.showCursor()
|
|
}
|
|
|
|
if shouldWarpToTarget {
|
|
try await Task.sleep(for: .milliseconds(20))
|
|
}
|
|
down.post(tap: .cghidEventTap)
|
|
down.postToPid(pid)
|
|
|
|
try await Task.sleep(for: .milliseconds(50))
|
|
up.post(tap: .cghidEventTap)
|
|
up.postToPid(pid)
|
|
// Double mouseUp prevents stuck drag state.
|
|
up.post(tap: .cghidEventTap)
|
|
up.postToPid(pid)
|
|
|
|
try await Task.sleep(for: .milliseconds(50))
|
|
}
|
|
|
|
/// Sends a left or right click to a menu bar item at its current bounds.
|
|
func click(item: MenuBarItem, button: CGMouseButton) async throws {
|
|
guard AXIsProcessTrusted() else { throw MenuBarHiddenEventError.notTrusted }
|
|
let pid = eventPID(for: item)
|
|
guard kill(pid, 0) == 0 else { throw MenuBarHiddenEventError.pidDead(pid) }
|
|
|
|
let source = try makeEventSource()
|
|
let clickBounds = MenuBarHiddenWindowServer.screenRect(for: item.windowID) ?? item.bounds
|
|
let clickPoint = clickBounds.center
|
|
|
|
let (downType, upType): (CGEventType, CGEventType) = button == .left
|
|
? (.leftMouseDown, .leftMouseUp)
|
|
: (.rightMouseDown, .rightMouseUp)
|
|
|
|
guard
|
|
let down = CGEvent(
|
|
mouseEventSource: source,
|
|
mouseType: downType,
|
|
mouseCursorPosition: clickPoint,
|
|
mouseButton: button
|
|
),
|
|
let up = CGEvent(
|
|
mouseEventSource: source,
|
|
mouseType: upType,
|
|
mouseCursorPosition: clickPoint,
|
|
mouseButton: button
|
|
)
|
|
else { throw MenuBarHiddenEventError.eventCreationFailed }
|
|
|
|
down.setMenuBarItemWindowID(item.windowID, includeWindowID: false)
|
|
up.setMenuBarItemWindowID(item.windowID, includeWindowID: false)
|
|
down.setIntegerValueField(.mouseEventClickState, value: 1)
|
|
up.setIntegerValueField(.mouseEventClickState, value: 1)
|
|
|
|
let savedCursor = MouseCursorHelper.locationCoreGraphics
|
|
MouseCursorHelper.warpCursor(to: clickPoint)
|
|
try await Task.sleep(for: .milliseconds(10))
|
|
MouseCursorHelper.hideCursor()
|
|
defer {
|
|
if let savedCursor {
|
|
MouseCursorHelper.warpCursor(to: savedCursor)
|
|
}
|
|
MouseCursorHelper.showCursor()
|
|
}
|
|
|
|
down.post(tap: .cghidEventTap)
|
|
down.postToPid(pid)
|
|
try await Task.sleep(for: .milliseconds(30))
|
|
up.post(tap: .cghidEventTap)
|
|
up.postToPid(pid)
|
|
up.post(tap: .cghidEventTap)
|
|
up.postToPid(pid)
|
|
try await Task.sleep(for: .milliseconds(20))
|
|
}
|
|
|
|
/// Brief poll until the user's cursor sits still — avoids fighting active
|
|
/// user input when synthesising drag events.
|
|
func waitForUserInputPause() async {
|
|
var stable = 0
|
|
var previous = NSEvent.mouseLocation
|
|
for _ in 0 ..< 20 {
|
|
let current = NSEvent.mouseLocation
|
|
if hypot(current.x - previous.x, current.y - previous.y) < 2 {
|
|
stable += 1
|
|
if stable >= 3 { return }
|
|
} else {
|
|
stable = 0
|
|
}
|
|
previous = current
|
|
try? await Task.sleep(for: .milliseconds(30))
|
|
}
|
|
}
|
|
|
|
// MARK: - Private
|
|
|
|
private func eventPID(for item: MenuBarItem) -> pid_t {
|
|
item.sourcePID ?? item.ownerPID
|
|
}
|
|
|
|
private func makeEventSource() throws -> CGEventSource {
|
|
guard let source = CGEventSource(stateID: .hidSystemState) else {
|
|
throw MenuBarHiddenEventError.eventCreationFailed
|
|
}
|
|
source.setLocalEventsFilterDuringSuppressionState(
|
|
CGEventFilterMask(rawValue: 0x7),
|
|
state: .eventSuppressionStateRemoteMouseDrag
|
|
)
|
|
source.setLocalEventsFilterDuringSuppressionState(
|
|
CGEventFilterMask(rawValue: 0x7),
|
|
state: .eventSuppressionStateSuppressionInterval
|
|
)
|
|
source.localEventsSuppressionInterval = 0
|
|
return source
|
|
}
|
|
}
|
|
|
|
// MARK: - Helpers
|
|
|
|
enum MouseCursorHelper {
|
|
static var locationCoreGraphics: CGPoint? {
|
|
CGEvent(source: nil)?.location
|
|
}
|
|
|
|
static func warpCursor(to point: CGPoint) {
|
|
let result = CGWarpMouseCursorPosition(point)
|
|
guard result != .success,
|
|
let source = CGEventSource(stateID: .hidSystemState),
|
|
let event = CGEvent(
|
|
mouseEventSource: source,
|
|
mouseType: .mouseMoved,
|
|
mouseCursorPosition: point,
|
|
mouseButton: .left
|
|
)
|
|
else {
|
|
return
|
|
}
|
|
event.post(tap: .cghidEventTap)
|
|
}
|
|
|
|
static func hideCursor() { CGDisplayHideCursor(kCGNullDirectDisplay) }
|
|
static func showCursor() { CGDisplayShowCursor(kCGNullDirectDisplay) }
|
|
|
|
static func isOnActiveDisplay(_ point: CGPoint) -> Bool {
|
|
var count: UInt32 = 0
|
|
guard CGGetActiveDisplayList(0, nil, &count) == .success else { return false }
|
|
var displays = [CGDirectDisplayID](repeating: 0, count: Int(count))
|
|
guard CGGetActiveDisplayList(count, &displays, nil) == .success else { return false }
|
|
return displays.contains { CGDisplayBounds($0).contains(point) }
|
|
}
|
|
|
|
static func isButtonPressed() -> Bool {
|
|
for rawButton in 0 ... 31 {
|
|
guard let button = CGMouseButton(rawValue: UInt32(rawButton)) else { continue }
|
|
if CGEventSource.buttonState(.combinedSessionState, button: button) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
static func lastMovementOccurred(within duration: TimeInterval) -> Bool {
|
|
let seconds = CGEventSource.secondsSinceLastEventType(.combinedSessionState, eventType: .mouseMoved)
|
|
return seconds <= duration
|
|
}
|
|
|
|
static func lastScrollWheelOccurred(within duration: TimeInterval) -> Bool {
|
|
let seconds = CGEventSource.secondsSinceLastEventType(.combinedSessionState, eventType: .scrollWheel)
|
|
return seconds <= duration
|
|
}
|
|
}
|
|
|
|
extension CGRect {
|
|
var center: CGPoint { CGPoint(x: midX, y: midY) }
|
|
}
|
|
|
|
private extension CGEventField {
|
|
static let menuBarHiddenWindowID = CGEventField(rawValue: 0x33)!
|
|
}
|
|
|
|
private extension CGEvent {
|
|
func setMenuBarItemWindowID(_ windowID: CGWindowID, includeWindowID: Bool) {
|
|
let value = Int64(windowID)
|
|
setIntegerValueField(.eventSourceUserData, value: Int64(Int(bitPattern: ObjectIdentifier(self))))
|
|
setIntegerValueField(.mouseEventWindowUnderMousePointer, value: value)
|
|
setIntegerValueField(.mouseEventWindowUnderMousePointerThatCanHandleThisEvent, value: value)
|
|
if includeWindowID {
|
|
setIntegerValueField(.menuBarHiddenWindowID, value: value)
|
|
}
|
|
}
|
|
}
|