import AppKit import Foundation import IOKit.pwr_mgt import OSLog import MacToolsPluginKit @MainActor protocol KeepAwakeSessionManaging: AnyObject { func start(until endDate: Date?, preventDisplaySleep: Bool) throws func setPreventDisplaySleep(_ preventDisplaySleep: Bool) throws func requestStop(reason: KeepAwakeSession.EndReason) } @MainActor final class KeepAwakeSession: KeepAwakeSessionManaging { enum AssertionKind: Equatable { case system case display var type: CFString { switch self { case .system: return kIOPMAssertionTypePreventUserIdleSystemSleep as CFString case .display: return kIOPMAssertionTypePreventUserIdleDisplaySleep as CFString } } var name: CFString { switch self { case .system: return "MacTools Keep Awake" as CFString case .display: return "MacTools Keep Awake Display" as CFString } } } typealias AssertionCreator = (AssertionKind) -> (result: IOReturn, assertionID: IOPMAssertionID) typealias AssertionReleaser = (IOPMAssertionID) -> IOReturn // IOKit releases are safe during nonisolated teardown; tests inject equivalent deterministic operations. private final class AssertionOperations: @unchecked Sendable { private let creator: AssertionCreator private let releaser: AssertionReleaser init(creator: @escaping AssertionCreator, releaser: @escaping AssertionReleaser) { self.creator = creator self.releaser = releaser } func create(_ kind: AssertionKind) -> (result: IOReturn, assertionID: IOPMAssertionID) { creator(kind) } func release(_ assertionID: IOPMAssertionID) -> IOReturn { releaser(assertionID) } } enum EndReason { case userRequested case completed } private enum SessionError: LocalizedError { case invalidEndDate(PluginLocalization) case systemAssertionCreationFailed(IOReturn, PluginLocalization) case displayAssertionCreationFailed(IOReturn, PluginLocalization) case displayAssertionReleaseFailed(IOReturn, PluginLocalization) var errorDescription: String? { switch self { case let .invalidEndDate(localization): return localization.string( "error.invalidEndDate", defaultValue: "自动停止时间必须晚于当前时间。" ) case let .systemAssertionCreationFailed(result, localization): return localization.format( "error.assertionCreationFailedFormat", defaultValue: "无法启用阻止休眠,系统返回错误 %d。", result ) case let .displayAssertionCreationFailed(result, localization): return localization.format( "error.displayAssertionCreationFailedFormat", defaultValue: "无法保持屏幕常亮,系统返回错误 %d。", result ) case let .displayAssertionReleaseFailed(result, localization): return localization.format( "error.displayAssertionReleaseFailedFormat", defaultValue: "无法允许屏幕关闭,系统返回错误 %d。", result ) } } } private let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "cc.ggbond.mactools", category: "KeepAwakeSession") private let localization: PluginLocalization private let onEnd: (EndReason) -> Void private let assertionOperations: AssertionOperations private var systemAssertionID = IOPMAssertionID(0) private var displayAssertionID = IOPMAssertionID(0) private var autoStopTask: Task? private var isStopping = false private var isObservingTermination = false init( localization: PluginLocalization = PluginLocalization(bundle: .main), onEnd: @escaping (EndReason) -> Void, assertionCreator: AssertionCreator? = nil, assertionReleaser: AssertionReleaser? = nil ) { self.localization = localization self.onEnd = onEnd let resolvedCreator: AssertionCreator = assertionCreator ?? { kind in var assertionID = IOPMAssertionID(0) let result = IOPMAssertionCreateWithName( kind.type, IOPMAssertionLevel(kIOPMAssertionLevelOn), kind.name, &assertionID ) return (result, assertionID) } let resolvedReleaser: AssertionReleaser = assertionReleaser ?? { assertionID in IOPMAssertionRelease(assertionID) } self.assertionOperations = AssertionOperations( creator: resolvedCreator, releaser: resolvedReleaser ) } deinit { autoStopTask?.cancel() if systemAssertionID != IOPMAssertionID(0) { _ = assertionOperations.release(systemAssertionID) } if displayAssertionID != IOPMAssertionID(0) { _ = assertionOperations.release(displayAssertionID) } if isObservingTermination { NotificationCenter.default.removeObserver( self, name: NSApplication.willTerminateNotification, object: nil ) } } func start(until endDate: Date?, preventDisplaySleep: Bool) throws { if systemAssertionID == IOPMAssertionID(0) { try createSystemAssertionIfNeeded() } do { try updateDisplayAssertion(preventDisplaySleep: preventDisplaySleep) } catch { logger.error("failed to update keep-awake display assertion at session start: \(error.localizedDescription, privacy: .public)") } try scheduleAutoStop(until: endDate) installTerminationObserverIfNeeded() } func setPreventDisplaySleep(_ preventDisplaySleep: Bool) throws { try updateDisplayAssertion(preventDisplaySleep: preventDisplaySleep) } func requestStop(reason: EndReason) { finish(reason: reason) } private func installTerminationObserverIfNeeded() { guard !isObservingTermination else { return } NotificationCenter.default.addObserver( self, selector: #selector(handleAppWillTerminate), name: NSApplication.willTerminateNotification, object: nil ) isObservingTermination = true } private func createSystemAssertionIfNeeded() throws { let creation = assertionOperations.create(.system) guard creation.result == kIOReturnSuccess else { logger.error("failed to create keep-awake system assertion result=\(creation.result, privacy: .public)") throw SessionError.systemAssertionCreationFailed(creation.result, localization) } systemAssertionID = creation.assertionID } private func updateDisplayAssertion(preventDisplaySleep: Bool) throws { if preventDisplaySleep { if displayAssertionID == IOPMAssertionID(0) { try createDisplayAssertionIfNeeded() } return } try releaseDisplayAssertionIfNeeded() } private func createDisplayAssertionIfNeeded() throws { let creation = assertionOperations.create(.display) guard creation.result == kIOReturnSuccess else { logger.error("failed to create keep-awake display assertion result=\(creation.result, privacy: .public)") throw SessionError.displayAssertionCreationFailed(creation.result, localization) } displayAssertionID = creation.assertionID } private func releaseDisplayAssertionIfNeeded() throws { guard displayAssertionID != IOPMAssertionID(0) else { return } let existingAssertionID = displayAssertionID let result = assertionOperations.release(existingAssertionID) guard result == kIOReturnSuccess else { logger.error("failed to release keep-awake display assertion result=\(result, privacy: .public)") throw SessionError.displayAssertionReleaseFailed(result, localization) } displayAssertionID = IOPMAssertionID(0) } private func scheduleAutoStop(until endDate: Date?) throws { autoStopTask?.cancel() autoStopTask = nil guard let endDate else { return } let remainingDuration = endDate.timeIntervalSinceNow guard remainingDuration > 0 else { throw SessionError.invalidEndDate(localization) } autoStopTask = Task { [weak self] in let duration = UInt64(remainingDuration * 1_000_000_000) do { try await Task.sleep(nanoseconds: duration) } catch { return } self?.finish(reason: .completed) } } private func finish(reason: EndReason) { guard !isStopping else { return } isStopping = true invalidateTerminationObserver() autoStopTask?.cancel() autoStopTask = nil try? releaseDisplayAssertionIfNeeded() if systemAssertionID != IOPMAssertionID(0) { let existingAssertionID = systemAssertionID systemAssertionID = IOPMAssertionID(0) let result = assertionOperations.release(existingAssertionID) if result != kIOReturnSuccess { logger.error("failed to release keep-awake system assertion result=\(result, privacy: .public)") } } onEnd(reason) } private func invalidateTerminationObserver() { guard isObservingTermination else { return } NotificationCenter.default.removeObserver( self, name: NSApplication.willTerminateNotification, object: nil ) isObservingTermination = false } @objc private func handleAppWillTerminate() { requestStop(reason: .userRequested) } }