554 lines
17 KiB
Swift
554 lines
17 KiB
Swift
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)
|
|
}
|
|
}
|