diff --git a/BatFiKit/Sources/AppShared/PowerSettingInfo.swift b/BatFiKit/Sources/AppShared/PowerSettingInfo.swift new file mode 100644 index 0000000..8d06557 --- /dev/null +++ b/BatFiKit/Sources/AppShared/PowerSettingInfo.swift @@ -0,0 +1,11 @@ +import Shared + +public struct PowerSettingInfo { + public let powerMode: PowerMode + public let supportsHighPowerMode: Bool + + public init(powerMode: PowerMode, supportsHighPowerMode: Bool) { + self.powerMode = powerMode + self.supportsHighPowerMode = supportsHighPowerMode + } +} diff --git a/BatFiKit/Sources/BatteryInfo/BatteryInfoView.Model.swift b/BatFiKit/Sources/BatteryInfo/BatteryInfoView.Model.swift index 08c86e5..73b41a0 100644 --- a/BatFiKit/Sources/BatteryInfo/BatteryInfoView.Model.swift +++ b/BatFiKit/Sources/BatteryInfo/BatteryInfoView.Model.swift @@ -10,6 +10,7 @@ import AsyncAlgorithms import Clients import Dependencies import Foundation +import Shared extension BatteryInfoView { @MainActor @@ -17,6 +18,7 @@ extension BatteryInfoView { @Dependency(\.powerSourceClient) private var powerSourceClient @Dependency(\.appChargingState) private var appChargingState @Dependency(\.defaults) private var defaults + @Dependency(\.powerSettingClient) private var powerSettingClient private(set) var state: PowerState? { didSet { @@ -31,6 +33,24 @@ extension BatteryInfoView { objectWillChange.send() } } + + private(set) var powerSettingInfo: PowerSettingInfo? { + willSet { + objectWillChange.send() + } + } + + var powerModeSelection: PowerMode? { + get { powerSettingInfo?.powerMode } + set { + // Resync picker selection + objectWillChange.send() + guard let mode = newValue else { + return + } + setPowerMode(mode) + } + } private var tasks: [Task]? @@ -57,8 +77,14 @@ extension BatteryInfoView { self.state = state } } + + let powerSettingInfoChanges = Task { + for await info in powerSettingClient.powerSettingInfoChanges() { + self.powerSettingInfo = info + } + } - tasks = [powerSourceChanges, observeChargingStateMode] + tasks = [powerSourceChanges, observeChargingStateMode, powerSettingInfoChanges] } func cancelObserving() { @@ -84,5 +110,17 @@ extension BatteryInfoView { let measurement = Measurement(value: temperature, unit: UnitTemperature.celsius) return temperatureFormatter.string(from: measurement) } + + private func setPowerMode(_ mode: PowerMode) { + guard + let sourceKey = state?.powerSource, + let source = PowerSource(key: sourceKey) + else { + return + } + Task { + try? await powerSettingClient.setPowerMode(mode, source) + } + } } } diff --git a/BatFiKit/Sources/BatteryInfo/BatteryInfoView.swift b/BatFiKit/Sources/BatteryInfo/BatteryInfoView.swift index 06b4ecd..f3c6f80 100644 --- a/BatFiKit/Sources/BatteryInfo/BatteryInfoView.swift +++ b/BatFiKit/Sources/BatteryInfo/BatteryInfoView.swift @@ -8,6 +8,7 @@ import SwiftUI import AppShared import L10n +import Shared public struct BatteryInfoView: View { @StateObject private var model = Model() @@ -56,6 +57,22 @@ public struct BatteryInfoView: View { label: l10n.Additional.batteryCapacity, info: percentageFormatter.string(from: NSNumber(floatLiteral: powerState.batteryCapacity))! ) + if let powerSettingInfo = model.powerSettingInfo { + BatteryAdditionalInfo( + label: { Text(l10n.Additional.powerMode) }, + info: { + Picker(l10n.Additional.powerMode, selection: $model.powerModeSelection) { + Text(l10n.Additional.lowPowerMode).tag(PowerMode.low as PowerMode?) + Text(l10n.Additional.autoPowerMode).tag(PowerMode.auto as PowerMode?) + if powerSettingInfo.supportsHighPowerMode { + Text(l10n.Additional.highPowerMode).tag(PowerMode.high as PowerMode?) + } + } + .pickerStyle(.segmented) + .labelsHidden() + } + ) + } } .frame(maxWidth: .infinity) } @@ -98,20 +115,20 @@ struct BatteryMainInfo: View { } } -struct BatteryAdditionalInfo: View { +struct BatteryAdditionalInfo: View { private let itemsSpace: CGFloat = 20 let label: () -> Label - let info: String + let info: () -> Info - init(label: @escaping () -> Label, info: String) { + init(label: @escaping () -> Label, info: @escaping () -> Info) { self.label = label self.info = info } - init(label: String, info: String) where Label == Text { + init(label: String, info: String) where Label == Text, Info == Text { self.label = { Text(label) } - self.info = info + self.info = { Text(info) } } var body: some View { @@ -119,7 +136,7 @@ struct BatteryAdditionalInfo: View { Group { label() Spacer(minLength: itemsSpace) - Text(info) + info() .multilineTextAlignment(.trailing) } .foregroundColor(.secondary) diff --git a/BatFiKit/Sources/Clients/PowerSettingClient.swift b/BatFiKit/Sources/Clients/PowerSettingClient.swift new file mode 100644 index 0000000..f6c59fe --- /dev/null +++ b/BatFiKit/Sources/Clients/PowerSettingClient.swift @@ -0,0 +1,25 @@ +import AppShared +import Dependencies +import Shared + +public struct PowerSettingClient: TestDependencyKey { + public var powerSettingInfoChanges: () -> AsyncStream + public var setPowerMode: (PowerMode, PowerSource) async throws -> Void + + public init( + powerSettingInfoChanges: @escaping () -> AsyncStream, + setPowerMode: @escaping (PowerMode, PowerSource) async throws -> Void + ) { + self.powerSettingInfoChanges = powerSettingInfoChanges + self.setPowerMode = setPowerMode + } + + public static var testValue: PowerSettingClient = unimplemented() +} + +extension DependencyValues { + public var powerSettingClient: PowerSettingClient { + get { self[PowerSettingClient.self] } + set { self[PowerSettingClient.self] = newValue } + } +} diff --git a/BatFiKit/Sources/ClientsLive/PowerSettingClient.swift b/BatFiKit/Sources/ClientsLive/PowerSettingClient.swift new file mode 100644 index 0000000..2d142a0 --- /dev/null +++ b/BatFiKit/Sources/ClientsLive/PowerSettingClient.swift @@ -0,0 +1,112 @@ +import AppShared +import Clients +import Dependencies +import Foundation +import SecureXPC +import Shared +import SystemConfiguration + +extension PowerSettingClient: DependencyKey { + public static var liveValue: PowerSettingClient { + func powerSettingInfo(_ store: SCDynamicStore) -> PowerSettingInfo? { + guard + let settings = SCDynamicStoreCopyValue(store, Private.kIOPMDynamicStoreSettingsKey as CFString) as? [String: Any], + let lowPowerMode = settings[Private.kIOPMLowPowerModeKey] as? UInt8, + let powerMode = PowerMode(rawValue: lowPowerMode) + else { + return nil + } + let supportsHighPowerMode = settings[Private.kIOPMHighPowerModeKey] != nil + return PowerSettingInfo(powerMode: powerMode, supportsHighPowerMode: supportsHighPowerMode) + } + + func createClient() -> XPCClient { + XPCClient.forMachService( + named: Constant.helperBundleIdentifier, + withServerRequirement: try! .sameTeamIdentifier + ) + } + + let observer = Observer() + let client = Self( + powerSettingInfoChanges: { + AsyncStream { continuation in + guard let observer else { + return + } + if let info = powerSettingInfo(observer.store) { + continuation.yield(info) + } + let id = UUID() + observer.addHandler(id) { + if let info = powerSettingInfo(observer.store) { + continuation.yield(info) + } + } + continuation.onTermination = { _ in + observer.removeHandler(id) + } + } + }, + setPowerMode: { powerMode, source in + let settings: [PowerSource: [PowerSetting]] = [source: [.powerMode(powerMode: powerMode)]] + let option = PowerSettingOption(settings: settings) + try await createClient().sendMessage(option, to: XPCRoute.powerSettingOption) + } + ) + return client + } + + private class Observer { + private let handlersQueue = DispatchQueue(label: "software.micropixels.BatFi.PowerSettingClient.Observer") + + private var handlers = [UUID: () -> Void]() + private(set) var store: SCDynamicStore! + + init?() { + guard let store = setUpObserving() else { + return nil + } + self.store = store + } + + private func setUpObserving() -> SCDynamicStore? { + let info = Unmanaged.passUnretained(self).toOpaque() + var context = SCDynamicStoreContext() + context.info = info + guard + let store = SCDynamicStoreCreate(nil, "software.micropixels.BatFi" as CFString, { store, changedKeys, info in + guard let info else { + return + } + let observer = Unmanaged.fromOpaque(info).takeUnretainedValue() + observer.updatePowerSetting() + }, &context), + SCDynamicStoreSetNotificationKeys(store, [Private.kIOPMDynamicStoreSettingsKey] as CFArray, nil) + else { + return nil + } + let source = SCDynamicStoreCreateRunLoopSource(nil, store, 0) + CFRunLoopAddSource(CFRunLoopGetMain(), source, CFRunLoopMode.commonModes) + return store + } + + private func updatePowerSetting() { + handlersQueue.sync { [weak self] in + self?.handlers.values.forEach { $0() } + } + } + + func removeHandler(_ id: UUID) { + handlersQueue.sync { [weak self] in + self?.handlers[id] = nil + } + } + + func addHandler(_ id: UUID, _ handler: @escaping () -> Void) { + handlersQueue.sync { [weak self] in + self?.handlers[id] = handler + } + } + } +} diff --git a/BatFiKit/Sources/L10n/Localizable.xcstrings b/BatFiKit/Sources/L10n/Localizable.xcstrings index 15589ef..ce83181 100644 --- a/BatFiKit/Sources/L10n/Localizable.xcstrings +++ b/BatFiKit/Sources/L10n/Localizable.xcstrings @@ -436,6 +436,17 @@ } } }, + "battery_info.label.additional.auto_power_mode" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Auto" + } + } + } + }, "battery_info.label.additional.battery_capacity" : { "extractionState" : "extracted_with_value", "localizations" : { @@ -494,6 +505,39 @@ } } }, + "battery_info.label.additional.high_power_mode" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "High" + } + } + } + }, + "battery_info.label.additional.low_power_mode" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Low" + } + } + } + }, + "battery_info.label.additional.power_mode" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Power Mode" + } + } + } + }, "battery_info.label.additional.power_source" : { "extractionState" : "extracted_with_value", "localizations" : { diff --git a/BatFiKit/Sources/L10n/Strings.swift b/BatFiKit/Sources/L10n/Strings.swift index f49cc82..9ab73f0 100644 --- a/BatFiKit/Sources/L10n/Strings.swift +++ b/BatFiKit/Sources/L10n/Strings.swift @@ -82,6 +82,14 @@ public enum L10n { public static let powerSource = String(localized: "battery_info.label.additional.power_source", defaultValue: "Power Source", bundle: Bundle.module) /// Temperature public static let temperature = String(localized: "battery_info.label.additional.temperature", defaultValue: "Temperature", bundle: Bundle.module) + /// Power Mode + public static let powerMode = String(localized: "battery_info.label.additional.power_mode", defaultValue: "Power Mode", bundle: Bundle.module) + /// Low + public static let lowPowerMode = String(localized: "battery_info.label.additional.low_power_mode", defaultValue: "Low", bundle: Bundle.module) + /// Auto + public static let autoPowerMode = String(localized: "battery_info.label.additional.auto_power_mode", defaultValue: "Auto", bundle: Bundle.module) + /// High + public static let highPowerMode = String(localized: "battery_info.label.additional.high_power_mode", defaultValue: "High", bundle: Bundle.module) } public enum Main { /// Battery diff --git a/BatFiKit/Sources/Server/RouteHandler.swift b/BatFiKit/Sources/Server/RouteHandler.swift index 3f1ac18..ffb0ee3 100644 --- a/BatFiKit/Sources/Server/RouteHandler.swift +++ b/BatFiKit/Sources/Server/RouteHandler.swift @@ -92,4 +92,46 @@ final class RouteHandler { guard let option = MagSafeLEDOption(rawValue: data.0) else { throw SMCError.canNotCreateMagSafeLEDOption } return option } + + func powerSettingOption(_ option: PowerSettingOption) { + guard + let IOPMCopyPMPreferences = Private.IOPMCopyPMPreferences, + let IOPMFeatureIsAvailable = Private.IOPMFeatureIsAvailable, + let IOPMSetPMPreferences = Private.IOPMSetPMPreferences + else { + return + } + guard var preferences = IOPMCopyPMPreferences().takeUnretainedValue() as? [String: [String: Any]] else { + return + } + for (source, settings) in option.settings { + for sourceValue in source.allValues { + guard + let sourceKey = sourceValue.key, + preferences[sourceKey] != nil + else { + continue + } + for setting in settings { + let settingKey: String + let settingValue: Any + switch setting { + case .powerMode(let powerMode): + if powerMode == .high { + guard IOPMFeatureIsAvailable(Private.kIOPMHighPowerModeKey as CFString, sourceKey as CFString) else { + continue + } + } + settingKey = Private.kIOPMLowPowerModeKey + settingValue = powerMode.rawValue + } + guard IOPMFeatureIsAvailable(settingKey as CFString, sourceKey as CFString) else { + continue + } + preferences[sourceKey]![settingKey] = settingValue + } + } + } + _ = IOPMSetPMPreferences(preferences as CFDictionary) + } } diff --git a/BatFiKit/Sources/Server/Server.swift b/BatFiKit/Sources/Server/Server.swift index 70b19e5..9d75f82 100644 --- a/BatFiKit/Sources/Server/Server.swift +++ b/BatFiKit/Sources/Server/Server.swift @@ -45,6 +45,7 @@ public final class Server { } ) server.registerRoute(XPCRoute.magSafeLEDColor, handler: routeHandler.magsafeLEDColor) + server.registerRoute(XPCRoute.powerSettingOption, handler: routeHandler.powerSettingOption) server.setErrorHandler(errorHandler) server.start() diff --git a/BatFiKit/Sources/Shared/PowerSettingOption.swift b/BatFiKit/Sources/Shared/PowerSettingOption.swift new file mode 100644 index 0000000..be0b861 --- /dev/null +++ b/BatFiKit/Sources/Shared/PowerSettingOption.swift @@ -0,0 +1,63 @@ +import IOKit.ps + +public struct PowerSettingOption: Codable { + public let settings: [PowerSource: [PowerSetting]] + + public init(settings: [PowerSource: [PowerSetting]]) { + self.settings = settings + } +} + +public struct PowerSource: OptionSet, Hashable, Codable { + public let rawValue: UInt8 + + public init(rawValue: UInt8) { + self.rawValue = rawValue + } + + public static let battery = Self(rawValue: 1 << 0) + public static let external = Self(rawValue: 1 << 1) + public static let all: PowerSource = [battery, external] +} + +extension PowerSource { + public init?(key: String) { + switch key { + case kIOPMBatteryPowerKey: + self = .battery + case kIOPMACPowerKey: + self = .external + default: + return nil + } + } + + public var key: String? { + switch self { + case .battery: + return kIOPMBatteryPowerKey + case .external: + return kIOPMACPowerKey + default: + return nil + } + } +} + +extension PowerSource { + public var allValues: [PowerSource] { + Self.allValues.filter { contains($0) } + } + + public static let allValues = [battery, external] +} + +public enum PowerSetting: Codable { + case powerMode(powerMode: PowerMode) +} + +public enum PowerMode: UInt8, Codable { + case auto = 0 + case low = 1 + case high = 2 +} diff --git a/BatFiKit/Sources/Shared/Private.swift b/BatFiKit/Sources/Shared/Private.swift new file mode 100644 index 0000000..eb80460 --- /dev/null +++ b/BatFiKit/Sources/Shared/Private.swift @@ -0,0 +1,27 @@ +import Foundation + +public class Private { + public static let kIOPMDynamicStoreSettingsKey = "State:/IOKit/PowerManagement/CurrentSettings" + + public static let kIOPMLowPowerModeKey = "LowPowerMode" + public static let kIOPMHighPowerModeKey = "HighPowerMode" + + public static let (IOPMCopyPMPreferences, IOPMFeatureIsAvailable, IOPMSetPMPreferences) = { + var IOPMCopyPMPreferencesPointer: UnsafeMutableRawPointer? + var IOPMFeatureIsAvailablePointer: UnsafeMutableRawPointer? + var IOPMSetPMPreferencesPointer: UnsafeMutableRawPointer? + if let handle = dlopen("/System/Library/Frameworks/IOKit.framework/IOKit", RTLD_LAZY) { + IOPMCopyPMPreferencesPointer = dlsym(handle, "IOPMCopyPMPreferences") + IOPMFeatureIsAvailablePointer = dlsym(handle, "IOPMFeatureIsAvailable") + IOPMSetPMPreferencesPointer = dlsym(handle, "IOPMSetPMPreferences") + dlclose(handle) + } + let IOPMCopyPMPreferences = + unsafeBitCast(IOPMCopyPMPreferencesPointer, to: (@convention(c) () -> Unmanaged)?.self) + let IOPMFeatureIsAvailable = + unsafeBitCast(IOPMFeatureIsAvailablePointer, to: (@convention(c) (_ feature: CFString, _ power_source: CFString) -> Bool)?.self) + let IOPMSetPMPreferences = + unsafeBitCast(IOPMSetPMPreferencesPointer, to: (@convention(c) (_ ESPrefs: CFDictionary) -> IOReturn)?.self) + return (IOPMCopyPMPreferences, IOPMFeatureIsAvailable, IOPMSetPMPreferences) + }() +} diff --git a/BatFiKit/Sources/Shared/Route.swift b/BatFiKit/Sources/Shared/Route.swift index aa7bf65..fcc47c1 100644 --- a/BatFiKit/Sources/Shared/Route.swift +++ b/BatFiKit/Sources/Shared/Route.swift @@ -23,4 +23,7 @@ public extension XPCRoute { .withMessageType(MagSafeLEDOption.self) .withReplyType(MagSafeLEDOption.self) .throwsType(SMCError.self) + + static let powerSettingOption = Self.named("powerSettingOption") + .withMessageType(PowerSettingOption.self) }