diff --git a/.gitignore b/.gitignore index 639728b..3e3cf4b 100644 --- a/.gitignore +++ b/.gitignore @@ -5,4 +5,16 @@ Carthage .DS_Store ### Xcode ### -xcuserdata/ \ No newline at end of file +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/ diff --git a/MonitorControl.xcodeproj/project.pbxproj b/MonitorControl.xcodeproj/project.pbxproj index 3ae92ca..f1b0156 100644 --- a/MonitorControl.xcodeproj/project.pbxproj +++ b/MonitorControl.xcodeproj/project.pbxproj @@ -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 = ""; }; FB5DB2902AD54C4600306223 /* pt-PT */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "pt-PT"; path = "pt-PT.lproj/Localizable.strings"; sourceTree = ""; }; FE4E0895249D584C003A50BB /* OSDUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSDUtils.swift; sourceTree = ""; }; + CE12345678901234003A50BC /* CustomHUD.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomHUD.swift; sourceTree = ""; }; /* 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 = ""; @@ -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 */, diff --git a/MonitorControl/Enums/PrefKey.swift b/MonitorControl/Enums/PrefKey.swift index 76b5f29..3f9a18e 100644 --- a/MonitorControl/Enums/PrefKey.swift +++ b/MonitorControl/Enums/PrefKey.swift @@ -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 } diff --git a/MonitorControl/Info.plist b/MonitorControl/Info.plist index 79873fe..9d91b77 100644 --- a/MonitorControl/Info.plist +++ b/MonitorControl/Info.plist @@ -19,7 +19,7 @@ CFBundleShortVersionString $(MARKETING_VERSION) CFBundleVersion - 7141 + 7184 LSApplicationCategoryType public.app-category.utilities LSMinimumSystemVersion diff --git a/MonitorControl/Support/AppDelegate.swift b/MonitorControl/Support/AppDelegate.swift index 6d1013b..7d7525b 100644 --- a/MonitorControl/Support/AppDelegate.swift +++ b/MonitorControl/Support/AppDelegate.swift @@ -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) } diff --git a/MonitorControl/Support/CustomHUD.swift b/MonitorControl/Support/CustomHUD.swift new file mode 100644 index 0000000..47c47dd --- /dev/null +++ b/MonitorControl/Support/CustomHUD.swift @@ -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) + } + } +} diff --git a/MonitorControl/Support/DisplayManager.swift b/MonitorControl/Support/DisplayManager.swift index 00a4627..a5ae305 100644 --- a/MonitorControl/Support/DisplayManager.swift +++ b/MonitorControl/Support/DisplayManager.swift @@ -312,6 +312,9 @@ class DisplayManager { } func clearDisplays() { + for display in self.displays { + CustomHUDManager.shared.cleanupDisplay(display.identifier) + } self.displays = [] } diff --git a/MonitorControl/Support/MediaKeyTapManager.swift b/MonitorControl/Support/MediaKeyTapManager.swift index f484a6e..5ebb97e 100644 --- a/MonitorControl/Support/MediaKeyTapManager.swift +++ b/MonitorControl/Support/MediaKeyTapManager.swift @@ -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 { diff --git a/MonitorControl/Support/MenuHandler.swift b/MonitorControl/Support/MenuHandler.swift index 6fcd486..27a5728 100644 --- a/MonitorControl/Support/MenuHandler.swift +++ b/MonitorControl/Support/MenuHandler.swift @@ -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 diff --git a/MonitorControl/Support/OSDUtils.swift b/MonitorControl/Support/OSDUtils.swift index a250273..60623d6 100644 --- a/MonitorControl/Support/OSDUtils.swift +++ b/MonitorControl/Support/OSDUtils.swift @@ -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) } } + diff --git a/MonitorControl/Support/SliderHandler.swift b/MonitorControl/Support/SliderHandler.swift index de28e90..d5f7e00 100644 --- a/MonitorControl/Support/SliderHandler.swift +++ b/MonitorControl/Support/SliderHandler.swift @@ -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) } } } diff --git a/MonitorControl/UI/Base.lproj/Main.storyboard b/MonitorControl/UI/Base.lproj/Main.storyboard index f5a9bb4..a3b1e56 100644 --- a/MonitorControl/UI/Base.lproj/Main.storyboard +++ b/MonitorControl/UI/Base.lproj/Main.storyboard @@ -709,7 +709,7 @@ - + @@ -997,13 +997,14 @@ - + + @@ -1164,7 +1165,7 @@ - + @@ -1280,7 +1281,7 @@ - + @@ -1340,7 +1341,7 @@ - + @@ -1987,7 +1988,7 @@ - + diff --git a/MonitorControl/UI/en.lproj/Main.strings b/MonitorControl/UI/en.lproj/Main.strings index 7c40246..41aa602 100644 --- a/MonitorControl/UI/en.lproj/Main.strings +++ b/MonitorControl/UI/en.lproj/Main.strings @@ -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:"; diff --git a/MonitorControl/View Controllers/Onboarding/OnboardingViewController.swift b/MonitorControl/View Controllers/Onboarding/OnboardingViewController.swift index 4d07325..130dcbb 100644 --- a/MonitorControl/View Controllers/Onboarding/OnboardingViewController.swift +++ b/MonitorControl/View Controllers/Onboarding/OnboardingViewController.swift @@ -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 diff --git a/MonitorControl/View Controllers/Preferences/KeyboardPrefsViewController.swift b/MonitorControl/View Controllers/Preferences/KeyboardPrefsViewController.swift index 8bccf3a..74eddfc 100644 --- a/MonitorControl/View Controllers/Preferences/KeyboardPrefsViewController.swift +++ b/MonitorControl/View Controllers/Preferences/KeyboardPrefsViewController.swift @@ -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() } } + diff --git a/MonitorControlHelper/Info.plist b/MonitorControlHelper/Info.plist index e6cc0a2..a17ee8c 100644 --- a/MonitorControlHelper/Info.plist +++ b/MonitorControlHelper/Info.plist @@ -19,7 +19,7 @@ CFBundleShortVersionString $(MARKETING_VERSION) CFBundleVersion - 7141 + 7184 LSApplicationCategoryType public.app-category.utilities LSBackgroundOnly