mirror of
https://github.com/MonitorControl/MonitorControl.git
synced 2026-05-15 14:15:55 -06:00
Merge 7d55f05419 into 3cfc40598a
This commit is contained in:
commit
31599a1815
16 changed files with 597 additions and 62 deletions
14
.gitignore
vendored
14
.gitignore
vendored
|
|
@ -5,4 +5,16 @@ Carthage
|
|||
.DS_Store
|
||||
|
||||
### Xcode ###
|
||||
xcuserdata/
|
||||
xcuserdata/
|
||||
*.xcuserstate
|
||||
*.xccheckout
|
||||
*.xcscmblueprint
|
||||
|
||||
### Xcode / SwiftPM (local build outputs beside the project) ###
|
||||
.build/
|
||||
.derivedData/
|
||||
build/
|
||||
DerivedData/
|
||||
|
||||
### SwiftPM — user-specific; keep Package.resolved tracked ###
|
||||
.swiftpm/xcode/xcuserdata/
|
||||
|
|
|
|||
|
|
@ -58,6 +58,7 @@
|
|||
F06792F6200A745F0066C438 /* MonitorControlHelper.app in [Login] Copy Helper to start at Login */ = {isa = PBXBuildFile; fileRef = F06792E7200A73460066C438 /* MonitorControlHelper.app */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||
F0A489C4279C71B200BEDFD6 /* OnboardingViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0A489C3279C71B200BEDFD6 /* OnboardingViewController.swift */; };
|
||||
FE4E0896249D584C003A50BB /* OSDUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE4E0895249D584C003A50BB /* OSDUtils.swift */; };
|
||||
CE12345678901234003A50BB /* CustomHUD.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE12345678901234003A50BC /* CustomHUD.swift */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXCopyFilesBuildPhase section */
|
||||
|
|
@ -181,6 +182,7 @@
|
|||
FB5DB28F2AD54C4600306223 /* pt-PT */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "pt-PT"; path = "pt-PT.lproj/InternetAccessPolicy.strings"; sourceTree = "<group>"; };
|
||||
FB5DB2902AD54C4600306223 /* pt-PT */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "pt-PT"; path = "pt-PT.lproj/Localizable.strings"; sourceTree = "<group>"; };
|
||||
FE4E0895249D584C003A50BB /* OSDUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSDUtils.swift; sourceTree = "<group>"; };
|
||||
CE12345678901234003A50BC /* CustomHUD.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomHUD.swift; sourceTree = "<group>"; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
|
|
@ -317,6 +319,7 @@
|
|||
AA16139A26BE772E00DCF027 /* Arm64DDC.swift */,
|
||||
AA4398A826DD55DA00943F16 /* IntelDDC.swift */,
|
||||
FE4E0895249D584C003A50BB /* OSDUtils.swift */,
|
||||
CE12345678901234003A50BC /* CustomHUD.swift */,
|
||||
);
|
||||
path = Support;
|
||||
sourceTree = "<group>";
|
||||
|
|
@ -629,6 +632,7 @@
|
|||
AA062E8E26CA7BE5007E628C /* DisplaysPrefsCellView.swift in Sources */,
|
||||
AA25F6D726E68C160087F3A2 /* MediaKeyTapManager.swift in Sources */,
|
||||
FE4E0896249D584C003A50BB /* OSDUtils.swift in Sources */,
|
||||
CE12345678901234003A50BB /* CustomHUD.swift in Sources */,
|
||||
6CBFE27A23DB266000D1BC41 /* Display.swift in Sources */,
|
||||
AA44E70727038F7F00E06865 /* KeyboardShortcutsManager.swift in Sources */,
|
||||
F03A8DF21FFBAA6F0034DC27 /* OtherDisplay.swift in Sources */,
|
||||
|
|
|
|||
|
|
@ -212,4 +212,6 @@ enum KeyboardVolume: Int {
|
|||
case custom = 1
|
||||
case both = 2
|
||||
case disabled = 3
|
||||
/// Like `media`, but always captures volume/mute keys even when macOS can control the default output device.
|
||||
case mediaForce = 4
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@
|
|||
<key>CFBundleShortVersionString</key>
|
||||
<string>$(MARKETING_VERSION)</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>7141</string>
|
||||
<string>7184</string>
|
||||
<key>LSApplicationCategoryType</key>
|
||||
<string>public.app-category.utilities</string>
|
||||
<key>LSMinimumSystemVersion</key>
|
||||
|
|
|
|||
|
|
@ -161,7 +161,7 @@ class AppDelegate: NSObject, NSApplicationDelegate {
|
|||
}
|
||||
|
||||
func checkPermissions(firstAsk: Bool = false) {
|
||||
let permissionsRequired: Bool = [KeyboardVolume.media.rawValue, KeyboardVolume.both.rawValue].contains(prefs.integer(forKey: PrefKey.keyboardVolume.rawValue)) || [KeyboardBrightness.media.rawValue, KeyboardBrightness.both.rawValue].contains(prefs.integer(forKey: PrefKey.keyboardBrightness.rawValue))
|
||||
let permissionsRequired: Bool = [KeyboardVolume.media.rawValue, KeyboardVolume.both.rawValue, KeyboardVolume.mediaForce.rawValue].contains(prefs.integer(forKey: PrefKey.keyboardVolume.rawValue)) || [KeyboardBrightness.media.rawValue, KeyboardBrightness.both.rawValue].contains(prefs.integer(forKey: PrefKey.keyboardBrightness.rawValue))
|
||||
if !MediaKeyTapManager.readPrivileges(prompt: false), permissionsRequired {
|
||||
MediaKeyTapManager.acquirePrivileges(firstAsk: firstAsk)
|
||||
}
|
||||
|
|
|
|||
243
MonitorControl/Support/CustomHUD.swift
Normal file
243
MonitorControl/Support/CustomHUD.swift
Normal file
|
|
@ -0,0 +1,243 @@
|
|||
// Copyright © MonitorControl. @JoniVR, @theOneyouseek, @waydabber and others
|
||||
// CustomHUD.swift - Custom OSD overlay for macOS Tahoe 26+ compatibility
|
||||
|
||||
import Cocoa
|
||||
|
||||
// MARK: - Custom HUD Manager
|
||||
|
||||
/// Manages custom HUD windows for brightness/volume display on macOS 26+
|
||||
/// where the native OSD API no longer works correctly
|
||||
class CustomHUDManager {
|
||||
static let shared = CustomHUDManager()
|
||||
|
||||
private var hudWindows: [CGDirectDisplayID: NSWindow] = [:]
|
||||
private let hudLock = NSLock()
|
||||
|
||||
private init() {}
|
||||
|
||||
/// Shows a custom HUD on the specified display
|
||||
func showHUD(displayID: CGDirectDisplayID, type: HUDType, value: Float, maxValue: Float = 1.0) {
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self = self else { return }
|
||||
|
||||
self.hudLock.lock()
|
||||
defer { self.hudLock.unlock() }
|
||||
|
||||
// Get or create HUD window for this display
|
||||
let window: NSWindow
|
||||
if let existingWindow = self.hudWindows[displayID] {
|
||||
window = existingWindow
|
||||
} else {
|
||||
window = self.createHUDWindow(displayID: displayID)
|
||||
self.hudWindows[displayID] = window
|
||||
}
|
||||
|
||||
// Update and show the HUD
|
||||
self.updateWindowContent(window: window, displayID: displayID, type: type, value: value, maxValue: maxValue)
|
||||
self.showWindowWithFade(window: window)
|
||||
}
|
||||
}
|
||||
|
||||
private func createHUDWindow(displayID: CGDirectDisplayID) -> NSWindow {
|
||||
let window = NSPanel(
|
||||
contentRect: NSRect(x: 0, y: 0, width: 280, height: 64),
|
||||
styleMask: [.nonactivatingPanel, .fullSizeContentView],
|
||||
backing: .buffered,
|
||||
defer: false
|
||||
)
|
||||
|
||||
window.level = .floating
|
||||
window.isFloatingPanel = true
|
||||
window.hidesOnDeactivate = false
|
||||
window.collectionBehavior = [.canJoinAllSpaces, .stationary, .ignoresCycle]
|
||||
window.isOpaque = false
|
||||
window.backgroundColor = .clear
|
||||
window.hasShadow = true
|
||||
window.isMovable = false
|
||||
window.isMovableByWindowBackground = false
|
||||
window.ignoresMouseEvents = true
|
||||
window.titleVisibility = .hidden
|
||||
window.titlebarAppearsTransparent = true
|
||||
|
||||
positionWindow(window, onDisplay: displayID)
|
||||
|
||||
return window
|
||||
}
|
||||
|
||||
private func positionWindow(_ window: NSWindow, onDisplay displayID: CGDirectDisplayID) {
|
||||
guard let screen = getScreen(for: displayID) else { return }
|
||||
|
||||
let screenFrame = screen.visibleFrame
|
||||
let windowSize = window.frame.size
|
||||
|
||||
// Position at center-bottom of screen, above the dock
|
||||
let x = screenFrame.origin.x + (screenFrame.width - windowSize.width) / 2
|
||||
let y = screenFrame.origin.y + 80 // 80 points from bottom for better visibility
|
||||
|
||||
window.setFrameOrigin(NSPoint(x: x, y: y))
|
||||
}
|
||||
|
||||
private func getScreen(for displayID: CGDirectDisplayID) -> NSScreen? {
|
||||
return NSScreen.screens.first { screen in
|
||||
guard let screenNumber = screen.deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] as? NSNumber else {
|
||||
return false
|
||||
}
|
||||
return CGDirectDisplayID(screenNumber.uint32Value) == displayID
|
||||
}
|
||||
}
|
||||
|
||||
private func updateWindowContent(window: NSWindow, displayID: CGDirectDisplayID, type: HUDType, value: Float, maxValue: Float) {
|
||||
// Create content view using AppKit for maximum compatibility
|
||||
let contentView = createHUDContentView(type: type, value: value, maxValue: maxValue)
|
||||
window.contentView = contentView
|
||||
|
||||
// Re-position in case screen changed
|
||||
positionWindow(window, onDisplay: displayID)
|
||||
}
|
||||
|
||||
private func createHUDContentView(type: HUDType, value: Float, maxValue: Float) -> NSView {
|
||||
let w: CGFloat = 280
|
||||
let h: CGFloat = 64
|
||||
|
||||
// Wrap the visual effect view in a transparent root view.
|
||||
let rootView = NSView(frame: NSRect(x: 0, y: 0, width: w, height: h))
|
||||
rootView.wantsLayer = true
|
||||
rootView.layer?.backgroundColor = NSColor.clear.cgColor
|
||||
|
||||
let containerView = NSVisualEffectView(frame: NSRect(x: 0, y: 0, width: w, height: h))
|
||||
containerView.material = .hudWindow
|
||||
containerView.blendingMode = .behindWindow
|
||||
containerView.state = .active
|
||||
containerView.appearance = NSAppearance(named: .vibrantDark)
|
||||
containerView.wantsLayer = true
|
||||
containerView.layer?.cornerRadius = h / 2
|
||||
containerView.layer?.masksToBounds = true
|
||||
|
||||
// Add premium liquid glass edge highlight
|
||||
containerView.layer?.borderWidth = 0.5
|
||||
containerView.layer?.borderColor = NSColor.white.withAlphaComponent(0.25).cgColor
|
||||
|
||||
rootView.addSubview(containerView)
|
||||
|
||||
let iconSize: CGFloat = 24
|
||||
let iconView = NSImageView(frame: NSRect(x: 20, y: (h - iconSize) / 2, width: iconSize, height: iconSize))
|
||||
iconView.imageScaling = .scaleProportionallyDown
|
||||
iconView.wantsLayer = true
|
||||
iconView.layer?.isOpaque = false
|
||||
iconView.layer?.backgroundColor = NSColor.clear.cgColor
|
||||
|
||||
if #available(macOS 11.0, *) {
|
||||
if let icon = NSImage(systemSymbolName: type.iconSystemName, accessibilityDescription: nil) {
|
||||
let config = NSImage.SymbolConfiguration(pointSize: iconSize * 0.9, weight: .bold)
|
||||
iconView.image = icon.withSymbolConfiguration(config)
|
||||
iconView.contentTintColor = .white
|
||||
}
|
||||
} else {
|
||||
let fallbackIcon: String
|
||||
switch type {
|
||||
case .brightness: fallbackIcon = NSImage.touchBarComposeTemplateName
|
||||
case .volume: fallbackIcon = NSImage.touchBarAudioOutputVolumeHighTemplateName
|
||||
case .volumeMuted: fallbackIcon = NSImage.touchBarAudioOutputMuteTemplateName
|
||||
case .contrast: fallbackIcon = NSImage.touchBarColorPickerFillName
|
||||
}
|
||||
iconView.image = NSImage(named: fallbackIcon)
|
||||
iconView.contentTintColor = .white
|
||||
}
|
||||
containerView.addSubview(iconView)
|
||||
|
||||
// Thick Liquid Slider
|
||||
let barX: CGFloat = 20 + iconSize + 16
|
||||
let barW: CGFloat = w - barX - 24 // More compact, no trailing percentage label by default
|
||||
let barH: CGFloat = 32 // Thicker, liquid-style bar
|
||||
let barY = (h - barH) / 2
|
||||
|
||||
let progressBg = NSBox(frame: NSRect(x: barX, y: barY, width: barW, height: barH))
|
||||
progressBg.boxType = .custom
|
||||
progressBg.borderType = .noBorder
|
||||
progressBg.fillColor = NSColor.white.withAlphaComponent(0.15)
|
||||
progressBg.cornerRadius = barH / 2
|
||||
containerView.addSubview(progressBg)
|
||||
|
||||
let normalizedValue = CGFloat(min(max(value / maxValue, 0), 1))
|
||||
let fillWidth = max(barH, barW * normalizedValue)
|
||||
let progressFill = NSBox(frame: NSRect(x: barX, y: barY, width: fillWidth, height: barH))
|
||||
progressFill.boxType = .custom
|
||||
progressFill.borderType = .noBorder
|
||||
progressFill.fillColor = NSColor.white.withAlphaComponent(0.9)
|
||||
progressFill.cornerRadius = barH / 2
|
||||
containerView.addSubview(progressFill)
|
||||
|
||||
return rootView
|
||||
}
|
||||
|
||||
private var fadeTimers: [CGDirectDisplayID: Timer] = [:]
|
||||
|
||||
private func showWindowWithFade(window: NSWindow) {
|
||||
// Find the displayID for this window
|
||||
guard let displayID = hudWindows.first(where: { $0.value === window })?.key else { return }
|
||||
|
||||
// Cancel any existing fade timer
|
||||
fadeTimers[displayID]?.invalidate()
|
||||
|
||||
// Make window fully visible
|
||||
window.alphaValue = 1.0
|
||||
window.orderFrontRegardless()
|
||||
|
||||
// Schedule fade out after 1.5 seconds (common mode so it still fires during nested run-loop work)
|
||||
let timer = Timer(timeInterval: 1.5, repeats: false) { [weak self, weak window] _ in
|
||||
guard let window = window else { return }
|
||||
self?.fadeOut(window: window)
|
||||
}
|
||||
fadeTimers[displayID] = timer
|
||||
RunLoop.main.add(timer, forMode: .common)
|
||||
}
|
||||
|
||||
private func fadeOut(window: NSWindow) {
|
||||
NSAnimationContext.runAnimationGroup { context in
|
||||
context.duration = 0.3
|
||||
window.animator().alphaValue = 0
|
||||
} completionHandler: {
|
||||
window.orderOut(nil)
|
||||
}
|
||||
}
|
||||
|
||||
/// Cleans up HUD windows for removed displays
|
||||
func cleanupDisplay(_ displayID: CGDirectDisplayID) {
|
||||
hudLock.lock()
|
||||
defer { hudLock.unlock() }
|
||||
|
||||
fadeTimers[displayID]?.invalidate()
|
||||
fadeTimers.removeValue(forKey: displayID)
|
||||
|
||||
if let window = hudWindows[displayID] {
|
||||
window.close()
|
||||
hudWindows.removeValue(forKey: displayID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - HUD Type
|
||||
|
||||
enum HUDType {
|
||||
case brightness
|
||||
case volume
|
||||
case volumeMuted
|
||||
case contrast
|
||||
|
||||
var iconSystemName: String {
|
||||
switch self {
|
||||
case .brightness: return "sun.max.fill"
|
||||
case .volume: return "speaker.wave.2.fill"
|
||||
case .volumeMuted: return "speaker.slash.fill"
|
||||
case .contrast: return "circle.lefthalf.filled"
|
||||
}
|
||||
}
|
||||
|
||||
var iconNSColor: NSColor {
|
||||
switch self {
|
||||
case .brightness: return .white.withAlphaComponent(0.9)
|
||||
case .volume, .volumeMuted: return .white.withAlphaComponent(0.9)
|
||||
case .contrast: return .white.withAlphaComponent(0.8)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -312,6 +312,9 @@ class DisplayManager {
|
|||
}
|
||||
|
||||
func clearDisplays() {
|
||||
for display in self.displays {
|
||||
CustomHUDManager.shared.cleanupDisplay(display.identifier)
|
||||
}
|
||||
self.displays = []
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -150,7 +150,7 @@ class MediaKeyTapManager: MediaKeyTapDelegate {
|
|||
if [KeyboardBrightness.media.rawValue, KeyboardBrightness.both.rawValue].contains(prefs.integer(forKey: PrefKey.keyboardBrightness.rawValue)) {
|
||||
keys.append(contentsOf: [.brightnessUp, .brightnessDown])
|
||||
}
|
||||
if [KeyboardVolume.media.rawValue, KeyboardVolume.both.rawValue].contains(prefs.integer(forKey: PrefKey.keyboardVolume.rawValue)) {
|
||||
if [KeyboardVolume.media.rawValue, KeyboardVolume.both.rawValue, KeyboardVolume.mediaForce.rawValue].contains(prefs.integer(forKey: PrefKey.keyboardVolume.rawValue)) {
|
||||
keys.append(contentsOf: [.mute, .volumeUp, .volumeDown])
|
||||
}
|
||||
// Remove brightness keys if no external displays are connected, but only if brightness fine control is not active
|
||||
|
|
@ -166,8 +166,8 @@ class MediaKeyTapManager: MediaKeyTapDelegate {
|
|||
let keysToDelete: [MediaKey] = [.brightnessUp, .brightnessDown]
|
||||
keys.removeAll { keysToDelete.contains($0) }
|
||||
}
|
||||
// Remove volume related keys if audio device is controllable
|
||||
if let defaultAudioDevice = app.coreAudio.defaultOutputDevice {
|
||||
// Remove volume related keys if audio device is controllable (skip when user chose force-capture mode)
|
||||
if prefs.integer(forKey: PrefKey.keyboardVolume.rawValue) != KeyboardVolume.mediaForce.rawValue, let defaultAudioDevice = app.coreAudio.defaultOutputDevice {
|
||||
let keysToDelete: [MediaKey] = [.volumeUp, .volumeDown, .mute]
|
||||
if prefs.integer(forKey: PrefKey.multiKeyboardVolume.rawValue) == MultiKeyboardVolume.audioDeviceNameMatching.rawValue {
|
||||
if DisplayManager.shared.updateAudioControlTargetDisplays(deviceName: defaultAudioDevice.name) == 0 {
|
||||
|
|
|
|||
|
|
@ -103,25 +103,59 @@ class MenuHandler: NSMenu, NSMenuDelegate {
|
|||
|
||||
func addDisplayMenuBlock(addedSliderHandlers: [SliderHandler], blockName: String, monitorSubMenu: NSMenu, numOfDisplays: Int, asSubMenu: Bool) {
|
||||
if numOfDisplays > 1, prefs.integer(forKey: PrefKey.multiSliders.rawValue) != MultiSliders.relevant.rawValue, !DEBUG_MACOS10, #available(macOS 11.0, *) {
|
||||
class BlockView: NSView {
|
||||
override func draw(_: NSRect) {
|
||||
/// Draws the original soft outer rings + inner edge; layered on top of `NSVisualEffectView` (which does not reliably call `draw(_:)`).
|
||||
class BlockBorderOverlayView: NSView {
|
||||
override var isOpaque: Bool { false }
|
||||
|
||||
override func viewDidChangeEffectiveAppearance() {
|
||||
super.viewDidChangeEffectiveAppearance()
|
||||
self.needsDisplay = true
|
||||
}
|
||||
|
||||
override func draw(_ dirtyRect: NSRect) {
|
||||
let radius = prefs.bool(forKey: PrefKey.showTickMarks.rawValue) ? CGFloat(4) : CGFloat(11)
|
||||
let outerMargin = CGFloat(15)
|
||||
let blockRect = self.frame.insetBy(dx: outerMargin, dy: outerMargin / 2 + 2).offsetBy(dx: 0, dy: outerMargin / 2 * -1 + 7)
|
||||
let blockRect = self.bounds.insetBy(dx: outerMargin, dy: outerMargin / 2 + 2).offsetBy(dx: 0, dy: outerMargin / 2 * -1 + 7)
|
||||
for i in 1 ... 5 {
|
||||
let blockPath = NSBezierPath(roundedRect: blockRect.insetBy(dx: CGFloat(i) * -1, dy: CGFloat(i) * -1), xRadius: radius + CGFloat(i) * 0.5, yRadius: radius + CGFloat(i) * 0.5)
|
||||
NSColor.black.withAlphaComponent(0.1 / CGFloat(i)).setStroke()
|
||||
blockPath.lineWidth = 1
|
||||
blockPath.stroke()
|
||||
}
|
||||
let blockPath = NSBezierPath(roundedRect: blockRect, xRadius: radius, yRadius: radius)
|
||||
if [NSAppearance.Name.darkAqua, NSAppearance.Name.vibrantDark].contains(effectiveAppearance.name) {
|
||||
if [NSAppearance.Name.darkAqua, NSAppearance.Name.vibrantDark].contains(self.effectiveAppearance.name) {
|
||||
NSColor.systemGray.withAlphaComponent(0.3).setStroke()
|
||||
blockPath.lineWidth = 1
|
||||
blockPath.stroke()
|
||||
} else {
|
||||
// With `NSVisualEffectView` behind, skip the old semi-opaque white fill so the material shows; keep a clear inner edge.
|
||||
NSColor.separatorColor.withAlphaComponent(0.45).setStroke()
|
||||
blockPath.lineWidth = 1
|
||||
blockPath.stroke()
|
||||
}
|
||||
if ![NSAppearance.Name.darkAqua, NSAppearance.Name.vibrantDark].contains(effectiveAppearance.name) {
|
||||
NSColor.white.withAlphaComponent(0.5).setFill()
|
||||
blockPath.fill()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class BlockView: NSVisualEffectView {
|
||||
override init(frame frameRect: NSRect) {
|
||||
super.init(frame: frameRect)
|
||||
// Match the surrounding NSMenu chrome (display name row above, etc.); `.popover` reads as a different panel.
|
||||
self.material = .menu
|
||||
self.blendingMode = .withinWindow
|
||||
self.state = .active
|
||||
self.isEmphasized = false
|
||||
self.wantsLayer = true
|
||||
let tickMarks = prefs.bool(forKey: PrefKey.showTickMarks.rawValue)
|
||||
self.layer?.cornerRadius = tickMarks ? CGFloat(10) : CGFloat(14)
|
||||
self.layer?.masksToBounds = true
|
||||
let borderOverlay = BlockBorderOverlayView(frame: self.bounds)
|
||||
borderOverlay.autoresizingMask = [.width, .height]
|
||||
self.addSubview(borderOverlay)
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder _: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
}
|
||||
var contentWidth: CGFloat = 0
|
||||
|
|
|
|||
|
|
@ -21,7 +21,37 @@ class OSDUtils: NSObject {
|
|||
return osdImage
|
||||
}
|
||||
|
||||
/// Check if we're running on macOS 26 (Tahoe) or later where native OSD is broken
|
||||
private static var shouldUseCustomHUD: Bool {
|
||||
if #available(macOS 26, *) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/// Convert Command to HUDType for custom HUD
|
||||
private static func getHUDType(command: Command, value: Float) -> HUDType {
|
||||
switch command {
|
||||
case .audioSpeakerVolume:
|
||||
return value > 0 ? .volume : .volumeMuted
|
||||
case .audioMuteScreenBlank:
|
||||
return .volumeMuted
|
||||
case .contrast:
|
||||
return .contrast
|
||||
default:
|
||||
return .brightness
|
||||
}
|
||||
}
|
||||
|
||||
static func showOsd(displayID: CGDirectDisplayID, command: Command, value: Float, maxValue: Float = 1, roundChiclet: Bool = false, lock: Bool = false) {
|
||||
// Use custom HUD on macOS 26+ where native OSD is broken
|
||||
if shouldUseCustomHUD {
|
||||
let hudType = getHUDType(command: command, value: value)
|
||||
CustomHUDManager.shared.showHUD(displayID: displayID, type: hudType, value: value, maxValue: maxValue)
|
||||
return
|
||||
}
|
||||
|
||||
// Fallback to native OSD for older macOS versions
|
||||
guard let manager = OSDManager.sharedManager() as? OSDManager else {
|
||||
return
|
||||
}
|
||||
|
|
@ -40,6 +70,11 @@ class OSDUtils: NSObject {
|
|||
}
|
||||
|
||||
static func showOsdVolumeDisabled(displayID: CGDirectDisplayID) {
|
||||
if shouldUseCustomHUD {
|
||||
CustomHUDManager.shared.showHUD(displayID: displayID, type: .volumeMuted, value: 0, maxValue: 1)
|
||||
return
|
||||
}
|
||||
|
||||
guard let manager = OSDManager.sharedManager() as? OSDManager else {
|
||||
return
|
||||
}
|
||||
|
|
@ -47,6 +82,11 @@ class OSDUtils: NSObject {
|
|||
}
|
||||
|
||||
static func showOsdMuteDisabled(displayID: CGDirectDisplayID) {
|
||||
if shouldUseCustomHUD {
|
||||
CustomHUDManager.shared.showHUD(displayID: displayID, type: .volumeMuted, value: 0, maxValue: 1)
|
||||
return
|
||||
}
|
||||
|
||||
guard let manager = OSDManager.sharedManager() as? OSDManager else {
|
||||
return
|
||||
}
|
||||
|
|
@ -54,6 +94,12 @@ class OSDUtils: NSObject {
|
|||
}
|
||||
|
||||
static func popEmptyOsd(displayID: CGDirectDisplayID, command: Command) {
|
||||
if shouldUseCustomHUD {
|
||||
let hudType = getHUDType(command: command, value: 0)
|
||||
CustomHUDManager.shared.showHUD(displayID: displayID, type: hudType, value: 0, maxValue: 1)
|
||||
return
|
||||
}
|
||||
|
||||
guard let manager = OSDManager.sharedManager() as? OSDManager else {
|
||||
return
|
||||
}
|
||||
|
|
@ -75,3 +121,4 @@ class OSDUtils: NSObject {
|
|||
abs(chiclet.rounded(.towardZero) - chiclet)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -14,26 +14,28 @@ class SliderHandler {
|
|||
var icon: ClickThroughImageView?
|
||||
|
||||
class MCSliderCell: NSSliderCell {
|
||||
let knobFillColor = NSColor(white: 1, alpha: 1)
|
||||
let knobFillColorTracking = NSColor(white: 0.8, alpha: 1)
|
||||
let knobStrokeColor = NSColor.systemGray.withAlphaComponent(0.5)
|
||||
let knobShadowColor = NSColor(white: 0, alpha: 0.03)
|
||||
let barFillColor = NSColor.systemGray.withAlphaComponent(0.2)
|
||||
let barStrokeColor = NSColor.systemGray.withAlphaComponent(0.5)
|
||||
let barFilledFillColor = NSColor(white: 1, alpha: 1)
|
||||
let highlightDisplayIndicatorColor = NSColor(white: 0.85, alpha: 1) // This is visible if there is more the 2 displays
|
||||
let tickMarkColor = NSColor.systemGray.withAlphaComponent(0.5)
|
||||
let knobFillColor = NSColor.white.withAlphaComponent(0.9)
|
||||
let knobFillColorTracking = NSColor.white.withAlphaComponent(0.75)
|
||||
let knobStrokeColor = NSColor.systemGray.withAlphaComponent(0.4)
|
||||
let knobShadowColor = NSColor(white: 0, alpha: 0.05)
|
||||
let barFillColor = NSColor.systemGray.withAlphaComponent(0.25)
|
||||
let barStrokeColor = NSColor.systemGray.withAlphaComponent(0.4)
|
||||
let barFilledFillColor = NSColor.white.withAlphaComponent(0.85)
|
||||
let highlightDisplayIndicatorColor = NSColor.white.withAlphaComponent(0.85) // This is visible if there is more the 2 displays
|
||||
let tickMarkColor = NSColor.systemGray.withAlphaComponent(0.4)
|
||||
|
||||
let inset: CGFloat = 3.5
|
||||
let offsetX: CGFloat = -1.5
|
||||
let offsetY: CGFloat = -1.5
|
||||
|
||||
let tickMarkKnobExtraInset: CGFloat = 4
|
||||
let tickMarkKnobExtraRadiusMultiplier: CGFloat = 0.25
|
||||
let tickMarkKnobExtraRadiusMultiplier: CGFloat = 0.75
|
||||
|
||||
var numOfTickmarks: Int = 0
|
||||
var isHighlightDisplayItems: Bool = false
|
||||
var displayHighlightItems: [CGDirectDisplayID: Float] = [:]
|
||||
/// Matches Control Center–style volume (accent) vs. brightness (neutral fill).
|
||||
var useAccentFill: Bool = false
|
||||
|
||||
var isTracking: Bool = false
|
||||
|
||||
|
|
@ -90,7 +92,7 @@ class SliderHandler {
|
|||
let barFilledWidth = (aRect.width - aRect.height) * CGFloat(maxValue) + aRect.height
|
||||
let barFilledRect = NSRect(x: aRect.origin.x, y: aRect.origin.y, width: barFilledWidth, height: aRect.height)
|
||||
let barFilled = NSBezierPath(roundedRect: barFilledRect, xRadius: barRadius, yRadius: barRadius)
|
||||
self.barFilledFillColor.setFill()
|
||||
(self.useAccentFill ? NSColor.controlAccentColor : self.barFilledFillColor).setFill()
|
||||
barFilled.fill()
|
||||
|
||||
let knobMinX = aRect.origin.x + (aRect.width - aRect.height) * CGFloat(minValue)
|
||||
|
|
@ -116,7 +118,7 @@ class SliderHandler {
|
|||
}
|
||||
|
||||
let knob = NSBezierPath(roundedRect: knobRect, xRadius: knobRadius, yRadius: knobRadius)
|
||||
(self.isTracking ? self.knobFillColorTracking : self.knobFillColor).withAlphaComponent(knobAlpha).setFill()
|
||||
(self.isTracking ? self.knobFillColorTracking : self.knobFillColor).withAlphaComponent(self.knobFillColor.alphaComponent * knobAlpha).setFill()
|
||||
knob.fill()
|
||||
|
||||
if self.isHighlightDisplayItems, self.displayHighlightItems.count > 2 {
|
||||
|
|
@ -133,7 +135,8 @@ class SliderHandler {
|
|||
|
||||
self.knobStrokeColor.withAlphaComponent(self.knobStrokeColor.alphaComponent * knobAlpha).setStroke()
|
||||
knob.stroke()
|
||||
self.barStrokeColor.setStroke()
|
||||
// Use a subtle stroke for the bar as well
|
||||
self.barStrokeColor.withAlphaComponent(self.barStrokeColor.alphaComponent * (1 - knobAlpha * 0.5)).setStroke()
|
||||
bar.stroke()
|
||||
}
|
||||
}
|
||||
|
|
@ -207,6 +210,30 @@ class SliderHandler {
|
|||
subviews.first { subview in subview.hitTest(point) != nil
|
||||
}
|
||||
}
|
||||
|
||||
func configureForMenuSymbol() {
|
||||
self.wantsLayer = true
|
||||
self.layer?.isOpaque = false
|
||||
self.layer?.backgroundColor = NSColor.clear.cgColor
|
||||
}
|
||||
}
|
||||
|
||||
/// Renders menu SF Symbols without multicolor white “matting” around fills (e.g. sun.max.fill).
|
||||
@available(macOS 11.0, *)
|
||||
private static func menuSymbolImage(named name: String, pointSize: CGFloat, command: Command) -> NSImage? {
|
||||
guard let base = NSImage(systemSymbolName: name, accessibilityDescription: nil) else { return nil }
|
||||
let weight: NSFont.Weight = .medium
|
||||
let baseConfig = NSImage.SymbolConfiguration(pointSize: pointSize, weight: weight)
|
||||
if #available(macOS 12.0, *) {
|
||||
let paletteColor: NSColor
|
||||
switch command {
|
||||
case .brightness: paletteColor = .labelColor.withAlphaComponent(0.85)
|
||||
default: paletteColor = .labelColor.withAlphaComponent(0.72)
|
||||
}
|
||||
let palette = NSImage.SymbolConfiguration(paletteColors: [paletteColor])
|
||||
return base.withSymbolConfiguration(baseConfig.applying(palette))
|
||||
}
|
||||
return base.withSymbolConfiguration(baseConfig)
|
||||
}
|
||||
|
||||
init(display: Display?, command: Command, title: String = "", position _: Int = 0) {
|
||||
|
|
@ -216,12 +243,28 @@ class SliderHandler {
|
|||
let showPercent = prefs.bool(forKey: PrefKey.enableSliderPercent.rawValue)
|
||||
slider.isEnabled = true
|
||||
slider.setNumOfCustomTickmarks(prefs.bool(forKey: PrefKey.showTickMarks.rawValue) ? 5 : 0)
|
||||
if let cell = slider.cell as? MCSliderCell {
|
||||
cell.useAccentFill = command == .audioSpeakerVolume
|
||||
}
|
||||
self.slider = slider
|
||||
if !DEBUG_MACOS10, #available(macOS 11.0, *) {
|
||||
let iconSize: CGFloat = 18
|
||||
let iconPadding: CGFloat = 10
|
||||
slider.frame.size.width = 180
|
||||
slider.frame.origin = NSPoint(x: 15, y: 5)
|
||||
let view = NSView(frame: NSRect(x: 0, y: 0, width: slider.frame.width + 30 + (showPercent ? 38 : 0), height: slider.frame.height + 14))
|
||||
var sliderFrame = slider.frame
|
||||
sliderFrame.size.height = max(sliderFrame.size.height, 22)
|
||||
slider.frame = sliderFrame
|
||||
|
||||
// Horizontal layout: Icon then Slider
|
||||
let iconX: CGFloat = 15
|
||||
let sliderX = iconX + iconSize + iconPadding
|
||||
slider.frame.origin = NSPoint(x: sliderX, y: 8)
|
||||
|
||||
let viewWidth = sliderX + slider.frame.width + 15 + (showPercent ? 38 : 0)
|
||||
let viewHeight = slider.frame.height + 16
|
||||
let view = NSView(frame: NSRect(x: 0, y: 0, width: viewWidth, height: viewHeight))
|
||||
view.frame.origin = NSPoint(x: 12, y: 0)
|
||||
|
||||
var iconName = "circle.dashed"
|
||||
switch command {
|
||||
case .audioSpeakerVolume: iconName = "speaker.wave.2.fill"
|
||||
|
|
@ -230,15 +273,23 @@ class SliderHandler {
|
|||
default: break
|
||||
}
|
||||
let icon = SliderHandler.ClickThroughImageView()
|
||||
icon.image = NSImage(systemSymbolName: iconName, accessibilityDescription: title)
|
||||
icon.contentTintColor = NSColor.black.withAlphaComponent(0.6)
|
||||
icon.frame = NSRect(x: view.frame.origin.x + 6.5, y: view.frame.origin.y + 13, width: 15, height: 15)
|
||||
icon.image = SliderHandler.menuSymbolImage(named: iconName, pointSize: iconSize, command: command)
|
||||
icon.imageScaling = .scaleProportionallyDown
|
||||
if #available(macOS 12.0, *) {
|
||||
icon.contentTintColor = nil
|
||||
} else {
|
||||
icon.contentTintColor = .labelColor.withAlphaComponent(0.72)
|
||||
}
|
||||
|
||||
// Position icon to the left of the slider, vertically centered
|
||||
icon.frame = NSRect(x: iconX, y: slider.frame.origin.y + (slider.frame.height - iconSize) / 2, width: iconSize, height: iconSize)
|
||||
icon.imageAlignment = .alignCenter
|
||||
icon.configureForMenuSymbol()
|
||||
view.addSubview(slider)
|
||||
view.addSubview(icon)
|
||||
self.icon = icon
|
||||
if showPercent {
|
||||
let percentageBox = NSTextField(frame: NSRect(x: 15 + slider.frame.size.width - 2, y: 17, width: 40, height: 12))
|
||||
let percentageBox = NSTextField(frame: NSRect(x: sliderX + slider.frame.size.width + 2, y: slider.frame.origin.y + (slider.frame.height - 12) / 2, width: 40, height: 12))
|
||||
self.setupPercentageBox(percentageBox)
|
||||
self.percentageBox = percentageBox
|
||||
view.addSubview(percentageBox)
|
||||
|
|
@ -320,9 +371,7 @@ class SliderHandler {
|
|||
slider.floatValue = value
|
||||
}
|
||||
}
|
||||
if self.percentageBox == self.percentageBox {
|
||||
self.percentageBox?.stringValue = "" + String(Int(value * 100)) + "%"
|
||||
}
|
||||
self.percentageBox?.stringValue = String(format: "%.0f%%", Double(value) * 100)
|
||||
for display in self.displays {
|
||||
slider.setHighlightItem(display.identifier, value: value)
|
||||
if self.command == .brightness, let appleDisplay = display as? AppleDisplay {
|
||||
|
|
@ -335,21 +384,21 @@ class SliderHandler {
|
|||
}
|
||||
|
||||
func updateIcon() {
|
||||
// This looks hideous so I disable it for now. Maybe after a bit of tinkering it will look better
|
||||
/*
|
||||
if self.command == .audioSpeakerVolume {
|
||||
let value = self.slider?.floatValue ?? 0.5
|
||||
if value > 2/3 {
|
||||
self.icon?.image = NSImage(systemSymbolName: "speaker.wave.3.fill", accessibilityDescription: "")
|
||||
} else if value > 1/3 {
|
||||
self.icon?.image = NSImage(systemSymbolName: "speaker.wave.2.fill", accessibilityDescription: "")
|
||||
} else if value != 0 {
|
||||
self.icon?.image = NSImage(systemSymbolName: "speaker.wave.1.fill", accessibilityDescription: "")
|
||||
} else {
|
||||
self.icon?.image = NSImage(systemSymbolName: "speaker.slash.fill", accessibilityDescription: "")
|
||||
}
|
||||
}
|
||||
*/
|
||||
if #available(macOS 11.0, *), self.command == .audioSpeakerVolume {
|
||||
let value = self.slider?.floatValue ?? 0.5
|
||||
let iconName: String
|
||||
if value > 2/3 {
|
||||
iconName = "speaker.wave.3.fill"
|
||||
} else if value > 1/3 {
|
||||
iconName = "speaker.wave.2.fill"
|
||||
} else if value > 0 {
|
||||
iconName = "speaker.wave.1.fill"
|
||||
} else {
|
||||
iconName = "speaker.slash.fill"
|
||||
}
|
||||
let iconSize: CGFloat = 18
|
||||
self.icon?.image = SliderHandler.menuSymbolImage(named: iconName, pointSize: iconSize, command: self.command)
|
||||
}
|
||||
}
|
||||
|
||||
func setValue(_ value: Float, displayID: CGDirectDisplayID = 0) {
|
||||
|
|
@ -378,9 +427,7 @@ class SliderHandler {
|
|||
} else {
|
||||
slider.setDisplayHighlightItems(false)
|
||||
}
|
||||
if self.percentageBox == self.percentageBox {
|
||||
self.percentageBox?.stringValue = "\(String(format: "%.0f%%", Double(value) * 100))"
|
||||
}
|
||||
self.percentageBox?.stringValue = String(format: "%.0f%%", Double(value) * 100)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -709,7 +709,7 @@
|
|||
<objects>
|
||||
<viewController storyboardIdentifier="KeyboardPrefsVC" id="eJ2-Cv-Vfp" customClass="KeyboardPrefsViewController" customModule="MonitorControl" customModuleProvider="target" sceneMemberID="viewController">
|
||||
<view key="view" translatesAutoresizingMaskIntoConstraints="NO" id="yCl-jO-kpd">
|
||||
<rect key="frame" x="0.0" y="0.0" width="730" height="676"/>
|
||||
<rect key="frame" x="0.0" y="0.0" width="730" height="726"/>
|
||||
<subviews>
|
||||
<gridView xPlacement="leading" yPlacement="fill" rowAlignment="none" rowSpacing="20" translatesAutoresizingMaskIntoConstraints="NO" id="bKX-vi-7gY">
|
||||
<rect key="frame" x="20" y="20" width="690" height="636"/>
|
||||
|
|
@ -997,13 +997,14 @@
|
|||
</gridCell>
|
||||
<gridCell row="6od-Ek-qTW" column="EiG-65-CTv" xPlacement="leading" id="pCd-dP-Y62">
|
||||
<popUpButton key="contentView" verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="9oZ-JM-bxD">
|
||||
<rect key="frame" x="217" y="187" width="293" height="25"/>
|
||||
<rect key="frame" x="217" y="187" width="360" height="25"/>
|
||||
<popUpButtonCell key="cell" type="push" title="Standard keyboard volume and mute keys" bezelStyle="rounded" alignment="left" lineBreakMode="truncatingTail" state="on" borderStyle="borderAndBezel" imageScaling="proportionallyDown" inset="2" selectedItem="1sy-Kd-WL5" id="gTf-PQ-2fA">
|
||||
<behavior key="behavior" pushIn="YES" lightByBackground="YES" lightByGray="YES"/>
|
||||
<font key="font" metaFont="message"/>
|
||||
<menu key="menu" id="LhY-cM-Msq">
|
||||
<items>
|
||||
<menuItem title="Standard keyboard volume and mute keys" state="on" id="1sy-Kd-WL5"/>
|
||||
<menuItem title="Standard keys (force capture)" tag="4" id="fVk-mR-9Xp"/>
|
||||
<menuItem title="Custom keyboard shortcuts" tag="1" id="4CG-0I-anB"/>
|
||||
<menuItem title="Both standard and custom shortcuts" tag="2" id="QDG-SA-mRX">
|
||||
<modifierMask key="keyEquivalentModifierMask"/>
|
||||
|
|
@ -1164,7 +1165,7 @@
|
|||
</gridView>
|
||||
</subviews>
|
||||
<constraints>
|
||||
<constraint firstAttribute="bottom" secondItem="bKX-vi-7gY" secondAttribute="bottom" constant="20" id="BWt-5s-vf7"/>
|
||||
<constraint firstAttribute="bottom" secondItem="bKX-vi-7gY" secondAttribute="bottom" constant="55" id="BWt-5s-vf7"/>
|
||||
<constraint firstItem="bKX-vi-7gY" firstAttribute="leading" secondItem="yCl-jO-kpd" secondAttribute="leading" constant="20" id="Pdh-1U-kqr"/>
|
||||
<constraint firstItem="bKX-vi-7gY" firstAttribute="top" secondItem="yCl-jO-kpd" secondAttribute="top" constant="20" id="fmV-It-Xaq"/>
|
||||
<constraint firstAttribute="trailing" secondItem="bKX-vi-7gY" secondAttribute="trailing" constant="20" id="ssM-r3-qK0"/>
|
||||
|
|
@ -1280,7 +1281,7 @@
|
|||
<textField focusRingType="none" horizontalHuggingPriority="251" verticalHuggingPriority="750" id="jz0-4Z-xVm">
|
||||
<rect key="frame" x="210" y="449" width="166" height="16"/>
|
||||
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMinY="YES"/>
|
||||
<textFieldCell key="cell" lineBreakMode="clipping" title="#bc-ignore!" id="6GJ-6Q-gqz">
|
||||
<textFieldCell key="cell" lineBreakMode="clipping" alignment="center" title="#bc-ignore!" id="6GJ-6Q-gqz">
|
||||
<font key="font" metaFont="system"/>
|
||||
<color key="textColor" name="labelColor" catalog="System" colorSpace="catalog"/>
|
||||
<color key="backgroundColor" name="textBackgroundColor" catalog="System" colorSpace="catalog"/>
|
||||
|
|
@ -1340,7 +1341,7 @@
|
|||
</textFieldCell>
|
||||
</textField>
|
||||
<textField focusRingType="none" verticalHuggingPriority="750" id="mIA-Wh-7aB">
|
||||
<rect key="frame" x="78" y="492" width="463" height="21"/>
|
||||
<rect key="frame" x="78" y="490" width="463" height="28"/>
|
||||
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMinY="YES"/>
|
||||
<textFieldCell key="cell" scrollable="YES" lineBreakMode="clipping" selectable="YES" editable="YES" sendsActionOnEndEditing="YES" title="#bc-ignore!" id="ibQ-4u-ClE">
|
||||
<font key="font" textStyle="title2" name=".SFNS-Regular"/>
|
||||
|
|
@ -1987,7 +1988,7 @@
|
|||
</textFieldCell>
|
||||
</textField>
|
||||
<textField identifier="softwareNameLabel" focusRingType="none" horizontalHuggingPriority="251" verticalHuggingPriority="750" id="dtg-g7-hZb">
|
||||
<rect key="frame" x="247" y="258" width="147" height="26"/>
|
||||
<rect key="frame" x="247" y="254" width="200" height="30"/>
|
||||
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMinY="YES"/>
|
||||
<textFieldCell key="cell" lineBreakMode="clipping" alignment="left" title="MonitorControl" id="1PJ-14-Bvn">
|
||||
<font key="font" textStyle="title1" name=".SFNS-Regular"/>
|
||||
|
|
|
|||
|
|
@ -415,5 +415,8 @@
|
|||
/* Class = "NSButtonCell"; title = "Show percentages"; ObjectID = "ZUu-MR-XwA"; */
|
||||
"ZUu-MR-XwA.title" = "Show percentages";
|
||||
|
||||
/* Class = "NSMenuItem"; title = "Standard keys (force capture)"; ObjectID = "fVk-mR-9Xp"; */
|
||||
"fVk-mR-9Xp.title" = "Standard keys (force capture)";
|
||||
|
||||
/* Class = "NSTextFieldCell"; title = "Combined dimming switchover point:"; ObjectID = "zv8-pZ-OPy"; */
|
||||
"zv8-pZ-OPy.title" = "Combined dimming switchover point:";
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ class OnboardingViewController: NSViewController {
|
|||
// MARK: - Style
|
||||
|
||||
private func setPermissionsButtonState() {
|
||||
let volumePermissions: Bool = [KeyboardVolume.media.rawValue, KeyboardVolume.both.rawValue].contains(prefs.integer(forKey: PrefKey.keyboardVolume.rawValue))
|
||||
let volumePermissions: Bool = [KeyboardVolume.media.rawValue, KeyboardVolume.both.rawValue, KeyboardVolume.mediaForce.rawValue].contains(prefs.integer(forKey: PrefKey.keyboardVolume.rawValue))
|
||||
let brigthnessPermissions: Bool = [KeyboardBrightness.media.rawValue, KeyboardBrightness.both.rawValue].contains(prefs.integer(forKey: PrefKey.keyboardBrightness.rawValue))
|
||||
let permissionsRequired: Bool = volumePermissions || brigthnessPermissions
|
||||
let enabled: Bool = !MediaKeyTapManager.readPrivileges(prompt: false) && permissionsRequired
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import Cocoa
|
|||
import KeyboardShortcuts
|
||||
import ServiceManagement
|
||||
import Settings
|
||||
import os.log
|
||||
|
||||
class KeyboardPrefsViewController: NSViewController, SettingsPane {
|
||||
let paneIdentifier = Settings.PaneIdentifier.keyboard
|
||||
|
|
@ -46,6 +47,10 @@ class KeyboardPrefsViewController: NSViewController, SettingsPane {
|
|||
@IBOutlet var rowUseAudioMouseText: NSGridRow!
|
||||
@IBOutlet var rowUseAudioNameText: NSGridRow!
|
||||
|
||||
// Accessibility troubleshooting UI
|
||||
var accessibilityHelpButton: NSButton?
|
||||
var resetAccessibilityButton: NSButton?
|
||||
|
||||
func updateGridLayout() {
|
||||
if self.keyboardBrightness.selectedTag() == KeyboardBrightness.media.rawValue {
|
||||
self.rowKeyboardBrightnessPopUp.bottomPadding = -13
|
||||
|
|
@ -141,9 +146,142 @@ class KeyboardPrefsViewController: NSViewController, SettingsPane {
|
|||
self.customVolumeDown.addSubview(customVolumeDownRecorder)
|
||||
self.customMute.addSubview(customMuteRecorder)
|
||||
|
||||
self.setupAccessibilityTroubleshootingUI()
|
||||
self.populateSettings()
|
||||
}
|
||||
|
||||
// MARK: - Accessibility Troubleshooting UI
|
||||
|
||||
private func setupAccessibilityTroubleshootingUI() {
|
||||
// Create container view - height matches other grid rows
|
||||
let containerHeight: CGFloat = 25
|
||||
let containerView = NSView()
|
||||
containerView.translatesAutoresizingMaskIntoConstraints = false
|
||||
|
||||
// Create "Troubleshooting:" label to match the existing UI style
|
||||
let label = NSTextField(labelWithString: NSLocalizedString("Troubleshooting:", comment: "Label for troubleshooting section"))
|
||||
label.font = NSFont.systemFont(ofSize: 13)
|
||||
label.textColor = NSColor.labelColor
|
||||
label.alignment = .right
|
||||
label.translatesAutoresizingMaskIntoConstraints = false
|
||||
|
||||
// Create help button with system help style
|
||||
let helpButton = NSButton()
|
||||
helpButton.bezelStyle = .helpButton
|
||||
helpButton.title = ""
|
||||
helpButton.toolTip = NSLocalizedString("Keyboard shortcuts troubleshooting", comment: "Tooltip for help button")
|
||||
helpButton.target = self
|
||||
helpButton.action = #selector(showAccessibilityHelp(_:))
|
||||
helpButton.translatesAutoresizingMaskIntoConstraints = false
|
||||
self.accessibilityHelpButton = helpButton
|
||||
|
||||
// Create Reset Accessibility button
|
||||
let resetButton = NSButton()
|
||||
resetButton.title = NSLocalizedString("Reset Accessibility Permission", comment: "Button to reset accessibility")
|
||||
resetButton.bezelStyle = .rounded
|
||||
resetButton.toolTip = NSLocalizedString("Reset and re-request accessibility permission (fixes keyboard shortcuts on macOS Tahoe)", comment: "Tooltip for reset button")
|
||||
resetButton.target = self
|
||||
resetButton.action = #selector(resetAccessibilityPermission(_:))
|
||||
resetButton.translatesAutoresizingMaskIntoConstraints = false
|
||||
self.resetAccessibilityButton = resetButton
|
||||
|
||||
// Add all elements to container
|
||||
containerView.addSubview(label)
|
||||
containerView.addSubview(helpButton)
|
||||
containerView.addSubview(resetButton)
|
||||
|
||||
// Add container to the main view
|
||||
self.view.addSubview(containerView)
|
||||
|
||||
// Layout constraints - positioning to match grid spacing (10px from grid bottom)
|
||||
NSLayoutConstraint.activate([
|
||||
// Container positioning - bottom of view with grid-like padding
|
||||
containerView.leadingAnchor.constraint(equalTo: self.view.leadingAnchor, constant: 20),
|
||||
containerView.trailingAnchor.constraint(equalTo: self.view.trailingAnchor, constant: -20),
|
||||
containerView.bottomAnchor.constraint(equalTo: self.view.bottomAnchor, constant: -18),
|
||||
containerView.heightAnchor.constraint(equalToConstant: containerHeight),
|
||||
|
||||
// Label - right aligned at 212 points width to match storyboard column
|
||||
label.leadingAnchor.constraint(equalTo: containerView.leadingAnchor),
|
||||
label.widthAnchor.constraint(equalToConstant: 210),
|
||||
label.centerYAnchor.constraint(equalTo: containerView.centerYAnchor),
|
||||
|
||||
// Help button - after label
|
||||
helpButton.leadingAnchor.constraint(equalTo: label.trailingAnchor, constant: 8),
|
||||
helpButton.centerYAnchor.constraint(equalTo: containerView.centerYAnchor),
|
||||
|
||||
// Reset button - after help button
|
||||
resetButton.leadingAnchor.constraint(equalTo: helpButton.trailingAnchor, constant: 8),
|
||||
resetButton.centerYAnchor.constraint(equalTo: containerView.centerYAnchor),
|
||||
])
|
||||
}
|
||||
|
||||
@objc func showAccessibilityHelp(_ sender: NSButton) {
|
||||
let alert = NSAlert()
|
||||
alert.messageText = NSLocalizedString("Keyboard Shortcuts Not Working?", comment: "Alert title")
|
||||
alert.informativeText = NSLocalizedString("""
|
||||
On macOS Tahoe (26+), you may need to reset accessibility permissions for keyboard shortcuts to work.
|
||||
|
||||
To fix this:
|
||||
1. Click "Reset Accessibility Permission" below
|
||||
2. When prompted, click "Open System Settings"
|
||||
3. Enable MonitorControl in the Accessibility list
|
||||
4. Restart MonitorControl if needed
|
||||
|
||||
Alternatively, go to:
|
||||
System Settings → Privacy & Security → Accessibility
|
||||
Remove MonitorControl, then add it back.
|
||||
""", comment: "Accessibility troubleshooting guide")
|
||||
alert.alertStyle = .informational
|
||||
if #available(macOS 11.0, *) {
|
||||
alert.icon = NSImage(systemSymbolName: "keyboard", accessibilityDescription: nil)
|
||||
}
|
||||
alert.addButton(withTitle: NSLocalizedString("OK", comment: "OK button"))
|
||||
alert.addButton(withTitle: NSLocalizedString("Open Accessibility Settings", comment: "Button to open settings"))
|
||||
|
||||
let response = alert.runModal()
|
||||
if response == .alertSecondButtonReturn {
|
||||
self.openAccessibilitySettings()
|
||||
}
|
||||
}
|
||||
|
||||
@objc func resetAccessibilityPermission(_ sender: NSButton) {
|
||||
let alert = NSAlert()
|
||||
alert.messageText = NSLocalizedString("Reset Accessibility Permission?", comment: "Confirmation alert title")
|
||||
alert.informativeText = NSLocalizedString("This will reset MonitorControl's accessibility permission. You will need to grant permission again for keyboard shortcuts to work.", comment: "Confirmation message")
|
||||
alert.alertStyle = .warning
|
||||
alert.addButton(withTitle: NSLocalizedString("Reset & Re-request", comment: "Reset button"))
|
||||
alert.addButton(withTitle: NSLocalizedString("Cancel", comment: "Cancel button"))
|
||||
|
||||
let response = alert.runModal()
|
||||
guard response == .alertFirstButtonReturn else { return }
|
||||
|
||||
// Run tccutil to reset accessibility for this app
|
||||
let bundleId = Bundle.main.bundleIdentifier ?? "me.guillaumeb.MonitorControl"
|
||||
let process = Process()
|
||||
process.executableURL = URL(fileURLWithPath: "/usr/bin/tccutil")
|
||||
process.arguments = ["reset", "Accessibility", bundleId]
|
||||
|
||||
do {
|
||||
try process.run()
|
||||
process.waitUntilExit()
|
||||
os_log("Reset accessibility permission for %{public}@, exit code: %{public}d", type: .info, bundleId, process.terminationStatus)
|
||||
} catch {
|
||||
os_log("Failed to reset accessibility: %{public}@", type: .error, error.localizedDescription)
|
||||
}
|
||||
|
||||
// Re-prompt for accessibility permission
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
||||
MediaKeyTapManager.acquirePrivileges()
|
||||
}
|
||||
}
|
||||
|
||||
private func openAccessibilitySettings() {
|
||||
if let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility") {
|
||||
NSWorkspace.shared.open(url)
|
||||
}
|
||||
}
|
||||
|
||||
func populateSettings() {
|
||||
self.keyboardBrightness.selectItem(withTag: prefs.integer(forKey: PrefKey.keyboardBrightness.rawValue))
|
||||
self.keyboardVolume.selectItem(withTag: prefs.integer(forKey: PrefKey.keyboardVolume.rawValue))
|
||||
|
|
@ -224,3 +362,4 @@ class KeyboardPrefsViewController: NSViewController, SettingsPane {
|
|||
self.updateGridLayout()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@
|
|||
<key>CFBundleShortVersionString</key>
|
||||
<string>$(MARKETING_VERSION)</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>7141</string>
|
||||
<string>7184</string>
|
||||
<key>LSApplicationCategoryType</key>
|
||||
<string>public.app-category.utilities</string>
|
||||
<key>LSBackgroundOnly</key>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue