From dd2adc87691dd3eb9e39d6963580f1e394202b86 Mon Sep 17 00:00:00 2001 From: ahaduoduoduo <224365942+ahaduoduoduo@users.noreply.github.com> Date: Sun, 14 Jun 2026 17:34:51 +0800 Subject: [PATCH] Add DisplayLink brightness and contrast control --- MonitorControl.xcodeproj/project.pbxproj | 4 + MonitorControl/Model/OtherDisplay.swift | 87 +++- .../Support/DisplayLinkControl.swift | 398 ++++++++++++++++++ MonitorControl/Support/DisplayManager.swift | 61 +++ MonitorControl/Support/MenuHandler.swift | 2 +- MonitorControl/Support/SliderHandler.swift | 3 + .../DisplaysPrefsViewController.swift | 7 +- 7 files changed, 557 insertions(+), 5 deletions(-) create mode 100644 MonitorControl/Support/DisplayLinkControl.swift diff --git a/MonitorControl.xcodeproj/project.pbxproj b/MonitorControl.xcodeproj/project.pbxproj index 3ae92ca..32d24a7 100644 --- a/MonitorControl.xcodeproj/project.pbxproj +++ b/MonitorControl.xcodeproj/project.pbxproj @@ -38,6 +38,7 @@ AA78BDBD2709FE7B00CA8DF7 /* UpdaterDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA78BDBC2709FE7B00CA8DF7 /* UpdaterDelegate.swift */; }; AA99521726FE25AB00612E07 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA99521626FE25AB00612E07 /* AppDelegate.swift */; }; AA99521926FE49A300612E07 /* MenuHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA99521826FE49A300612E07 /* MenuHandler.swift */; }; + AAB41E2D2C1F6A8200E22A10 /* DisplayLinkControl.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAB41E2C2C1F6A8200E22A10 /* DisplayLinkControl.swift */; }; AA9AE86F26B5BF3D00B6CA65 /* OSD.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = AA9AE86E26B5BF3D00B6CA65 /* OSD.framework */; }; AA9AE87126B5BFB700B6CA65 /* CoreDisplay.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = AA9AE87026B5BFB700B6CA65 /* CoreDisplay.framework */; }; AAB2F638273ED099004AB5A4 /* .swiftlint.yml in Resources */ = {isa = PBXBuildFile; fileRef = AAB2F637273ED099004AB5A4 /* .swiftlint.yml */; }; @@ -125,6 +126,7 @@ AA90102027C56A0E00CC1DF7 /* cs */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = cs; path = cs.lproj/Localizable.strings; sourceTree = ""; }; AA99521626FE25AB00612E07 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; AA99521826FE49A300612E07 /* MenuHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuHandler.swift; sourceTree = ""; }; + AAB41E2C2C1F6A8200E22A10 /* DisplayLinkControl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DisplayLinkControl.swift; sourceTree = ""; }; AA99E81527622EBE00413316 /* pt-BR */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "pt-BR"; path = "pt-BR.lproj/Main.strings"; sourceTree = ""; }; AA99E81627622EBE00413316 /* pt-BR */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "pt-BR"; path = "pt-BR.lproj/InternetAccessPolicy.strings"; sourceTree = ""; }; AA99E81727622EBE00413316 /* pt-BR */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "pt-BR"; path = "pt-BR.lproj/Localizable.strings"; sourceTree = ""; }; @@ -314,6 +316,7 @@ 6C85EFD922C941B000227EA1 /* DisplayManager.swift */, AA25F6D626E68C160087F3A2 /* MediaKeyTapManager.swift */, AA44E70627038F7F00E06865 /* KeyboardShortcutsManager.swift */, + AAB41E2C2C1F6A8200E22A10 /* DisplayLinkControl.swift */, AA16139A26BE772E00DCF027 /* Arm64DDC.swift */, AA4398A826DD55DA00943F16 /* IntelDDC.swift */, FE4E0895249D584C003A50BB /* OSDUtils.swift */, @@ -636,6 +639,7 @@ AA44E7052703790100E06865 /* KeyboardShortcuts+Extension.swift in Sources */, F0A489C4279C71B200BEDFD6 /* OnboardingViewController.swift in Sources */, AA16139B26BE772E00DCF027 /* Arm64DDC.swift in Sources */, + AAB41E2D2C1F6A8200E22A10 /* DisplayLinkControl.swift in Sources */, F0445D3820023E710025AE82 /* MainPrefsViewController.swift in Sources */, 28D1DDF2227FBE71004CB494 /* NSScreen+Extension.swift in Sources */, AA99521726FE25AB00612E07 /* AppDelegate.swift in Sources */, diff --git a/MonitorControl/Model/OtherDisplay.swift b/MonitorControl/Model/OtherDisplay.swift index 7c579c4..5cc6c0e 100644 --- a/MonitorControl/Model/OtherDisplay.swift +++ b/MonitorControl/Model/OtherDisplay.swift @@ -8,6 +8,7 @@ class OtherDisplay: Display { var ddc: IntelDDC? var arm64ddc: Bool = false var arm64avService: IOAVService? + var displayLinkDisplay: DisplayLinkDisplay? var isDiscouraged: Bool = false let writeDDCQueue = DispatchQueue(label: "Local write DDC queue") var writeDDCNextValue: [Command: UInt16] = [:] @@ -200,14 +201,20 @@ class OtherDisplay: Display { } func stepContrast(isUp: Bool, isSmallIncrement: Bool) { - guard !self.readPrefAsBool(key: .unavailableDDC, for: .contrast), !self.isSw() else { + guard !self.readPrefAsBool(key: .unavailableDDC, for: .contrast), !self.isSw() || self.hasDisplayLinkContrastControl() else { return } let currentValue = self.readPrefAsFloat(for: .contrast) let contrastOSDValue = self.calcNewValue(currentValue: currentValue, isUp: isUp, isSmallIncrement: isSmallIncrement) let isAlreadySet = contrastOSDValue == self.readPrefAsFloat(for: .contrast) if !isAlreadySet { - self.writeDDCValues(command: .contrast, value: self.convValueToDDC(for: .contrast, from: contrastOSDValue)) + if self.hasDisplayLinkContrastControl() { + if !self.setDisplayLinkContrast(contrastOSDValue) { + return + } + } else { + self.writeDDCValues(command: .contrast, value: self.convValueToDDC(for: .contrast, from: contrastOSDValue)) + } } OSDUtils.showOsd(displayID: self.identifier, command: .contrast, value: contrastOSDValue, roundChiclet: !isSmallIncrement) if !isAlreadySet { @@ -328,11 +335,34 @@ class OtherDisplay: Display { override func setBrightness(_ to: Float = -1, slow: Bool = false) -> Bool { self.checkGammaInterference() + if self.hasDisplayLinkBrightnessControl() { + let value = to == -1 ? self.readPrefAsFloat(for: .brightness) : to + return self.setDirectBrightness(value) + } return super.setBrightness(to, slow: slow) } override func setDirectBrightness(_ to: Float, transient: Bool = false) -> Bool { let value = max(min(to, 1), 0) + if self.hasDisplayLinkBrightnessControl() { + if DisplayLinkControl.shared.setBrightness(for: self.identifier, value: value) { + if self.readPrefAsFloat(key: .SwBrightness) != 1 { + _ = self.setSwBrightness(1) + } else { + self.savePref(1, key: .SwBrightness) + } + _ = DisplayManager.shared.destroyShade(displayID: DisplayManager.resolveEffectiveDisplayID(self.identifier)) + if !transient { + self.savePref(value, for: .brightness) + self.brightnessSyncSourceValue = value + self.smoothBrightnessTransient = value + } + return true + } + os_log("DisplayLink brightness write failed for display %{public}@. Falling back to software brightness.", type: .info, String(self.identifier)) + _ = super.setDirectBrightness(to, transient: transient) + return true + } if !self.isSw() { if !prefs.bool(forKey: PrefKey.disableCombinedBrightness.rawValue) { var brightnessValue: Float = 0 @@ -362,7 +392,58 @@ class OtherDisplay: Display { } override func getBrightness() -> Float { - self.prefExists(for: .brightness) ? self.readPrefAsFloat(for: .brightness) : 1 + if let displayLinkDisplay = self.displayLinkDisplay, !self.prefExists(for: .brightness) { + return displayLinkDisplay.brightness + } + return self.prefExists(for: .brightness) ? self.readPrefAsFloat(for: .brightness) : 1 + } + + func bindDisplayLinkDisplay(_ display: DisplayLinkDisplay) { + self.applyDisplayLinkDisplay(display, updateSliders: false) + } + + func applyDisplayLinkDisplay(_ display: DisplayLinkDisplay, updateSliders: Bool = true) { + self.displayLinkDisplay = display + guard display.isEnabled else { + return + } + self.savePref(display.brightness, for: .brightness) + self.savePref(1, key: .SwBrightness) + self.brightnessSyncSourceValue = display.brightness + self.smoothBrightnessTransient = display.brightness + if updateSliders, let slider = self.sliderHandler[.brightness] { + slider.setValue(display.brightness, displayID: self.identifier) + } + if let contrast = display.contrast { + self.savePref(contrast, for: .contrast) + self.savePref(DDC_MAX_DETECT_LIMIT, key: .maxDDC, for: .contrast) + if updateSliders, let slider = self.sliderHandler[.contrast] { + slider.setValue(contrast, displayID: self.identifier) + } + } + self.savePref(DDC_MAX_DETECT_LIMIT, key: .maxDDC, for: .brightness) + _ = DisplayManager.shared.destroyShade(displayID: DisplayManager.resolveEffectiveDisplayID(self.identifier)) + } + + func hasDisplayLinkBrightnessControl() -> Bool { + self.displayLinkDisplay?.isEnabled ?? false + } + + func hasDisplayLinkContrastControl() -> Bool { + (self.displayLinkDisplay?.isEnabled ?? false) && self.displayLinkDisplay?.contrast != nil + } + + @discardableResult + func setDisplayLinkContrast(_ value: Float) -> Bool { + guard self.hasDisplayLinkContrastControl() else { + return false + } + if DisplayLinkControl.shared.setContrast(for: self.identifier, value: value) { + self.savePref(value, for: .contrast) + return true + } + os_log("DisplayLink contrast write failed for display %{public}@.", type: .info, String(self.identifier)) + return false } func getRemapControlCodes(command: Command) -> [UInt8] { diff --git a/MonitorControl/Support/DisplayLinkControl.swift b/MonitorControl/Support/DisplayLinkControl.swift new file mode 100644 index 0000000..454fc27 --- /dev/null +++ b/MonitorControl/Support/DisplayLinkControl.swift @@ -0,0 +1,398 @@ +// Copyright © MonitorControl. @JoniVR, @theOneyouseek, @waydabber and others + +import CoreGraphics +import Foundation +import os.log + +struct DisplayLinkDisplay { + let cgID: CGDirectDisplayID + let persistentDisplayId: String + let name: String + let isEnabled: Bool + let brightness: Float + let contrast: Float? +} + +final class DisplayLinkControl { + static let shared = DisplayLinkControl() + static let displayDidUpdateNotification = Notification.Name("MonitorControl.DisplayLinkDisplayDidUpdate") + static let userInfoDisplayIDKey = "displayID" + static let userInfoBrightnessKey = "brightness" + static let userInfoContrastKey = "contrast" + + private enum ControlKind: Hashable { + case brightness + case contrast + + var requestName: String { + switch self { + case .brightness: return "com.displaylink.SetBrightness" + case .contrast: return "com.displaylink.SetContrast" + } + } + + var updateName: String { + switch self { + case .brightness: return "com.displaylink.BrightnessUpdated" + case .contrast: return "com.displaylink.ContrastUpdated" + } + } + + var payloadKey: String { + switch self { + case .brightness: return "brightness" + case .contrast: return "contrast" + } + } + } + + private struct DisplayPayload: Decodable { + let persistentDisplayId: String + let isEnabled: Bool? + let brightness: Float? + let contrast: Float? + let CGID: UInt32? + let name: String? + } + + private struct UpdatePayload: Decodable { + let persistentDisplayId: String? + let statusCode: Int? + let brightness: Float? + let contrast: Float? + } + + private struct DisplayWriteKey: Hashable { + let displayID: CGDirectDisplayID + let kind: ControlKind + } + + private struct LocalWriteTarget { + let value: Float + let createdAt: Date + } + + private let notificationCenter = DistributedNotificationCenter.default() + private let timeout: TimeInterval = 1.5 + private let writeCoalescingDelay: TimeInterval = 0.08 + private let localWriteIgnoreInterval: TimeInterval = 3 + private let stateQueue = DispatchQueue(label: "MonitorControl DisplayLink state queue") + private let writeQueue = DispatchQueue(label: "MonitorControl DisplayLink write queue") + private var observerTokens: [NSObjectProtocol] = [] + private var displaysByID: [CGDirectDisplayID: DisplayLinkDisplay] = [:] + private var displayIDsByPersistentID: [String: CGDirectDisplayID] = [:] + private var pendingWrites: [DisplayWriteKey: Float] = [:] + private var scheduledWrites: Set = [] + private var localWriteTargets: [DisplayWriteKey: LocalWriteTarget] = [:] + + private init() { + self.startObserving() + } + + @discardableResult + func refreshDisplays() -> [DisplayLinkDisplay] { + let note = self.waitForNotification(name: "com.displaylink.DisplayListUpdated", timeout: self.timeout) { + self.notificationCenter.postNotificationName(Notification.Name("com.displaylink.GetDisplays"), object: nil, userInfo: nil, deliverImmediately: true) + } + guard let raw = Self.objectString(note) else { + os_log("DisplayLink display query timed out or returned no data.", type: .info) + self.replaceDisplays([], notify: false) + return [] + } + guard let displays = Self.decodeDisplays(from: raw) else { + os_log("DisplayLink display query returned unparseable payload: %{public}@", type: .error, raw) + self.replaceDisplays([], notify: false) + return [] + } + self.replaceDisplays(displays, notify: false) + os_log("DisplayLink display query found %{public}@ display(s).", type: .info, String(displays.count)) + return displays + } + + func display(for displayID: CGDirectDisplayID) -> DisplayLinkDisplay? { + self.stateQueue.sync { + self.displaysByID[displayID] + } + } + + func setBrightness(for displayID: CGDirectDisplayID, value: Float) -> Bool { + self.enqueueValue(kind: .brightness, for: displayID, value: value) + } + + func setContrast(for displayID: CGDirectDisplayID, value: Float) -> Bool { + self.enqueueValue(kind: .contrast, for: displayID, value: value) + } + + private func startObserving() { + self.observerTokens.append(self.notificationCenter.addObserver(forName: Notification.Name("com.displaylink.DisplayListUpdated"), object: nil, queue: .main) { [weak self] note in + self?.handleDisplayListNotification(note) + }) + self.observerTokens.append(self.notificationCenter.addObserver(forName: Notification.Name("com.displaylink.BrightnessUpdated"), object: nil, queue: .main) { [weak self] note in + self?.handleUpdateNotification(note, kind: .brightness) + }) + self.observerTokens.append(self.notificationCenter.addObserver(forName: Notification.Name("com.displaylink.ContrastUpdated"), object: nil, queue: .main) { [weak self] note in + self?.handleUpdateNotification(note, kind: .contrast) + }) + } + + private func enqueueValue(kind: ControlKind, for displayID: CGDirectDisplayID, value: Float) -> Bool { + guard let display = self.display(for: displayID), display.isEnabled else { + return false + } + let normalizedValue = max(min(value, 1), 0) + let key = DisplayWriteKey(displayID: displayID, kind: kind) + self.setLocalWriteTarget(key, value: normalizedValue) + self.writeQueue.async { + self.pendingWrites[key] = normalizedValue + guard !self.scheduledWrites.contains(key) else { + return + } + self.scheduledWrites.insert(key) + self.writeQueue.asyncAfter(deadline: .now() + self.writeCoalescingDelay) { + self.flushQueuedValue(for: key) + } + } + return true + } + + private func flushQueuedValue(for key: DisplayWriteKey) { + guard let value = self.pendingWrites.removeValue(forKey: key) else { + self.scheduledWrites.remove(key) + return + } + if !self.writeValueSynchronously(kind: key.kind, for: key.displayID, value: value) { + self.clearLocalWriteTarget(key, force: true) + } + if self.pendingWrites[key] != nil { + self.writeQueue.asyncAfter(deadline: .now() + self.writeCoalescingDelay) { + self.flushQueuedValue(for: key) + } + } else { + self.scheduledWrites.remove(key) + } + } + + private func writeValueSynchronously(kind: ControlKind, for displayID: CGDirectDisplayID, value: Float) -> Bool { + guard let display = self.display(for: displayID), display.isEnabled else { + return false + } + guard let payload = Self.jsonString([ + "persistentDisplayId": display.persistentDisplayId, + kind.payloadKey: Double(value), + ]) else { + return false + } + let note = self.waitForNotification(name: kind.updateName, timeout: self.timeout) { + self.notificationCenter.postNotificationName(Notification.Name(kind.requestName), object: payload, userInfo: nil, deliverImmediately: true) + } filter: { note in + guard let raw = Self.objectString(note), + let update = try? JSONDecoder().decode(UpdatePayload.self, from: Data(raw.utf8)) else { + return false + } + return update.persistentDisplayId == display.persistentDisplayId + } + guard let raw = Self.objectString(note), + let update = try? JSONDecoder().decode(UpdatePayload.self, from: Data(raw.utf8)), + update.statusCode == 0 else { + os_log("DisplayLink %{public}@ write failed for display %{public}@.", type: .info, kind.payloadKey, display.persistentDisplayId) + return false + } + guard let acknowledgedValue = self.updatedValue(kind: kind, fallback: value, update: update) else { + return false + } + self.updateCache(displayID: displayID, kind: kind, value: acknowledgedValue, notify: !self.hasActiveLocalWriteTarget(key: DisplayWriteKey(displayID: displayID, kind: kind))) + self.clearLocalWriteTarget(DisplayWriteKey(displayID: displayID, kind: kind), acknowledgedValue: acknowledgedValue) + return true + } + + private func handleDisplayListNotification(_ note: Notification) { + guard let raw = Self.objectString(note), let displays = Self.decodeDisplays(from: raw) else { + return + } + self.replaceDisplays(displays, notify: true) + } + + private func handleUpdateNotification(_ note: Notification, kind: ControlKind) { + guard let raw = Self.objectString(note), + let update = try? JSONDecoder().decode(UpdatePayload.self, from: Data(raw.utf8)), + (update.statusCode ?? 0) == 0, + let persistentDisplayId = update.persistentDisplayId else { + return + } + let value = self.updatedValue(kind: kind, fallback: nil, update: update) + guard let value else { + return + } + guard let displayID = self.displayID(forPersistentDisplayId: persistentDisplayId) else { + return + } + let key = DisplayWriteKey(displayID: displayID, kind: kind) + self.updateCache(displayID: displayID, kind: kind, value: value, notify: !self.hasActiveLocalWriteTarget(key: key)) + self.clearLocalWriteTarget(key, acknowledgedValue: value) + } + + private func displayID(forPersistentDisplayId persistentDisplayId: String) -> CGDirectDisplayID? { + self.stateQueue.sync { + self.displayIDsByPersistentID[persistentDisplayId] + } + } + + @discardableResult + private func updateCache(displayID: CGDirectDisplayID, kind: ControlKind, value: Float, notify: Bool) -> DisplayLinkDisplay? { + var updatedDisplay: DisplayLinkDisplay? + self.stateQueue.sync { + guard let current = self.displaysByID[displayID] else { + return + } + let brightness = kind == .brightness ? value : current.brightness + let contrast = kind == .contrast ? value : current.contrast + let updated = DisplayLinkDisplay( + cgID: current.cgID, + persistentDisplayId: current.persistentDisplayId, + name: current.name, + isEnabled: current.isEnabled, + brightness: brightness, + contrast: contrast + ) + self.displaysByID[displayID] = updated + self.displayIDsByPersistentID[updated.persistentDisplayId] = displayID + updatedDisplay = updated + } + if notify, let updatedDisplay { + self.postDisplayUpdate(updatedDisplay) + } + return updatedDisplay + } + + private func replaceDisplays(_ displays: [DisplayLinkDisplay], notify: Bool) { + self.stateQueue.sync { + var displaysByID: [CGDirectDisplayID: DisplayLinkDisplay] = [:] + var displayIDsByPersistentID: [String: CGDirectDisplayID] = [:] + for display in displays { + displaysByID[display.cgID] = display + displayIDsByPersistentID[display.persistentDisplayId] = display.cgID + } + self.displaysByID = displaysByID + self.displayIDsByPersistentID = displayIDsByPersistentID + } + if notify { + for display in displays { + if self.shouldNotifyUpdate(for: display.cgID) { + self.postDisplayUpdate(display) + } + } + } + } + + private func postDisplayUpdate(_ display: DisplayLinkDisplay) { + var userInfo: [String: Any] = [ + Self.userInfoDisplayIDKey: display.cgID, + Self.userInfoBrightnessKey: display.brightness, + ] + if let contrast = display.contrast { + userInfo[Self.userInfoContrastKey] = contrast + } + DispatchQueue.main.async { + NotificationCenter.default.post(name: Self.displayDidUpdateNotification, object: self, userInfo: userInfo) + } + } + + private func setLocalWriteTarget(_ key: DisplayWriteKey, value: Float) { + self.stateQueue.sync { + self.localWriteTargets[key] = LocalWriteTarget(value: value, createdAt: Date()) + } + } + + private func hasActiveLocalWriteTarget(key: DisplayWriteKey) -> Bool { + self.stateQueue.sync { + guard let target = self.localWriteTargets[key] else { + return false + } + if Date().timeIntervalSince(target.createdAt) > self.localWriteIgnoreInterval { + self.localWriteTargets.removeValue(forKey: key) + return false + } + return true + } + } + + private func clearLocalWriteTarget(_ key: DisplayWriteKey, acknowledgedValue: Float? = nil, force: Bool = false) { + self.stateQueue.sync { + guard force || acknowledgedValue != nil else { + return + } + if force { + self.localWriteTargets.removeValue(forKey: key) + return + } + if let target = self.localWriteTargets[key], let acknowledgedValue, abs(target.value - acknowledgedValue) < 0.002 { + self.localWriteTargets.removeValue(forKey: key) + } + } + } + + private func shouldNotifyUpdate(for displayID: CGDirectDisplayID) -> Bool { + !self.hasActiveLocalWriteTarget(key: DisplayWriteKey(displayID: displayID, kind: .brightness)) && !self.hasActiveLocalWriteTarget(key: DisplayWriteKey(displayID: displayID, kind: .contrast)) + } + + private func updatedValue(kind: ControlKind, fallback: Float?, update: UpdatePayload) -> Float? { + switch kind { + case .brightness: + return update.brightness ?? fallback + case .contrast: + return update.contrast ?? fallback + } + } + + private func waitForNotification(name: String, timeout: TimeInterval, trigger: () -> Void, filter: ((Notification) -> Bool)? = nil) -> Notification? { + var received: Notification? + let token = self.notificationCenter.addObserver(forName: Notification.Name(name), object: nil, queue: nil) { note in + if filter?(note) ?? true { + received = note + } + } + trigger() + let until = Date().addingTimeInterval(timeout) + while received == nil, Date() < until { + RunLoop.current.run(mode: .default, before: Date().addingTimeInterval(0.05)) + } + self.notificationCenter.removeObserver(token) + return received + } + + private static func objectString(_ note: Notification?) -> String? { + if let string = note?.object as? String { + return string + } + if let string = note?.object as? NSString { + return string as String + } + return nil + } + + private static func decodeDisplays(from raw: String) -> [DisplayLinkDisplay]? { + guard let payloads = try? JSONDecoder().decode([DisplayPayload].self, from: Data(raw.utf8)) else { + return nil + } + return payloads.compactMap { payload -> DisplayLinkDisplay? in + guard let cgID = payload.CGID, let brightness = payload.brightness else { + return nil + } + return DisplayLinkDisplay( + cgID: CGDirectDisplayID(cgID), + persistentDisplayId: payload.persistentDisplayId, + name: payload.name ?? payload.persistentDisplayId, + isEnabled: payload.isEnabled ?? true, + brightness: brightness, + contrast: payload.contrast + ) + } + } + + private static func jsonString(_ object: [String: Any]) -> String? { + guard let data = try? JSONSerialization.data(withJSONObject: object, options: []) else { + return nil + } + return String(data: data, encoding: .utf8) + } +} diff --git a/MonitorControl/Support/DisplayManager.swift b/MonitorControl/Support/DisplayManager.swift index 00a4627..e7f5096 100644 --- a/MonitorControl/Support/DisplayManager.swift +++ b/MonitorControl/Support/DisplayManager.swift @@ -13,6 +13,13 @@ class DisplayManager { let gammaActivityEnforcer = NSWindow(contentRect: .init(origin: NSPoint(x: 0, y: 0), size: .init(width: DEBUG_GAMMA_ENFORCER ? 15 : 1, height: DEBUG_GAMMA_ENFORCER ? 15 : 1)), styleMask: [], backing: .buffered, defer: false) var gammaInterferenceCounter = 0 var gammaInterferenceWarningShown = false + private var displayLinkUpdateObserver: NSObjectProtocol? + + private init() { + self.displayLinkUpdateObserver = NotificationCenter.default.addObserver(forName: DisplayLinkControl.displayDidUpdateNotification, object: nil, queue: .main) { [weak self] note in + self?.displayLinkDisplayDidUpdate(note) + } + } func createGammaActivityEnforcer() { self.gammaActivityEnforcer.title = "Monitor Control Gamma Activity Enforcer" @@ -160,8 +167,35 @@ class DisplayManager { return false } + private func displayLinkDisplayDidUpdate(_ note: Notification) { + guard app != nil, app.sleepID == 0, app.reconfigureID == 0, + let displayID = self.displayLinkDisplayID(from: note.userInfo), + let displayLinkDisplay = DisplayLinkControl.shared.display(for: displayID), + let otherDisplay = self.getOtherDisplays().first(where: { $0.identifier == displayID }) else { + return + } + otherDisplay.applyDisplayLinkDisplay(displayLinkDisplay) + } + + private func displayLinkDisplayID(from userInfo: [AnyHashable: Any]?) -> CGDirectDisplayID? { + guard let rawDisplayID = userInfo?[DisplayLinkControl.userInfoDisplayIDKey] else { + return nil + } + if let displayID = rawDisplayID as? CGDirectDisplayID { + return displayID + } + if let displayID = rawDisplayID as? UInt32 { + return CGDirectDisplayID(displayID) + } + if let displayID = rawDisplayID as? NSNumber { + return CGDirectDisplayID(displayID.uint32Value) + } + return nil + } + func configureDisplays() { self.clearDisplays() + DisplayLinkControl.shared.refreshDisplays() var onlineDisplayIDs = [CGDirectDisplayID](repeating: 0, count: 16) var displayCount: UInt32 = 0 guard CGGetOnlineDisplayList(16, &onlineDisplayIDs, &displayCount) == .success else { @@ -183,6 +217,10 @@ class DisplayManager { self.addDisplay(display: appleDisplay) } else { let otherDisplay = OtherDisplay(id, name: name, vendorNumber: vendorNumber, modelNumber: modelNumber, serialNumber: serialNumber, isVirtual: isVirtual, isDummy: isDummy) + if let displayLinkDisplay = DisplayLinkControl.shared.display(for: id) { + otherDisplay.bindDisplayLinkDisplay(displayLinkDisplay) + os_log("DisplayLink display matched - %{public}@", type: .info, "ID: \(otherDisplay.identifier), Persistent ID: \(displayLinkDisplay.persistentDisplayId)") + } os_log("Other display found - %{public}@", type: .info, "ID: \(otherDisplay.identifier), Name: \(otherDisplay.name) (Vendor: \(otherDisplay.vendorNumber ?? 0), Model: \(otherDisplay.modelNumber ?? 0))") self.addDisplay(display: otherDisplay) } @@ -191,6 +229,21 @@ class DisplayManager { func setupOtherDisplays(firstrun: Bool = false) { for otherDisplay in self.getOtherDisplays() { + if otherDisplay.hasDisplayLinkBrightnessControl() { + if !otherDisplay.readPrefAsBool(key: .unavailableDDC, for: .brightness) { + let brightness = otherDisplay.displayLinkDisplay?.brightness ?? otherDisplay.readPrefAsFloat(for: .brightness) + otherDisplay.savePref(brightness, for: .brightness) + otherDisplay.savePref(1, key: .SwBrightness) + otherDisplay.brightnessSyncSourceValue = brightness + otherDisplay.smoothBrightnessTransient = brightness + _ = DisplayManager.shared.destroyShade(displayID: DisplayManager.resolveEffectiveDisplayID(otherDisplay.identifier)) + } + if otherDisplay.hasDisplayLinkContrastControl(), !otherDisplay.readPrefAsBool(key: .unavailableDDC, for: .contrast), let contrast = otherDisplay.displayLinkDisplay?.contrast { + otherDisplay.savePref(contrast, for: .contrast) + otherDisplay.savePref(DDC_MAX_DETECT_LIMIT, key: .maxDDC, for: .contrast) + } + continue + } for command in [Command.audioSpeakerVolume, Command.contrast] where !otherDisplay.readPrefAsBool(key: .unavailableDDC, for: command) && !otherDisplay.isSw() { otherDisplay.setupCurrentAndMaxValues(command: command, firstrun: firstrun) } @@ -379,6 +432,14 @@ class DisplayManager { func restoreSwBrightnessForAllDisplays(async: Bool = false) { for otherDisplay in self.getOtherDisplays() { + if otherDisplay.hasDisplayLinkBrightnessControl() { + _ = otherDisplay.setSwBrightness(1, smooth: async) + _ = DisplayManager.shared.destroyShade(displayID: DisplayManager.resolveEffectiveDisplayID(otherDisplay.identifier)) + if let slider = otherDisplay.sliderHandler[.brightness] { + slider.setValue(otherDisplay.readPrefAsFloat(for: .brightness), displayID: otherDisplay.identifier) + } + continue + } if (otherDisplay.readPrefAsFloat(for: .brightness) == 0 && !prefs.bool(forKey: PrefKey.disableCombinedBrightness.rawValue)) || (otherDisplay.readPrefAsFloat(for: .brightness) < otherDisplay.combinedBrightnessSwitchingValue() && !prefs.bool(forKey: PrefKey.separateCombinedScale.rawValue) && !prefs.bool(forKey: PrefKey.disableCombinedBrightness.rawValue)) || otherDisplay.isSw() { let savedPrefValue = otherDisplay.readPrefAsFloat(key: .SwBrightness) if otherDisplay.getSwBrightness() != savedPrefValue { diff --git a/MonitorControl/Support/MenuHandler.swift b/MonitorControl/Support/MenuHandler.swift index 6fcd486..606dbc3 100644 --- a/MonitorControl/Support/MenuHandler.swift +++ b/MonitorControl/Support/MenuHandler.swift @@ -185,7 +185,7 @@ class MenuHandler: NSMenu, NSMenuDelegate { addedSliderHandlers.append(self.setupMenuSliderHandler(command: .audioSpeakerVolume, display: display, title: title)) } display.sliderHandler[.contrast] = nil - if let otherDisplay = display as? OtherDisplay, !otherDisplay.isSw(), !display.readPrefAsBool(key: .unavailableDDC, for: .contrast), prefs.bool(forKey: PrefKey.showContrast.rawValue) { + if let otherDisplay = display as? OtherDisplay, (!otherDisplay.isSw() || otherDisplay.hasDisplayLinkContrastControl()), !display.readPrefAsBool(key: .unavailableDDC, for: .contrast), prefs.bool(forKey: PrefKey.showContrast.rawValue) { let title = NSLocalizedString("Contrast", comment: "Shown in menu") addedSliderHandlers.append(self.setupMenuSliderHandler(command: .contrast, display: display, title: title)) } diff --git a/MonitorControl/Support/SliderHandler.swift b/MonitorControl/Support/SliderHandler.swift index de28e90..8f93940 100644 --- a/MonitorControl/Support/SliderHandler.swift +++ b/MonitorControl/Support/SliderHandler.swift @@ -292,6 +292,9 @@ class SliderHandler { if self.command == Command.brightness { _ = otherDisplay.setBrightness(value) return + } else if self.command == Command.contrast, otherDisplay.hasDisplayLinkContrastControl() { + _ = otherDisplay.setDisplayLinkContrast(value) + return } else if !otherDisplay.isSw() { if self.command == Command.audioSpeakerVolume { if !otherDisplay.readPrefAsBool(key: .enableMuteUnmute) || value != 0 { diff --git a/MonitorControl/View Controllers/Preferences/DisplaysPrefsViewController.swift b/MonitorControl/View Controllers/Preferences/DisplaysPrefsViewController.swift index 0a8384d..d08cda2 100644 --- a/MonitorControl/View Controllers/Preferences/DisplaysPrefsViewController.swift +++ b/MonitorControl/View Controllers/Preferences/DisplaysPrefsViewController.swift @@ -87,7 +87,12 @@ class DisplaysPrefsViewController: NSViewController, SettingsPane, NSTableViewDa var displayImage = "display.trianglebadge.exclamationmark" var controlMethod = NSLocalizedString("No Control", comment: "Shown in the Display Settings") + " ⚠️" var controlStatus = NSLocalizedString("This display has an unspecified control status.", comment: "Shown in the Display Settings") - if display.isVirtual, !display.isDummy { + if let otherDisplay = display as? OtherDisplay, otherDisplay.hasDisplayLinkBrightnessControl(), !display.isDummy { + displayType = NSLocalizedString("Virtual Display", comment: "Shown in the Display Settings") + displayImage = "tv.and.mediabox" + controlMethod = NSLocalizedString("Hardware (DisplayLink)", comment: "Shown in the Display Settings") + controlStatus = NSLocalizedString("This display supports native DisplayLink brightness and contrast control.", comment: "Shown in the Display Settings") + } else if display.isVirtual, !display.isDummy { displayType = NSLocalizedString("Virtual Display", comment: "Shown in the Display Settings") displayImage = "tv.and.mediabox" controlMethod = NSLocalizedString("Software (shade)", comment: "Shown in the Display Settings") + " ⚠️"