diff --git a/DynamicIsland/Localizable.xcstrings b/DynamicIsland/Localizable.xcstrings index 0e3b184..d7e9074 100644 --- a/DynamicIsland/Localizable.xcstrings +++ b/DynamicIsland/Localizable.xcstrings @@ -1803,6 +1803,13 @@ } } }, + "Accessibility" : { + + }, + "Accessibility permission required" : { + "comment" : "A label explaining that accessibility permissions are required.", + "isCommentAutoGenerated" : true + }, "Actions" : { "localizations" : { "fr" : { @@ -9526,6 +9533,12 @@ } } } + }, + "Grant Accessibility permission in System Settings to control macOS HUD replacements from here." : { + + }, + "Grant Accessibility permission in System Settings to customize the on-screen display replacements here." : { + }, "Graph Visibility" : { "localizations" : { @@ -14756,6 +14769,10 @@ }, "Open Model Settings" : { + }, + "Open Settings" : { + "comment" : "A button that opens system settings when pressed.", + "isCommentAutoGenerated" : true }, "Open System Settings" : { diff --git a/DynamicIsland/components/Settings/SettingsView.swift b/DynamicIsland/components/Settings/SettingsView.swift index c1e9691..b7ce27f 100644 --- a/DynamicIsland/components/Settings/SettingsView.swift +++ b/DynamicIsland/components/Settings/SettingsView.swift @@ -1282,16 +1282,33 @@ struct HUD: View { @Default(.enableKeyboardBacklightHUD) var enableKeyboardBacklightHUD @Default(.systemHUDSensitivity) var systemHUDSensitivity @ObservedObject var coordinator = DynamicIslandViewCoordinator.shared + @ObservedObject private var accessibilityPermission = AccessibilityPermissionStore.shared private func highlightID(_ title: String) -> String { SettingsTab.hud.highlightID(for: title) } + + private var hasAccessibilityPermission: Bool { + accessibilityPermission.isAuthorized + } var body: some View { Form { + if !hasAccessibilityPermission { + Section { + SettingsPermissionCallout( + message: "Accessibility permission lets Dynamic Island replace the native volume, brightness, and keyboard HUDs.", + requestAction: { accessibilityPermission.requestAuthorizationPrompt() }, + openSettingsAction: { accessibilityPermission.openSystemSettings() } + ) + } header: { + Text("Accessibility") + } + } + Section { Toggle("Enable HUDs", isOn: $enableSystemHUD) - .disabled(Defaults[.enableCustomOSD]) + .disabled(Defaults[.enableCustomOSD] || !hasAccessibilityPermission) } header: { Text("General") } footer: { @@ -1299,6 +1316,10 @@ struct HUD: View { Text("HUDs are disabled because Custom OSD is enabled. Disable Custom OSD in the Custom OSD tab to use HUDs.") .foregroundStyle(.secondary) .font(.caption) + } else if !hasAccessibilityPermission { + Text("Grant Accessibility permission in System Settings to control macOS HUD replacements from here.") + .foregroundStyle(.secondary) + .font(.caption) } else { Text("Replaces macOS system HUD with Dynamic Island displays for volume, brightness, and keyboard backlight.") .foregroundStyle(.secondary) @@ -1306,7 +1327,7 @@ struct HUD: View { } } - if enableSystemHUD && !Defaults[.enableCustomOSD] { + if enableSystemHUD && !Defaults[.enableCustomOSD] && hasAccessibilityPermission { Section { Toggle("Volume HUD", isOn: $enableVolumeHUD) Toggle("Brightness HUD", isOn: $enableBrightnessHUD) @@ -1407,6 +1428,14 @@ struct HUD: View { } } .navigationTitle("HUDs") + .onAppear { + accessibilityPermission.refreshStatus() + } + .onChange(of: accessibilityPermission.isAuthorized) { _, granted in + if !granted { + enableSystemHUD = false + } + } } } @@ -4418,6 +4447,7 @@ struct CustomOSDSettings: View { @Default(.osdMaterial) var osdMaterial @Default(.osdIconColorStyle) var osdIconColorStyle @Default(.enableSystemHUD) var enableSystemHUD + @ObservedObject private var accessibilityPermission = AccessibilityPermissionStore.shared @State private var showAlphaWarning = false @State private var previewValue: CGFloat = 0.65 @@ -4426,13 +4456,33 @@ struct CustomOSDSettings: View { private func highlightID(_ title: String) -> String { SettingsTab.osd.highlightID(for: title) } + + private var hasAccessibilityPermission: Bool { + accessibilityPermission.isAuthorized + } var body: some View { Form { + if !hasAccessibilityPermission { + Section { + SettingsPermissionCallout( + message: "Accessibility permission is needed to intercept system controls for the Custom OSD.", + requestAction: { accessibilityPermission.requestAuthorizationPrompt() }, + openSettingsAction: { accessibilityPermission.openSystemSettings() } + ) + } header: { + Text("Accessibility") + } + } + Section { Toggle("Enable Custom OSD", isOn: Binding( get: { enableCustomOSD }, set: { newValue in + guard hasAccessibilityPermission else { + enableCustomOSD = false + return + } if newValue && !hasSeenOSDAlphaWarning { showAlphaWarning = true } else { @@ -4445,7 +4495,7 @@ struct CustomOSDSettings: View { } )) .settingsHighlight(id: highlightID("Enable Custom OSD")) - .disabled(enableSystemHUD) + .disabled(enableSystemHUD || !hasAccessibilityPermission) } header: { Text("General") } footer: { @@ -4453,6 +4503,10 @@ struct CustomOSDSettings: View { Text("Custom OSD is disabled because HUDs are enabled. Disable HUDs in the HUDs tab to use Custom OSD.") .foregroundStyle(.secondary) .font(.caption) + } else if !hasAccessibilityPermission { + Text("Grant Accessibility permission in System Settings to customize the on-screen display replacements here.") + .foregroundStyle(.secondary) + .font(.caption) } else { Text("Display custom macOS-style OSD windows at the bottom center of the screen when adjusting volume, brightness, or keyboard backlight.") .foregroundStyle(.secondary) @@ -4460,7 +4514,7 @@ struct CustomOSDSettings: View { } } - if enableCustomOSD { + if enableCustomOSD && hasAccessibilityPermission { Section { Toggle("Volume OSD", isOn: $enableOSDVolume) .settingsHighlight(id: highlightID("Volume OSD")) @@ -4572,6 +4626,49 @@ struct CustomOSDSettings: View { Text("Custom OSD is an experimental alpha feature and may contain bugs or unexpected behavior.\n\nThis feature requires macOS 15 or later and is still under active development. It's recommended to keep it disabled unless you want to help test it.") } .navigationTitle("Custom OSD") + .onAppear { + accessibilityPermission.refreshStatus() + } + .onChange(of: accessibilityPermission.isAuthorized) { _, granted in + if !granted { + enableCustomOSD = false + } + } + } +} + +struct SettingsPermissionCallout: View { + let message: String + let requestAction: () -> Void + let openSettingsAction: () -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + Label("Accessibility permission required", systemImage: "exclamationmark.triangle.fill") + .font(.subheadline.weight(.semibold)) + .foregroundStyle(.orange) + + Text(message) + .font(.caption) + .foregroundStyle(.secondary) + + HStack(spacing: 8) { + Button("Request Access") { + requestAction() + } + .buttonStyle(.borderedProminent) + + Button("Open Settings") { + openSettingsAction() + } + .buttonStyle(.bordered) + } + .controlSize(.small) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(12) + .background(Color.secondary.opacity(0.08)) + .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) } } diff --git a/DynamicIsland/helpers/AccessibilityPermissionStore.swift b/DynamicIsland/helpers/AccessibilityPermissionStore.swift new file mode 100644 index 0000000..bfab87f --- /dev/null +++ b/DynamicIsland/helpers/AccessibilityPermissionStore.swift @@ -0,0 +1,76 @@ +import Foundation +import Combine +import AppKit +#if canImport(ApplicationServices) +import ApplicationServices +#endif + +/// Tracks accessibility permission status and exposes helpers to request access. +@MainActor +final class AccessibilityPermissionStore: ObservableObject { + static let shared = AccessibilityPermissionStore() + + @Published private(set) var isAuthorized: Bool = AccessibilityPermissionStore.isAccessibilityAuthorized() + + private var pollingTask: Task? + + private init() {} + + deinit { + pollingTask?.cancel() + } + + func refreshStatus() { + updateAuthorizationStatus(to: Self.isAccessibilityAuthorized()) + } + + func requestAuthorizationPrompt() { +#if canImport(ApplicationServices) + let promptKey = kAXTrustedCheckOptionPrompt.takeUnretainedValue() as String + let options: CFDictionary = [promptKey: true] as CFDictionary + _ = AXIsProcessTrustedWithOptions(options) +#endif + beginPollingForStatusChanges() + } + + func openSystemSettings() { +#if os(macOS) + if let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility") { + NSWorkspace.shared.open(url) + } +#endif + } + + private func beginPollingForStatusChanges() { + pollingTask?.cancel() + pollingTask = Task { [weak self] in + for _ in 0..<30 { + try? await Task.sleep(nanoseconds: 500_000_000) + let status = Self.isAccessibilityAuthorized() + await MainActor.run { + guard let self else { return } + self.updateAuthorizationStatus(to: status) + } + if status { + break + } + } + } + } + + private func updateAuthorizationStatus(to newValue: Bool) { + guard newValue != isAuthorized else { return } + isAuthorized = newValue + if !newValue { + beginPollingForStatusChanges() + } + } + + private static func isAccessibilityAuthorized() -> Bool { +#if canImport(ApplicationServices) + return AXIsProcessTrusted() +#else + return true +#endif + } +}