import Foundation import OSLog import SwiftUI import MacToolsPluginKit public final class KeepAwakePluginFactory: NSObject, MacToolsPluginBundleFactory { public static func makeProvider(context: PluginRuntimeContext) throws -> any PluginProvider { KeepAwakePluginProvider(context: context) } } @MainActor private struct KeepAwakePluginProvider: PluginProvider { let context: PluginRuntimeContext func makePlugins() -> [any MacToolsPlugin] { [KeepAwakePlugin(localization: PluginLocalization(bundle: context.resourceBundle))] } } @MainActor final class KeepAwakePlugin: MacToolsPlugin, PluginPrimaryPanel, PluginPrimaryPanelIndicatorProviding { typealias SessionFactory = ( PluginLocalization, @escaping (KeepAwakeSession.EndReason) -> Void ) -> any KeepAwakeSessionManaging private enum Timing { static let secondsPerMinute: TimeInterval = 60 } private enum StorageKey { static let persistentEnabled = "persistent-enabled" static let keepDisplayOn = "keep-display-on" } private enum ControlID { static let duration = "duration" } private enum DurationPreset: String { case forever case thirtyMinutes case oneHour case twoHours case fiveHours var timeInterval: TimeInterval? { switch self { case .forever: return nil case .thirtyMinutes: return 30 * 60 case .oneHour: return 60 * 60 case .twoHours: return 2 * 60 * 60 case .fiveHours: return 5 * 60 * 60 } } } private enum DurationOptionID { static let forever = DurationPreset.forever.rawValue static let thirtyMinutes = DurationPreset.thirtyMinutes.rawValue static let oneHour = DurationPreset.oneHour.rawValue static let twoHours = DurationPreset.twoHours.rawValue static let fiveHours = DurationPreset.fiveHours.rawValue } let metadata: PluginMetadata let primaryPanelDescriptor = PluginPrimaryPanelDescriptor( controlStyle: .switch, menuActionBehavior: .keepPresented ) var onStateChange: (() -> Void)? var requestPermissionGuidance: ((String) -> Void)? var shortcutBindingResolver: ((String) -> ShortcutBinding?)? private let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "cc.ggbond.mactools", category: "KeepAwakePlugin") private let localization: PluginLocalization private let sessionFactory: SessionFactory private var storage: PluginStorage private var lastErrorMessage: String? private var session: (any KeepAwakeSessionManaging)? private var selectedDurationPreset: DurationPreset = .forever private var keepDisplayOn = false private var scheduledEndDate: Date? private var timedStateRefreshTimer: Timer? init( context: PluginRuntimeContext = PluginRuntimeContext(pluginID: "keep-awake"), localization: PluginLocalization = PluginLocalization(bundle: .main), sessionFactory: @escaping SessionFactory = { localization, onEnd in KeepAwakeSession(localization: localization, onEnd: onEnd) } ) { self.localization = localization self.storage = context.storage self.sessionFactory = sessionFactory self.keepDisplayOn = context.storage.bool(forKey: StorageKey.keepDisplayOn) self.metadata = PluginMetadata( id: "keep-awake", title: localization.string("metadata.title", defaultValue: "阻止休眠"), iconName: "moon", iconTint: Color(nsColor: .systemOrange), order: 50, defaultDescription: localization.string( "metadata.description", defaultValue: "阻止系统空闲休眠;可选保持屏幕常亮" ) ) } var primaryPanelState: PluginPanelState { PluginPanelState( subtitle: panelSubtitle, isOn: session != nil, isExpanded: false, isEnabled: true, isVisible: true, detail: panelDetail, errorMessage: lastErrorMessage ) } var primaryPanelIndicator: PluginPrimaryPanelIndicator? { guard keepDisplayOn, session != nil else { return nil } return PluginPrimaryPanelIndicator( text: localization.string("panel.display.indicator", defaultValue: "屏幕常亮"), systemImage: "display" ) } var permissionRequirements: [PluginPermissionRequirement] { [] } var settingsSections: [PluginSettingsSection] { [] } var shortcutDefinitions: [PluginShortcutDefinition] { [] } var configuration: PluginConfiguration? { PluginConfiguration(description: metadata.defaultDescription) { [weak self, localization] _ in KeepAwakeSettingsView( keepDisplayOn: Binding( get: { self?.keepDisplayOn ?? false }, set: { [weak self] in self?.setKeepDisplayOn($0) } ), localization: localization ) } } func activate(context: PluginRuntimeContext) { storage = context.storage keepDisplayOn = storage.bool(forKey: StorageKey.keepDisplayOn) guard storage.bool(forKey: StorageKey.persistentEnabled) else { return } selectedDurationPreset = .forever scheduledEndDate = nil applyKeepAwakeConfiguration() } func refresh() { scheduleTimedStateRefreshIfNeeded() } func deactivate(reason: PluginDeactivationReason) { guard reason.requiresStateCleanup else { return } session?.requestStop(reason: .userRequested) } func handleAction(_ action: PluginPanelAction) { switch action { case let .setSwitch(isEnabled): setKeepAwakeEnabled(isEnabled) case .setDisclosureExpanded, .setNavigationSelection, .clearNavigationSelection: return case let .setSelection(controlID, optionID): guard controlID == ControlID.duration else { return } updateDurationPreset(using: optionID) case .setDate, .setSlider, .invokeAction: return } } func permissionState(for permissionID: String) -> PluginPermissionState { PluginPermissionState(isGranted: true, footnote: nil) } func handlePermissionAction(id: String) {} func handleSettingsAction(id: String) {} func handleShortcutAction(id: String) {} private var panelSubtitle: String { guard session != nil else { return metadata.defaultDescription } if let scheduledEndDate { let referenceDate = Date() let remaining = remainingTimeDescription( until: scheduledEndDate, referenceDate: referenceDate ) let stopAt = KeepAwakeStopScheduleFormatting.absoluteStopLabel( until: scheduledEndDate, referenceDate: referenceDate, localization: localization ) return localization.format( "panel.subtitle.timedFormat", defaultValue: "%@ · %@", remaining, stopAt ) } return localization.string( "panel.duration.noAutomaticStop", defaultValue: "不会自动停止" ) } private var panelDetail: PluginPanelDetail? { guard session != nil else { return nil } return PluginPanelDetail( primaryControls: [ PluginPanelControl( id: ControlID.duration, kind: .segmented, options: [ PluginPanelControlOption( id: DurationOptionID.forever, title: localization.string("panel.duration.forever", defaultValue: "永不") ), PluginPanelControlOption(id: DurationOptionID.thirtyMinutes, title: "30min"), PluginPanelControlOption(id: DurationOptionID.oneHour, title: "1h"), PluginPanelControlOption(id: DurationOptionID.twoHours, title: "2h"), PluginPanelControlOption(id: DurationOptionID.fiveHours, title: "5h") ], selectedOptionID: selectedDurationPreset.rawValue, dateValue: nil, minimumDate: nil, displayedComponents: nil, datePickerStyle: nil, sectionTitle: nil, isEnabled: true ) ], secondaryPanel: nil ) } private func setKeepAwakeEnabled(_ isEnabled: Bool) { guard isEnabled else { lastErrorMessage = nil clearPersistentEnabled() session?.requestStop(reason: .userRequested) if session == nil { resetSelectionToDefaults() notifyChange() } return } selectedDurationPreset = .forever lastErrorMessage = nil applyKeepAwakeConfiguration() } private func updateDurationPreset(using optionID: String) { guard let preset = DurationPreset(rawValue: optionID) else { return } selectedDurationPreset = preset lastErrorMessage = nil persistCurrentSelectionIfRunning() guard session != nil else { notifyChange() return } applyKeepAwakeConfiguration() } func setKeepDisplayOn(_ shouldKeepDisplayOn: Bool) { guard keepDisplayOn != shouldKeepDisplayOn else { return } lastErrorMessage = nil guard let session else { keepDisplayOn = shouldKeepDisplayOn persistKeepDisplayOnPreference() notifyChange() return } do { try session.setPreventDisplaySleep(shouldKeepDisplayOn) keepDisplayOn = shouldKeepDisplayOn persistKeepDisplayOnPreference() notifyChange() } catch { logger.error("keep-awake display update failed: \(error.localizedDescription, privacy: .public)") lastErrorMessage = error.localizedDescription notifyChange() } } private func applyKeepAwakeConfiguration() { let session = session ?? sessionFactory(localization) { [weak self] reason in self?.handleSessionEnd(reason) } let endDate = resolvedScheduledEndDate(referenceDate: Date()) do { try session.start(until: endDate, preventDisplaySleep: keepDisplayOn) self.session = session scheduledEndDate = endDate persistCurrentSelectionIfRunning() scheduleTimedStateRefreshIfNeeded() lastErrorMessage = nil notifyChange() } catch { logger.error("keep-awake session update failed: \(error.localizedDescription, privacy: .public)") lastErrorMessage = error.localizedDescription notifyChange() } } private func resolvedScheduledEndDate(referenceDate: Date) -> Date? { selectedDurationPreset.timeInterval.map(referenceDate.addingTimeInterval) } private func remainingTimeDescription( until endDate: Date, referenceDate: Date ) -> String { let remainingDuration = max(endDate.timeIntervalSince(referenceDate), 0) let remainingMinutes = max( Int(ceil(remainingDuration / Timing.secondsPerMinute)), 1 ) let hours = remainingMinutes / 60 let minutes = remainingMinutes % 60 if hours == 0 { return localization.format( "panel.duration.remainingMinutesFormat", defaultValue: "剩余 %d 分钟", remainingMinutes ) } if minutes == 0 { return localization.format( "panel.duration.remainingHoursFormat", defaultValue: "剩余 %d 小时", hours ) } return localization.format( "panel.duration.remainingHoursMinutesFormat", defaultValue: "剩余 %d 小时 %d 分钟", hours, minutes ) } private func scheduleTimedStateRefreshIfNeeded() { invalidateTimedStateRefreshTimer() guard session != nil, let scheduledEndDate else { return } let remainingDuration = scheduledEndDate.timeIntervalSinceNow guard remainingDuration > 0 else { return } let remainder = remainingDuration.truncatingRemainder(dividingBy: Timing.secondsPerMinute) let nextRefreshInterval = remainder > 0 ? remainder : Timing.secondsPerMinute let timer = Timer( timeInterval: nextRefreshInterval, repeats: false ) { [weak self] _ in Task { @MainActor [weak self] in self?.handleTimedStateRefreshTimerFired() } } timer.tolerance = min(1, nextRefreshInterval * 0.1) RunLoop.main.add(timer, forMode: .common) timedStateRefreshTimer = timer } private func handleTimedStateRefreshTimerFired() { guard session != nil, scheduledEndDate != nil else { invalidateTimedStateRefreshTimer() return } notifyChange() scheduleTimedStateRefreshIfNeeded() } private func invalidateTimedStateRefreshTimer() { timedStateRefreshTimer?.invalidate() timedStateRefreshTimer = nil } private func handleSessionEnd(_ reason: KeepAwakeSession.EndReason) { session = nil resetSelectionToDefaults() switch reason { case .userRequested, .completed: lastErrorMessage = nil } notifyChange() } private func persistCurrentSelectionIfRunning() { guard session != nil, selectedDurationPreset == .forever else { clearPersistentEnabled() return } storage.set(true, forKey: StorageKey.persistentEnabled) } private func clearPersistentEnabled() { storage.removeObject(forKey: StorageKey.persistentEnabled) } private func persistKeepDisplayOnPreference() { if keepDisplayOn { storage.set(true, forKey: StorageKey.keepDisplayOn) } else { storage.removeObject(forKey: StorageKey.keepDisplayOn) } } private func resetSelectionToDefaults() { selectedDurationPreset = .forever scheduledEndDate = nil invalidateTimedStateRefreshTimer() } private func notifyChange() { onStateChange?() } } /// Formats the scheduled stop moment for Keep Awake subtitles. enum KeepAwakeStopScheduleFormatting { static func absoluteStopLabel( until endDate: Date, referenceDate: Date, calendar: Calendar = .current, localization: PluginLocalization, locale: Locale = .current ) -> String { let timeZone = calendar.timeZone let timeText = timeString(from: endDate, locale: locale, timeZone: timeZone) if calendar.isDate(endDate, inSameDayAs: referenceDate) { return timeText } if let tomorrowStart = calendar.date( byAdding: .day, value: 1, to: calendar.startOfDay(for: referenceDate) ), calendar.isDate(endDate, inSameDayAs: tomorrowStart) { return localization.format( "panel.subtitle.stopTomorrowAtTimeFormat", defaultValue: "明天 %@", timeText ) } let dateText = dateString( from: endDate, referenceDate: referenceDate, calendar: calendar, locale: locale, timeZone: timeZone ) return localization.format( "panel.subtitle.stopAtDateTimeFormat", defaultValue: "%@ %@", dateText, timeText ) } private static func timeString(from date: Date, locale: Locale, timeZone: TimeZone) -> String { let formatter = DateFormatter() formatter.locale = locale formatter.timeZone = timeZone // Follow the user's locale and preferred 12/24-hour clock while keeping minutes explicit. if let format = DateFormatter.dateFormat(fromTemplate: "jm", options: 0, locale: locale) { formatter.dateFormat = format } else { formatter.timeStyle = .short formatter.dateStyle = .none } return formatter.string(from: date) } private static func dateString( from date: Date, referenceDate: Date, calendar: Calendar, locale: Locale, timeZone: TimeZone ) -> String { let formatter = DateFormatter() formatter.locale = locale formatter.timeZone = timeZone let sameYear = calendar.component(.year, from: date) == calendar.component(.year, from: referenceDate) formatter.setLocalizedDateFormatFromTemplate(sameYear ? "MMMd" : "yMMMd") return formatter.string(from: date) } }