mirror of
https://github.com/FBukovina/Atoll.git
synced 2026-10-27 15:14:33 +00:00
Merge remote-tracking branch 'origin/main' into pr-47
This commit is contained in:
commit
30fafccf5e
8 changed files with 827 additions and 576 deletions
54
.gitignore
vendored
54
.gitignore
vendored
|
|
@ -1,4 +1,57 @@
|
|||
.DS_Store
|
||||
# Xcode
|
||||
*.xcuserstate
|
||||
*.xcuserdata
|
||||
*.xcuserdatad
|
||||
*.pbxuser
|
||||
*.xccheckout
|
||||
*.xcplayground
|
||||
*.xcuserdatad
|
||||
*.xctest
|
||||
*.xcuserdata
|
||||
*.xcodeproj/xcshareddata/WorkspaceSettings.xcsettings
|
||||
|
||||
# CocoaPods
|
||||
Pods/
|
||||
podfile.lock
|
||||
|
||||
# Carthage
|
||||
Carthage/Build/
|
||||
|
||||
# Swift Package Manager
|
||||
.swiftpm/
|
||||
.build/
|
||||
|
||||
# Derived data
|
||||
DerivedData/
|
||||
|
||||
# Build output
|
||||
build/
|
||||
*.app
|
||||
*.dSYM
|
||||
|
||||
# Temporary files
|
||||
*.swp
|
||||
*.swo
|
||||
*.tmp
|
||||
*.log
|
||||
|
||||
# Other
|
||||
*.DS_Store
|
||||
*.vscode/
|
||||
*.idea/
|
||||
|
||||
# User-specific files
|
||||
*.xcuserdatad/
|
||||
*.xcodeproj/xcuserdata
|
||||
*.xcodeproj/project.xcuserdata
|
||||
|
||||
# Build artifacts
|
||||
*.ipa
|
||||
*.xcarchive
|
||||
*.dSYM
|
||||
|
||||
|
||||
# Xcode
|
||||
#
|
||||
build/
|
||||
|
|
@ -88,6 +141,7 @@ ONBOARDING_ENHANCEMENT.md
|
|||
SHEET_PRESENTATION_FIX.md
|
||||
lock_mode_reference.md
|
||||
liquid_glass.md
|
||||
huds.md
|
||||
|
||||
MoveToSky.xcodeproj
|
||||
/MoveToSky/
|
||||
|
|
@ -379,12 +379,13 @@
|
|||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CODE_SIGN_ENTITLEMENTS = DynamicIsland/DynamicIsland.entitlements;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "-";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
COMBINE_HIDPI_IMAGES = YES;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_ASSET_PATHS = "\"DynamicIsland/Preview Content\"";
|
||||
DEVELOPMENT_TEAM = 9Y64TRM77N;
|
||||
DEVELOPMENT_TEAM = "";
|
||||
ENABLE_APP_SANDBOX = NO;
|
||||
ENABLE_HARDENED_RUNTIME = YES;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
|
|
@ -410,6 +411,7 @@
|
|||
MARKETING_VERSION = "1.0.0-beta";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.karthikinformationtechnology.DynamicIsland;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
SYSTEM_FRAMEWORK_SEARCH_PATHS = (
|
||||
|
|
@ -425,12 +427,13 @@
|
|||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CODE_SIGN_ENTITLEMENTS = DynamicIsland/DynamicIsland.entitlements;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "-";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
COMBINE_HIDPI_IMAGES = YES;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_ASSET_PATHS = "\"DynamicIsland/Preview Content\"";
|
||||
DEVELOPMENT_TEAM = 9Y64TRM77N;
|
||||
DEVELOPMENT_TEAM = "";
|
||||
ENABLE_APP_SANDBOX = NO;
|
||||
ENABLE_HARDENED_RUNTIME = YES;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
|
|
@ -456,6 +459,7 @@
|
|||
MARKETING_VERSION = "1.0.0-beta";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.karthikinformationtechnology.DynamicIsland;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
SYSTEM_FRAMEWORK_SEARCH_PATHS = (
|
||||
|
|
|
|||
|
|
@ -0,0 +1,81 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "2600"
|
||||
version = "1.7">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
buildImplicitDependencies = "YES"
|
||||
buildArchitectures = "Automatic">
|
||||
<BuildActionEntries>
|
||||
<BuildActionEntry
|
||||
buildForTesting = "YES"
|
||||
buildForRunning = "YES"
|
||||
buildForProfiling = "YES"
|
||||
buildForArchiving = "YES"
|
||||
buildForAnalyzing = "YES">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "F54406E62CD4987A00C7AFD9"
|
||||
BuildableName = "DynamicIsland.app"
|
||||
BlueprintName = "DynamicIsland"
|
||||
ReferencedContainer = "container:DynamicIsland.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildActionEntry>
|
||||
</BuildActionEntries>
|
||||
</BuildAction>
|
||||
<TestAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
shouldAutocreateTestPlan = "YES">
|
||||
</TestAction>
|
||||
<LaunchAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
launchStyle = "0"
|
||||
useCustomWorkingDirectory = "NO"
|
||||
ignoresPersistentStateOnLaunch = "NO"
|
||||
debugDocumentVersioning = "YES"
|
||||
debugServiceExtension = "internal"
|
||||
allowLocationSimulation = "YES">
|
||||
<PathRunnable
|
||||
runnableDebuggingMode = "0"
|
||||
FilePath = "/Applications/DynamicIsland.app">
|
||||
</PathRunnable>
|
||||
<MacroExpansion>
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "F54406E62CD4987A00C7AFD9"
|
||||
BuildableName = "DynamicIsland.app"
|
||||
BlueprintName = "DynamicIsland"
|
||||
ReferencedContainer = "container:DynamicIsland.xcodeproj">
|
||||
</BuildableReference>
|
||||
</MacroExpansion>
|
||||
</LaunchAction>
|
||||
<ProfileAction
|
||||
buildConfiguration = "Release"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
savedToolIdentifier = ""
|
||||
useCustomWorkingDirectory = "NO"
|
||||
debugDocumentVersioning = "YES">
|
||||
<BuildableProductRunnable
|
||||
runnableDebuggingMode = "0">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "F54406E62CD4987A00C7AFD9"
|
||||
BuildableName = "DynamicIsland.app"
|
||||
BlueprintName = "DynamicIsland"
|
||||
ReferencedContainer = "container:DynamicIsland.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
</ProfileAction>
|
||||
<AnalyzeAction
|
||||
buildConfiguration = "Debug">
|
||||
</AnalyzeAction>
|
||||
<ArchiveAction
|
||||
buildConfiguration = "Release"
|
||||
revealArchiveInOrganizer = "YES">
|
||||
</ArchiveAction>
|
||||
</Scheme>
|
||||
|
|
@ -93,10 +93,6 @@ class AppDelegate: NSObject, NSApplicationDelegate {
|
|||
private var cancellables = Set<AnyCancellable>()
|
||||
private var windowsHiddenForLock = false
|
||||
|
||||
// Media key monitoring
|
||||
private var globalEventMonitor: Any?
|
||||
private var localEventMonitor: Any?
|
||||
|
||||
// Debouncing mechanism for window size updates
|
||||
private var windowSizeUpdateWorkItem: DispatchWorkItem?
|
||||
// let calendarManager = CalendarManager.shared
|
||||
|
|
@ -127,84 +123,9 @@ class AppDelegate: NSObject, NSApplicationDelegate {
|
|||
return false
|
||||
}
|
||||
|
||||
// MARK: - Media Key Monitoring
|
||||
|
||||
private func setupMediaKeyMonitoring() {
|
||||
// Monitor both global and local events to catch media keys
|
||||
globalEventMonitor = NSEvent.addGlobalMonitorForEvents(matching: .systemDefined) { [weak self] event in
|
||||
self?.handleSystemEvent(event)
|
||||
}
|
||||
|
||||
localEventMonitor = NSEvent.addLocalMonitorForEvents(matching: .systemDefined) { [weak self] event in
|
||||
self?.handleSystemEvent(event)
|
||||
return event
|
||||
}
|
||||
|
||||
print("🎹 Media key monitoring started using NSEvent")
|
||||
}
|
||||
|
||||
private func handleSystemEvent(_ event: NSEvent) {
|
||||
guard event.subtype.rawValue == 8 else { return }
|
||||
|
||||
let data1 = event.data1
|
||||
let keyCode = Int32((data1 & 0xFFFF0000) >> 16)
|
||||
let keyFlags = Int(data1 & 0x0000FFFF)
|
||||
let keyState = ((keyFlags & 0xFF00) >> 8) == 0xA // KeyDown
|
||||
let keyRepeat = (keyFlags & 0x1) != 0
|
||||
|
||||
guard keyState && !keyRepeat else { return } // Only handle key down, ignore repeats
|
||||
|
||||
switch keyCode {
|
||||
case NX_KEYTYPE_SOUND_UP, NX_KEYTYPE_SOUND_DOWN, NX_KEYTYPE_MUTE:
|
||||
print("🔊 Volume key detected: \(keyCode)")
|
||||
SystemHUDManager.shared.handleVolumeKeyEvent()
|
||||
case NX_KEYTYPE_BRIGHTNESS_UP, NX_KEYTYPE_BRIGHTNESS_DOWN:
|
||||
print("☀️ Brightness key detected: \(keyCode)")
|
||||
SystemHUDManager.shared.handleBrightnessKeyEvent()
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
private func stopMediaKeyMonitoring() {
|
||||
if let globalMonitor = globalEventMonitor {
|
||||
NSEvent.removeMonitor(globalMonitor)
|
||||
globalEventMonitor = nil
|
||||
}
|
||||
|
||||
if let localMonitor = localEventMonitor {
|
||||
NSEvent.removeMonitor(localMonitor)
|
||||
localEventMonitor = nil
|
||||
}
|
||||
|
||||
print("🎹 Media key monitoring stopped")
|
||||
}
|
||||
|
||||
// MARK: - MediaKeyApplicationDelegate (for future use)
|
||||
|
||||
func didDetectVolumeKey() {
|
||||
SystemHUDManager.shared.handleVolumeKeyEvent()
|
||||
}
|
||||
|
||||
func didDetectBrightnessKey() {
|
||||
SystemHUDManager.shared.handleBrightnessKeyEvent()
|
||||
}
|
||||
|
||||
func applicationWillTerminate(_ notification: Notification) {
|
||||
// Cancel any pending window size updates
|
||||
windowSizeUpdateWorkItem?.cancel()
|
||||
|
||||
// Stop media key monitoring
|
||||
stopMediaKeyMonitoring()
|
||||
|
||||
print("🚪 Application terminating - ensuring system HUD is restored...")
|
||||
|
||||
// Re-enable system HUD on app termination
|
||||
SystemOSDManager.enableSystemHUD()
|
||||
|
||||
// Give it time to complete
|
||||
usleep(500000) // 500ms
|
||||
|
||||
NotificationCenter.default.removeObserver(self)
|
||||
}
|
||||
|
||||
|
|
@ -454,9 +375,6 @@ class AppDelegate: NSObject, NSApplicationDelegate {
|
|||
// Setup Privacy Indicator Manager (camera and microphone monitoring)
|
||||
PrivacyIndicatorManager.shared.startMonitoring()
|
||||
|
||||
// Setup media key monitoring
|
||||
setupMediaKeyMonitoring()
|
||||
|
||||
// Observe tab changes - use debounced updates
|
||||
coordinator.$currentView.sink { [weak self] newView in
|
||||
self?.debouncedUpdateWindowSize()
|
||||
|
|
|
|||
204
DynamicIsland/managers/MediaKeyInterceptor.swift
Normal file
204
DynamicIsland/managers/MediaKeyInterceptor.swift
Normal file
|
|
@ -0,0 +1,204 @@
|
|||
import Foundation
|
||||
import AppKit
|
||||
import CoreGraphics
|
||||
#if canImport(ApplicationServices)
|
||||
import ApplicationServices
|
||||
#endif
|
||||
|
||||
private let NX_SYSDEFINED_EVENT_TYPE: UInt32 = 14
|
||||
private let NX_KEYTYPE_SOUND_UP: Int32 = 0
|
||||
private let NX_KEYTYPE_SOUND_DOWN: Int32 = 1
|
||||
private let NX_KEYTYPE_BRIGHTNESS_UP: Int32 = 2
|
||||
private let NX_KEYTYPE_BRIGHTNESS_DOWN: Int32 = 3
|
||||
private let NX_KEYTYPE_MUTE: Int32 = 7
|
||||
|
||||
enum MediaKeyDirection {
|
||||
case up
|
||||
case down
|
||||
}
|
||||
|
||||
struct MediaKeyConfiguration {
|
||||
var interceptVolume: Bool
|
||||
var interceptBrightness: Bool
|
||||
|
||||
static let disabled = MediaKeyConfiguration(interceptVolume: false, interceptBrightness: false)
|
||||
}
|
||||
|
||||
protocol MediaKeyInterceptorDelegate: AnyObject {
|
||||
func mediaKeyInterceptor(_ interceptor: MediaKeyInterceptor, didReceiveVolumeCommand direction: MediaKeyDirection, isRepeat: Bool)
|
||||
func mediaKeyInterceptor(_ interceptor: MediaKeyInterceptor, didReceiveBrightnessCommand direction: MediaKeyDirection, isRepeat: Bool)
|
||||
func mediaKeyInterceptorDidToggleMute(_ interceptor: MediaKeyInterceptor)
|
||||
}
|
||||
|
||||
final class MediaKeyInterceptor {
|
||||
static let shared = MediaKeyInterceptor()
|
||||
|
||||
weak var delegate: MediaKeyInterceptorDelegate?
|
||||
var configuration: MediaKeyConfiguration = .disabled {
|
||||
didSet {
|
||||
updateTapState()
|
||||
}
|
||||
}
|
||||
|
||||
private var eventTap: CFMachPort?
|
||||
private var runLoopSource: CFRunLoopSource?
|
||||
private var isTapEnabled = false
|
||||
#if canImport(ApplicationServices)
|
||||
private var didRequestAccessibilityPrompt = false
|
||||
#endif
|
||||
private let systemDefinedEventType = CGEventType(rawValue: NX_SYSDEFINED_EVENT_TYPE)
|
||||
private let eventTapLocations: [CGEventTapLocation] = [.cghidEventTap, .cgSessionEventTap]
|
||||
|
||||
private init() {}
|
||||
|
||||
@discardableResult
|
||||
func start() -> Bool {
|
||||
guard eventTap == nil else {
|
||||
updateTapState()
|
||||
return true
|
||||
}
|
||||
|
||||
#if canImport(ApplicationServices)
|
||||
requestAccessibilityPermissionIfNeeded()
|
||||
#endif
|
||||
|
||||
guard let systemDefinedType = systemDefinedEventType else {
|
||||
NSLog("❌ Unable to resolve system-defined event type")
|
||||
return false
|
||||
}
|
||||
let mask = CGEventMask(1) << systemDefinedType.rawValue
|
||||
let callback: CGEventTapCallBack = { _, type, cgEvent, userInfo in
|
||||
guard let userInfo else { return Unmanaged.passUnretained(cgEvent) }
|
||||
let interceptor = Unmanaged<MediaKeyInterceptor>.fromOpaque(userInfo).takeUnretainedValue()
|
||||
return interceptor.handleEvent(cgEvent: cgEvent, type: type)
|
||||
}
|
||||
|
||||
var createdTap: CFMachPort?
|
||||
for location in eventTapLocations {
|
||||
if let tap = CGEvent.tapCreate(
|
||||
tap: location,
|
||||
place: .headInsertEventTap,
|
||||
options: .defaultTap,
|
||||
eventsOfInterest: mask,
|
||||
callback: callback,
|
||||
userInfo: UnsafeMutableRawPointer(Unmanaged.passUnretained(self).toOpaque())
|
||||
) {
|
||||
createdTap = tap
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
guard let tap = createdTap else {
|
||||
#if canImport(ApplicationServices)
|
||||
if !AXIsProcessTrusted() {
|
||||
NSLog("⚠️ Accessibility permission missing; grant access in System Settings › Privacy & Security › Accessibility")
|
||||
}
|
||||
#endif
|
||||
NSLog("❌ Failed to create media key event tap")
|
||||
return false
|
||||
}
|
||||
|
||||
eventTap = tap
|
||||
runLoopSource = CFMachPortCreateRunLoopSource(kCFAllocatorDefault, tap, 0)
|
||||
if let source = runLoopSource {
|
||||
CFRunLoopAddSource(CFRunLoopGetMain(), source, .commonModes)
|
||||
}
|
||||
CGEvent.tapEnable(tap: tap, enable: true)
|
||||
isTapEnabled = true
|
||||
NSLog("✅ Media key event tap installed (HID)")
|
||||
return true
|
||||
}
|
||||
|
||||
func stop() {
|
||||
if let tap = eventTap {
|
||||
CGEvent.tapEnable(tap: tap, enable: false)
|
||||
CFMachPortInvalidate(tap)
|
||||
}
|
||||
if let source = runLoopSource {
|
||||
CFRunLoopRemoveSource(CFRunLoopGetMain(), source, .commonModes)
|
||||
}
|
||||
eventTap = nil
|
||||
runLoopSource = nil
|
||||
isTapEnabled = false
|
||||
}
|
||||
|
||||
private func updateTapState() {
|
||||
guard let tap = eventTap else { return }
|
||||
let shouldEnable = configuration.interceptVolume || configuration.interceptBrightness
|
||||
if shouldEnable != isTapEnabled {
|
||||
CGEvent.tapEnable(tap: tap, enable: shouldEnable)
|
||||
isTapEnabled = shouldEnable
|
||||
}
|
||||
}
|
||||
|
||||
private func handleEvent(cgEvent: CGEvent, type: CGEventType) -> Unmanaged<CGEvent>? {
|
||||
guard let systemDefinedType = systemDefinedEventType,
|
||||
type == systemDefinedType,
|
||||
let nsEvent = NSEvent(cgEvent: cgEvent),
|
||||
nsEvent.subtype.rawValue == 8 else {
|
||||
return Unmanaged.passUnretained(cgEvent)
|
||||
}
|
||||
|
||||
let data1 = nsEvent.data1
|
||||
let keyCode = (data1 & 0xFFFF0000) >> 16
|
||||
let keyFlags = data1 & 0x0000FFFF
|
||||
let keyState = ((keyFlags & 0xFF00) >> 8) == 0xA // 0xA = keyDown, 0xB = keyUp
|
||||
let isRepeat = (keyFlags & 0x0001) == 1
|
||||
|
||||
guard keyState else {
|
||||
// Swallow key-up events only when intercepting, otherwise let them pass through
|
||||
if shouldHandle(keyCode: Int32(keyCode)) {
|
||||
return nil
|
||||
}
|
||||
return Unmanaged.passUnretained(cgEvent)
|
||||
}
|
||||
|
||||
switch Int32(keyCode) {
|
||||
case NX_KEYTYPE_SOUND_UP:
|
||||
guard configuration.interceptVolume else { return Unmanaged.passUnretained(cgEvent) }
|
||||
delegate?.mediaKeyInterceptor(self, didReceiveVolumeCommand: .up, isRepeat: isRepeat)
|
||||
return nil
|
||||
case NX_KEYTYPE_SOUND_DOWN:
|
||||
guard configuration.interceptVolume else { return Unmanaged.passUnretained(cgEvent) }
|
||||
delegate?.mediaKeyInterceptor(self, didReceiveVolumeCommand: .down, isRepeat: isRepeat)
|
||||
return nil
|
||||
case NX_KEYTYPE_MUTE:
|
||||
guard configuration.interceptVolume else { return Unmanaged.passUnretained(cgEvent) }
|
||||
delegate?.mediaKeyInterceptorDidToggleMute(self)
|
||||
return nil
|
||||
case NX_KEYTYPE_BRIGHTNESS_UP:
|
||||
guard configuration.interceptBrightness else { return Unmanaged.passUnretained(cgEvent) }
|
||||
delegate?.mediaKeyInterceptor(self, didReceiveBrightnessCommand: .up, isRepeat: isRepeat)
|
||||
return nil
|
||||
case NX_KEYTYPE_BRIGHTNESS_DOWN:
|
||||
guard configuration.interceptBrightness else { return Unmanaged.passUnretained(cgEvent) }
|
||||
delegate?.mediaKeyInterceptor(self, didReceiveBrightnessCommand: .down, isRepeat: isRepeat)
|
||||
return nil
|
||||
default:
|
||||
return Unmanaged.passUnretained(cgEvent)
|
||||
}
|
||||
}
|
||||
|
||||
private func shouldHandle(keyCode: Int32) -> Bool {
|
||||
switch keyCode {
|
||||
case NX_KEYTYPE_SOUND_UP, NX_KEYTYPE_SOUND_DOWN, NX_KEYTYPE_MUTE:
|
||||
return configuration.interceptVolume
|
||||
case NX_KEYTYPE_BRIGHTNESS_UP, NX_KEYTYPE_BRIGHTNESS_DOWN:
|
||||
return configuration.interceptBrightness
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#if canImport(ApplicationServices)
|
||||
extension MediaKeyInterceptor {
|
||||
private func requestAccessibilityPermissionIfNeeded() {
|
||||
guard !AXIsProcessTrusted(), !didRequestAccessibilityPrompt else { return }
|
||||
let promptKey = kAXTrustedCheckOptionPrompt.takeUnretainedValue() as String
|
||||
let options: CFDictionary = [promptKey: true] as CFDictionary
|
||||
_ = AXIsProcessTrustedWithOptions(options)
|
||||
didRequestAccessibilityPrompt = true
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
|
@ -1,347 +1,97 @@
|
|||
//
|
||||
// SystemChangesObserver.swift
|
||||
// DynamicIsland
|
||||
//
|
||||
// Adapted from TheBoringWorker-HUD ChangesObserver.swift
|
||||
// Created by GitHub Copilot on 06/09/25.
|
||||
//
|
||||
|
||||
import Cocoa
|
||||
import Foundation
|
||||
import CoreGraphics
|
||||
import Defaults
|
||||
import CoreAudio
|
||||
|
||||
class SystemChangesObserver {
|
||||
private var oldVolume: Float = 0
|
||||
private var oldMuted: Bool = false
|
||||
private var oldBrightness: Float = 0
|
||||
|
||||
final class SystemChangesObserver: MediaKeyInterceptorDelegate {
|
||||
private weak var coordinator: DynamicIslandViewCoordinator?
|
||||
|
||||
// Adaptive polling timer
|
||||
private var pollingTimer: Timer?
|
||||
private var currentPollingInterval: TimeInterval = 2.0
|
||||
|
||||
// Polling intervals
|
||||
private let rapidPollingInterval: TimeInterval = 0.05 // Burst mode for active changes
|
||||
private let normalPollingInterval: TimeInterval = 2.0 // Normal mode when idle
|
||||
|
||||
// Activity tracking
|
||||
private var lastBrightnessChangeTime: Date = Date.distantPast
|
||||
private var lastVolumeChangeTime: Date = Date.distantPast
|
||||
private var lastVolumeCheckTime: Date = Date.distantPast
|
||||
private var lastExternalChangeTime: Date = Date.distantPast // Track external volume changes
|
||||
|
||||
// Headphone detection (for info only, doesn't affect polling)
|
||||
private var hasHeadphonesConnected: Bool = false
|
||||
private let volumeController = SystemVolumeController.shared
|
||||
private let brightnessController = SystemBrightnessController.shared
|
||||
private let mediaKeyInterceptor = MediaKeyInterceptor.shared
|
||||
|
||||
private let volumeStep: Float = 1.0 / 16.0
|
||||
private let brightnessStep: Float = 1.0 / 16.0
|
||||
|
||||
private var volumeEnabled = false
|
||||
private var brightnessEnabled = false
|
||||
|
||||
init(coordinator: DynamicIslandViewCoordinator) {
|
||||
self.coordinator = coordinator
|
||||
|
||||
// Initialize baseline values only once
|
||||
oldVolume = SystemVolumeManager.getOutputVolume()
|
||||
oldMuted = SystemVolumeManager.isMuted()
|
||||
|
||||
do {
|
||||
oldBrightness = try SystemDisplayManager.getDisplayBrightness()
|
||||
} catch {
|
||||
NSLog("Failed to retrieve initial display brightness: \(error)")
|
||||
}
|
||||
|
||||
// Detect headphones on init
|
||||
updateHeadphoneStatus()
|
||||
setupAudioRouteChangeListener()
|
||||
}
|
||||
|
||||
func startObserving() {
|
||||
guard Defaults[.enableSystemHUD] else { return }
|
||||
|
||||
// Start with appropriate polling rate
|
||||
startPollingTimer(interval: determinePollingInterval())
|
||||
}
|
||||
|
||||
private func determinePollingInterval() -> TimeInterval {
|
||||
let now = Date()
|
||||
|
||||
// Burst mode: Rapid polling within 5 seconds of any activity
|
||||
// Activity = key press OR external change detected
|
||||
let timeSinceKeyPress = min(
|
||||
now.timeIntervalSince(lastVolumeChangeTime),
|
||||
now.timeIntervalSince(lastBrightnessChangeTime)
|
||||
func startObserving(volumeEnabled: Bool, brightnessEnabled: Bool) {
|
||||
self.volumeEnabled = volumeEnabled
|
||||
self.brightnessEnabled = brightnessEnabled
|
||||
|
||||
volumeController.onVolumeChange = { [weak self] volume, muted in
|
||||
guard let self, self.volumeEnabled else { return }
|
||||
let value = muted ? 0 : volume
|
||||
self.sendVolumeNotification(value: value)
|
||||
}
|
||||
volumeController.onRouteChange = { [weak self] in
|
||||
guard let self, self.volumeEnabled else { return }
|
||||
self.sendVolumeNotification(value: self.volumeController.isMuted ? 0 : self.volumeController.currentVolume)
|
||||
}
|
||||
volumeController.start()
|
||||
|
||||
brightnessController.onBrightnessChange = { [weak self] brightness in
|
||||
guard let self, self.brightnessEnabled else { return }
|
||||
self.sendBrightnessNotification(value: brightness)
|
||||
}
|
||||
brightnessController.start()
|
||||
|
||||
mediaKeyInterceptor.delegate = self
|
||||
let tapStarted = mediaKeyInterceptor.start()
|
||||
if !tapStarted {
|
||||
NSLog("⚠️ Media key interception unavailable; system HUD will remain visible")
|
||||
}
|
||||
mediaKeyInterceptor.configuration = MediaKeyConfiguration(
|
||||
interceptVolume: volumeEnabled,
|
||||
interceptBrightness: brightnessEnabled
|
||||
)
|
||||
let timeSinceExternalChange = now.timeIntervalSince(lastExternalChangeTime)
|
||||
|
||||
// Use burst mode if there's been recent activity (within 5 seconds)
|
||||
if timeSinceKeyPress < 5.0 || timeSinceExternalChange < 5.0 {
|
||||
return rapidPollingInterval
|
||||
}
|
||||
|
||||
// Normal mode: Idle state (regardless of headphones)
|
||||
return normalPollingInterval
|
||||
}
|
||||
|
||||
private func startPollingTimer(interval: TimeInterval) {
|
||||
// Don't restart if same interval
|
||||
if pollingTimer != nil && currentPollingInterval == interval {
|
||||
return
|
||||
}
|
||||
|
||||
currentPollingInterval = interval
|
||||
pollingTimer?.invalidate()
|
||||
|
||||
pollingTimer = Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { [weak self] _ in
|
||||
guard let self = self, Defaults[.enableSystemHUD] else { return }
|
||||
|
||||
// Check if we need to adjust polling rate
|
||||
let newInterval = self.determinePollingInterval()
|
||||
if newInterval != self.currentPollingInterval {
|
||||
self.startPollingTimer(interval: newInterval)
|
||||
return
|
||||
}
|
||||
|
||||
// Poll volume
|
||||
self.checkVolumeChanges()
|
||||
|
||||
// Poll brightness only within 5 seconds of key press
|
||||
if Date().timeIntervalSince(self.lastBrightnessChangeTime) < 5.0 {
|
||||
self.checkBrightnessChanges()
|
||||
}
|
||||
}
|
||||
|
||||
let mode = interval == rapidPollingInterval ? "BURST (50ms)" : "NORMAL (2s)"
|
||||
print("✅ Polling started: \(mode) mode\(hasHeadphonesConnected ? " [🎧 Headphones connected]" : "")")
|
||||
}
|
||||
|
||||
// MARK: - Headphone Detection
|
||||
|
||||
private func setupAudioRouteChangeListener() {
|
||||
// Listen for default audio device changes
|
||||
var propertyAddress = AudioObjectPropertyAddress(
|
||||
mSelector: kAudioHardwarePropertyDefaultOutputDevice,
|
||||
mScope: kAudioObjectPropertyScopeGlobal,
|
||||
mElement: kAudioObjectPropertyElementMain
|
||||
)
|
||||
|
||||
AudioObjectAddPropertyListener(
|
||||
AudioObjectID(kAudioObjectSystemObject),
|
||||
&propertyAddress,
|
||||
audioDeviceChangeListener,
|
||||
Unmanaged.passUnretained(self).toOpaque()
|
||||
)
|
||||
}
|
||||
|
||||
@objc fileprivate func audioRouteChanged() {
|
||||
updateHeadphoneStatus()
|
||||
|
||||
// Adjust polling rate immediately
|
||||
startPollingTimer(interval: determinePollingInterval())
|
||||
}
|
||||
|
||||
private func updateHeadphoneStatus() {
|
||||
// Check for headphones/external audio devices
|
||||
var propertyAddress = AudioObjectPropertyAddress(
|
||||
mSelector: kAudioHardwarePropertyDefaultOutputDevice,
|
||||
mScope: kAudioObjectPropertyScopeGlobal,
|
||||
mElement: kAudioObjectPropertyElementMain
|
||||
)
|
||||
|
||||
var deviceID: AudioDeviceID = 0
|
||||
var propertySize = UInt32(MemoryLayout<AudioDeviceID>.size)
|
||||
|
||||
let status = AudioObjectGetPropertyData(
|
||||
AudioObjectID(kAudioObjectSystemObject),
|
||||
&propertyAddress,
|
||||
0,
|
||||
nil,
|
||||
&propertySize,
|
||||
&deviceID
|
||||
)
|
||||
|
||||
guard status == noErr else {
|
||||
hasHeadphonesConnected = false
|
||||
return
|
||||
}
|
||||
|
||||
// Check if device is not built-in speakers
|
||||
var transportType: UInt32 = 0
|
||||
var transportSize = UInt32(MemoryLayout<UInt32>.size)
|
||||
var transportAddress = AudioObjectPropertyAddress(
|
||||
mSelector: kAudioDevicePropertyTransportType,
|
||||
mScope: kAudioObjectPropertyScopeGlobal,
|
||||
mElement: kAudioObjectPropertyElementMain
|
||||
)
|
||||
|
||||
let transportStatus = AudioObjectGetPropertyData(
|
||||
deviceID,
|
||||
&transportAddress,
|
||||
0,
|
||||
nil,
|
||||
&transportSize,
|
||||
&transportType
|
||||
)
|
||||
|
||||
// Consider headphones if: Bluetooth, USB, or any non-built-in device
|
||||
let wasConnected = hasHeadphonesConnected
|
||||
hasHeadphonesConnected = transportStatus == noErr &&
|
||||
transportType != kAudioDeviceTransportTypeBuiltIn
|
||||
|
||||
if wasConnected != hasHeadphonesConnected {
|
||||
print(hasHeadphonesConnected ? "🎧 Headphones connected (will use burst mode on change detection)" :
|
||||
"🔊 Headphones disconnected")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Public Media Key Event Handlers (called from MediaKeyApplication)
|
||||
|
||||
func handleVolumeKeyEvent() {
|
||||
// Mark as user-initiated and trigger burst mode
|
||||
lastVolumeChangeTime = Date()
|
||||
|
||||
// Switch to burst mode immediately
|
||||
startPollingTimer(interval: rapidPollingInterval)
|
||||
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
|
||||
self.handleVolumeKeyPress()
|
||||
}
|
||||
}
|
||||
|
||||
func handleBrightnessKeyEvent() {
|
||||
// Mark as user-initiated and trigger burst mode
|
||||
lastBrightnessChangeTime = Date()
|
||||
|
||||
// Switch to burst mode immediately
|
||||
startPollingTimer(interval: rapidPollingInterval)
|
||||
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
|
||||
self.handleBrightnessKeyPress()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Key Press Event Handlers
|
||||
|
||||
private func handleVolumeKeyPress() {
|
||||
guard Defaults[.enableVolumeHUD] else { return }
|
||||
|
||||
// Mark as user-initiated (don't debounce key presses)
|
||||
lastVolumeChangeTime = Date()
|
||||
|
||||
let newVolume = SystemVolumeManager.getOutputVolume()
|
||||
let newMuted = SystemVolumeManager.isMuted()
|
||||
|
||||
// Always show HUD for key press feedback, even if value doesn't change
|
||||
print("🔊 Volume key pressed - showing HUD: volume=\(newVolume), muted=\(newMuted)")
|
||||
|
||||
if newMuted {
|
||||
sendVolumeNotification(value: 0.0)
|
||||
} else {
|
||||
sendVolumeNotification(value: newVolume)
|
||||
}
|
||||
|
||||
// Update stored values
|
||||
oldVolume = newVolume
|
||||
oldMuted = newMuted
|
||||
}
|
||||
|
||||
private func handleBrightnessKeyPress() {
|
||||
guard Defaults[.enableBrightnessHUD] else { return }
|
||||
guard NSScreen.screens.count > 0 else { return }
|
||||
|
||||
do {
|
||||
let newBrightness = try SystemDisplayManager.getDisplayBrightness()
|
||||
|
||||
// Always show HUD for key press feedback, even if value doesn't change
|
||||
print("☀️ Brightness key pressed - showing HUD: brightness=\(newBrightness) [BURST MODE]")
|
||||
sendBrightnessNotification(value: newBrightness)
|
||||
|
||||
// Update stored value
|
||||
oldBrightness = newBrightness
|
||||
} catch {
|
||||
// Silently ignore brightness errors to reduce log spam
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Polling Check Methods
|
||||
|
||||
private func checkVolumeChanges() {
|
||||
guard Defaults[.enableVolumeHUD] else { return }
|
||||
|
||||
// Throttle AppleScript checks based on current mode
|
||||
let now = Date()
|
||||
let minInterval: TimeInterval = currentPollingInterval == rapidPollingInterval ? 0.05 : 0.2
|
||||
|
||||
if now.timeIntervalSince(lastVolumeCheckTime) < minInterval {
|
||||
return
|
||||
}
|
||||
lastVolumeCheckTime = now
|
||||
|
||||
let newVolume = SystemVolumeManager.getOutputVolume()
|
||||
let newMuted = SystemVolumeManager.isMuted()
|
||||
|
||||
let volumeChanged = !isAlmost(firstNumber: oldVolume, secondNumber: newVolume)
|
||||
let muteChanged = newMuted != oldMuted
|
||||
|
||||
if volumeChanged || muteChanged {
|
||||
// Check if this is an external change (not from key press)
|
||||
let debounceWindow = currentPollingInterval == rapidPollingInterval ? 0.1 : 0.5
|
||||
let timeSinceKeyPress = now.timeIntervalSince(lastVolumeChangeTime)
|
||||
|
||||
// External change detected!
|
||||
if timeSinceKeyPress > debounceWindow {
|
||||
// Mark it and trigger burst mode
|
||||
lastExternalChangeTime = now
|
||||
|
||||
let mode = currentPollingInterval == rapidPollingInterval ? "[BURST]" : "[EXTERNAL→BURST]"
|
||||
print("🔊 Volume change detected \(mode): \(oldVolume) → \(newVolume), muted: \(oldMuted) → \(newMuted)")
|
||||
|
||||
// Show HUD immediately
|
||||
if newMuted {
|
||||
sendVolumeNotification(value: 0.0)
|
||||
} else {
|
||||
sendVolumeNotification(value: newVolume)
|
||||
}
|
||||
|
||||
// Switch to burst mode immediately if we're in normal mode
|
||||
if currentPollingInterval == normalPollingInterval {
|
||||
print("⚡ Switching from NORMAL to BURST mode due to external change")
|
||||
startPollingTimer(interval: rapidPollingInterval)
|
||||
}
|
||||
} else {
|
||||
// Within debounce window of key press - still update values but don't show HUD
|
||||
print("🔇 Volume change within debounce window (\(timeSinceKeyPress)s) - skipping HUD")
|
||||
}
|
||||
|
||||
oldVolume = newVolume
|
||||
oldMuted = newMuted
|
||||
}
|
||||
}
|
||||
|
||||
private func checkBrightnessChanges() {
|
||||
guard Defaults[.enableBrightnessHUD] else { return }
|
||||
guard NSScreen.screens.count > 0 else { return }
|
||||
|
||||
do {
|
||||
let newBrightness = try SystemDisplayManager.getDisplayBrightness()
|
||||
let brightnessChanged = !isAlmost(firstNumber: oldBrightness, secondNumber: newBrightness)
|
||||
|
||||
if brightnessChanged {
|
||||
print("☀️ Brightness change detected: \(oldBrightness) → \(newBrightness)")
|
||||
sendBrightnessNotification(value: newBrightness)
|
||||
oldBrightness = newBrightness
|
||||
}
|
||||
} catch {
|
||||
// Silently ignore brightness errors to reduce log spam
|
||||
}
|
||||
func update(volumeEnabled: Bool, brightnessEnabled: Bool) {
|
||||
self.volumeEnabled = volumeEnabled
|
||||
self.brightnessEnabled = brightnessEnabled
|
||||
mediaKeyInterceptor.configuration = MediaKeyConfiguration(
|
||||
interceptVolume: volumeEnabled,
|
||||
interceptBrightness: brightnessEnabled
|
||||
)
|
||||
}
|
||||
|
||||
private func isAlmost(firstNumber: Float, secondNumber: Float) -> Bool {
|
||||
// Use a fixed sensitive threshold for volume changes
|
||||
// 0.015 = 1.5% change required (sensitive enough for headphone increments)
|
||||
let threshold: Float = 0.015
|
||||
return abs(firstNumber - secondNumber) <= threshold
|
||||
|
||||
func stopObserving() {
|
||||
mediaKeyInterceptor.stop()
|
||||
mediaKeyInterceptor.delegate = nil
|
||||
|
||||
volumeController.stop()
|
||||
volumeController.onVolumeChange = nil
|
||||
volumeController.onRouteChange = nil
|
||||
|
||||
brightnessController.stop()
|
||||
brightnessController.onBrightnessChange = nil
|
||||
}
|
||||
|
||||
// MARK: - Internal Notification Methods
|
||||
|
||||
|
||||
// MARK: - MediaKeyInterceptorDelegate
|
||||
|
||||
func mediaKeyInterceptor(_ interceptor: MediaKeyInterceptor, didReceiveVolumeCommand direction: MediaKeyDirection, isRepeat: Bool) {
|
||||
guard volumeEnabled else { return }
|
||||
let delta = (direction == .up ? volumeStep : -volumeStep)
|
||||
volumeController.adjust(by: delta)
|
||||
}
|
||||
|
||||
func mediaKeyInterceptorDidToggleMute(_ interceptor: MediaKeyInterceptor) {
|
||||
guard volumeEnabled else { return }
|
||||
volumeController.toggleMute()
|
||||
}
|
||||
|
||||
func mediaKeyInterceptor(_ interceptor: MediaKeyInterceptor, didReceiveBrightnessCommand direction: MediaKeyDirection, isRepeat: Bool) {
|
||||
guard brightnessEnabled else { return }
|
||||
let delta = (direction == .up ? brightnessStep : -brightnessStep)
|
||||
brightnessController.adjust(by: delta)
|
||||
}
|
||||
|
||||
// MARK: - HUD Dispatch
|
||||
|
||||
private func sendVolumeNotification(value: Float) {
|
||||
coordinator?.toggleSneakPeek(
|
||||
status: true,
|
||||
|
|
@ -350,7 +100,7 @@ class SystemChangesObserver {
|
|||
icon: ""
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
private func sendBrightnessNotification(value: Float) {
|
||||
coordinator?.toggleSneakPeek(
|
||||
status: true,
|
||||
|
|
@ -359,52 +109,6 @@ class SystemChangesObserver {
|
|||
icon: ""
|
||||
)
|
||||
}
|
||||
|
||||
deinit {
|
||||
// Remove audio device change listener
|
||||
var propertyAddress = AudioObjectPropertyAddress(
|
||||
mSelector: kAudioHardwarePropertyDefaultOutputDevice,
|
||||
mScope: kAudioObjectPropertyScopeGlobal,
|
||||
mElement: kAudioObjectPropertyElementMain
|
||||
)
|
||||
|
||||
AudioObjectRemovePropertyListener(
|
||||
AudioObjectID(kAudioObjectSystemObject),
|
||||
&propertyAddress,
|
||||
audioDeviceChangeListener,
|
||||
Unmanaged.passUnretained(self).toOpaque()
|
||||
)
|
||||
|
||||
stopObserving()
|
||||
}
|
||||
|
||||
func stopObserving() {
|
||||
// Clean up polling timer
|
||||
pollingTimer?.invalidate()
|
||||
pollingTimer = nil
|
||||
|
||||
print("🎹 Media key detection stopped")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - CoreAudio Callback for Device Changes
|
||||
|
||||
private func audioDeviceChangeListener(
|
||||
_ inObjectID: AudioObjectID,
|
||||
_ inNumberAddresses: UInt32,
|
||||
_ inAddresses: UnsafePointer<AudioObjectPropertyAddress>,
|
||||
_ inClientData: UnsafeMutableRawPointer?
|
||||
) -> OSStatus {
|
||||
guard let inClientData = inClientData else { return noErr }
|
||||
|
||||
let observer = Unmanaged<SystemChangesObserver>.fromOpaque(inClientData).takeUnretainedValue()
|
||||
|
||||
// Handle on main thread
|
||||
DispatchQueue.main.async {
|
||||
observer.audioRouteChanged()
|
||||
}
|
||||
|
||||
return noErr
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@
|
|||
import Foundation
|
||||
import Defaults
|
||||
import Combine
|
||||
import AppKit
|
||||
|
||||
class SystemHUDManager {
|
||||
static let shared = SystemHUDManager()
|
||||
|
|
@ -43,20 +42,20 @@ class SystemHUDManager {
|
|||
guard let self = self, self.isSetupComplete, Defaults[.enableSystemHUD] else {
|
||||
return
|
||||
}
|
||||
// When volume HUD is enabled, ensure OSD is disabled
|
||||
if change.newValue {
|
||||
self.disableSystemHUD()
|
||||
}
|
||||
self.changesObserver?.update(
|
||||
volumeEnabled: change.newValue,
|
||||
brightnessEnabled: Defaults[.enableBrightnessHUD]
|
||||
)
|
||||
}.store(in: &cancellables)
|
||||
|
||||
Defaults.publisher(.enableBrightnessHUD, options: []).sink { [weak self] change in
|
||||
guard let self = self, self.isSetupComplete, Defaults[.enableSystemHUD] else {
|
||||
return
|
||||
}
|
||||
// When brightness HUD is enabled, ensure OSD is disabled
|
||||
if change.newValue {
|
||||
self.disableSystemHUD()
|
||||
}
|
||||
self.changesObserver?.update(
|
||||
volumeEnabled: Defaults[.enableVolumeHUD],
|
||||
brightnessEnabled: change.newValue
|
||||
)
|
||||
}.store(in: &cancellables)
|
||||
}
|
||||
|
||||
|
|
@ -88,13 +87,9 @@ class SystemHUDManager {
|
|||
await stopSystemHUD() // Stop any existing observer
|
||||
|
||||
changesObserver = SystemChangesObserver(coordinator: coordinator)
|
||||
changesObserver?.startObserving()
|
||||
|
||||
// Disable system HUD if possible
|
||||
disableSystemHUD()
|
||||
|
||||
// Start event-driven OSD monitoring
|
||||
startOSDMonitoring()
|
||||
let volumeEnabled = Defaults[.enableVolumeHUD]
|
||||
let brightnessEnabled = Defaults[.enableBrightnessHUD]
|
||||
changesObserver?.startObserving(volumeEnabled: volumeEnabled, brightnessEnabled: brightnessEnabled)
|
||||
|
||||
print("System HUD replacement started")
|
||||
isSystemOperationInProgress = false
|
||||
|
|
@ -108,120 +103,14 @@ class SystemHUDManager {
|
|||
changesObserver?.stopObserving()
|
||||
changesObserver = nil
|
||||
|
||||
// Stop OSD monitoring
|
||||
stopOSDMonitoring()
|
||||
|
||||
// Re-enable system HUD
|
||||
enableOriginalSystemHUD()
|
||||
|
||||
print("System HUD replacement stopped")
|
||||
isSystemOperationInProgress = false
|
||||
}
|
||||
|
||||
private func disableSystemHUD() {
|
||||
print("🔇 Disabling system HUD (OSDUIHelper running: \(SystemOSDManager.isOSDUIHelperRunning()))")
|
||||
// Disable the system HUD using SystemOSDManager
|
||||
SystemOSDManager.disableSystemHUD()
|
||||
}
|
||||
|
||||
private func enableOriginalSystemHUD() {
|
||||
print("🔊 Re-enabling system HUD (OSDUIHelper running: \(SystemOSDManager.isOSDUIHelperRunning()))")
|
||||
// Re-enable the system HUD using SystemOSDManager
|
||||
SystemOSDManager.enableSystemHUD()
|
||||
|
||||
// Check status after a reasonable delay, using async to avoid blocking
|
||||
Task {
|
||||
try await Task.sleep(nanoseconds: 2_000_000_000) // 2 seconds instead of 1
|
||||
let isRunning = await SystemOSDManager.isOSDUIHelperRunningAsync()
|
||||
print("🔍 OSDUIHelper status after re-enable: \(isRunning ? "✅ Running" : "❌ Not running")")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Media Key Event Handling
|
||||
|
||||
/// Handle volume key press events from MediaKeyApplication
|
||||
public func handleVolumeKeyEvent() {
|
||||
changesObserver?.handleVolumeKeyEvent()
|
||||
}
|
||||
|
||||
/// Handle brightness key press events from MediaKeyApplication
|
||||
public func handleBrightnessKeyEvent() {
|
||||
changesObserver?.handleBrightnessKeyEvent()
|
||||
}
|
||||
|
||||
// MARK: - OSD Monitoring
|
||||
|
||||
/// Start event-driven monitoring for OSDUIHelper process launches
|
||||
@MainActor
|
||||
private func startOSDMonitoring() {
|
||||
stopOSDMonitoring() // Stop any existing observer
|
||||
|
||||
// Listen for process launch notifications
|
||||
NSWorkspace.shared.notificationCenter.publisher(for: NSWorkspace.didLaunchApplicationNotification)
|
||||
.compactMap { notification -> NSRunningApplication? in
|
||||
notification.userInfo?[NSWorkspace.applicationUserInfoKey] as? NSRunningApplication
|
||||
}
|
||||
.filter { app in
|
||||
// Check if OSDUIHelper was launched
|
||||
app.bundleIdentifier == "com.apple.OSDUIHelper" ||
|
||||
app.localizedName?.contains("OSDUIHelper") == true
|
||||
}
|
||||
.sink { [weak self] _ in
|
||||
guard let self = self else { return }
|
||||
|
||||
// Check if any HUD is enabled
|
||||
let anyHUDEnabled = Defaults[.enableVolumeHUD] || Defaults[.enableBrightnessHUD]
|
||||
guard anyHUDEnabled else { return }
|
||||
|
||||
Task { @MainActor in
|
||||
print("⚠️ OSDUIHelper launched - re-disabling")
|
||||
self.disableSystemHUD()
|
||||
}
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
// Also listen for screen wake events (OSDUIHelper often restarts after wake)
|
||||
NSWorkspace.shared.notificationCenter.publisher(for: NSWorkspace.screensDidWakeNotification)
|
||||
.sink { [weak self] _ in
|
||||
guard let self = self else { return }
|
||||
|
||||
let anyHUDEnabled = Defaults[.enableVolumeHUD] || Defaults[.enableBrightnessHUD]
|
||||
guard anyHUDEnabled else { return }
|
||||
|
||||
// Check after a short delay to ensure system is ready
|
||||
Task { @MainActor in
|
||||
try? await Task.sleep(nanoseconds: 500_000_000) // 0.5 seconds
|
||||
|
||||
let isRunning = await SystemOSDManager.isOSDUIHelperRunningAsync()
|
||||
if isRunning {
|
||||
print("⚠️ OSDUIHelper detected running after wake - re-disabling")
|
||||
self.disableSystemHUD()
|
||||
}
|
||||
}
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
print("✅ OSD monitoring started (event-driven)")
|
||||
}
|
||||
|
||||
/// Stop OSD monitoring
|
||||
@MainActor
|
||||
private func stopOSDMonitoring() {
|
||||
// Cancellables are automatically cleaned up
|
||||
print("🛑 OSD monitoring stopped")
|
||||
}
|
||||
|
||||
deinit {
|
||||
cancellables.removeAll()
|
||||
Task { @MainActor in
|
||||
await stopSystemHUD()
|
||||
}
|
||||
// Emergency restore is available via SystemHUDManager.emergencyRestoreSystemHUD() if needed
|
||||
}
|
||||
|
||||
/// Emergency restore function - call this if system HUD is not working
|
||||
public static func emergencyRestoreSystemHUD() {
|
||||
print("🚨 Emergency system HUD restore initiated")
|
||||
SystemHUDDebugger.forceRestartOSDUIHelper()
|
||||
}
|
||||
}
|
||||
397
DynamicIsland/managers/SystemMediaControllers.swift
Normal file
397
DynamicIsland/managers/SystemMediaControllers.swift
Normal file
|
|
@ -0,0 +1,397 @@
|
|||
import Foundation
|
||||
import CoreAudio
|
||||
import CoreGraphics
|
||||
import IOKit
|
||||
|
||||
extension Notification.Name {
|
||||
static let systemVolumeDidChange = Notification.Name("DynamicIsland.systemVolumeDidChange")
|
||||
static let systemBrightnessDidChange = Notification.Name("DynamicIsland.systemBrightnessDidChange")
|
||||
static let systemAudioRouteDidChange = Notification.Name("DynamicIsland.systemAudioRouteDidChange")
|
||||
}
|
||||
|
||||
final class SystemVolumeController {
|
||||
static let shared = SystemVolumeController()
|
||||
|
||||
var onVolumeChange: ((Float, Bool) -> Void)?
|
||||
var onRouteChange: (() -> Void)?
|
||||
|
||||
private let callbackQueue = DispatchQueue(label: "com.dynamicisland.volume-listener")
|
||||
private var currentDeviceID: AudioDeviceID = 0
|
||||
private var listenersInstalled = false
|
||||
private var volumeElement: AudioObjectPropertyElement?
|
||||
private var muteElement: AudioObjectPropertyElement?
|
||||
|
||||
private let candidateElements: [AudioObjectPropertyElement] = [
|
||||
kAudioObjectPropertyElementMain,
|
||||
AudioObjectPropertyElement(1),
|
||||
AudioObjectPropertyElement(2)
|
||||
]
|
||||
|
||||
private init() {
|
||||
currentDeviceID = resolveDefaultDevice()
|
||||
refreshPropertyElements()
|
||||
installDefaultDeviceListener()
|
||||
installVolumeListeners(for: currentDeviceID)
|
||||
notifyCurrentState()
|
||||
}
|
||||
|
||||
func start() {
|
||||
// Listeners are installed during init, nothing else required.
|
||||
}
|
||||
|
||||
func stop() {
|
||||
// We keep listeners alive for the app lifetime; clearing closures prevents UI updates.
|
||||
onVolumeChange = nil
|
||||
onRouteChange = nil
|
||||
}
|
||||
|
||||
func adjust(by delta: Float) {
|
||||
var newValue = currentVolume + delta
|
||||
newValue = max(0, min(1, newValue))
|
||||
setVolume(newValue)
|
||||
}
|
||||
|
||||
func toggleMute() {
|
||||
setMuted(!isMuted)
|
||||
}
|
||||
|
||||
var currentVolume: Float {
|
||||
getVolume()
|
||||
}
|
||||
|
||||
var isMuted: Bool {
|
||||
getMuteState()
|
||||
}
|
||||
|
||||
func setVolume(_ value: Float) {
|
||||
var volume = max(0, min(1, value))
|
||||
let status = setData(selector: kAudioDevicePropertyVolumeScalar, data: &volume)
|
||||
if status != noErr {
|
||||
NSLog("⚠️ Failed to set volume: \(status)")
|
||||
}
|
||||
}
|
||||
|
||||
func setMuted(_ muted: Bool) {
|
||||
var muteFlag: UInt32 = muted ? 1 : 0
|
||||
let status = setData(selector: kAudioDevicePropertyMute, data: &muteFlag)
|
||||
if status != noErr {
|
||||
NSLog("⚠️ Failed to set mute state: \(status)")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
private func resolveDefaultDevice() -> AudioDeviceID {
|
||||
var deviceID = AudioDeviceID()
|
||||
var size = UInt32(MemoryLayout.size(ofValue: deviceID))
|
||||
var address = AudioObjectPropertyAddress(
|
||||
mSelector: kAudioHardwarePropertyDefaultOutputDevice,
|
||||
mScope: kAudioObjectPropertyScopeGlobal,
|
||||
mElement: kAudioObjectPropertyElementMain
|
||||
)
|
||||
let status = AudioObjectGetPropertyData(AudioObjectID(kAudioObjectSystemObject), &address, 0, nil, &size, &deviceID)
|
||||
if status != noErr {
|
||||
NSLog("⚠️ Unable to fetch default audio device: \(status)")
|
||||
}
|
||||
return deviceID
|
||||
}
|
||||
|
||||
private func installDefaultDeviceListener() {
|
||||
guard !listenersInstalled else { return }
|
||||
var address = AudioObjectPropertyAddress(
|
||||
mSelector: kAudioHardwarePropertyDefaultOutputDevice,
|
||||
mScope: kAudioObjectPropertyScopeGlobal,
|
||||
mElement: kAudioObjectPropertyElementMain
|
||||
)
|
||||
let status = AudioObjectAddPropertyListenerBlock(
|
||||
AudioObjectID(kAudioObjectSystemObject),
|
||||
&address,
|
||||
callbackQueue
|
||||
) { [weak self] _, _ in
|
||||
guard let self else { return }
|
||||
self.handleDefaultDeviceChanged()
|
||||
}
|
||||
if status != noErr {
|
||||
NSLog("⚠️ Failed to install default device listener: \(status)")
|
||||
}
|
||||
listenersInstalled = true
|
||||
}
|
||||
|
||||
private func installVolumeListeners(for deviceID: AudioDeviceID) {
|
||||
if let element = resolveElement(selector: kAudioDevicePropertyVolumeScalar, deviceID: deviceID) {
|
||||
volumeElement = element
|
||||
var address = makeAddress(selector: kAudioDevicePropertyVolumeScalar, element: element)
|
||||
AudioObjectAddPropertyListenerBlock(deviceID, &address, callbackQueue) { [weak self] _, _ in
|
||||
self?.notifyCurrentState()
|
||||
}
|
||||
}
|
||||
|
||||
if let element = resolveElement(selector: kAudioDevicePropertyMute, deviceID: deviceID) {
|
||||
muteElement = element
|
||||
var address = makeAddress(selector: kAudioDevicePropertyMute, element: element)
|
||||
AudioObjectAddPropertyListenerBlock(deviceID, &address, callbackQueue) { [weak self] _, _ in
|
||||
self?.notifyCurrentState()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func handleDefaultDeviceChanged() {
|
||||
callbackQueue.async { [weak self] in
|
||||
guard let self else { return }
|
||||
self.currentDeviceID = self.resolveDefaultDevice()
|
||||
self.refreshPropertyElements()
|
||||
self.installVolumeListeners(for: self.currentDeviceID)
|
||||
self.notifyCurrentState()
|
||||
DispatchQueue.main.async {
|
||||
self.onRouteChange?()
|
||||
NotificationCenter.default.post(name: .systemAudioRouteDidChange, object: nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func notifyCurrentState() {
|
||||
let volume = getVolume()
|
||||
let muted = getMuteState()
|
||||
DispatchQueue.main.async {
|
||||
self.onVolumeChange?(volume, muted)
|
||||
NotificationCenter.default.post(name: .systemVolumeDidChange, object: nil, userInfo: ["value": volume, "muted": muted])
|
||||
}
|
||||
}
|
||||
|
||||
private func getVolume() -> Float {
|
||||
var volume = Float32(0)
|
||||
let status = getData(selector: kAudioDevicePropertyVolumeScalar, data: &volume)
|
||||
if status != noErr {
|
||||
NSLog("⚠️ Unable to fetch volume: \(status)")
|
||||
}
|
||||
return volume
|
||||
}
|
||||
|
||||
private func getMuteState() -> Bool {
|
||||
var mute: UInt32 = 0
|
||||
let status = getData(selector: kAudioDevicePropertyMute, data: &mute)
|
||||
if status != noErr {
|
||||
// Some devices do not support mute; treat as not muted
|
||||
return false
|
||||
}
|
||||
return mute != 0
|
||||
}
|
||||
|
||||
private func refreshPropertyElements() {
|
||||
volumeElement = resolveElement(selector: kAudioDevicePropertyVolumeScalar, deviceID: currentDeviceID)
|
||||
muteElement = resolveElement(selector: kAudioDevicePropertyMute, deviceID: currentDeviceID)
|
||||
}
|
||||
|
||||
private func resolveElement(selector: AudioObjectPropertySelector, deviceID: AudioDeviceID) -> AudioObjectPropertyElement? {
|
||||
for element in candidateElements {
|
||||
var address = makeAddress(selector: selector, element: element)
|
||||
if propertyExists(deviceID: deviceID, address: &address) {
|
||||
return element
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private func preferredElements(for selector: AudioObjectPropertySelector) -> [AudioObjectPropertyElement] {
|
||||
if let cached = cachedElement(for: selector) {
|
||||
return [cached] + candidateElements.filter { $0 != cached }
|
||||
}
|
||||
return candidateElements
|
||||
}
|
||||
|
||||
private func cachedElement(for selector: AudioObjectPropertySelector) -> AudioObjectPropertyElement? {
|
||||
switch selector {
|
||||
case kAudioDevicePropertyVolumeScalar:
|
||||
return volumeElement
|
||||
case kAudioDevicePropertyMute:
|
||||
return muteElement
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
private func cache(element: AudioObjectPropertyElement, for selector: AudioObjectPropertySelector) {
|
||||
switch selector {
|
||||
case kAudioDevicePropertyVolumeScalar:
|
||||
volumeElement = element
|
||||
case kAudioDevicePropertyMute:
|
||||
muteElement = element
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
private func makeAddress(selector: AudioObjectPropertySelector, element: AudioObjectPropertyElement) -> AudioObjectPropertyAddress {
|
||||
AudioObjectPropertyAddress(
|
||||
mSelector: selector,
|
||||
mScope: kAudioDevicePropertyScopeOutput,
|
||||
mElement: element
|
||||
)
|
||||
}
|
||||
|
||||
private func propertyExists(deviceID: AudioDeviceID, address: inout AudioObjectPropertyAddress) -> Bool {
|
||||
withUnsafePointer(to: &address) { pointer in
|
||||
AudioObjectHasProperty(deviceID, pointer)
|
||||
}
|
||||
}
|
||||
|
||||
private func getData<T>(selector: AudioObjectPropertySelector, data: inout T) -> OSStatus {
|
||||
var lastStatus: OSStatus = kAudioHardwareUnspecifiedError
|
||||
for element in preferredElements(for: selector) {
|
||||
var address = makeAddress(selector: selector, element: element)
|
||||
guard propertyExists(deviceID: currentDeviceID, address: &address) else { continue }
|
||||
var size = UInt32(MemoryLayout<T>.size)
|
||||
lastStatus = AudioObjectGetPropertyData(currentDeviceID, &address, 0, nil, &size, &data)
|
||||
if lastStatus == noErr {
|
||||
cache(element: element, for: selector)
|
||||
return lastStatus
|
||||
}
|
||||
}
|
||||
return lastStatus
|
||||
}
|
||||
|
||||
private func setData<T>(selector: AudioObjectPropertySelector, data: inout T) -> OSStatus {
|
||||
var lastStatus: OSStatus = kAudioHardwareUnspecifiedError
|
||||
for element in preferredElements(for: selector) {
|
||||
var address = makeAddress(selector: selector, element: element)
|
||||
guard propertyExists(deviceID: currentDeviceID, address: &address) else { continue }
|
||||
let size = UInt32(MemoryLayout<T>.size)
|
||||
lastStatus = AudioObjectSetPropertyData(currentDeviceID, &address, 0, nil, size, &data)
|
||||
if lastStatus == noErr {
|
||||
cache(element: element, for: selector)
|
||||
return lastStatus
|
||||
}
|
||||
}
|
||||
return lastStatus
|
||||
}
|
||||
}
|
||||
|
||||
final class SystemBrightnessController {
|
||||
static let shared = SystemBrightnessController()
|
||||
|
||||
var onBrightnessChange: ((Float) -> Void)?
|
||||
|
||||
private let notificationCenter = NotificationCenter.default
|
||||
private var observers: [NSObjectProtocol] = []
|
||||
private var notificationsInstalled = false
|
||||
private var displayID: CGDirectDisplayID = CGMainDisplayID()
|
||||
|
||||
private init() {
|
||||
registerExternalNotifications()
|
||||
}
|
||||
|
||||
func start() {
|
||||
notifyCurrentBrightness()
|
||||
}
|
||||
|
||||
func stop() {
|
||||
onBrightnessChange = nil
|
||||
}
|
||||
|
||||
func adjust(by delta: Float) {
|
||||
setBrightness(currentBrightness + delta)
|
||||
}
|
||||
|
||||
func setBrightness(_ value: Float) {
|
||||
let clamped = max(0, min(1, value))
|
||||
if setBrightnessViaDisplayServices(clamped) {
|
||||
notifyCurrentBrightness()
|
||||
return
|
||||
}
|
||||
guard let service = displayService() else { return }
|
||||
let status = IODisplaySetFloatParameter(service, 0, kIODisplayBrightnessKey as CFString, clamped)
|
||||
IOObjectRelease(service)
|
||||
if status == kIOReturnSuccess {
|
||||
notifyCurrentBrightness()
|
||||
} else {
|
||||
NSLog("⚠️ Failed to set brightness via IODisplay: \(status)")
|
||||
}
|
||||
}
|
||||
|
||||
var currentBrightness: Float {
|
||||
if let level = getBrightnessViaDisplayServices() {
|
||||
return level
|
||||
}
|
||||
guard let service = displayService() else { return 0.5 }
|
||||
var brightness: Float = 0
|
||||
let result = IODisplayGetFloatParameter(service, 0, kIODisplayBrightnessKey as CFString, &brightness)
|
||||
IOObjectRelease(service)
|
||||
if result != kIOReturnSuccess {
|
||||
return 0.5
|
||||
}
|
||||
return brightness
|
||||
}
|
||||
|
||||
private func notifyCurrentBrightness() {
|
||||
let brightness = currentBrightness
|
||||
DispatchQueue.main.async {
|
||||
self.onBrightnessChange?(brightness)
|
||||
self.notificationCenter.post(name: .systemBrightnessDidChange, object: nil, userInfo: ["value": brightness])
|
||||
}
|
||||
}
|
||||
|
||||
private func displayService() -> io_service_t? {
|
||||
var iterator = io_iterator_t()
|
||||
guard IOServiceGetMatchingServices(kIOMainPortDefault, IOServiceMatching("IODisplayConnect"), &iterator) == KERN_SUCCESS else {
|
||||
return nil
|
||||
}
|
||||
let service = IOIteratorNext(iterator)
|
||||
IOObjectRelease(iterator)
|
||||
return service
|
||||
}
|
||||
|
||||
private func setBrightnessViaDisplayServices(_ value: Float) -> Bool {
|
||||
let status = DisplayServicesSetBrightness(displayID, value)
|
||||
if status == kIOReturnSuccess {
|
||||
return true
|
||||
}
|
||||
// Attempt to refresh display ID in case the main display changed
|
||||
displayID = CGMainDisplayID()
|
||||
let retry = DisplayServicesSetBrightness(displayID, value)
|
||||
if retry != kIOReturnSuccess {
|
||||
NSLog("⚠️ DisplayServicesSetBrightness failed: \(retry)")
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private func getBrightnessViaDisplayServices() -> Float? {
|
||||
var brightness: Float = 0
|
||||
let status = DisplayServicesGetBrightness(displayID, &brightness)
|
||||
if status == kIOReturnSuccess {
|
||||
return brightness
|
||||
}
|
||||
displayID = CGMainDisplayID()
|
||||
let retry = DisplayServicesGetBrightness(displayID, &brightness)
|
||||
if retry == kIOReturnSuccess {
|
||||
return brightness
|
||||
}
|
||||
NSLog("⚠️ DisplayServicesGetBrightness failed: \(retry)")
|
||||
return nil
|
||||
}
|
||||
|
||||
private func registerExternalNotifications() {
|
||||
guard !notificationsInstalled else { return }
|
||||
let names = [
|
||||
Notification.Name("com.apple.BezelEngine.BrightnessChanged"),
|
||||
Notification.Name("com.apple.BezelServices.BrightnessChanged"),
|
||||
Notification.Name("com.apple.controlcenter.display.brightness")
|
||||
]
|
||||
observers = names.map { name in
|
||||
DistributedNotificationCenter.default().addObserver(forName: name, object: nil, queue: .main) { [weak self] _ in
|
||||
self?.notifyCurrentBrightness()
|
||||
}
|
||||
}
|
||||
notificationsInstalled = true
|
||||
}
|
||||
|
||||
deinit {
|
||||
observers.forEach { DistributedNotificationCenter.default().removeObserver($0) }
|
||||
}
|
||||
}
|
||||
|
||||
@_silgen_name("DisplayServicesGetBrightness")
|
||||
private func DisplayServicesGetBrightness(_ display: UInt32, _ brightness: UnsafeMutablePointer<Float>) -> Int32
|
||||
|
||||
@_silgen_name("DisplayServicesSetBrightness")
|
||||
private func DisplayServicesSetBrightness(_ display: UInt32, _ brightness: Float) -> Int32
|
||||
Loading…
Add table
Add a link
Reference in a new issue