diff --git a/DynamicIsland/DynamicIslandApp.swift b/DynamicIsland/DynamicIslandApp.swift index bee93b2..6bceecd 100644 --- a/DynamicIsland/DynamicIslandApp.swift +++ b/DynamicIsland/DynamicIslandApp.swift @@ -405,6 +405,9 @@ class AppDelegate: NSObject, NSApplicationDelegate { // Setup SystemHUD Manager SystemHUDManager.shared.setup(coordinator: coordinator) + + // Setup BetterDisplay integration + BetterDisplayManager.shared.configure(coordinator: coordinator) // Setup ScreenRecording Manager if Defaults[.enableScreenRecordingDetection] { diff --git a/DynamicIsland/Localizable.xcstrings b/DynamicIsland/Localizable.xcstrings index 734637b..bb20861 100644 --- a/DynamicIsland/Localizable.xcstrings +++ b/DynamicIsland/Localizable.xcstrings @@ -4762,6 +4762,17 @@ "comment" : "A label indicating a beta version.", "isCommentAutoGenerated" : true, "shouldTranslate" : false + }, + "BetterDisplay" : { + "comment" : "The name of the BetterDisplay integration option.", + "isCommentAutoGenerated" : true + }, + "BetterDisplay Integration" : { + "comment" : "The header text for the \"BetterDisplay Integration\" section in the settings view.", + "isCommentAutoGenerated" : true + }, + "BetterDisplay OSD events will be routed through Atoll's active HUD style. Make sure BetterDisplay's OSD integration is enabled in its Settings › Application › Integration." : { + }, "Blue dashed box shows actual size in Atoll" : { "extractionState" : "stale", @@ -8469,6 +8480,10 @@ "comment" : "Name of a user profile for a designer.", "isCommentAutoGenerated" : true }, + "Detected" : { + "comment" : "A label indicating that BetterDisplay is detected on the system.", + "isCommentAutoGenerated" : true + }, "Detection Status" : { "comment" : "The header for a section that displays whether the screen recording detection feature is currently active or inactive.", "isCommentAutoGenerated" : true, @@ -10205,6 +10220,10 @@ } } }, + "Enable to route BetterDisplay's brightness and volume changes through Atoll's HUD instead of the system OSD." : { + "comment" : "A description of the benefit of enabling BetterDisplay integration.", + "isCommentAutoGenerated" : true + }, "Enable when scaling beyond 30x20px so the Atoll opens gracefully." : { "localizations" : { "pt-BR" : { @@ -12990,6 +13009,10 @@ } } }, + "Install [BetterDisplay](https://betterdisplay.pro) to control external display brightness and volume through Atoll's HUD." : { + "comment" : "A description of the benefits of installing BetterDisplay.", + "isCommentAutoGenerated" : true + }, "Install updates automatically" : { "extractionState" : "stale", "localizations" : { @@ -17324,6 +17347,9 @@ "Not charging" : { "comment" : "Text displayed in the status text when the battery is not charging.", "isCommentAutoGenerated" : true + }, + "Not detected" : { + }, "Not Now" : { "localizations" : { @@ -19125,6 +19151,10 @@ } } }, + "Refresh detection" : { + "comment" : "A button label that refreshes the detection status of BetterDisplay.", + "isCommentAutoGenerated" : true + }, "Release name" : { "localizations" : { "ar" : { @@ -25308,6 +25338,10 @@ } } }, + "When enabled, Atoll listens for OSD notifications from BetterDisplay and displays them using your selected HUD style above. This works alongside the existing media key interception — BetterDisplay handles external display controls while Atoll provides the visual feedback." : { + "comment" : "A footer description for the \"BetterDisplay Integration\" section in the settings view.", + "isCommentAutoGenerated" : true + }, "When enabled, Focus appears briefly (on/off) and then collapses instead of staying visible." : { "comment" : "A help text for the setting that allows the focus indicator to collapse instead of staying visible.", "isCommentAutoGenerated" : true, diff --git a/DynamicIsland/components/Settings/SettingsView.swift b/DynamicIsland/components/Settings/SettingsView.swift index 4e81f35..1ee0ec8 100644 --- a/DynamicIsland/components/Settings/SettingsView.swift +++ b/DynamicIsland/components/Settings/SettingsView.swift @@ -650,6 +650,7 @@ struct SettingsView: View { SettingsSearchEntry(tab: .hudAndOSD, title: "Keyboard Backlight OSD", keywords: ["keyboard", "backlight", "osd"], highlightID: SettingsTab.hudAndOSD.highlightID(for: "Keyboard Backlight OSD")), SettingsSearchEntry(tab: .hudAndOSD, title: "Material", keywords: ["material", "frosted", "liquid", "glass", "solid", "osd"], highlightID: SettingsTab.hudAndOSD.highlightID(for: "Material")), SettingsSearchEntry(tab: .hudAndOSD, title: "Icon & Progress Color", keywords: ["color", "icon", "white", "black", "gray", "osd"], highlightID: SettingsTab.hudAndOSD.highlightID(for: "Icon & Progress Color")), + SettingsSearchEntry(tab: .hudAndOSD, title: "BetterDisplay integration", keywords: ["betterdisplay", "external", "display", "brightness", "integration", "third party"], highlightID: SettingsTab.hudAndOSD.highlightID(for: "BetterDisplay integration")), // Media SettingsSearchEntry(tab: .media, title: "Music Source", keywords: ["media source", "controller"], highlightID: SettingsTab.media.highlightID(for: "Music Source")), @@ -1794,6 +1795,9 @@ private struct HUDAndOSDSettingsView: View { } } } + + // BetterDisplay Integration (shared across all HUD variants) + BetterDisplayIntegrationSection() } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) .background(paneBackgroundColor) @@ -1807,6 +1811,74 @@ private struct HUDAndOSDSettingsView: View { } } +// MARK: - BetterDisplay Integration Settings Section + +private struct BetterDisplayIntegrationSection: View { + @Default(.enableBetterDisplayIntegration) var enableBetterDisplayIntegration + @ObservedObject private var betterDisplayManager = BetterDisplayManager.shared + + private func highlightID(_ title: String) -> String { + SettingsTab.hudAndOSD.highlightID(for: title) + } + + var body: some View { + Form { + Section { + HStack { + VStack(alignment: .leading, spacing: 2) { + Text("BetterDisplay") + .font(.system(size: 13, weight: .medium)) + Text(betterDisplayManager.isDetected ? "Detected" : "Not detected") + .font(.caption) + .foregroundStyle(betterDisplayManager.isDetected ? .green : .secondary) + } + + Spacer() + + Toggle("", isOn: $enableBetterDisplayIntegration) + .toggleStyle(.switch) + .labelsHidden() + .disabled(!betterDisplayManager.isDetected) + } + .settingsHighlight(id: highlightID("BetterDisplay integration")) + + if !betterDisplayManager.isDetected { + Text("Install [BetterDisplay](https://betterdisplay.pro) to control external display brightness and volume through Atoll's HUD.") + .font(.caption) + .foregroundStyle(.secondary) + } else if enableBetterDisplayIntegration { + Text("BetterDisplay OSD events will be routed through Atoll's active HUD style. Make sure BetterDisplay's OSD integration is enabled in its Settings › Application › Integration.") + .font(.caption) + .foregroundStyle(.secondary) + } else { + Text("Enable to route BetterDisplay's brightness and volume changes through Atoll's HUD instead of the system OSD.") + .font(.caption) + .foregroundStyle(.secondary) + } + + if betterDisplayManager.isDetected { + Button { + betterDisplayManager.refreshDetectionStatus() + } label: { + Label("Refresh detection", systemImage: "arrow.clockwise") + .font(.caption) + } + .buttonStyle(.link) + } + } header: { + HStack(spacing: 6) { + Image(systemName: "display.2") + Text("BetterDisplay Integration") + } + } footer: { + Text("When enabled, Atoll listens for OSD notifications from BetterDisplay and displays them using your selected HUD style above. This works alongside the existing media key interception — BetterDisplay handles external display controls while Atoll provides the visual feedback.") + .foregroundStyle(.secondary) + .font(.caption) + } + } + } +} + private struct HUDSelectionCard: View { let title: String let isSelected: Bool diff --git a/DynamicIsland/managers/BetterDisplayManager.swift b/DynamicIsland/managers/BetterDisplayManager.swift new file mode 100644 index 0000000..8f1fcc2 --- /dev/null +++ b/DynamicIsland/managers/BetterDisplayManager.swift @@ -0,0 +1,435 @@ +/* + * 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 . + */ + +@preconcurrency import Foundation +import AppKit +import Defaults +import Combine + +// MARK: - BetterDisplay OSD Notification Model + +/// Matches the JSON structure dispatched by BetterDisplay's OSD notification system. +/// See: https://github.com/waydabber/BetterDisplay/wiki/Integration-features,-CLI#osd-notification-dispatch-integration +struct BetterDisplayOSDNotification: Codable { + var displayID: Int? + var systemIconID: Int? + var customSymbol: String? + var text: String? + var lock: Bool? + var controlTarget: String? + var value: Double? + var maxValue: Double? + var symbolFadeAfter: Int? + var symbolSizeMultiplier: Double? + var textFadeAfter: Int? +} + +// MARK: - BetterDisplay Request / Response Models + +/// JSON structure for sending commands to BetterDisplay via distributed notifications. +struct BetterDisplayRequestData: Codable { + var uuid: String? + var commands: [String] = [] + var parameters: [String: String?] = [:] +} + +/// JSON structure for receiving responses from BetterDisplay. +struct BetterDisplayResponseData: Codable { + var uuid: String? + var result: Bool? + var payload: String? +} + +// MARK: - BetterDisplay Control Target Classification + +enum BetterDisplayControlCategory { + case brightness + case volume + case other +} + +private let brightnessControlTargets: Set = [ + "combinedBrightness", + "hardwareBrightness", + "softwareBrightness", +] + +private let volumeControlTargets: Set = [ + "volume", + "mute", +] + +// MARK: - BetterDisplay Manager + +/// Manages integration with the BetterDisplay app (waydabber.BetterDisplay). +/// +/// Responsibilities: +/// - Detect whether BetterDisplay is installed +/// - Observe OSD notifications from BetterDisplay and route them to Atoll's HUD pipeline +/// - Provide request/response primitives for controlling display properties +@MainActor +final class BetterDisplayManager: ObservableObject { + static let shared = BetterDisplayManager() + + /// The bundle identifier of BetterDisplay. + nonisolated static let bundleID = "pro.betterdisplay.BetterDisplay" + + // Notification names + private static let osdNotificationName = NSNotification.Name("com.betterdisplay.BetterDisplay.osd") + private static let requestNotificationName = NSNotification.Name("com.betterdisplay.BetterDisplay.request") + private static let responseNotificationName = NSNotification.Name("com.betterdisplay.BetterDisplay.response") + + // MARK: Published state + + /// Whether BetterDisplay is currently detected on this machine. + @Published private(set) var isDetected: Bool = false + + // MARK: Private + + private var osdObserver: NSObjectProtocol? + private var responseObserver: NSObjectProtocol? + private var workspaceObserver: NSObjectProtocol? + private var cancellables = Set() + private weak var coordinator: DynamicIslandViewCoordinator? + + private init() { + isDetected = Self.checkInstallation() + setupWorkspaceObserver() + setupSettingsObserver() + } + + // MARK: - Public API + + /// Configure with the view coordinator for HUD dispatch. + func configure(coordinator: DynamicIslandViewCoordinator) { + self.coordinator = coordinator + + // Start listening if integration is enabled and BetterDisplay is detected + if Defaults[.enableBetterDisplayIntegration] && isDetected { + startListening() + } + } + + /// Refresh detection status (e.g. after app install/uninstall). + func refreshDetectionStatus() { + let wasDetected = isDetected + isDetected = Self.checkInstallation() + if wasDetected && !isDetected { + stopListening() + } else if !wasDetected && isDetected && Defaults[.enableBetterDisplayIntegration] { + startListening() + } + } + + // MARK: - Detection + + /// Check if BetterDisplay is installed by looking for its bundle ID in running apps + /// and falling back to NSWorkspace URL lookup. + static func checkInstallation() -> Bool { + // Check running apps first (fast path) + if NSWorkspace.shared.runningApplications.contains(where: { $0.bundleIdentifier == bundleID }) { + return true + } + + // Fallback: check if the app is installed via URL scheme or bundle lookup + if let url = NSWorkspace.shared.urlForApplication(withBundleIdentifier: bundleID) { + return FileManager.default.fileExists(atPath: url.path) + } + + return false + } + + // MARK: - OSD Listening + + private func startListening() { + guard osdObserver == nil else { return } + + osdObserver = DistributedNotificationCenter.default().addObserver( + forName: Self.osdNotificationName, + object: nil, + queue: .main + ) { [weak self] notification in + Task { @MainActor in + self?.handleOSDNotification(notification) + } + } + + NSLog("✅ BetterDisplay OSD listener started") + } + + private func stopListening() { + if let observer = osdObserver { + DistributedNotificationCenter.default().removeObserver(observer) + osdObserver = nil + NSLog("⏹ BetterDisplay OSD listener stopped") + } + } + + // MARK: - OSD Handling + + private func handleOSDNotification(_ notification: Notification) { + guard Defaults[.enableBetterDisplayIntegration] else { return } + + guard let notificationString = notification.object as? String else { + NSLog("⚠️ BetterDisplay OSD: unexpected notification format") + return + } + + do { + let osd = try JSONDecoder().decode( + BetterDisplayOSDNotification.self, + from: Data(notificationString.utf8) + ) + routeOSDToHUD(osd) + } catch { + NSLog("⚠️ BetterDisplay OSD decode error: \(error.localizedDescription)") + } + } + + /// Route a decoded BetterDisplay OSD notification to the active Atoll HUD variant. + private func routeOSDToHUD(_ osd: BetterDisplayOSDNotification) { + let category = classifyControlTarget(osd.controlTarget, systemIconID: osd.systemIconID) + let normalizedValue = normalizeValue(osd.value, maxValue: osd.maxValue) + + switch category { + case .brightness: + guard Defaults[.enableBrightnessHUD] else { return } + dispatchBrightnessHUD(value: normalizedValue) + + case .volume: + guard Defaults[.enableVolumeHUD] else { return } + let isMuted = osd.controlTarget == "mute" || osd.systemIconID == 4 + dispatchVolumeHUD(value: normalizedValue, isMuted: isMuted) + + case .other: + // For unsupported control targets (contrast, gamma, temperature, etc.), + // show a generic brightness-style HUD if the user has brightness HUD enabled + guard Defaults[.enableBrightnessHUD] else { return } + dispatchBrightnessHUD(value: normalizedValue, customSymbol: osd.customSymbol) + } + } + + // MARK: - HUD Dispatch (mirrors SystemChangesObserver logic) + + private func dispatchVolumeHUD(value: CGFloat, isMuted: Bool) { + if HUDSuppressionCoordinator.shared.shouldSuppressVolumeHUD { return } + + if Defaults[.enableCircularHUD] { + CircularHUDWindowManager.shared.show(type: .volume, value: value) + return + } + if Defaults[.enableVerticalHUD] { + VerticalHUDWindowManager.shared.show(type: .volume, value: value, icon: "") + return + } + if Defaults[.enableCustomOSD] && Defaults[.enableOSDVolume] { + CustomOSDWindowManager.shared.showVolume(value: value) + } + if Defaults[.enableSystemHUD] && !Defaults[.enableCustomOSD] && !Defaults[.enableVerticalHUD] && !Defaults[.enableCircularHUD] { + coordinator?.toggleSneakPeek( + status: true, + type: .volume, + value: value, + icon: "" + ) + } + } + + private func dispatchBrightnessHUD(value: CGFloat, customSymbol: String? = nil) { + if Defaults[.enableCircularHUD] { + CircularHUDWindowManager.shared.show(type: .brightness, value: value) + return + } + if Defaults[.enableVerticalHUD] { + VerticalHUDWindowManager.shared.show(type: .brightness, value: value) + return + } + if Defaults[.enableCustomOSD] && Defaults[.enableOSDBrightness] { + CustomOSDWindowManager.shared.showBrightness(value: value) + } + if Defaults[.enableSystemHUD] && !Defaults[.enableCustomOSD] && !Defaults[.enableVerticalHUD] && !Defaults[.enableCircularHUD] { + coordinator?.toggleSneakPeek( + status: true, + type: .brightness, + value: value, + icon: "" + ) + } + } + + // MARK: - Helpers + + private func classifyControlTarget(_ target: String?, systemIconID: Int?) -> BetterDisplayControlCategory { + if let target { + if brightnessControlTargets.contains(target) { return .brightness } + if volumeControlTargets.contains(target) { return .volume } + } + + // Fallback to systemIconID + switch systemIconID { + case 1: return .brightness // brightness icon + case 3: return .volume // volume icon + case 4: return .volume // mute icon + default: return .other + } + } + + /// Normalize a BetterDisplay value (0...maxValue) to 0...1 range. + private func normalizeValue(_ value: Double?, maxValue: Double?) -> CGFloat { + guard let value else { return 0 } + let maxVal = maxValue ?? 1.0 + guard maxVal > 0 else { return 0 } + return CGFloat(Swift.min(Swift.max(value / maxVal, 0), 1)) + } + + // MARK: - Request API + + /// Send a command to BetterDisplay and optionally receive a response. + /// - Parameters: + /// - commands: e.g. ["set"], ["get"] + /// - parameters: e.g. ["brightness": "0.8"] + /// - completion: Called with the response on the main queue, or nil on timeout. + func sendRequest( + commands: [String], + parameters: [String: String?], + completion: (@MainActor @Sendable (BetterDisplayResponseData?) -> Void)? = nil + ) { + let uuid = UUID().uuidString + let request = BetterDisplayRequestData(uuid: uuid, commands: commands, parameters: parameters) + + // If we need a response, set up a temporary observer + if let completion { + // Use a class wrapper so the observer closure can safely capture & cancel + final class ResponseState: @unchecked Sendable { + var observer: NSObjectProtocol? + var timeoutItem: DispatchWorkItem? + } + let state = ResponseState() + + let timeoutItem = DispatchWorkItem { + if let obs = state.observer { + DistributedNotificationCenter.default().removeObserver(obs) + state.observer = nil + } + Task { @MainActor in + completion(nil) + } + } + state.timeoutItem = timeoutItem + + state.observer = DistributedNotificationCenter.default().addObserver( + forName: Self.responseNotificationName, + object: nil, + queue: .main + ) { notification in + guard let responseString = notification.object as? String, + let response = try? JSONDecoder().decode( + BetterDisplayResponseData.self, + from: Data(responseString.utf8) + ), + response.uuid == uuid + else { return } + + state.timeoutItem?.cancel() + if let obs = state.observer { + DistributedNotificationCenter.default().removeObserver(obs) + state.observer = nil + } + Task { @MainActor in + completion(response) + } + } + + DispatchQueue.main.asyncAfter(deadline: .now() + 5.0, execute: timeoutItem) + } + + // Send the request + do { + let encoded = try JSONEncoder().encode(request) + if let encodedString = String(data: encoded, encoding: .utf8) { + DistributedNotificationCenter.default().postNotificationName( + Self.requestNotificationName, + object: encodedString, + userInfo: nil, + deliverImmediately: true + ) + } + } catch { + NSLog("⚠️ BetterDisplay request encode error: \(error.localizedDescription)") + completion?(nil) + } + } + + // MARK: - Workspace Observer (detect install/uninstall) + + private func setupWorkspaceObserver() { + let betterDisplayBundleID = Self.bundleID + + workspaceObserver = NSWorkspace.shared.notificationCenter.addObserver( + forName: NSWorkspace.didLaunchApplicationNotification, + object: nil, + queue: .main + ) { [weak self] notification in + guard let app = notification.userInfo?[NSWorkspace.applicationUserInfoKey] as? NSRunningApplication, + app.bundleIdentifier == betterDisplayBundleID + else { return } + Task { @MainActor in + self?.refreshDetectionStatus() + } + } + + // Also observe app termination + NSWorkspace.shared.notificationCenter.addObserver( + forName: NSWorkspace.didTerminateApplicationNotification, + object: nil, + queue: .main + ) { notification in + guard let app = notification.userInfo?[NSWorkspace.applicationUserInfoKey] as? NSRunningApplication, + app.bundleIdentifier == betterDisplayBundleID + else { return } + // Don't clear isDetected on termination — app is still installed, just not running. + } + } + + // MARK: - Settings Observer + + private func setupSettingsObserver() { + Defaults.publisher(.enableBetterDisplayIntegration, options: []) + .sink { [weak self] change in + Task { @MainActor in + guard let self else { return } + if change.newValue && self.isDetected { + self.startListening() + } else { + self.stopListening() + } + } + } + .store(in: &cancellables) + } + + deinit { + if let osdObserver { + DistributedNotificationCenter.default().removeObserver(osdObserver) + } + if let workspaceObserver { + NSWorkspace.shared.notificationCenter.removeObserver(workspaceObserver) + } + cancellables.removeAll() + } +} diff --git a/DynamicIsland/models/Constants.swift b/DynamicIsland/models/Constants.swift index 5e38106..252fe03 100644 --- a/DynamicIsland/models/Constants.swift +++ b/DynamicIsland/models/Constants.swift @@ -1009,6 +1009,9 @@ extension Defaults.Keys { static let circularHUDSize = Key("circularHUDSize", default: 65) static let circularHUDStrokeWidth = Key("circularHUDStrokeWidth", default: 4) static let circularHUDUseAccentColor = Key("circularHUDUseAccentColor", default: true) + + // MARK: BetterDisplay Integration + static let enableBetterDisplayIntegration = Key("enableBetterDisplayIntegration", default: false) static let hasSeenOSDAlphaWarning = Key("hasSeenOSDAlphaWarning", default: false) static let enableOSDVolume = Key("enableOSDVolume", default: true)