919 lines
31 KiB
Swift
919 lines
31 KiB
Swift
import AppKit
|
|
import ApplicationServices
|
|
import Foundation
|
|
import IOKit.pwr_mgt
|
|
import OSLog
|
|
import SwiftUI
|
|
import MacToolsPluginKit
|
|
|
|
@MainActor
|
|
final class PhysicalCleanModeSession: NSObject, NSWindowDelegate {
|
|
private static let nullAssertionID = IOPMAssertionID(0)
|
|
private static let rightMouseHoldDurationSeconds = 3
|
|
|
|
private struct NotificationObservation {
|
|
let center: NotificationCenter
|
|
let token: NSObjectProtocol
|
|
}
|
|
|
|
enum EndReason {
|
|
case userRequested
|
|
case emergency(String)
|
|
}
|
|
|
|
private enum SessionError: LocalizedError {
|
|
case eventTapUnavailable
|
|
case runLoopSourceUnavailable
|
|
case missingScreens
|
|
case overlayWindowCreationFailed
|
|
case idleLockPreventionFailed
|
|
|
|
var errorDescription: String? {
|
|
localizedDescription(localization: PluginLocalization(bundle: .main))
|
|
}
|
|
|
|
func localizedDescription(localization: PluginLocalization) -> String {
|
|
switch self {
|
|
case .eventTapUnavailable:
|
|
return localization.string(
|
|
"session.error.eventTapUnavailable",
|
|
defaultValue: "无法创建输入拦截器,请确认已授予辅助功能权限。"
|
|
)
|
|
case .runLoopSourceUnavailable:
|
|
return localization.string(
|
|
"session.error.runLoopSourceUnavailable",
|
|
defaultValue: "无法初始化输入拦截运行循环。"
|
|
)
|
|
case .missingScreens:
|
|
return localization.string(
|
|
"session.error.missingScreens",
|
|
defaultValue: "未检测到可用屏幕,无法进入清洁模式。"
|
|
)
|
|
case .overlayWindowCreationFailed:
|
|
return localization.string(
|
|
"session.error.overlayWindowCreationFailed",
|
|
defaultValue: "无法创建屏幕覆盖窗口,已取消进入清洁模式。"
|
|
)
|
|
case .idleLockPreventionFailed:
|
|
return localization.string(
|
|
"session.error.idleLockPreventionFailed",
|
|
defaultValue: "无法启用防空闲锁屏保护,已取消进入清洁模式。"
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
private enum EventTapExitReason {
|
|
case shortcut
|
|
case rightMouseHold
|
|
case disabledByTimeout
|
|
case disabledByUserInput
|
|
}
|
|
|
|
private enum RightMouseButtonEvent {
|
|
case down
|
|
case up
|
|
}
|
|
|
|
private enum EventTapInterruptionReason: CustomStringConvertible {
|
|
case disabledByTimeout
|
|
case disabledByUserInput
|
|
|
|
var description: String {
|
|
switch self {
|
|
case .disabledByTimeout:
|
|
return "disabledByTimeout"
|
|
case .disabledByUserInput:
|
|
return "disabledByUserInput"
|
|
}
|
|
}
|
|
|
|
var exitReason: EventTapExitReason {
|
|
switch self {
|
|
case .disabledByTimeout:
|
|
return .disabledByTimeout
|
|
case .disabledByUserInput:
|
|
return .disabledByUserInput
|
|
}
|
|
}
|
|
}
|
|
|
|
private final class OverlayWindow: NSWindow {
|
|
override var canBecomeKey: Bool { true }
|
|
override var canBecomeMain: Bool { true }
|
|
}
|
|
|
|
@MainActor
|
|
private final class OverlayHintModel: ObservableObject {
|
|
@Published var keyboardExitHintText = ""
|
|
@Published var rightMouseExitHintText = ""
|
|
}
|
|
|
|
private struct OverlayWatermarkView: View {
|
|
@ObservedObject var model: OverlayHintModel
|
|
|
|
var body: some View {
|
|
ZStack(alignment: .bottomTrailing) {
|
|
Color.clear
|
|
|
|
VStack(alignment: .trailing, spacing: 4) {
|
|
Text(model.keyboardExitHintText)
|
|
.lineLimit(1)
|
|
.minimumScaleFactor(0.8)
|
|
Text(model.rightMouseExitHintText)
|
|
.lineLimit(1)
|
|
.minimumScaleFactor(0.8)
|
|
}
|
|
.font(.system(size: 18, weight: .medium, design: .rounded))
|
|
.foregroundStyle(Color.white.opacity(0.78))
|
|
.multilineTextAlignment(.trailing)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
.padding(.horizontal, 18)
|
|
.padding(.vertical, 10)
|
|
.background(
|
|
RoundedRectangle(cornerRadius: 14, style: .continuous)
|
|
.fill(Color.white.opacity(0.07))
|
|
)
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: 14, style: .continuous)
|
|
.strokeBorder(Color.white.opacity(0.14), lineWidth: 1)
|
|
)
|
|
.shadow(color: Color.black.opacity(0.65), radius: 18, y: 4)
|
|
.padding(.trailing, 32)
|
|
.padding(.bottom, 28)
|
|
}
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
.allowsHitTesting(false)
|
|
.accessibilityHidden(true)
|
|
}
|
|
}
|
|
|
|
private final class EventTapContext {
|
|
let exitBinding: ShortcutBinding
|
|
private let lock = NSLock()
|
|
private let onExit: (EventTapExitReason) -> Void
|
|
private let onInterruption: (EventTapInterruptionReason, Bool) -> Void
|
|
private let onRightMouseButtonEvent: (RightMouseButtonEvent) -> Void
|
|
private var hasTriggeredExit = false
|
|
private var eventTap: CFMachPort?
|
|
|
|
init(
|
|
exitBinding: ShortcutBinding,
|
|
onExit: @escaping (EventTapExitReason) -> Void,
|
|
onInterruption: @escaping (EventTapInterruptionReason, Bool) -> Void,
|
|
onRightMouseButtonEvent: @escaping (RightMouseButtonEvent) -> Void
|
|
) {
|
|
self.exitBinding = exitBinding
|
|
self.onExit = onExit
|
|
self.onInterruption = onInterruption
|
|
self.onRightMouseButtonEvent = onRightMouseButtonEvent
|
|
}
|
|
|
|
func requestExit(_ reason: EventTapExitReason) {
|
|
let shouldNotify: Bool
|
|
|
|
lock.lock()
|
|
shouldNotify = !hasTriggeredExit
|
|
if shouldNotify {
|
|
hasTriggeredExit = true
|
|
}
|
|
lock.unlock()
|
|
|
|
guard shouldNotify else {
|
|
return
|
|
}
|
|
|
|
onExit(reason)
|
|
}
|
|
|
|
func setEventTap(_ eventTap: CFMachPort) {
|
|
lock.lock()
|
|
self.eventTap = eventTap
|
|
lock.unlock()
|
|
}
|
|
|
|
func notifyRightMouseButtonEvent(_ event: RightMouseButtonEvent) {
|
|
let shouldNotify: Bool
|
|
|
|
lock.lock()
|
|
shouldNotify = !hasTriggeredExit
|
|
lock.unlock()
|
|
|
|
guard shouldNotify else {
|
|
return
|
|
}
|
|
|
|
onRightMouseButtonEvent(event)
|
|
}
|
|
|
|
func recoverTap(_ reason: EventTapInterruptionReason) {
|
|
var didRecover = false
|
|
var exitReason: EventTapExitReason?
|
|
|
|
lock.lock()
|
|
|
|
if hasTriggeredExit {
|
|
lock.unlock()
|
|
return
|
|
}
|
|
|
|
if let eventTap {
|
|
CGEvent.tapEnable(tap: eventTap, enable: true)
|
|
didRecover = CGEvent.tapIsEnabled(tap: eventTap)
|
|
|
|
if !didRecover {
|
|
hasTriggeredExit = true
|
|
exitReason = reason.exitReason
|
|
}
|
|
} else {
|
|
hasTriggeredExit = true
|
|
exitReason = reason.exitReason
|
|
}
|
|
|
|
lock.unlock()
|
|
|
|
onInterruption(reason, didRecover)
|
|
|
|
if let exitReason {
|
|
onExit(exitReason)
|
|
}
|
|
}
|
|
}
|
|
|
|
let exitBinding: ShortcutBinding
|
|
|
|
private let onEnd: (EndReason) -> Void
|
|
private let localization: PluginLocalization
|
|
private let logger = PhysicalCleanModeLog.session
|
|
private let sessionIdentifier = String(UUID().uuidString.prefix(8))
|
|
private let overlayHintModel = OverlayHintModel()
|
|
|
|
private var overlayWindows: [OverlayWindow] = []
|
|
private var eventTap: CFMachPort?
|
|
private var eventTapRunLoopSource: CFRunLoopSource?
|
|
private var eventTapContextPointer: UnsafeMutableRawPointer?
|
|
private var notificationObservers: [NotificationObservation] = []
|
|
private var previousPresentationOptions: NSApplication.PresentationOptions?
|
|
private var cursorHidden = false
|
|
private var idleSleepAssertionID = IOPMAssertionID(0)
|
|
private var isStopping = false
|
|
private var rightMouseHoldTimer: Timer?
|
|
private var rightMouseHoldRemainingSeconds: Int?
|
|
private var tapDisableTimestamps: [Date] = []
|
|
|
|
private var keyboardExitHintText: String {
|
|
localization.format(
|
|
"overlay.exit.keyboardFormat",
|
|
defaultValue: "按 %@ 退出",
|
|
Self.displayTokens(for: exitBinding).joined(separator: " + ")
|
|
)
|
|
}
|
|
|
|
private var rightMouseExitHintText: String {
|
|
let remainingSeconds = rightMouseHoldRemainingSeconds ?? Self.rightMouseHoldDurationSeconds
|
|
return localization.format(
|
|
"overlay.exit.rightMouseFormat",
|
|
defaultValue: "或长按鼠标右键 %ds 退出",
|
|
remainingSeconds
|
|
)
|
|
}
|
|
|
|
init(
|
|
exitBinding: ShortcutBinding,
|
|
onEnd: @escaping (EndReason) -> Void,
|
|
localization: PluginLocalization = PluginLocalization(bundle: .main)
|
|
) {
|
|
self.exitBinding = exitBinding
|
|
self.onEnd = onEnd
|
|
self.localization = localization
|
|
super.init()
|
|
overlayHintModel.keyboardExitHintText = keyboardExitHintText
|
|
overlayHintModel.rightMouseExitHintText = rightMouseExitHintText
|
|
}
|
|
|
|
func start() throws {
|
|
guard eventTap == nil, overlayWindows.isEmpty else {
|
|
return
|
|
}
|
|
|
|
if PhysicalCleanModeLog.isVerboseLoggingEnabled {
|
|
logger.debug("[\(self.sessionIdentifier, privacy: .public)] starting session")
|
|
}
|
|
|
|
let tapContext = EventTapContext(
|
|
exitBinding: exitBinding,
|
|
onExit: { [weak self] reason in
|
|
Task { @MainActor in
|
|
self?.handleEventTapExit(reason)
|
|
}
|
|
},
|
|
onInterruption: { [weak self] reason, didRecover in
|
|
Task { @MainActor in
|
|
self?.handleEventTapInterruption(reason, didRecover: didRecover)
|
|
}
|
|
},
|
|
onRightMouseButtonEvent: { [weak self] event in
|
|
Task { @MainActor in
|
|
self?.handleRightMouseButtonEvent(event)
|
|
}
|
|
}
|
|
)
|
|
let contextPointer = UnsafeMutableRawPointer(Unmanaged.passRetained(tapContext).toOpaque())
|
|
|
|
let tap = CGEvent.tapCreate(
|
|
tap: .cgSessionEventTap,
|
|
place: .headInsertEventTap,
|
|
options: .defaultTap,
|
|
eventsOfInterest: Self.interceptedEventMask,
|
|
callback: Self.eventTapCallback,
|
|
userInfo: contextPointer
|
|
)
|
|
|
|
guard let tap else {
|
|
logger.error("[\(self.sessionIdentifier, privacy: .public)] failed to create event tap")
|
|
Unmanaged<EventTapContext>.fromOpaque(contextPointer).release()
|
|
throw SessionError.eventTapUnavailable
|
|
}
|
|
|
|
guard let runLoopSource = CFMachPortCreateRunLoopSource(kCFAllocatorDefault, tap, 0) else {
|
|
logger.error("[\(self.sessionIdentifier, privacy: .public)] failed to create event tap run loop source")
|
|
CFMachPortInvalidate(tap)
|
|
Unmanaged<EventTapContext>.fromOpaque(contextPointer).release()
|
|
throw SessionError.runLoopSourceUnavailable
|
|
}
|
|
|
|
eventTap = tap
|
|
eventTapRunLoopSource = runLoopSource
|
|
eventTapContextPointer = contextPointer
|
|
tapContext.setEventTap(tap)
|
|
|
|
CGEvent.tapEnable(tap: tap, enable: false)
|
|
|
|
do {
|
|
previousPresentationOptions = NSApp.presentationOptions
|
|
|
|
NSApp.activate(ignoringOtherApps: true)
|
|
NSApp.presentationOptions = [
|
|
.hideDock,
|
|
.hideMenuBar,
|
|
.disableProcessSwitching
|
|
]
|
|
|
|
try enableIdleLockPrevention()
|
|
try rebuildOverlayWindows()
|
|
installObservers()
|
|
hideCursor()
|
|
|
|
CFRunLoopAddSource(CFRunLoopGetMain(), runLoopSource, CFRunLoopMode.commonModes)
|
|
CGEvent.tapEnable(tap: tap, enable: true)
|
|
if PhysicalCleanModeLog.isVerboseLoggingEnabled {
|
|
logger.debug("[\(self.sessionIdentifier, privacy: .public)] session start completed windows=\(self.overlayWindows.count, privacy: .public)")
|
|
}
|
|
} catch {
|
|
logger.error("[\(self.sessionIdentifier, privacy: .public)] startup failed: \(error.localizedDescription, privacy: .public)")
|
|
tearDown(shouldNotify: false, endReason: .userRequested)
|
|
throw localizedStartupError(from: error)
|
|
}
|
|
}
|
|
|
|
func requestStop(reason: EndReason) {
|
|
tearDown(shouldNotify: true, endReason: reason)
|
|
}
|
|
|
|
func requestEmergencyExit(message: String) {
|
|
logger.error("[\(self.sessionIdentifier, privacy: .public)] emergency exit requested message=\(message, privacy: .public)")
|
|
requestStop(reason: .emergency(message))
|
|
}
|
|
|
|
func windowWillClose(_ notification: Notification) {
|
|
guard !isStopping else {
|
|
return
|
|
}
|
|
|
|
logger.error("[\(self.sessionIdentifier, privacy: .public)] overlay window will close unexpectedly")
|
|
requestEmergencyExit(message: localization.string(
|
|
"session.error.overlayWindowUnexpectedlyClosed",
|
|
defaultValue: "清洁模式覆盖窗口意外关闭,已恢复系统输入。"
|
|
))
|
|
}
|
|
|
|
private func installObservers() {
|
|
let appCenter = NotificationCenter.default
|
|
let workspaceCenter = NSWorkspace.shared.notificationCenter
|
|
|
|
notificationObservers.append(
|
|
NotificationObservation(
|
|
center: appCenter,
|
|
token: appCenter.addObserver(
|
|
forName: NSApplication.didChangeScreenParametersNotification,
|
|
object: nil,
|
|
queue: .main
|
|
) { [weak self] _ in
|
|
Task { @MainActor in
|
|
self?.handleScreenParametersChanged()
|
|
}
|
|
}
|
|
)
|
|
)
|
|
|
|
notificationObservers.append(
|
|
NotificationObservation(
|
|
center: appCenter,
|
|
token: appCenter.addObserver(
|
|
forName: NSApplication.willTerminateNotification,
|
|
object: NSApp,
|
|
queue: .main
|
|
) { [weak self] _ in
|
|
Task { @MainActor in
|
|
self?.requestStop(reason: .userRequested)
|
|
}
|
|
}
|
|
)
|
|
)
|
|
|
|
notificationObservers.append(
|
|
NotificationObservation(
|
|
center: workspaceCenter,
|
|
token: workspaceCenter.addObserver(
|
|
forName: NSWorkspace.sessionDidResignActiveNotification,
|
|
object: nil,
|
|
queue: .main
|
|
) { [weak self] _ in
|
|
Task { @MainActor in
|
|
guard let self else { return }
|
|
self.handleWorkspaceInterruption(
|
|
message: self.localization.string(
|
|
"session.error.workspaceInactiveEmergencyExit",
|
|
defaultValue: "当前会话已锁定或切出,已自动退出清洁模式。"
|
|
)
|
|
)
|
|
}
|
|
}
|
|
)
|
|
)
|
|
|
|
notificationObservers.append(
|
|
NotificationObservation(
|
|
center: workspaceCenter,
|
|
token: workspaceCenter.addObserver(
|
|
forName: NSWorkspace.screensDidSleepNotification,
|
|
object: nil,
|
|
queue: .main
|
|
) { [weak self] _ in
|
|
Task { @MainActor in
|
|
guard let self else { return }
|
|
self.handleWorkspaceInterruption(
|
|
message: self.localization.string(
|
|
"session.error.screensDidSleepEmergencyExit",
|
|
defaultValue: "屏幕已进入睡眠,已自动退出清洁模式。"
|
|
)
|
|
)
|
|
}
|
|
}
|
|
)
|
|
)
|
|
|
|
notificationObservers.append(
|
|
NotificationObservation(
|
|
center: workspaceCenter,
|
|
token: workspaceCenter.addObserver(
|
|
forName: NSWorkspace.willSleepNotification,
|
|
object: nil,
|
|
queue: .main
|
|
) { [weak self] _ in
|
|
Task { @MainActor in
|
|
guard let self else { return }
|
|
self.handleWorkspaceInterruption(
|
|
message: self.localization.string(
|
|
"session.error.systemWillSleepEmergencyExit",
|
|
defaultValue: "系统即将进入睡眠,已自动退出清洁模式。"
|
|
)
|
|
)
|
|
}
|
|
}
|
|
)
|
|
)
|
|
}
|
|
|
|
private func removeObservers() {
|
|
for observer in notificationObservers {
|
|
observer.center.removeObserver(observer.token)
|
|
}
|
|
|
|
notificationObservers.removeAll()
|
|
}
|
|
|
|
private func handleScreenParametersChanged() {
|
|
guard !isStopping else {
|
|
return
|
|
}
|
|
|
|
if PhysicalCleanModeLog.isVerboseLoggingEnabled {
|
|
logger.debug("[\(self.sessionIdentifier, privacy: .public)] screen parameters changed, rebuilding overlay windows")
|
|
}
|
|
|
|
do {
|
|
try rebuildOverlayWindows()
|
|
} catch {
|
|
requestEmergencyExit(message: localizedDescription(from: error))
|
|
}
|
|
}
|
|
|
|
private func handleWorkspaceInterruption(message: String) {
|
|
guard !isStopping else {
|
|
return
|
|
}
|
|
|
|
requestEmergencyExit(message: message)
|
|
}
|
|
|
|
private func handleRightMouseButtonEvent(_ event: RightMouseButtonEvent) {
|
|
guard !isStopping else {
|
|
return
|
|
}
|
|
|
|
switch event {
|
|
case .down:
|
|
startRightMouseHoldCountdown()
|
|
case .up:
|
|
cancelRightMouseHoldCountdown(resetHintText: true)
|
|
}
|
|
}
|
|
|
|
private func rebuildOverlayWindows() throws {
|
|
guard !NSScreen.screens.isEmpty else {
|
|
throw SessionError.missingScreens
|
|
}
|
|
|
|
let level = NSWindow.Level(rawValue: Int(CGWindowLevelForKey(.screenSaverWindow)))
|
|
let windows = try createOverlayWindows(level: level)
|
|
|
|
guard let focusWindow = windows.first else {
|
|
throw SessionError.overlayWindowCreationFailed
|
|
}
|
|
|
|
focusWindow.makeKeyAndOrderFront(nil)
|
|
|
|
let previousWindows = overlayWindows
|
|
overlayWindows = windows
|
|
closeOverlayWindows(previousWindows)
|
|
}
|
|
|
|
private func createOverlayWindows(level: NSWindow.Level) throws -> [OverlayWindow] {
|
|
var windows: [OverlayWindow] = []
|
|
|
|
for screen in NSScreen.screens {
|
|
let window = OverlayWindow(
|
|
contentRect: screen.frame,
|
|
styleMask: .borderless,
|
|
backing: .buffered,
|
|
defer: false,
|
|
screen: screen
|
|
)
|
|
|
|
window.delegate = self
|
|
window.level = level
|
|
window.collectionBehavior = [
|
|
.canJoinAllSpaces,
|
|
.fullScreenAuxiliary,
|
|
.stationary,
|
|
.ignoresCycle
|
|
]
|
|
window.backgroundColor = .black
|
|
window.isOpaque = true
|
|
window.hasShadow = false
|
|
window.ignoresMouseEvents = true
|
|
window.isMovable = false
|
|
window.isReleasedWhenClosed = false
|
|
window.setFrame(screen.frame, display: true)
|
|
window.contentView = NSHostingView(
|
|
rootView: OverlayWatermarkView(model: overlayHintModel)
|
|
)
|
|
window.orderFrontRegardless()
|
|
windows.append(window)
|
|
}
|
|
|
|
guard !windows.isEmpty else {
|
|
throw SessionError.overlayWindowCreationFailed
|
|
}
|
|
|
|
return windows
|
|
}
|
|
|
|
private func enableIdleLockPrevention() throws {
|
|
guard idleSleepAssertionID == Self.nullAssertionID else {
|
|
return
|
|
}
|
|
|
|
var assertionID = Self.nullAssertionID
|
|
let result = IOPMAssertionCreateWithName(
|
|
kIOPMAssertionTypePreventUserIdleDisplaySleep as CFString,
|
|
IOPMAssertionLevel(kIOPMAssertionLevelOn),
|
|
"MacTools Physical Clean Mode" as CFString,
|
|
&assertionID
|
|
)
|
|
|
|
guard result == kIOReturnSuccess else {
|
|
logger.error(
|
|
"[\(self.sessionIdentifier, privacy: .public)] failed to create idle-sleep assertion result=\(result, privacy: .public)"
|
|
)
|
|
throw SessionError.idleLockPreventionFailed
|
|
}
|
|
|
|
idleSleepAssertionID = assertionID
|
|
}
|
|
|
|
private func releaseIdleLockPrevention() {
|
|
guard idleSleepAssertionID != Self.nullAssertionID else {
|
|
return
|
|
}
|
|
|
|
let assertionID = idleSleepAssertionID
|
|
idleSleepAssertionID = Self.nullAssertionID
|
|
let result = IOPMAssertionRelease(assertionID)
|
|
|
|
if result != kIOReturnSuccess {
|
|
logger.error(
|
|
"[\(self.sessionIdentifier, privacy: .public)] failed to release idle-sleep assertion result=\(result, privacy: .public)"
|
|
)
|
|
}
|
|
}
|
|
|
|
private func closeOverlayWindows(_ windows: [OverlayWindow]? = nil) {
|
|
let windowsToClose = windows ?? overlayWindows
|
|
|
|
if windows == nil {
|
|
overlayWindows.removeAll()
|
|
}
|
|
|
|
for window in windowsToClose {
|
|
window.delegate = nil
|
|
window.close()
|
|
}
|
|
}
|
|
|
|
private func hideCursor() {
|
|
guard !cursorHidden else {
|
|
return
|
|
}
|
|
|
|
NSCursor.hide()
|
|
cursorHidden = true
|
|
}
|
|
|
|
private func showCursorIfNeeded() {
|
|
guard cursorHidden else {
|
|
return
|
|
}
|
|
|
|
NSCursor.unhide()
|
|
cursorHidden = false
|
|
}
|
|
|
|
private func startRightMouseHoldCountdown() {
|
|
guard rightMouseHoldTimer == nil else {
|
|
return
|
|
}
|
|
|
|
rightMouseHoldRemainingSeconds = Self.rightMouseHoldDurationSeconds
|
|
updateOverlayHintText()
|
|
|
|
let timer = Timer(timeInterval: 1, repeats: true) { [weak self] _ in
|
|
Task { @MainActor in
|
|
self?.handleRightMouseHoldTimerTick()
|
|
}
|
|
}
|
|
rightMouseHoldTimer = timer
|
|
RunLoop.main.add(timer, forMode: .common)
|
|
}
|
|
|
|
private func cancelRightMouseHoldCountdown(resetHintText: Bool) {
|
|
rightMouseHoldTimer?.invalidate()
|
|
rightMouseHoldTimer = nil
|
|
rightMouseHoldRemainingSeconds = nil
|
|
|
|
if resetHintText {
|
|
updateOverlayHintText()
|
|
}
|
|
}
|
|
|
|
private func handleRightMouseHoldTimerTick() {
|
|
guard let remainingSeconds = rightMouseHoldRemainingSeconds else {
|
|
cancelRightMouseHoldCountdown(resetHintText: false)
|
|
return
|
|
}
|
|
|
|
if remainingSeconds > 1 {
|
|
rightMouseHoldRemainingSeconds = remainingSeconds - 1
|
|
updateOverlayHintText()
|
|
return
|
|
}
|
|
|
|
cancelRightMouseHoldCountdown(resetHintText: false)
|
|
handleEventTapExit(.rightMouseHold)
|
|
}
|
|
|
|
private func updateOverlayHintText() {
|
|
overlayHintModel.keyboardExitHintText = keyboardExitHintText
|
|
overlayHintModel.rightMouseExitHintText = rightMouseExitHintText
|
|
}
|
|
|
|
private func tearDown(shouldNotify: Bool, endReason: EndReason) {
|
|
guard !isStopping else {
|
|
return
|
|
}
|
|
|
|
if case .emergency = endReason {
|
|
logger.error("[\(self.sessionIdentifier, privacy: .public)] tearing down session reason=\(String(describing: endReason), privacy: .public)")
|
|
} else if PhysicalCleanModeLog.isVerboseLoggingEnabled {
|
|
logger.debug("[\(self.sessionIdentifier, privacy: .public)] tearing down session reason=\(String(describing: endReason), privacy: .public)")
|
|
}
|
|
|
|
isStopping = true
|
|
|
|
cancelRightMouseHoldCountdown(resetHintText: false)
|
|
removeObservers()
|
|
closeOverlayWindows()
|
|
|
|
if let eventTap {
|
|
CGEvent.tapEnable(tap: eventTap, enable: false)
|
|
}
|
|
|
|
if let eventTapRunLoopSource {
|
|
CFRunLoopRemoveSource(CFRunLoopGetMain(), eventTapRunLoopSource, CFRunLoopMode.commonModes)
|
|
}
|
|
|
|
if let eventTap {
|
|
CFMachPortInvalidate(eventTap)
|
|
}
|
|
|
|
eventTap = nil
|
|
eventTapRunLoopSource = nil
|
|
|
|
if let eventTapContextPointer {
|
|
Unmanaged<EventTapContext>.fromOpaque(eventTapContextPointer).release()
|
|
self.eventTapContextPointer = nil
|
|
}
|
|
|
|
showCursorIfNeeded()
|
|
releaseIdleLockPrevention()
|
|
|
|
if let previousPresentationOptions {
|
|
NSApp.presentationOptions = previousPresentationOptions
|
|
self.previousPresentationOptions = nil
|
|
}
|
|
|
|
isStopping = false
|
|
|
|
if shouldNotify {
|
|
onEnd(endReason)
|
|
}
|
|
}
|
|
|
|
private func handleEventTapExit(_ reason: EventTapExitReason) {
|
|
switch reason {
|
|
case .shortcut:
|
|
if PhysicalCleanModeLog.isVerboseLoggingEnabled {
|
|
logger.debug("[\(self.sessionIdentifier, privacy: .public)] event tap requested exit reason=shortcut")
|
|
}
|
|
requestStop(reason: .userRequested)
|
|
case .rightMouseHold:
|
|
if PhysicalCleanModeLog.isVerboseLoggingEnabled {
|
|
logger.debug("[\(self.sessionIdentifier, privacy: .public)] event tap requested exit reason=rightMouseHold")
|
|
}
|
|
requestStop(reason: .userRequested)
|
|
case .disabledByTimeout:
|
|
logger.error("[\(self.sessionIdentifier, privacy: .public)] event tap could not recover from timeout interruption")
|
|
requestEmergencyExit(message: localization.string(
|
|
"session.error.eventTapTimeoutEmergencyExit",
|
|
defaultValue: "输入拦截重启失败,已自动退出清洁模式。"
|
|
))
|
|
case .disabledByUserInput:
|
|
logger.error("[\(self.sessionIdentifier, privacy: .public)] event tap could not recover from user-input interruption")
|
|
requestEmergencyExit(message: localization.string(
|
|
"session.error.eventTapUserInputEmergencyExit",
|
|
defaultValue: "输入拦截被系统停用且无法恢复,已自动退出清洁模式。"
|
|
))
|
|
}
|
|
}
|
|
|
|
private func handleEventTapInterruption(
|
|
_ reason: EventTapInterruptionReason,
|
|
didRecover: Bool
|
|
) {
|
|
if PhysicalCleanModeLog.isVerboseLoggingEnabled {
|
|
logger.debug("[\(self.sessionIdentifier, privacy: .public)] event tap interruption reason=\(reason.description, privacy: .public) recovered=\(didRecover, privacy: .public)")
|
|
}
|
|
|
|
guard didRecover else {
|
|
return
|
|
}
|
|
|
|
let now = Date()
|
|
tapDisableTimestamps.append(now)
|
|
tapDisableTimestamps.removeAll { now.timeIntervalSince($0) > 2 }
|
|
|
|
if tapDisableTimestamps.count >= 3 {
|
|
requestEmergencyExit(message: localization.string(
|
|
"session.error.eventTapRepeatedlyDisabledEmergencyExit",
|
|
defaultValue: "输入拦截被系统连续停用,已自动退出清洁模式。"
|
|
))
|
|
}
|
|
}
|
|
|
|
private func localizedStartupError(from error: Error) -> Error {
|
|
guard let sessionError = error as? SessionError else {
|
|
return error
|
|
}
|
|
|
|
return NSError(
|
|
domain: "PhysicalCleanModeSession",
|
|
code: 1,
|
|
userInfo: [
|
|
NSLocalizedDescriptionKey: sessionError.localizedDescription(localization: localization)
|
|
]
|
|
)
|
|
}
|
|
|
|
private func localizedDescription(from error: Error) -> String {
|
|
guard let sessionError = error as? SessionError else {
|
|
return error.localizedDescription
|
|
}
|
|
|
|
return sessionError.localizedDescription(localization: localization)
|
|
}
|
|
|
|
private nonisolated static let interceptedEventMask: CGEventMask = {
|
|
let eventTypes: [CGEventType] = [
|
|
.keyDown,
|
|
.keyUp,
|
|
.flagsChanged,
|
|
.mouseMoved,
|
|
.leftMouseDown,
|
|
.leftMouseUp,
|
|
.leftMouseDragged,
|
|
.rightMouseDown,
|
|
.rightMouseUp,
|
|
.rightMouseDragged,
|
|
.otherMouseDown,
|
|
.otherMouseUp,
|
|
.otherMouseDragged,
|
|
.scrollWheel
|
|
]
|
|
|
|
return eventTypes.reduce(CGEventMask(0)) { partialResult, eventType in
|
|
partialResult | (CGEventMask(1) << UInt64(eventType.rawValue))
|
|
}
|
|
}()
|
|
|
|
private static func displayTokens(for binding: ShortcutBinding) -> [String] {
|
|
ShortcutFormatter.displayTokens(for: binding)
|
|
}
|
|
|
|
private nonisolated static let eventTapCallback: CGEventTapCallBack = { _, type, event, userInfo in
|
|
guard let userInfo else {
|
|
return Unmanaged.passUnretained(event)
|
|
}
|
|
|
|
let context = Unmanaged<EventTapContext>.fromOpaque(userInfo).takeUnretainedValue()
|
|
|
|
switch type {
|
|
case .tapDisabledByTimeout:
|
|
context.recoverTap(.disabledByTimeout)
|
|
return Unmanaged.passUnretained(event)
|
|
case .tapDisabledByUserInput:
|
|
context.recoverTap(.disabledByUserInput)
|
|
return Unmanaged.passUnretained(event)
|
|
case .rightMouseDown:
|
|
context.notifyRightMouseButtonEvent(.down)
|
|
return nil
|
|
case .rightMouseUp:
|
|
context.notifyRightMouseButtonEvent(.up)
|
|
return nil
|
|
case .keyDown:
|
|
let keyCode = UInt16(event.getIntegerValueField(.keyboardEventKeycode))
|
|
let modifiers = ShortcutModifiers.from(event.flags)
|
|
|
|
if keyCode == context.exitBinding.keyCode, modifiers == context.exitBinding.modifiers {
|
|
context.requestExit(.shortcut)
|
|
}
|
|
|
|
return nil
|
|
case .keyUp,
|
|
.flagsChanged,
|
|
.mouseMoved,
|
|
.leftMouseDown,
|
|
.leftMouseUp,
|
|
.leftMouseDragged,
|
|
.rightMouseDragged,
|
|
.otherMouseDown,
|
|
.otherMouseUp,
|
|
.otherMouseDragged,
|
|
.scrollWheel:
|
|
return nil
|
|
default:
|
|
return nil
|
|
}
|
|
}
|
|
}
|