Files
wehub-resource-sync 1d1286fadb
Build / Build and test (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:18:38 +08:00

315 lines
10 KiB
Swift

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<Void, Never>?
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)
}
}