mirror of
https://github.com/FBukovina/Atoll.git
synced 2026-10-27 23:24:32 +00:00
The brightness HUD broke on Tahoe because Apple deprecated/changed the DisplayServices private framework and stopped posting the BezelEngine distributed notifications that Atoll relied on for change detection. This commit: - Adds CoreBrightnessDisplayClient using Apple's CoreBrightness framework (mirrors the existing KeyboardBrightnessClient pattern) as the primary brightness read/write path - Falls back gracefully to DisplayServices then IODisplay if CoreBrightness is unavailable - Adds a lightweight polling fallback (150ms) so the HUD fires even when no distributed notifications are posted (Control Center, Touch Bar, auto- brightness changes) - Registers the new com.apple.CoreBrightness.DisplayBrightnessChanged notification - Fixes deprecated kIOMasterPortDefault → kIOMainPortDefault in SystemDisplayManager.swift Fixes #452 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
710 lines
26 KiB
Swift
710 lines
26 KiB
Swift
/*
|
|
* Atoll (DynamicIsland)
|
|
* Copyright (C) 2024-2026 Atoll Contributors
|
|
*
|
|
* This program is free software: you can redistribute it and/or modify
|
|
* it under the terms of the GNU General Public License as published by
|
|
* the Free Software Foundation, either version 3 of the License, or
|
|
* (at your option) any later version.
|
|
*
|
|
* This program is distributed in the hope that it will be useful,
|
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
* GNU General Public License for more details.
|
|
*
|
|
* You should have received a copy of the GNU General Public License
|
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
*/
|
|
|
|
import Foundation
|
|
import CoreAudio
|
|
import CoreGraphics
|
|
import IOKit
|
|
|
|
extension Notification.Name {
|
|
static let systemVolumeDidChange = Notification.Name("DynamicIsland.systemVolumeDidChange")
|
|
static let systemBrightnessDidChange = Notification.Name("DynamicIsland.systemBrightnessDidChange")
|
|
static let systemAudioRouteDidChange = Notification.Name("DynamicIsland.systemAudioRouteDidChange")
|
|
}
|
|
|
|
final class HUDSuppressionCoordinator {
|
|
static let shared = HUDSuppressionCoordinator()
|
|
|
|
private let queue = DispatchQueue(label: "com.dynamicisland.hud-suppression")
|
|
private var volumeSuppressedUntil: Date?
|
|
|
|
func suppressVolumeHUD(for interval: TimeInterval) {
|
|
guard interval > 0 else { return }
|
|
queue.sync {
|
|
let proposed = Date().addingTimeInterval(interval)
|
|
if let current = volumeSuppressedUntil {
|
|
volumeSuppressedUntil = max(current, proposed)
|
|
} else {
|
|
volumeSuppressedUntil = proposed
|
|
}
|
|
}
|
|
}
|
|
|
|
var shouldSuppressVolumeHUD: Bool {
|
|
queue.sync {
|
|
guard let expiration = volumeSuppressedUntil else {
|
|
return false
|
|
}
|
|
if Date() < expiration {
|
|
return true
|
|
}
|
|
volumeSuppressedUntil = nil
|
|
return false
|
|
}
|
|
}
|
|
}
|
|
|
|
final class SystemVolumeController {
|
|
static let shared = SystemVolumeController()
|
|
|
|
var onVolumeChange: ((Float, Bool) -> Void)?
|
|
var onRouteChange: (() -> Void)?
|
|
|
|
private let callbackQueue = DispatchQueue(label: "com.dynamicisland.volume-listener")
|
|
private var currentDeviceID: AudioDeviceID = 0
|
|
private var listenersInstalled = false
|
|
private var volumeElement: AudioObjectPropertyElement?
|
|
private var muteElement: AudioObjectPropertyElement?
|
|
private let silenceThreshold: Float = 0.001 // Treat very low values as mute requests.
|
|
|
|
private let candidateElements: [AudioObjectPropertyElement] = [
|
|
kAudioObjectPropertyElementMain,
|
|
AudioObjectPropertyElement(1),
|
|
AudioObjectPropertyElement(2)
|
|
]
|
|
|
|
private init() {
|
|
currentDeviceID = resolveDefaultDevice()
|
|
refreshPropertyElements()
|
|
installDefaultDeviceListener()
|
|
installVolumeListeners(for: currentDeviceID)
|
|
notifyCurrentState()
|
|
}
|
|
|
|
func start() {
|
|
// Listeners are installed during init, nothing else required.
|
|
}
|
|
|
|
func stop() {
|
|
// We keep listeners alive for the app lifetime; clearing closures prevents UI updates.
|
|
onVolumeChange = nil
|
|
onRouteChange = nil
|
|
}
|
|
|
|
func adjust(by delta: Float) {
|
|
guard delta != 0 else { return }
|
|
if isMuted {
|
|
setMuted(false)
|
|
}
|
|
var newValue = currentVolume + delta
|
|
newValue = max(0, min(1, newValue))
|
|
setVolume(newValue)
|
|
}
|
|
|
|
func toggleMute() {
|
|
setMuted(!isMuted)
|
|
}
|
|
|
|
var currentVolume: Float {
|
|
getVolume()
|
|
}
|
|
|
|
var isMuted: Bool {
|
|
getMuteState()
|
|
}
|
|
|
|
func setVolume(_ value: Float) {
|
|
let clamped = max(0, min(1, value))
|
|
let currentlyMuted = isMuted
|
|
|
|
if clamped <= silenceThreshold {
|
|
if !currentlyMuted {
|
|
setMuted(true)
|
|
}
|
|
} else if currentlyMuted {
|
|
setMuted(false)
|
|
}
|
|
|
|
let elements = volumeElements()
|
|
|
|
if elements.isEmpty {
|
|
var volume = clamped
|
|
let status = setData(selector: kAudioDevicePropertyVolumeScalar, data: &volume)
|
|
if status != noErr {
|
|
NSLog("⚠️ Failed to set volume: \(status)")
|
|
}
|
|
} else {
|
|
for element in elements {
|
|
var volume = clamped
|
|
let status = setData(selector: kAudioDevicePropertyVolumeScalar, element: element, data: &volume)
|
|
if status != noErr {
|
|
NSLog("⚠️ Failed to set volume for element \(element): \(status)")
|
|
} else {
|
|
cache(element: element, for: kAudioDevicePropertyVolumeScalar)
|
|
}
|
|
}
|
|
}
|
|
notifyCurrentState()
|
|
}
|
|
|
|
func setMuted(_ muted: Bool) {
|
|
var muteFlag: UInt32 = muted ? 1 : 0
|
|
let elements = muteElements()
|
|
|
|
if elements.isEmpty {
|
|
let status = setData(selector: kAudioDevicePropertyMute, data: &muteFlag)
|
|
if status != noErr {
|
|
NSLog("⚠️ Failed to set mute state: \(status)")
|
|
}
|
|
return
|
|
}
|
|
|
|
for element in elements {
|
|
var value = muteFlag
|
|
let status = setData(selector: kAudioDevicePropertyMute, element: element, data: &value)
|
|
if status != noErr {
|
|
NSLog("⚠️ Failed to set mute state for element \(element): \(status)")
|
|
} else {
|
|
cache(element: element, for: kAudioDevicePropertyMute)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Private
|
|
|
|
private func resolveDefaultDevice() -> AudioDeviceID {
|
|
var deviceID = AudioDeviceID()
|
|
var size = UInt32(MemoryLayout.size(ofValue: deviceID))
|
|
var address = AudioObjectPropertyAddress(
|
|
mSelector: kAudioHardwarePropertyDefaultOutputDevice,
|
|
mScope: kAudioObjectPropertyScopeGlobal,
|
|
mElement: kAudioObjectPropertyElementMain
|
|
)
|
|
let status = AudioObjectGetPropertyData(AudioObjectID(kAudioObjectSystemObject), &address, 0, nil, &size, &deviceID)
|
|
if status != noErr {
|
|
NSLog("⚠️ Unable to fetch default audio device: \(status)")
|
|
}
|
|
return deviceID
|
|
}
|
|
|
|
private func installDefaultDeviceListener() {
|
|
guard !listenersInstalled else { return }
|
|
var address = AudioObjectPropertyAddress(
|
|
mSelector: kAudioHardwarePropertyDefaultOutputDevice,
|
|
mScope: kAudioObjectPropertyScopeGlobal,
|
|
mElement: kAudioObjectPropertyElementMain
|
|
)
|
|
let status = AudioObjectAddPropertyListenerBlock(
|
|
AudioObjectID(kAudioObjectSystemObject),
|
|
&address,
|
|
callbackQueue
|
|
) { [weak self] _, _ in
|
|
guard let self else { return }
|
|
self.handleDefaultDeviceChanged()
|
|
}
|
|
if status != noErr {
|
|
NSLog("⚠️ Failed to install default device listener: \(status)")
|
|
}
|
|
listenersInstalled = true
|
|
}
|
|
|
|
private func installVolumeListeners(for deviceID: AudioDeviceID) {
|
|
if let element = resolveElement(selector: kAudioDevicePropertyVolumeScalar, deviceID: deviceID) {
|
|
volumeElement = element
|
|
var address = makeAddress(selector: kAudioDevicePropertyVolumeScalar, element: element)
|
|
AudioObjectAddPropertyListenerBlock(deviceID, &address, callbackQueue) { [weak self] _, _ in
|
|
self?.notifyCurrentState()
|
|
}
|
|
}
|
|
|
|
if let element = resolveElement(selector: kAudioDevicePropertyMute, deviceID: deviceID) {
|
|
muteElement = element
|
|
var address = makeAddress(selector: kAudioDevicePropertyMute, element: element)
|
|
AudioObjectAddPropertyListenerBlock(deviceID, &address, callbackQueue) { [weak self] _, _ in
|
|
self?.notifyCurrentState()
|
|
}
|
|
}
|
|
}
|
|
|
|
private func handleDefaultDeviceChanged() {
|
|
callbackQueue.async { [weak self] in
|
|
guard let self else { return }
|
|
self.currentDeviceID = self.resolveDefaultDevice()
|
|
self.refreshPropertyElements()
|
|
self.installVolumeListeners(for: self.currentDeviceID)
|
|
self.notifyCurrentState()
|
|
DispatchQueue.main.async {
|
|
self.onRouteChange?()
|
|
NotificationCenter.default.post(name: .systemAudioRouteDidChange, object: nil)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func notifyCurrentState() {
|
|
let volume = getVolume()
|
|
let muted = getMuteState()
|
|
DispatchQueue.main.async {
|
|
self.onVolumeChange?(volume, muted)
|
|
NotificationCenter.default.post(name: .systemVolumeDidChange, object: nil, userInfo: ["value": volume, "muted": muted])
|
|
}
|
|
}
|
|
|
|
private func getVolume() -> Float {
|
|
let elements = volumeElements()
|
|
|
|
if elements.isEmpty {
|
|
var volume = Float32(0)
|
|
let status = getData(selector: kAudioDevicePropertyVolumeScalar, data: &volume)
|
|
if status != noErr {
|
|
NSLog("⚠️ Unable to fetch volume: \(status)")
|
|
}
|
|
return volume
|
|
}
|
|
|
|
var masterVolume: Float?
|
|
var accumulator: Float = 0
|
|
var count: Float = 0
|
|
|
|
for element in elements {
|
|
var value = Float32(0)
|
|
let status = getData(selector: kAudioDevicePropertyVolumeScalar, element: element, data: &value)
|
|
if status == noErr {
|
|
if element == kAudioObjectPropertyElementMaster {
|
|
masterVolume = value
|
|
}
|
|
accumulator += value
|
|
count += 1
|
|
}
|
|
}
|
|
|
|
if let masterVolume {
|
|
return masterVolume
|
|
}
|
|
|
|
if count > 0 {
|
|
return accumulator / count
|
|
}
|
|
|
|
var fallback = Float32(0)
|
|
let status = getData(selector: kAudioDevicePropertyVolumeScalar, data: &fallback)
|
|
if status != noErr {
|
|
NSLog("⚠️ Unable to fetch fallback volume: \(status)")
|
|
}
|
|
return fallback
|
|
}
|
|
|
|
private func getMuteState() -> Bool {
|
|
let elements = muteElements()
|
|
|
|
if elements.isEmpty {
|
|
var mute: UInt32 = 0
|
|
let status = getData(selector: kAudioDevicePropertyMute, data: &mute)
|
|
if status != noErr {
|
|
return false
|
|
}
|
|
return mute != 0
|
|
}
|
|
|
|
var retrieved = false
|
|
var allMuted = true
|
|
|
|
for element in elements {
|
|
var value: UInt32 = 0
|
|
let status = getData(selector: kAudioDevicePropertyMute, element: element, data: &value)
|
|
if status == noErr {
|
|
retrieved = true
|
|
if value == 0 {
|
|
allMuted = false
|
|
}
|
|
}
|
|
}
|
|
|
|
if retrieved {
|
|
return allMuted
|
|
}
|
|
|
|
var fallback: UInt32 = 0
|
|
let status = getData(selector: kAudioDevicePropertyMute, data: &fallback)
|
|
if status != noErr {
|
|
return false
|
|
}
|
|
return fallback != 0
|
|
}
|
|
|
|
private func refreshPropertyElements() {
|
|
volumeElement = resolveElement(selector: kAudioDevicePropertyVolumeScalar, deviceID: currentDeviceID)
|
|
muteElement = resolveElement(selector: kAudioDevicePropertyMute, deviceID: currentDeviceID)
|
|
}
|
|
|
|
private func resolveElement(selector: AudioObjectPropertySelector, deviceID: AudioDeviceID) -> AudioObjectPropertyElement? {
|
|
for element in candidateElements {
|
|
var address = makeAddress(selector: selector, element: element)
|
|
if propertyExists(deviceID: deviceID, address: &address) {
|
|
return element
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
private func preferredElements(for selector: AudioObjectPropertySelector) -> [AudioObjectPropertyElement] {
|
|
if let cached = cachedElement(for: selector) {
|
|
return [cached] + candidateElements.filter { $0 != cached }
|
|
}
|
|
return candidateElements
|
|
}
|
|
|
|
private func cachedElement(for selector: AudioObjectPropertySelector) -> AudioObjectPropertyElement? {
|
|
switch selector {
|
|
case kAudioDevicePropertyVolumeScalar:
|
|
return volumeElement
|
|
case kAudioDevicePropertyMute:
|
|
return muteElement
|
|
default:
|
|
return nil
|
|
}
|
|
}
|
|
|
|
private func cache(element: AudioObjectPropertyElement, for selector: AudioObjectPropertySelector) {
|
|
switch selector {
|
|
case kAudioDevicePropertyVolumeScalar:
|
|
volumeElement = element
|
|
case kAudioDevicePropertyMute:
|
|
muteElement = element
|
|
default:
|
|
break
|
|
}
|
|
}
|
|
|
|
private func makeAddress(selector: AudioObjectPropertySelector, element: AudioObjectPropertyElement) -> AudioObjectPropertyAddress {
|
|
AudioObjectPropertyAddress(
|
|
mSelector: selector,
|
|
mScope: kAudioDevicePropertyScopeOutput,
|
|
mElement: element
|
|
)
|
|
}
|
|
|
|
private func propertyExists(deviceID: AudioDeviceID, address: inout AudioObjectPropertyAddress) -> Bool {
|
|
withUnsafePointer(to: &address) { pointer in
|
|
AudioObjectHasProperty(deviceID, pointer)
|
|
}
|
|
}
|
|
|
|
private func getData<T>(selector: AudioObjectPropertySelector, data: inout T) -> OSStatus {
|
|
var lastStatus: OSStatus = kAudioHardwareUnspecifiedError
|
|
for element in preferredElements(for: selector) {
|
|
var address = makeAddress(selector: selector, element: element)
|
|
guard propertyExists(deviceID: currentDeviceID, address: &address) else { continue }
|
|
var size = UInt32(MemoryLayout<T>.size)
|
|
lastStatus = AudioObjectGetPropertyData(currentDeviceID, &address, 0, nil, &size, &data)
|
|
if lastStatus == noErr {
|
|
cache(element: element, for: selector)
|
|
return lastStatus
|
|
}
|
|
}
|
|
return lastStatus
|
|
}
|
|
|
|
private func setData<T>(selector: AudioObjectPropertySelector, data: inout T) -> OSStatus {
|
|
var lastStatus: OSStatus = kAudioHardwareUnspecifiedError
|
|
for element in preferredElements(for: selector) {
|
|
var address = makeAddress(selector: selector, element: element)
|
|
guard propertyExists(deviceID: currentDeviceID, address: &address) else { continue }
|
|
let size = UInt32(MemoryLayout<T>.size)
|
|
lastStatus = AudioObjectSetPropertyData(currentDeviceID, &address, 0, nil, size, &data)
|
|
if lastStatus == noErr {
|
|
cache(element: element, for: selector)
|
|
return lastStatus
|
|
}
|
|
}
|
|
return lastStatus
|
|
}
|
|
|
|
private func getData<T>(selector: AudioObjectPropertySelector, element: AudioObjectPropertyElement, data: inout T) -> OSStatus {
|
|
var address = makeAddress(selector: selector, element: element)
|
|
guard propertyExists(deviceID: currentDeviceID, address: &address) else {
|
|
return kAudioHardwareUnknownPropertyError
|
|
}
|
|
var size = UInt32(MemoryLayout<T>.size)
|
|
return AudioObjectGetPropertyData(currentDeviceID, &address, 0, nil, &size, &data)
|
|
}
|
|
|
|
private func setData<T>(selector: AudioObjectPropertySelector, element: AudioObjectPropertyElement, data: inout T) -> OSStatus {
|
|
var address = makeAddress(selector: selector, element: element)
|
|
guard propertyExists(deviceID: currentDeviceID, address: &address) else {
|
|
return kAudioHardwareUnknownPropertyError
|
|
}
|
|
let size = UInt32(MemoryLayout<T>.size)
|
|
return AudioObjectSetPropertyData(currentDeviceID, &address, 0, nil, size, &data)
|
|
}
|
|
|
|
private func volumeElements() -> [AudioObjectPropertyElement] {
|
|
candidateElements.filter { element in
|
|
var address = makeAddress(selector: kAudioDevicePropertyVolumeScalar, element: element)
|
|
return propertyExists(deviceID: currentDeviceID, address: &address)
|
|
}
|
|
}
|
|
|
|
private func muteElements() -> [AudioObjectPropertyElement] {
|
|
candidateElements.filter { element in
|
|
var address = makeAddress(selector: kAudioDevicePropertyMute, element: element)
|
|
return propertyExists(deviceID: currentDeviceID, address: &address)
|
|
}
|
|
}
|
|
}
|
|
|
|
final class SystemBrightnessController {
|
|
static let shared = SystemBrightnessController()
|
|
|
|
var onBrightnessChange: ((Float) -> Void)?
|
|
|
|
private let notificationCenter = NotificationCenter.default
|
|
private var observers: [NSObjectProtocol] = []
|
|
private var notificationsInstalled = false
|
|
private var displayID: CGDirectDisplayID = CGMainDisplayID()
|
|
private var brightnessAnimationTimer: Timer?
|
|
private var brightnessAnimationStart: Float = 0
|
|
private var brightnessAnimationTarget: Float = 0
|
|
private var brightnessAnimationStartDate: Date?
|
|
private var currentBrightnessAnimationDuration: TimeInterval = 0.18
|
|
private let brightnessAnimationSteps = 10
|
|
private let minimumBrightnessAnimationDuration: TimeInterval = 0.08
|
|
private let maximumBrightnessAnimationDuration: TimeInterval = 0.3
|
|
private let brightnessAnimationDurationScale: TimeInterval = 1.6
|
|
private var lastEmittedBrightness: Float = 0.5
|
|
private let coreBrightnessClient = CoreBrightnessDisplayClient.shared
|
|
private var pollTimer: Timer?
|
|
private let pollInterval: TimeInterval = 0.15
|
|
private let pollChangeThreshold: Float = 0.005
|
|
|
|
private init() {
|
|
registerExternalNotifications()
|
|
lastEmittedBrightness = currentBrightness
|
|
}
|
|
|
|
func start() {
|
|
notifyCurrentBrightness()
|
|
startPollingIfNeeded()
|
|
}
|
|
|
|
func stop() {
|
|
onBrightnessChange = nil
|
|
brightnessAnimationTimer?.invalidate()
|
|
brightnessAnimationTimer = nil
|
|
pollTimer?.invalidate()
|
|
pollTimer = nil
|
|
}
|
|
|
|
func adjust(by delta: Float) {
|
|
// Refresh baseline to avoid jumping if auto-brightness changed the level.
|
|
syncWithSystemBrightnessIfNeeded()
|
|
setBrightness(lastEmittedBrightness + delta)
|
|
}
|
|
|
|
func setBrightness(_ value: Float) {
|
|
let clamped = max(0, min(1, value))
|
|
DispatchQueue.main.async {
|
|
self.beginBrightnessAnimation(to: clamped)
|
|
}
|
|
}
|
|
|
|
var currentBrightness: Float {
|
|
if let level = coreBrightnessClient.currentBrightness() {
|
|
return level
|
|
}
|
|
if let level = getBrightnessViaDisplayServices() {
|
|
return level
|
|
}
|
|
guard let service = displayService() else { return 0.5 }
|
|
var brightness: Float = 0
|
|
let result = IODisplayGetFloatParameter(service, 0, kIODisplayBrightnessKey as CFString, &brightness)
|
|
IOObjectRelease(service)
|
|
if result != kIOReturnSuccess {
|
|
return 0.5
|
|
}
|
|
return brightness
|
|
}
|
|
|
|
private func notifyCurrentBrightness() {
|
|
let brightness = currentBrightness
|
|
emitBrightnessChange(value: brightness)
|
|
}
|
|
|
|
private func syncWithSystemBrightnessIfNeeded() {
|
|
// Align our internal baseline with the actual system brightness so that
|
|
// subsequent adjustments apply deltas from the true value (important when
|
|
// auto-brightness has changed the level behind our back).
|
|
let systemLevel = currentBrightness
|
|
if abs(systemLevel - lastEmittedBrightness) > 0.001 {
|
|
emitBrightnessChange(value: systemLevel)
|
|
}
|
|
}
|
|
|
|
private func beginBrightnessAnimation(to target: Float) {
|
|
brightnessAnimationTimer?.invalidate()
|
|
|
|
// Refresh baseline from system in case auto-brightness adjusted it.
|
|
syncWithSystemBrightnessIfNeeded()
|
|
|
|
let start = lastEmittedBrightness
|
|
if abs(start - target) <= 0.0005 {
|
|
applyBrightness(target)
|
|
emitBrightnessChange(value: target)
|
|
return
|
|
}
|
|
|
|
brightnessAnimationStart = start
|
|
brightnessAnimationTarget = target
|
|
brightnessAnimationStartDate = Date()
|
|
currentBrightnessAnimationDuration = animationDuration(forDelta: abs(target - start))
|
|
|
|
let interval = currentBrightnessAnimationDuration / Double(brightnessAnimationSteps)
|
|
let timer = Timer(timeInterval: interval, repeats: true) { [weak self] timer in
|
|
guard let self else { return }
|
|
guard let startDate = self.brightnessAnimationStartDate else {
|
|
timer.invalidate()
|
|
self.brightnessAnimationTimer = nil
|
|
return
|
|
}
|
|
let elapsed = Date().timeIntervalSince(startDate)
|
|
let progress = min(elapsed / self.currentBrightnessAnimationDuration, 1)
|
|
let eased = self.ease(progress)
|
|
let value = self.brightnessAnimationStart + (self.brightnessAnimationTarget - self.brightnessAnimationStart) * Float(eased)
|
|
self.applyBrightness(value)
|
|
self.emitBrightnessChange(value: value)
|
|
if progress >= 1 {
|
|
timer.invalidate()
|
|
self.brightnessAnimationTimer = nil
|
|
}
|
|
}
|
|
brightnessAnimationTimer = timer
|
|
RunLoop.main.add(timer, forMode: .common)
|
|
timer.fire()
|
|
}
|
|
|
|
private func animationDuration(forDelta delta: Float) -> TimeInterval {
|
|
let scaled = minimumBrightnessAnimationDuration + TimeInterval(delta) * brightnessAnimationDurationScale
|
|
return min(maximumBrightnessAnimationDuration, max(minimumBrightnessAnimationDuration, scaled))
|
|
}
|
|
|
|
private func applyBrightness(_ value: Float) {
|
|
let clamped = max(0, min(1, value))
|
|
if coreBrightnessClient.setBrightness(clamped) {
|
|
return
|
|
}
|
|
if setBrightnessViaDisplayServices(clamped) {
|
|
return
|
|
}
|
|
guard let service = displayService() else { return }
|
|
let status = IODisplaySetFloatParameter(service, 0, kIODisplayBrightnessKey as CFString, clamped)
|
|
IOObjectRelease(service)
|
|
if status != kIOReturnSuccess {
|
|
NSLog("⚠️ Failed to set brightness via IODisplay: \(status)")
|
|
}
|
|
}
|
|
|
|
private func emitBrightnessChange(value: Float) {
|
|
let clamped = max(0, min(1, value))
|
|
lastEmittedBrightness = clamped
|
|
let dispatchBlock = {
|
|
self.onBrightnessChange?(clamped)
|
|
self.notificationCenter.post(name: .systemBrightnessDidChange, object: nil, userInfo: ["value": clamped])
|
|
}
|
|
if Thread.isMainThread {
|
|
dispatchBlock()
|
|
} else {
|
|
DispatchQueue.main.async(execute: dispatchBlock)
|
|
}
|
|
}
|
|
|
|
private func ease(_ t: Double) -> Double {
|
|
let clamped = min(max(t, 0), 1)
|
|
return 1 - pow(1 - clamped, 3)
|
|
}
|
|
|
|
private func displayService() -> io_service_t? {
|
|
var iterator = io_iterator_t()
|
|
guard IOServiceGetMatchingServices(kIOMainPortDefault, IOServiceMatching("IODisplayConnect"), &iterator) == KERN_SUCCESS else {
|
|
return nil
|
|
}
|
|
let service = IOIteratorNext(iterator)
|
|
IOObjectRelease(iterator)
|
|
return service
|
|
}
|
|
|
|
private func setBrightnessViaDisplayServices(_ value: Float) -> Bool {
|
|
guard let status = DisplayServicesDynamic.shared.setBrightness(displayID: displayID, value: value) else {
|
|
return false
|
|
}
|
|
if status == kIOReturnSuccess {
|
|
return true
|
|
}
|
|
// Attempt to refresh display ID in case the main display changed
|
|
displayID = CGMainDisplayID()
|
|
guard let retry = DisplayServicesDynamic.shared.setBrightness(displayID: displayID, value: value) else {
|
|
NSLog("⚠️ DisplayServicesSetBrightness unavailable after display refresh")
|
|
return false
|
|
}
|
|
if retry != kIOReturnSuccess {
|
|
NSLog("⚠️ DisplayServicesSetBrightness failed: \(retry)")
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
private func getBrightnessViaDisplayServices() -> Float? {
|
|
guard let result = DisplayServicesDynamic.shared.getBrightness(displayID: displayID) else {
|
|
return nil
|
|
}
|
|
if result.status == kIOReturnSuccess {
|
|
return result.value
|
|
}
|
|
displayID = CGMainDisplayID()
|
|
guard let retry = DisplayServicesDynamic.shared.getBrightness(displayID: displayID) else {
|
|
NSLog("⚠️ DisplayServicesGetBrightness unavailable after display refresh")
|
|
return nil
|
|
}
|
|
if retry.status == kIOReturnSuccess {
|
|
return retry.value
|
|
}
|
|
NSLog("⚠️ DisplayServicesGetBrightness failed: \(retry.status)")
|
|
return nil
|
|
}
|
|
|
|
private func registerExternalNotifications() {
|
|
guard !notificationsInstalled else { return }
|
|
let names = [
|
|
Notification.Name("com.apple.BezelEngine.BrightnessChanged"),
|
|
Notification.Name("com.apple.BezelServices.BrightnessChanged"),
|
|
Notification.Name("com.apple.controlcenter.display.brightness"),
|
|
Notification.Name("com.apple.CoreBrightness.DisplayBrightnessChanged")
|
|
]
|
|
observers = names.map { name in
|
|
DistributedNotificationCenter.default().addObserver(forName: name, object: nil, queue: .main) { [weak self] _ in
|
|
self?.notifyCurrentBrightness()
|
|
}
|
|
}
|
|
notificationsInstalled = true
|
|
}
|
|
|
|
private func startPollingIfNeeded() {
|
|
guard pollTimer == nil else { return }
|
|
pollTimer = Timer.scheduledTimer(withTimeInterval: pollInterval, repeats: true) { [weak self] _ in
|
|
guard let self else { return }
|
|
let system = self.currentBrightness
|
|
if abs(system - self.lastEmittedBrightness) > self.pollChangeThreshold {
|
|
self.emitBrightnessChange(value: system)
|
|
}
|
|
}
|
|
}
|
|
|
|
deinit {
|
|
brightnessAnimationTimer?.invalidate()
|
|
pollTimer?.invalidate()
|
|
observers.forEach { DistributedNotificationCenter.default().removeObserver($0) }
|
|
}
|
|
}
|