mirror of
https://github.com/FBukovina/Atoll.git
synced 2026-10-27 15:14:33 +00:00
Add: Lunar Support
This commit is contained in:
parent
93e0c86033
commit
14c8f244db
6 changed files with 1653 additions and 2923 deletions
|
|
@ -159,6 +159,9 @@ class AppDelegate: NSObject, NSApplicationDelegate {
|
||||||
windowSizeUpdateWorkItem?.cancel()
|
windowSizeUpdateWorkItem?.cancel()
|
||||||
NotificationCenter.default.removeObserver(self)
|
NotificationCenter.default.removeObserver(self)
|
||||||
extensionXPCServiceHost.stop()
|
extensionXPCServiceHost.stop()
|
||||||
|
|
||||||
|
// Restore Lunar's native OSD if integration was active
|
||||||
|
LunarManager.shared.appWillTerminate()
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc func onScreenLocked(_: Notification) {
|
@objc func onScreenLocked(_: Notification) {
|
||||||
|
|
@ -416,6 +419,9 @@ class AppDelegate: NSObject, NSApplicationDelegate {
|
||||||
// Setup BetterDisplay integration
|
// Setup BetterDisplay integration
|
||||||
BetterDisplayManager.shared.configure(coordinator: coordinator)
|
BetterDisplayManager.shared.configure(coordinator: coordinator)
|
||||||
|
|
||||||
|
// Setup Lunar integration
|
||||||
|
LunarManager.shared.configure(coordinator: coordinator)
|
||||||
|
|
||||||
// Setup ScreenRecording Manager
|
// Setup ScreenRecording Manager
|
||||||
if Defaults[.enableScreenRecordingDetection] {
|
if Defaults[.enableScreenRecordingDetection] {
|
||||||
ScreenRecordingManager.shared.startMonitoring()
|
ScreenRecordingManager.shared.startMonitoring()
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -740,6 +740,7 @@ struct SettingsView: View {
|
||||||
SettingsSearchEntry(tab: .hudAndOSD, title: "Material", keywords: ["material", "frosted", "liquid", "glass", "solid", "osd"], highlightID: SettingsTab.hudAndOSD.highlightID(for: "Material")),
|
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: "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")),
|
SettingsSearchEntry(tab: .hudAndOSD, title: "BetterDisplay integration", keywords: ["betterdisplay", "external", "display", "brightness", "integration", "third party"], highlightID: SettingsTab.hudAndOSD.highlightID(for: "BetterDisplay integration")),
|
||||||
|
SettingsSearchEntry(tab: .hudAndOSD, title: "Lunar integration", keywords: ["lunar", "ddc", "external", "display", "brightness", "contrast", "integration", "third party"], highlightID: SettingsTab.hudAndOSD.highlightID(for: "Lunar integration")),
|
||||||
|
|
||||||
// Media
|
// Media
|
||||||
SettingsSearchEntry(tab: .media, title: "Music Source", keywords: ["media source", "controller"], highlightID: SettingsTab.media.highlightID(for: "Music Source")),
|
SettingsSearchEntry(tab: .media, title: "Music Source", keywords: ["media source", "controller"], highlightID: SettingsTab.media.highlightID(for: "Music Source")),
|
||||||
|
|
@ -1495,6 +1496,7 @@ private struct HUDAndOSDSettingsView: View {
|
||||||
@Default(.enableBrightnessHUD) var enableBrightnessHUD
|
@Default(.enableBrightnessHUD) var enableBrightnessHUD
|
||||||
@Default(.enableKeyboardBacklightHUD) var enableKeyboardBacklightHUD
|
@Default(.enableKeyboardBacklightHUD) var enableKeyboardBacklightHUD
|
||||||
@Default(.enableBetterDisplayIntegration) var enableBetterDisplayIntegration
|
@Default(.enableBetterDisplayIntegration) var enableBetterDisplayIntegration
|
||||||
|
@Default(.enableLunarIntegration) var enableLunarIntegration
|
||||||
@Default(.verticalHUDShowValue) var verticalHUDShowValue
|
@Default(.verticalHUDShowValue) var verticalHUDShowValue
|
||||||
@Default(.verticalHUDInteractive) var verticalHUDInteractive
|
@Default(.verticalHUDInteractive) var verticalHUDInteractive
|
||||||
@Default(.verticalHUDHeight) var verticalHUDHeight
|
@Default(.verticalHUDHeight) var verticalHUDHeight
|
||||||
|
|
@ -1730,7 +1732,7 @@ private struct HUDAndOSDSettingsView: View {
|
||||||
}
|
}
|
||||||
case .vertical:
|
case .vertical:
|
||||||
Form {
|
Form {
|
||||||
if !accessibilityPermission.isAuthorized && !enableBetterDisplayIntegration {
|
if !accessibilityPermission.isAuthorized && !enableBetterDisplayIntegration && !enableLunarIntegration {
|
||||||
Section {
|
Section {
|
||||||
SettingsPermissionCallout(
|
SettingsPermissionCallout(
|
||||||
message: "Accessibility permission is needed to intercept system controls for the Vertical HUD.",
|
message: "Accessibility permission is needed to intercept system controls for the Vertical HUD.",
|
||||||
|
|
@ -1746,13 +1748,13 @@ private struct HUDAndOSDSettingsView: View {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if accessibilityPermission.isAuthorized || enableBetterDisplayIntegration {
|
if accessibilityPermission.isAuthorized || enableBetterDisplayIntegration || enableLunarIntegration {
|
||||||
Section {
|
Section {
|
||||||
Toggle("Volume HUD", isOn: $enableVolumeHUD)
|
Toggle("Volume HUD", isOn: $enableVolumeHUD)
|
||||||
Toggle("Brightness HUD", isOn: $enableBrightnessHUD)
|
Toggle("Brightness HUD", isOn: $enableBrightnessHUD)
|
||||||
Toggle("Keyboard Backlight HUD", isOn: $enableKeyboardBacklightHUD)
|
Toggle("Keyboard Backlight HUD", isOn: $enableKeyboardBacklightHUD)
|
||||||
.disabled(enableBetterDisplayIntegration)
|
.disabled(enableBetterDisplayIntegration || enableLunarIntegration)
|
||||||
.help(enableBetterDisplayIntegration ? "Disabled while BetterDisplay integration is active — BetterDisplay uses Cmd+Brightness keys for its own controls." : "")
|
.help((enableBetterDisplayIntegration || enableLunarIntegration) ? "Disabled while external display integration is active — brightness keys are handled by the external app." : "")
|
||||||
} header: {
|
} header: {
|
||||||
Text("Controls")
|
Text("Controls")
|
||||||
} footer: {
|
} footer: {
|
||||||
|
|
@ -1847,7 +1849,7 @@ private struct HUDAndOSDSettingsView: View {
|
||||||
|
|
||||||
case .circular:
|
case .circular:
|
||||||
Form {
|
Form {
|
||||||
if !accessibilityPermission.isAuthorized && !enableBetterDisplayIntegration {
|
if !accessibilityPermission.isAuthorized && !enableBetterDisplayIntegration && !enableLunarIntegration {
|
||||||
Section {
|
Section {
|
||||||
SettingsPermissionCallout(
|
SettingsPermissionCallout(
|
||||||
message: "Accessibility permission is needed to intercept system controls for the Circular HUD.",
|
message: "Accessibility permission is needed to intercept system controls for the Circular HUD.",
|
||||||
|
|
@ -1863,13 +1865,13 @@ private struct HUDAndOSDSettingsView: View {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if accessibilityPermission.isAuthorized || enableBetterDisplayIntegration {
|
if accessibilityPermission.isAuthorized || enableBetterDisplayIntegration || enableLunarIntegration {
|
||||||
Section {
|
Section {
|
||||||
Toggle("Volume HUD", isOn: $enableVolumeHUD)
|
Toggle("Volume HUD", isOn: $enableVolumeHUD)
|
||||||
Toggle("Brightness HUD", isOn: $enableBrightnessHUD)
|
Toggle("Brightness HUD", isOn: $enableBrightnessHUD)
|
||||||
Toggle("Keyboard Backlight HUD", isOn: $enableKeyboardBacklightHUD)
|
Toggle("Keyboard Backlight HUD", isOn: $enableKeyboardBacklightHUD)
|
||||||
.disabled(enableBetterDisplayIntegration)
|
.disabled(enableBetterDisplayIntegration || enableLunarIntegration)
|
||||||
.help(enableBetterDisplayIntegration ? "Disabled while BetterDisplay integration is active — BetterDisplay uses Cmd+Brightness keys for its own controls." : "")
|
.help((enableBetterDisplayIntegration || enableLunarIntegration) ? "Disabled while external display integration is active — brightness keys are handled by the external app." : "")
|
||||||
} header: {
|
} header: {
|
||||||
Text("Controls")
|
Text("Controls")
|
||||||
} footer: {
|
} footer: {
|
||||||
|
|
@ -1909,8 +1911,8 @@ private struct HUDAndOSDSettingsView: View {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// BetterDisplay Integration (shared across all HUD variants)
|
// Third-party display integrations (shared across all HUD variants)
|
||||||
BetterDisplayIntegrationSection()
|
ExternalDisplayIntegrationsSection()
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
||||||
.background(paneBackgroundColor)
|
.background(paneBackgroundColor)
|
||||||
|
|
@ -1924,93 +1926,175 @@ private struct HUDAndOSDSettingsView: View {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - BetterDisplay Integration Settings Section
|
// MARK: - External Display Integrations Settings Section
|
||||||
|
|
||||||
private struct BetterDisplayIntegrationSection: View {
|
private struct ExternalDisplayIntegrationsSection: View {
|
||||||
@Default(.enableBetterDisplayIntegration) var enableBetterDisplayIntegration
|
@Default(.enableBetterDisplayIntegration) var enableBetterDisplayIntegration
|
||||||
|
@Default(.enableLunarIntegration) var enableLunarIntegration
|
||||||
@ObservedObject private var betterDisplayManager = BetterDisplayManager.shared
|
@ObservedObject private var betterDisplayManager = BetterDisplayManager.shared
|
||||||
|
@ObservedObject private var lunarManager = LunarManager.shared
|
||||||
|
|
||||||
|
@State private var isBetterDisplayExpanded = false
|
||||||
|
@State private var isLunarExpanded = false
|
||||||
|
|
||||||
private func highlightID(_ title: String) -> String {
|
private func highlightID(_ title: String) -> String {
|
||||||
SettingsTab.hudAndOSD.highlightID(for: title)
|
SettingsTab.hudAndOSD.highlightID(for: title)
|
||||||
}
|
}
|
||||||
|
|
||||||
private var statusText: String {
|
// MARK: BetterDisplay helpers
|
||||||
if betterDisplayManager.isRunning {
|
|
||||||
return "Running"
|
private var bdStatusText: String {
|
||||||
} else if betterDisplayManager.isDetected {
|
if betterDisplayManager.isRunning { return "Running" }
|
||||||
return "Not running"
|
if betterDisplayManager.isDetected { return "Not running" }
|
||||||
} else {
|
return "Not detected"
|
||||||
return "Not detected"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private var statusColor: Color {
|
private var bdStatusColor: Color {
|
||||||
if betterDisplayManager.isRunning {
|
if betterDisplayManager.isRunning { return .green }
|
||||||
return .green
|
if betterDisplayManager.isDetected { return .orange }
|
||||||
} else if betterDisplayManager.isDetected {
|
return .secondary
|
||||||
return .orange
|
}
|
||||||
} else {
|
|
||||||
return .secondary
|
// MARK: Lunar helpers
|
||||||
}
|
|
||||||
|
private var lunarStatusText: String {
|
||||||
|
if lunarManager.isConnected { return "Connected" }
|
||||||
|
if lunarManager.isRunning { return "Running" }
|
||||||
|
if lunarManager.isDetected { return "Not running" }
|
||||||
|
return "Not detected"
|
||||||
|
}
|
||||||
|
|
||||||
|
private var lunarStatusColor: Color {
|
||||||
|
if lunarManager.isConnected { return .green }
|
||||||
|
if lunarManager.isRunning { return .orange }
|
||||||
|
if lunarManager.isDetected { return .orange }
|
||||||
|
return .secondary
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Form {
|
Form {
|
||||||
|
// MARK: - BetterDisplay
|
||||||
|
|
||||||
Section {
|
Section {
|
||||||
HStack {
|
DisclosureGroup(isExpanded: $isBetterDisplayExpanded) {
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
HStack {
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(bdStatusText)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(bdStatusColor)
|
||||||
|
}
|
||||||
|
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 !betterDisplayManager.isRunning {
|
||||||
|
Text("BetterDisplay is installed but not currently running. Launch BetterDisplay to enable integration.")
|
||||||
|
.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)
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Image(systemName: "display.2")
|
||||||
Text("BetterDisplay")
|
Text("BetterDisplay")
|
||||||
.font(.system(size: 13, weight: .medium))
|
.font(.system(size: 13, weight: .medium))
|
||||||
Text(statusText)
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundStyle(statusColor)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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 !betterDisplayManager.isRunning {
|
|
||||||
Text("BetterDisplay is installed but not currently running. Launch BetterDisplay to enable integration.")
|
|
||||||
.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: {
|
} 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.")
|
if isBetterDisplayExpanded {
|
||||||
.foregroundStyle(.secondary)
|
Text("When enabled, Atoll listens for OSD notifications from BetterDisplay and displays them using your selected HUD style above.")
|
||||||
.font(.caption)
|
.foregroundStyle(.secondary)
|
||||||
|
.font(.caption)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Lunar
|
||||||
|
|
||||||
|
Section {
|
||||||
|
DisclosureGroup(isExpanded: $isLunarExpanded) {
|
||||||
|
HStack {
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(lunarStatusText)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(lunarStatusColor)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
Toggle("", isOn: $enableLunarIntegration)
|
||||||
|
.toggleStyle(.switch)
|
||||||
|
.labelsHidden()
|
||||||
|
.disabled(!lunarManager.isDetected)
|
||||||
|
}
|
||||||
|
.settingsHighlight(id: highlightID("Lunar integration"))
|
||||||
|
|
||||||
|
if !lunarManager.isDetected {
|
||||||
|
Text("Install [Lunar](https://lunar.fyi) to control external display brightness, contrast, and volume through Atoll's HUD via DDC.")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
} else if !lunarManager.isRunning {
|
||||||
|
Text("Lunar is installed but not currently running. Launch Lunar to enable integration.")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
} else if lunarManager.isConnected && enableLunarIntegration {
|
||||||
|
Text("Connected to Lunar's DDC socket. Brightness, contrast, and volume adjustments will be shown through Atoll's HUD.")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
} else if enableLunarIntegration {
|
||||||
|
Text("Lunar is running but the socket connection is not yet established. It will connect automatically.")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
} else {
|
||||||
|
Text("Enable to route Lunar's DDC brightness, contrast, and volume changes through Atoll's HUD.")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
if lunarManager.isDetected {
|
||||||
|
Button {
|
||||||
|
lunarManager.refreshDetectionStatus()
|
||||||
|
} label: {
|
||||||
|
Label("Refresh detection", systemImage: "arrow.clockwise")
|
||||||
|
.font(.caption)
|
||||||
|
}
|
||||||
|
.buttonStyle(.link)
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Image(systemName: "moon.fill")
|
||||||
|
Text("Lunar")
|
||||||
|
.font(.system(size: 13, weight: .medium))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} footer: {
|
||||||
|
if isLunarExpanded {
|
||||||
|
Text("When enabled, Atoll connects to Lunar's TCP socket and displays DDC brightness, contrast, and volume events using your selected HUD style above.")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.font(.caption)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -2158,6 +2242,7 @@ struct HUD: View {
|
||||||
@Default(.enableBrightnessHUD) var enableBrightnessHUD
|
@Default(.enableBrightnessHUD) var enableBrightnessHUD
|
||||||
@Default(.enableKeyboardBacklightHUD) var enableKeyboardBacklightHUD
|
@Default(.enableKeyboardBacklightHUD) var enableKeyboardBacklightHUD
|
||||||
@Default(.enableBetterDisplayIntegration) var enableBetterDisplayIntegration
|
@Default(.enableBetterDisplayIntegration) var enableBetterDisplayIntegration
|
||||||
|
@Default(.enableLunarIntegration) var enableLunarIntegration
|
||||||
@Default(.systemHUDSensitivity) var systemHUDSensitivity
|
@Default(.systemHUDSensitivity) var systemHUDSensitivity
|
||||||
@ObservedObject var coordinator = DynamicIslandViewCoordinator.shared
|
@ObservedObject var coordinator = DynamicIslandViewCoordinator.shared
|
||||||
@ObservedObject private var accessibilityPermission = AccessibilityPermissionStore.shared
|
@ObservedObject private var accessibilityPermission = AccessibilityPermissionStore.shared
|
||||||
|
|
@ -2176,7 +2261,7 @@ struct HUD: View {
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Form {
|
Form {
|
||||||
if !hasAccessibilityPermission && !enableBetterDisplayIntegration {
|
if !hasAccessibilityPermission && !enableBetterDisplayIntegration && !enableLunarIntegration {
|
||||||
Section {
|
Section {
|
||||||
SettingsPermissionCallout(
|
SettingsPermissionCallout(
|
||||||
message: "Accessibility permission lets Dynamic Island replace the native volume, brightness, and keyboard HUDs.",
|
message: "Accessibility permission lets Dynamic Island replace the native volume, brightness, and keyboard HUDs.",
|
||||||
|
|
@ -2190,13 +2275,13 @@ struct HUD: View {
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
if enableSystemHUD && !Defaults[.enableCustomOSD] && (hasAccessibilityPermission || enableBetterDisplayIntegration) {
|
if enableSystemHUD && !Defaults[.enableCustomOSD] && (hasAccessibilityPermission || enableBetterDisplayIntegration || enableLunarIntegration) {
|
||||||
Section {
|
Section {
|
||||||
Toggle("Volume HUD", isOn: $enableVolumeHUD)
|
Toggle("Volume HUD", isOn: $enableVolumeHUD)
|
||||||
Toggle("Brightness HUD", isOn: $enableBrightnessHUD)
|
Toggle("Brightness HUD", isOn: $enableBrightnessHUD)
|
||||||
Toggle("Keyboard Backlight HUD", isOn: $enableKeyboardBacklightHUD)
|
Toggle("Keyboard Backlight HUD", isOn: $enableKeyboardBacklightHUD)
|
||||||
.disabled(enableBetterDisplayIntegration)
|
.disabled(enableBetterDisplayIntegration || enableLunarIntegration)
|
||||||
.help(enableBetterDisplayIntegration ? "Disabled while BetterDisplay integration is active \u{2014} BetterDisplay uses Cmd+Brightness keys for its own controls." : "")
|
.help((enableBetterDisplayIntegration || enableLunarIntegration) ? "Disabled while external display integration is active \u{2014} brightness keys are handled by the external app." : "")
|
||||||
} header: {
|
} header: {
|
||||||
Text("Controls")
|
Text("Controls")
|
||||||
} footer: {
|
} footer: {
|
||||||
|
|
@ -6909,6 +6994,7 @@ struct CustomOSDSettings: View {
|
||||||
@Default(.enableOSDBrightness) var enableOSDBrightness
|
@Default(.enableOSDBrightness) var enableOSDBrightness
|
||||||
@Default(.enableOSDKeyboardBacklight) var enableOSDKeyboardBacklight
|
@Default(.enableOSDKeyboardBacklight) var enableOSDKeyboardBacklight
|
||||||
@Default(.enableBetterDisplayIntegration) var enableBetterDisplayIntegration
|
@Default(.enableBetterDisplayIntegration) var enableBetterDisplayIntegration
|
||||||
|
@Default(.enableLunarIntegration) var enableLunarIntegration
|
||||||
@Default(.osdMaterial) var osdMaterial
|
@Default(.osdMaterial) var osdMaterial
|
||||||
@Default(.osdLiquidGlassCustomizationMode) var osdLiquidGlassCustomizationMode
|
@Default(.osdLiquidGlassCustomizationMode) var osdLiquidGlassCustomizationMode
|
||||||
@Default(.osdLiquidGlassVariant) var osdLiquidGlassVariant
|
@Default(.osdLiquidGlassVariant) var osdLiquidGlassVariant
|
||||||
|
|
@ -6951,7 +7037,7 @@ struct CustomOSDSettings: View {
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Form {
|
Form {
|
||||||
if !hasAccessibilityPermission && !enableBetterDisplayIntegration {
|
if !hasAccessibilityPermission && !enableBetterDisplayIntegration && !enableLunarIntegration {
|
||||||
Section {
|
Section {
|
||||||
SettingsPermissionCallout(
|
SettingsPermissionCallout(
|
||||||
message: "Accessibility permission is needed to intercept system controls for the Custom OSD.",
|
message: "Accessibility permission is needed to intercept system controls for the Custom OSD.",
|
||||||
|
|
@ -6963,7 +7049,7 @@ struct CustomOSDSettings: View {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if hasAccessibilityPermission || enableBetterDisplayIntegration {
|
if hasAccessibilityPermission || enableBetterDisplayIntegration || enableLunarIntegration {
|
||||||
Section {
|
Section {
|
||||||
Toggle("Volume OSD", isOn: $enableOSDVolume)
|
Toggle("Volume OSD", isOn: $enableOSDVolume)
|
||||||
.settingsHighlight(id: highlightID("Volume OSD"))
|
.settingsHighlight(id: highlightID("Volume OSD"))
|
||||||
|
|
@ -6971,8 +7057,8 @@ struct CustomOSDSettings: View {
|
||||||
.settingsHighlight(id: highlightID("Brightness OSD"))
|
.settingsHighlight(id: highlightID("Brightness OSD"))
|
||||||
Toggle("Keyboard Backlight OSD", isOn: $enableOSDKeyboardBacklight)
|
Toggle("Keyboard Backlight OSD", isOn: $enableOSDKeyboardBacklight)
|
||||||
.settingsHighlight(id: highlightID("Keyboard Backlight OSD"))
|
.settingsHighlight(id: highlightID("Keyboard Backlight OSD"))
|
||||||
.disabled(enableBetterDisplayIntegration)
|
.disabled(enableBetterDisplayIntegration || enableLunarIntegration)
|
||||||
.help(enableBetterDisplayIntegration ? "Disabled while BetterDisplay integration is active \u{2014} BetterDisplay uses Cmd+Brightness keys for its own controls." : "")
|
.help((enableBetterDisplayIntegration || enableLunarIntegration) ? "Disabled while external display integration is active \u{2014} brightness keys are handled by the external app." : "")
|
||||||
} header: {
|
} header: {
|
||||||
Text("Controls")
|
Text("Controls")
|
||||||
} footer: {
|
} footer: {
|
||||||
|
|
|
||||||
505
DynamicIsland/managers/LunarManager.swift
Normal file
505
DynamicIsland/managers/LunarManager.swift
Normal file
|
|
@ -0,0 +1,505 @@
|
||||||
|
/*
|
||||||
|
* 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/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
@preconcurrency import Foundation
|
||||||
|
import AppKit
|
||||||
|
import Combine
|
||||||
|
import Defaults
|
||||||
|
import Network
|
||||||
|
|
||||||
|
// MARK: - Lunar Display Data Model
|
||||||
|
|
||||||
|
/// Matches the JSON structure streamed by Lunar's socket listener.
|
||||||
|
struct LunarDisplayData: Codable {
|
||||||
|
var brightness: Float?
|
||||||
|
var contrast: Float?
|
||||||
|
var volume: Float?
|
||||||
|
var mute: Bool?
|
||||||
|
var nits: Float?
|
||||||
|
var display: Int
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Lunar Control Category
|
||||||
|
|
||||||
|
enum LunarControlCategory {
|
||||||
|
case brightness
|
||||||
|
case contrast
|
||||||
|
case volume
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Lunar Manager
|
||||||
|
|
||||||
|
/// Manages integration with the Lunar app (fyi.lunar.Lunar).
|
||||||
|
///
|
||||||
|
/// Responsibilities:
|
||||||
|
/// - Detect whether Lunar is installed and running
|
||||||
|
/// - Observe app launch / termination to connect / disconnect automatically
|
||||||
|
/// - Connect to Lunar's TCP socket on localhost:23803 and listen for DDC changes
|
||||||
|
/// - Route brightness / contrast / volume events to Atoll's HUD pipeline
|
||||||
|
@MainActor
|
||||||
|
final class LunarManager: ObservableObject {
|
||||||
|
static let shared = LunarManager()
|
||||||
|
|
||||||
|
/// Bundle identifier of the Lunar app.
|
||||||
|
nonisolated static let bundleID = "fyi.lunar.Lunar"
|
||||||
|
|
||||||
|
/// UserDefaults suite used by Lunar to store its API key.
|
||||||
|
private static let lunarDomain = "fyi.lunar.Lunar"
|
||||||
|
private static let apiKeyKey = "apiKey"
|
||||||
|
|
||||||
|
// MARK: Published state
|
||||||
|
|
||||||
|
/// Whether Lunar is currently detected (installed) on this machine.
|
||||||
|
@Published private(set) var isDetected: Bool = false
|
||||||
|
|
||||||
|
/// Whether Lunar is currently running.
|
||||||
|
@Published private(set) var isRunning: Bool = false
|
||||||
|
|
||||||
|
/// Whether the TCP socket connection to Lunar is active.
|
||||||
|
@Published private(set) var isConnected: Bool = false
|
||||||
|
|
||||||
|
// MARK: Private
|
||||||
|
|
||||||
|
private var connection: NWConnection?
|
||||||
|
private var workspaceLaunchObserver: NSObjectProtocol?
|
||||||
|
private var workspaceTermObserver: NSObjectProtocol?
|
||||||
|
private var cancellables = Set<AnyCancellable>()
|
||||||
|
private weak var coordinator: DynamicIslandViewCoordinator?
|
||||||
|
|
||||||
|
/// Reconnection back-off state.
|
||||||
|
private var reconnectTask: Task<Void, Never>?
|
||||||
|
private var reconnectAttempts: Int = 0
|
||||||
|
private static let maxReconnectAttempts = 5
|
||||||
|
private static let baseReconnectDelay: UInt64 = 2_000_000_000 // 2 seconds
|
||||||
|
|
||||||
|
private init() {
|
||||||
|
isDetected = Self.checkInstallation()
|
||||||
|
isRunning = Self.checkRunning()
|
||||||
|
setupWorkspaceObservers()
|
||||||
|
setupSettingsObserver()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Public API
|
||||||
|
|
||||||
|
/// Configure with the view coordinator for HUD dispatch.
|
||||||
|
func configure(coordinator: DynamicIslandViewCoordinator) {
|
||||||
|
self.coordinator = coordinator
|
||||||
|
|
||||||
|
if Defaults[.enableLunarIntegration] && isRunning {
|
||||||
|
connectToLunar()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Called when Atoll is about to quit so Lunar's native OSD is restored.
|
||||||
|
func appWillTerminate() {
|
||||||
|
setLunarHideOSD(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Refresh detection status (e.g. after manual check from Settings).
|
||||||
|
func refreshDetectionStatus() {
|
||||||
|
let wasRunning = isRunning
|
||||||
|
isDetected = Self.checkInstallation()
|
||||||
|
isRunning = Self.checkRunning()
|
||||||
|
if wasRunning && !isRunning {
|
||||||
|
disconnect()
|
||||||
|
} else if !wasRunning && isRunning && Defaults[.enableLunarIntegration] {
|
||||||
|
connectToLunar()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Detection
|
||||||
|
|
||||||
|
/// Check if Lunar is installed by looking for its bundle ID.
|
||||||
|
nonisolated static func checkInstallation() -> Bool {
|
||||||
|
if NSWorkspace.shared.runningApplications.contains(where: { $0.bundleIdentifier == bundleID }) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if let url = NSWorkspace.shared.urlForApplication(withBundleIdentifier: bundleID) {
|
||||||
|
return FileManager.default.fileExists(atPath: url.path)
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if Lunar is currently running.
|
||||||
|
nonisolated static func checkRunning() -> Bool {
|
||||||
|
NSWorkspace.shared.runningApplications.contains(where: { $0.bundleIdentifier == bundleID })
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Socket Connection
|
||||||
|
|
||||||
|
private func connectToLunar() {
|
||||||
|
guard connection == nil else { return }
|
||||||
|
guard let apiKey = Self.retrieveAPIKey() else {
|
||||||
|
NSLog("⚠️ Lunar: failed to retrieve API key from UserDefaults suite '\(Self.lunarDomain)'")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setLunarHideOSD(true)
|
||||||
|
|
||||||
|
let host = NWEndpoint.Host("localhost")
|
||||||
|
let port = NWEndpoint.Port(23803)
|
||||||
|
let conn = NWConnection(host: host, port: port, using: .tcp)
|
||||||
|
|
||||||
|
conn.stateUpdateHandler = { [weak self] state in
|
||||||
|
Task { @MainActor in
|
||||||
|
self?.handleConnectionState(state, apiKey: apiKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
connection = conn
|
||||||
|
conn.start(queue: DispatchQueue.global(qos: .utility))
|
||||||
|
NSLog("🌙 Lunar: connecting to socket on \(host):\(port)…")
|
||||||
|
}
|
||||||
|
|
||||||
|
private func disconnect() {
|
||||||
|
reconnectTask?.cancel()
|
||||||
|
reconnectTask = nil
|
||||||
|
reconnectAttempts = 0
|
||||||
|
|
||||||
|
if let conn = connection {
|
||||||
|
conn.stateUpdateHandler = nil
|
||||||
|
conn.cancel()
|
||||||
|
connection = nil
|
||||||
|
}
|
||||||
|
isConnected = false
|
||||||
|
setLunarHideOSD(false)
|
||||||
|
NSLog("⏹ Lunar: socket disconnected")
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handleConnectionState(_ state: NWConnection.State, apiKey: String) {
|
||||||
|
switch state {
|
||||||
|
case .ready:
|
||||||
|
isConnected = true
|
||||||
|
reconnectAttempts = 0
|
||||||
|
NSLog("🌙 Lunar: connected to socket")
|
||||||
|
sendListenCommand(apiKey: apiKey)
|
||||||
|
receiveMessages()
|
||||||
|
|
||||||
|
case .failed(let error):
|
||||||
|
NSLog("🌙 Lunar: connection failed — \(error.localizedDescription)")
|
||||||
|
isConnected = false
|
||||||
|
connection?.cancel()
|
||||||
|
connection = nil
|
||||||
|
scheduleReconnect()
|
||||||
|
|
||||||
|
case .cancelled:
|
||||||
|
isConnected = false
|
||||||
|
|
||||||
|
case .waiting(let error):
|
||||||
|
NSLog("🌙 Lunar: connection waiting — \(error.localizedDescription)")
|
||||||
|
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Send Lunar's listen command with the API key.
|
||||||
|
private func sendListenCommand(apiKey: String) {
|
||||||
|
guard let conn = connection else { return }
|
||||||
|
|
||||||
|
let separator = String(UnicodeScalar(0x01))
|
||||||
|
let message = [apiKey, "listen", "--json", "--only-user-adjustments"]
|
||||||
|
.joined(separator: separator)
|
||||||
|
|
||||||
|
guard let data = message.data(using: .utf8) else {
|
||||||
|
NSLog("⚠️ Lunar: failed to encode listen command")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
conn.send(content: data, completion: .contentProcessed { error in
|
||||||
|
if let error {
|
||||||
|
NSLog("⚠️ Lunar: failed to send listen command — \(error.localizedDescription)")
|
||||||
|
} else {
|
||||||
|
NSLog("🌙 Lunar: listen command sent")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Receive JSON messages from Lunar's socket in a loop.
|
||||||
|
private func receiveMessages() {
|
||||||
|
guard let conn = connection else { return }
|
||||||
|
|
||||||
|
conn.receive(minimumIncompleteLength: 1, maximumLength: 4096) { [weak self] data, _, isComplete, error in
|
||||||
|
if let data, !data.isEmpty {
|
||||||
|
Task { @MainActor in
|
||||||
|
self?.handleReceivedData(data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if isComplete {
|
||||||
|
NSLog("🌙 Lunar: stream ended")
|
||||||
|
Task { @MainActor in
|
||||||
|
self?.isConnected = false
|
||||||
|
self?.connection?.cancel()
|
||||||
|
self?.connection = nil
|
||||||
|
self?.scheduleReconnect()
|
||||||
|
}
|
||||||
|
} else if let error {
|
||||||
|
NSLog("⚠️ Lunar: receive error — \(error.localizedDescription)")
|
||||||
|
Task { @MainActor in
|
||||||
|
self?.isConnected = false
|
||||||
|
self?.connection?.cancel()
|
||||||
|
self?.connection = nil
|
||||||
|
self?.scheduleReconnect()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Continue reading
|
||||||
|
Task { @MainActor in
|
||||||
|
self?.receiveMessages()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handleReceivedData(_ data: Data) {
|
||||||
|
guard Defaults[.enableLunarIntegration], isRunning else { return }
|
||||||
|
|
||||||
|
do {
|
||||||
|
let displayData = try JSONDecoder().decode(LunarDisplayData.self, from: data)
|
||||||
|
NSLog("🌙 Lunar: display=\(displayData.display) brightness=\(displayData.brightness ?? -1) contrast=\(displayData.contrast ?? -1) volume=\(displayData.volume ?? -1) mute=\(displayData.mute.map(String.init) ?? "nil")")
|
||||||
|
routeToHUD(displayData)
|
||||||
|
} catch {
|
||||||
|
// Lunar may send multi-line JSON or partial data; try splitting by newlines
|
||||||
|
let rawString = String(data: data, encoding: .utf8) ?? ""
|
||||||
|
let lines = rawString.components(separatedBy: .newlines).filter { !$0.isEmpty }
|
||||||
|
for line in lines {
|
||||||
|
guard let lineData = line.data(using: .utf8) else { continue }
|
||||||
|
if let displayData = try? JSONDecoder().decode(LunarDisplayData.self, from: lineData) {
|
||||||
|
NSLog("🌙 Lunar: display=\(displayData.display) brightness=\(displayData.brightness ?? -1) contrast=\(displayData.contrast ?? -1) volume=\(displayData.volume ?? -1)")
|
||||||
|
routeToHUD(displayData)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - HUD Routing
|
||||||
|
|
||||||
|
private func routeToHUD(_ data: LunarDisplayData) {
|
||||||
|
let targetScreen = resolveScreen(for: data.display)
|
||||||
|
let isExternal = isExternalDisplay(data.display)
|
||||||
|
|
||||||
|
// Nits-only events are informational (no user-facing HUD needed).
|
||||||
|
let hasActionableChange = data.brightness != nil || data.contrast != nil || data.volume != nil || data.mute != nil
|
||||||
|
guard hasActionableChange else { return }
|
||||||
|
|
||||||
|
// Brightness — values arrive in 0…1 range from Lunar
|
||||||
|
if let brightness = data.brightness {
|
||||||
|
let value = CGFloat(brightness)
|
||||||
|
let icon = isExternal ? "display" : ""
|
||||||
|
dispatchBrightnessHUD(value: value, icon: icon, onScreen: targetScreen)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Contrast — show as brightness-style HUD with a contrast icon
|
||||||
|
if let contrast = data.contrast {
|
||||||
|
let value = CGFloat(contrast)
|
||||||
|
dispatchBrightnessHUD(value: value, icon: "circle.righthalf.filled", onScreen: targetScreen)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Volume
|
||||||
|
if let volume = data.volume {
|
||||||
|
let value = CGFloat(volume)
|
||||||
|
let isMuted = data.mute ?? false
|
||||||
|
dispatchVolumeHUD(value: value, isMuted: isMuted, onScreen: targetScreen)
|
||||||
|
} else if let mute = data.mute {
|
||||||
|
// Mute toggle without a volume value
|
||||||
|
dispatchVolumeHUD(value: mute ? 0 : 1, isMuted: mute, onScreen: targetScreen)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - HUD Dispatch (mirrors BetterDisplayManager / SystemChangesObserver logic)
|
||||||
|
|
||||||
|
private func dispatchVolumeHUD(value: CGFloat, isMuted: Bool, onScreen targetScreen: NSScreen? = nil) {
|
||||||
|
if HUDSuppressionCoordinator.shared.shouldSuppressVolumeHUD { return }
|
||||||
|
|
||||||
|
if Defaults[.enableCircularHUD] {
|
||||||
|
CircularHUDWindowManager.shared.show(type: .volume, value: value, onScreen: targetScreen)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if Defaults[.enableVerticalHUD] {
|
||||||
|
VerticalHUDWindowManager.shared.show(type: .volume, value: value, icon: "", onScreen: targetScreen)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if Defaults[.enableCustomOSD] && Defaults[.enableOSDVolume] {
|
||||||
|
CustomOSDWindowManager.shared.showVolume(value: value, onScreen: targetScreen)
|
||||||
|
}
|
||||||
|
if Defaults[.enableSystemHUD] && !Defaults[.enableCustomOSD] && !Defaults[.enableVerticalHUD] && !Defaults[.enableCircularHUD] {
|
||||||
|
coordinator?.toggleSneakPeek(
|
||||||
|
status: true,
|
||||||
|
type: .volume,
|
||||||
|
value: value,
|
||||||
|
icon: ""
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func dispatchBrightnessHUD(value: CGFloat, icon: String = "", onScreen targetScreen: NSScreen? = nil) {
|
||||||
|
if Defaults[.enableCircularHUD] {
|
||||||
|
CircularHUDWindowManager.shared.show(type: .brightness, value: value, icon: icon, onScreen: targetScreen)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if Defaults[.enableVerticalHUD] {
|
||||||
|
VerticalHUDWindowManager.shared.show(type: .brightness, value: value, icon: icon, onScreen: targetScreen)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if Defaults[.enableCustomOSD] && Defaults[.enableOSDBrightness] {
|
||||||
|
CustomOSDWindowManager.shared.showBrightness(value: value, icon: icon, onScreen: targetScreen)
|
||||||
|
}
|
||||||
|
if Defaults[.enableSystemHUD] && !Defaults[.enableCustomOSD] && !Defaults[.enableVerticalHUD] && !Defaults[.enableCircularHUD] {
|
||||||
|
coordinator?.toggleSneakPeek(
|
||||||
|
status: true,
|
||||||
|
type: .brightness,
|
||||||
|
value: value,
|
||||||
|
icon: icon
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Helpers
|
||||||
|
|
||||||
|
/// Retrieve the Lunar API key from its UserDefaults suite.
|
||||||
|
nonisolated private static func retrieveAPIKey() -> String? {
|
||||||
|
UserDefaults(suiteName: lunarDomain)?.string(forKey: apiKeyKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve a Lunar display ID (CGDirectDisplayID) to the matching NSScreen.
|
||||||
|
private func resolveScreen(for displayID: Int) -> NSScreen? {
|
||||||
|
let target = UInt32(displayID)
|
||||||
|
return NSScreen.screens.first { screen in
|
||||||
|
guard let num = screen.deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] as? NSNumber else { return false }
|
||||||
|
return num.uint32Value == target
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the given displayID refers to an external (non-built-in) display.
|
||||||
|
private func isExternalDisplay(_ displayID: Int) -> Bool {
|
||||||
|
CGDisplayIsBuiltin(UInt32(displayID)) == 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Reconnection
|
||||||
|
|
||||||
|
private func scheduleReconnect() {
|
||||||
|
guard Defaults[.enableLunarIntegration], isRunning else { return }
|
||||||
|
guard reconnectAttempts < Self.maxReconnectAttempts else {
|
||||||
|
NSLog("🌙 Lunar: max reconnect attempts reached, giving up")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
reconnectTask?.cancel()
|
||||||
|
let attempt = reconnectAttempts
|
||||||
|
reconnectAttempts += 1
|
||||||
|
|
||||||
|
let delay = Self.baseReconnectDelay * UInt64(1 << min(attempt, 3)) // exponential back-off, max 16s
|
||||||
|
|
||||||
|
reconnectTask = Task { @MainActor in
|
||||||
|
try? await Task.sleep(nanoseconds: delay)
|
||||||
|
guard !Task.isCancelled, self.isRunning, Defaults[.enableLunarIntegration] else { return }
|
||||||
|
NSLog("🌙 Lunar: reconnect attempt \(attempt + 1)/\(Self.maxReconnectAttempts)")
|
||||||
|
self.connectToLunar()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Workspace Observers (detect Lunar launch / quit)
|
||||||
|
|
||||||
|
private func setupWorkspaceObservers() {
|
||||||
|
let lunarBundleID = Self.bundleID
|
||||||
|
|
||||||
|
workspaceLaunchObserver = 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 == lunarBundleID
|
||||||
|
else { return }
|
||||||
|
Task { @MainActor in
|
||||||
|
NSLog("🟢 Lunar launched (pid: \(app.processIdentifier))")
|
||||||
|
self?.isDetected = true
|
||||||
|
self?.isRunning = true
|
||||||
|
self?.reconnectAttempts = 0
|
||||||
|
if Defaults[.enableLunarIntegration] {
|
||||||
|
// Give Lunar a moment to start its socket server
|
||||||
|
try? await Task.sleep(nanoseconds: 1_500_000_000)
|
||||||
|
self?.connectToLunar()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
workspaceTermObserver = NSWorkspace.shared.notificationCenter.addObserver(
|
||||||
|
forName: NSWorkspace.didTerminateApplicationNotification,
|
||||||
|
object: nil,
|
||||||
|
queue: .main
|
||||||
|
) { [weak self] notification in
|
||||||
|
guard let app = notification.userInfo?[NSWorkspace.applicationUserInfoKey] as? NSRunningApplication,
|
||||||
|
app.bundleIdentifier == lunarBundleID
|
||||||
|
else { return }
|
||||||
|
Task { @MainActor in
|
||||||
|
NSLog("🔴 Lunar terminated (pid: \(app.processIdentifier))")
|
||||||
|
self?.isRunning = false
|
||||||
|
self?.disconnect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Settings Observer
|
||||||
|
|
||||||
|
private func setupSettingsObserver() {
|
||||||
|
Defaults.publisher(.enableLunarIntegration, options: [])
|
||||||
|
.sink { [weak self] change in
|
||||||
|
Task { @MainActor in
|
||||||
|
guard let self else { return }
|
||||||
|
if change.newValue && self.isRunning {
|
||||||
|
self.connectToLunar()
|
||||||
|
} else {
|
||||||
|
self.disconnect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
}
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
// Restore Lunar's native OSD synchronously on teardown
|
||||||
|
LunarManager.writeLunarDefault(hideOSD: false)
|
||||||
|
connection?.cancel()
|
||||||
|
reconnectTask?.cancel()
|
||||||
|
if let workspaceLaunchObserver {
|
||||||
|
NSWorkspace.shared.notificationCenter.removeObserver(workspaceLaunchObserver)
|
||||||
|
}
|
||||||
|
if let workspaceTermObserver {
|
||||||
|
NSWorkspace.shared.notificationCenter.removeObserver(workspaceTermObserver)
|
||||||
|
}
|
||||||
|
cancellables.removeAll()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Lunar OSD Suppression
|
||||||
|
|
||||||
|
/// Tell Lunar to hide (or show) its own native OSD by writing to its UserDefaults.
|
||||||
|
private func setLunarHideOSD(_ hide: Bool) {
|
||||||
|
Self.writeLunarDefault(hideOSD: hide)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Synchronous, nonisolated helper so it can also be called from `deinit`.
|
||||||
|
nonisolated private static func writeLunarDefault(hideOSD hide: Bool) {
|
||||||
|
guard let defaults = UserDefaults(suiteName: lunarDomain) else {
|
||||||
|
NSLog("⚠️ Lunar: unable to open UserDefaults suite '\(lunarDomain)' to set hideOSD")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defaults.set(hide, forKey: "hideOSD")
|
||||||
|
NSLog("🌙 Lunar: hideOSD set to \(hide)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -188,6 +188,14 @@ class SystemHUDManager {
|
||||||
await self.startSystemObserver()
|
await self.startSystemObserver()
|
||||||
}
|
}
|
||||||
}.store(in: &cancellables)
|
}.store(in: &cancellables)
|
||||||
|
|
||||||
|
// When Lunar integration toggled, restart observer to update brightness key interception
|
||||||
|
Defaults.publisher(.enableLunarIntegration, options: []).sink { [weak self] _ in
|
||||||
|
guard let self = self, self.isSetupComplete else { return }
|
||||||
|
Task { @MainActor in
|
||||||
|
await self.startSystemObserver()
|
||||||
|
}
|
||||||
|
}.store(in: &cancellables)
|
||||||
}
|
}
|
||||||
|
|
||||||
private var cancellables = Set<AnyCancellable>()
|
private var cancellables = Set<AnyCancellable>()
|
||||||
|
|
@ -240,9 +248,9 @@ class SystemHUDManager {
|
||||||
keyboardBacklightEnabled = Defaults[.enableKeyboardBacklightHUD]
|
keyboardBacklightEnabled = Defaults[.enableKeyboardBacklightHUD]
|
||||||
}
|
}
|
||||||
|
|
||||||
// When BetterDisplay integration is on, stop intercepting brightness and
|
// When BetterDisplay or Lunar integration is on, stop intercepting brightness and
|
||||||
// Cmd+Brightness keys so BetterDisplay receives them and sends OSD notifications.
|
// Cmd+Brightness keys so the external app receives them and sends DDC/OSD events.
|
||||||
if Defaults[.enableBetterDisplayIntegration] {
|
if Defaults[.enableBetterDisplayIntegration] || Defaults[.enableLunarIntegration] {
|
||||||
brightnessEnabled = false
|
brightnessEnabled = false
|
||||||
keyboardBacklightEnabled = false
|
keyboardBacklightEnabled = false
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1030,6 +1030,9 @@ extension Defaults.Keys {
|
||||||
// MARK: BetterDisplay Integration
|
// MARK: BetterDisplay Integration
|
||||||
static let enableBetterDisplayIntegration = Key<Bool>("enableBetterDisplayIntegration", default: false)
|
static let enableBetterDisplayIntegration = Key<Bool>("enableBetterDisplayIntegration", default: false)
|
||||||
|
|
||||||
|
// MARK: Lunar Integration
|
||||||
|
static let enableLunarIntegration = Key<Bool>("enableLunarIntegration", default: false)
|
||||||
|
|
||||||
static let hasSeenOSDAlphaWarning = Key<Bool>("hasSeenOSDAlphaWarning", default: false)
|
static let hasSeenOSDAlphaWarning = Key<Bool>("hasSeenOSDAlphaWarning", default: false)
|
||||||
static let enableOSDVolume = Key<Bool>("enableOSDVolume", default: true)
|
static let enableOSDVolume = Key<Bool>("enableOSDVolume", default: true)
|
||||||
static let enableOSDBrightness = Key<Bool>("enableOSDBrightness", default: true)
|
static let enableOSDBrightness = Key<Bool>("enableOSDBrightness", default: true)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue