From b053c4ca332c5d4385d8b5a073eb17fbef7fb525 Mon Sep 17 00:00:00 2001 From: Hariharan Mudaliar Date: Fri, 7 Nov 2025 12:25:29 +0530 Subject: [PATCH 1/6] Kinda fixed the timeline issue --- .../MediaControllers/NowPlayingController.swift | 15 +++------------ 1 file changed, 3 insertions(+), 12 deletions(-) diff --git a/DynamicIsland/MediaControllers/NowPlayingController.swift b/DynamicIsland/MediaControllers/NowPlayingController.swift index f357908..2c0faa0 100644 --- a/DynamicIsland/MediaControllers/NowPlayingController.swift +++ b/DynamicIsland/MediaControllers/NowPlayingController.swift @@ -181,18 +181,9 @@ final class NowPlayingController: ObservableObject, MediaControllerProtocol { newPlaybackState.album = payload.album ?? (diff ? self.playbackState.album : "") newPlaybackState.duration = payload.duration ?? (diff ? self.playbackState.duration : 0) - if let elapsedTime = payload.elapsedTime { - newPlaybackState.currentTime = elapsedTime - } else if diff { - if payload.playing == false { - let timeSinceLastUpdate = Date().timeIntervalSince(self.playbackState.lastUpdated) - newPlaybackState.currentTime = self.playbackState.currentTime + (self.playbackState.playbackRate * timeSinceLastUpdate) - } else { - newPlaybackState.currentTime = self.playbackState.currentTime - } - } else { - newPlaybackState.currentTime = 0 - } + // Match boring.notch behavior: if elapsedTime is provided use it, + // if this update is a diff keep the previous currentTime, otherwise default to 0. + newPlaybackState.currentTime = payload.elapsedTime ?? (diff ? self.playbackState.currentTime : 0) if let shuffleMode = payload.shuffleMode { From fa1bd97d847e2a4148304a2c618f65cf1fe62301 Mon Sep 17 00:00:00 2001 From: Hariharan Mudaliar Date: Fri, 7 Nov 2025 15:14:14 +0530 Subject: [PATCH 2/6] Working LIVE indicator --- DynamicIsland/Localizable.xcstrings | 8 ++ .../Music/LiveStreamProgressIndicator.swift | 68 ++++++++++++++ .../Notch/MinimalisticMusicPlayerView.swift | 91 ++++++++++--------- .../components/Notch/NotchHomeView.swift | 71 +++++++++++---- DynamicIsland/managers/MusicManager.swift | 77 ++++++++++++++++ 5 files changed, 256 insertions(+), 59 deletions(-) create mode 100644 DynamicIsland/components/Music/LiveStreamProgressIndicator.swift diff --git a/DynamicIsland/Localizable.xcstrings b/DynamicIsland/Localizable.xcstrings index 2210001..4df518e 100644 --- a/DynamicIsland/Localizable.xcstrings +++ b/DynamicIsland/Localizable.xcstrings @@ -10479,6 +10479,10 @@ } } }, + "LIVE" : { + "comment" : "A label indicating that the music is live.", + "isCommentAutoGenerated" : true + }, "Live Monitoring" : { "localizations" : { "fr" : { @@ -10499,6 +10503,10 @@ } } }, + "Live stream indicator" : { + "comment" : "A visual indicator that appears when a music stream is live.", + "isCommentAutoGenerated" : true + }, "Load Average" : { "comment" : "A label displayed above the load average values.", "isCommentAutoGenerated" : true, diff --git a/DynamicIsland/components/Music/LiveStreamProgressIndicator.swift b/DynamicIsland/components/Music/LiveStreamProgressIndicator.swift new file mode 100644 index 0000000..5ca0f58 --- /dev/null +++ b/DynamicIsland/components/Music/LiveStreamProgressIndicator.swift @@ -0,0 +1,68 @@ +// +// LiveStreamProgressIndicator.swift +// DynamicIsland +// +// Shared indicator used for live stream playback timelines. +// + +import SwiftUI + +struct LiveStreamProgressIndicator: View { + let tint: Color + + private var baseGradient: LinearGradient { + LinearGradient( + colors: [ + tint.opacity(0.18), + tint.opacity(0.42), + tint.opacity(0.18) + ], + startPoint: .leading, + endPoint: .trailing + ) + } + + private var centerShade: LinearGradient { + LinearGradient( + stops: [ + .init(color: .black.opacity(0.0), location: 0.0), + .init(color: .black.opacity(0.38), location: 0.5), + .init(color: .black.opacity(0.0), location: 1.0) + ], + startPoint: .leading, + endPoint: .trailing + ) + } + + var body: some View { + Capsule() + .fill(Color.white.opacity(0.07)) + .overlay( + Capsule() + .fill(baseGradient) + .opacity(0.9) + .blendMode(.plusLighter) + ) + .overlay( + Capsule() + .fill(centerShade) + .blendMode(.multiply) + ) + .overlay( + Capsule() + .stroke(tint.opacity(0.6), lineWidth: 0.7) + .blendMode(.screen) + ) + .frame(height: 10) + .overlay { + Text("LIVE") + .font(.system(size: 12, weight: .black, design: .rounded)) + .tracking(1.4) + .foregroundStyle(Color.white) + .shadow(color: .black.opacity(0.65), radius: 4, y: 1) + } + .allowsHitTesting(false) + .accessibilityLabel("Live stream indicator") + .opacity(0.95) + } +} diff --git a/DynamicIsland/components/Notch/MinimalisticMusicPlayerView.swift b/DynamicIsland/components/Notch/MinimalisticMusicPlayerView.swift index 4726843..e10e5f9 100644 --- a/DynamicIsland/components/Notch/MinimalisticMusicPlayerView.swift +++ b/DynamicIsland/components/Notch/MinimalisticMusicPlayerView.swift @@ -89,49 +89,56 @@ struct MinimalisticMusicPlayerView: View { private var progressBar: some View { TimelineView(.animation(minimumInterval: musicManager.playbackRate > 0 ? 0.1 : nil)) { timeline in - let currentElapsed = currentSliderValue(timeline.date) - - HStack(spacing: 8) { - // Elapsed time - left - Text(formatTime(dragging ? sliderValue : currentElapsed)) - .font(.system(size: 11, weight: .medium, design: .monospaced)) - .foregroundColor(.white.opacity(0.7)) - .frame(width: 42, alignment: .leading) - - // Progress bar - GeometryReader { geometry in - ZStack(alignment: .leading) { - // Background track - RoundedRectangle(cornerRadius: 3) - .fill(Color.white.opacity(0.2)) - .frame(height: 6) - - // Filled portion - RoundedRectangle(cornerRadius: 3) - .fill(sliderColor) - .frame(width: max(0, geometry.size.width * (currentSliderValue(timeline.date) / max(musicManager.songDuration, 1))), height: 6) - } - .gesture( - DragGesture(minimumDistance: 0) - .onChanged { value in - dragging = true - let newValue = min(max(0, Double(value.location.x / geometry.size.width) * musicManager.songDuration), musicManager.songDuration) - sliderValue = newValue - lastDragged = Date() - } - .onEnded { _ in - musicManager.seek(to: sliderValue) - dragging = false - } - ) + if musicManager.isLiveStream { + HStack(spacing: 8) { + Spacer() + .frame(width: 42) + LiveStreamProgressIndicator(tint: sliderColor) + .frame(maxWidth: .infinity, minHeight: 6, maxHeight: 6) + Spacer() + .frame(width: 48) + } + .allowsHitTesting(false) + } else { + let currentElapsed = currentSliderValue(timeline.date) + + HStack(spacing: 8) { + Text(formatTime(dragging ? sliderValue : currentElapsed)) + .font(.system(size: 11, weight: .medium, design: .monospaced)) + .foregroundColor(.white.opacity(0.7)) + .frame(width: 42, alignment: .leading) + + GeometryReader { geometry in + ZStack(alignment: .leading) { + RoundedRectangle(cornerRadius: 3) + .fill(Color.white.opacity(0.2)) + .frame(height: 6) + + RoundedRectangle(cornerRadius: 3) + .fill(sliderColor) + .frame(width: max(0, geometry.size.width * (currentSliderValue(timeline.date) / max(musicManager.songDuration, 1))), height: 6) + } + .gesture( + DragGesture(minimumDistance: 0) + .onChanged { value in + dragging = true + let newValue = min(max(0, Double(value.location.x / geometry.size.width) * musicManager.songDuration), musicManager.songDuration) + sliderValue = newValue + lastDragged = Date() + } + .onEnded { _ in + musicManager.seek(to: sliderValue) + dragging = false + } + ) + } + .frame(height: 6) + + Text("-\(formatTime(musicManager.songDuration - (dragging ? sliderValue : currentElapsed)))") + .font(.system(size: 11, weight: .medium, design: .monospaced)) + .foregroundColor(.white.opacity(0.7)) + .frame(width: 48, alignment: .trailing) } - .frame(height: 6) - - // Time remaining - right - Text("-\(formatTime(musicManager.songDuration - (dragging ? sliderValue : currentElapsed)))") - .font(.system(size: 11, weight: .medium, design: .monospaced)) - .foregroundColor(.white.opacity(0.7)) - .frame(width: 48, alignment: .trailing) } } .onAppear { diff --git a/DynamicIsland/components/Notch/NotchHomeView.swift b/DynamicIsland/components/Notch/NotchHomeView.swift index 1744fb5..40eb0d8 100644 --- a/DynamicIsland/components/Notch/NotchHomeView.swift +++ b/DynamicIsland/components/Notch/NotchHomeView.swift @@ -170,8 +170,10 @@ struct MusicControlsView: View { timestampDate: musicManager.timestampDate, elapsedTime: musicManager.elapsedTime, playbackRate: musicManager.playbackRate, - isPlaying: musicManager.isPlaying + isPlaying: musicManager.isPlaying, + isLiveStream: musicManager.isLiveStream ) { newValue in + guard !musicManager.isLiveStream else { return } MusicManager.shared.seek(to: newValue) } .padding(.top, 5) @@ -296,38 +298,71 @@ struct MusicSliderView: View { let elapsedTime: Double let playbackRate: Double let isPlaying: Bool + let isLiveStream: Bool var onValueChange: (Double) -> Void var body: some View { VStack { + sliderContent + .frame(height: 10, alignment: .center) + if !isLiveStream { + HStack { + Text(timeString(from: sliderValue)) + Spacer() + Text(timeString(from: duration)) + } + .fontWeight(.medium) + .foregroundColor(timeLabelColor) + .font(.caption) + } + } + .onChange(of: currentDate) { newDate in + guard !isLiveStream else { return } + guard !dragging, timestampDate.timeIntervalSince(lastDragged) > -1 else { return } + sliderValue = MusicManager.shared.estimatedPlaybackPosition(at: newDate) + } + .onChange(of: isLiveStream) { isLive in + if isLive { + sliderValue = 0 + } + } + } + + @ViewBuilder + private var sliderContent: some View { + if isLiveStream { + LiveStreamProgressIndicator(tint: sliderTint) + .frame(maxWidth: .infinity) + } else { CustomSlider( value: $sliderValue, range: 0 ... duration, - color: Defaults[.sliderColor] == SliderColorEnum.albumArt ? Color( - nsColor: color - ).ensureMinimumBrightness(factor: 0.8) : Defaults[.sliderColor] == SliderColorEnum.accent ? .accentColor : .white, + color: sliderTint, dragging: $dragging, lastDragged: $lastDragged, onValueChange: onValueChange ) - .frame(height: 10, alignment: .center) - HStack { - Text(timeString(from: sliderValue)) - Spacer() - Text(timeString(from: duration)) - } - .fontWeight(.medium) - .foregroundColor(Defaults[.playerColorTinting] ? Color(nsColor: color) - .ensureMinimumBrightness(factor: 0.6) : .gray) - .font(.caption) } - .onChange(of: currentDate) { newDate in - guard !dragging, timestampDate.timeIntervalSince(lastDragged) > -1 else { return } - sliderValue = MusicManager.shared.estimatedPlaybackPosition(at: newDate) + } + + private var sliderTint: Color { + switch Defaults[.sliderColor] { + case .albumArt: + return Color(nsColor: color).ensureMinimumBrightness(factor: 0.8) + case .accent: + return .accentColor + case .white: + return .white } } + private var timeLabelColor: Color { + Defaults[.playerColorTinting] + ? Color(nsColor: color).ensureMinimumBrightness(factor: 0.6) + : .gray + } + func timeString(from seconds: Double) -> String { let totalMinutes = Int(seconds) / 60 let remainingSeconds = Int(seconds) % 60 @@ -340,8 +375,10 @@ struct MusicSliderView: View { return String(format: "%d:%02d", minutes, remainingSeconds) } } + } + struct CustomSlider: View { @Binding var value: Double var range: ClosedRange diff --git a/DynamicIsland/managers/MusicManager.swift b/DynamicIsland/managers/MusicManager.swift index 3691163..0604315 100644 --- a/DynamicIsland/managers/MusicManager.swift +++ b/DynamicIsland/managers/MusicManager.swift @@ -44,11 +44,17 @@ class MusicManager: ObservableObject { @Published var playbackRate: Double = 1 @Published var isShuffled: Bool = false @Published var repeatMode: RepeatMode = .off + @Published var isLiveStream: Bool = false @ObservedObject var coordinator = DynamicIslandViewCoordinator.shared @Published var usingAppIconForArtwork: Bool = false private var artworkData: Data? = nil + private var liveStreamUnknownDurationCount: Int = 0 + private var liveStreamEdgeObservationCount: Int = 0 + private var liveStreamCompletionObservationCount: Int = 0 + private var liveStreamCompletionReleaseCount: Int = 0 + // Store last values at the time artwork was changed private var lastArtworkTitle: String = "I'm Handsome" private var lastArtworkArtist: String = "Me" @@ -262,6 +268,7 @@ class MusicManager: ObservableObject { self.repeatMode = state.repeatMode } + updateLiveStreamState(with: state) self.timestampDate = state.lastUpdated } @@ -271,6 +278,76 @@ class MusicManager: ObservableObject { } } + private func updateLiveStreamState(with state: PlaybackState) { + let duration = state.duration + let current = max(state.currentTime, elapsedTime) + let hasKnownDuration = duration.isFinite && duration > 0 + let isPlaying = state.isPlaying + + if hasKnownDuration { + liveStreamUnknownDurationCount = 0 + + let remaining = duration - current + let clampedDuration = max(duration, 0) + let clampedCurrent = clampedDuration > 0 + ? max(0, min(current, clampedDuration)) + : max(0, current) + let progress = clampedDuration > 0 ? clampedCurrent / clampedDuration : 0 + let sliderAppearsComplete = isPlaying && clampedDuration > 0 && progress >= 0.999 + let nearDurationEdge = isPlaying && remaining.isFinite && remaining <= 1.0 && clampedCurrent >= 10 + + if sliderAppearsComplete { + liveStreamCompletionObservationCount = min(liveStreamCompletionObservationCount + 1, 8) + liveStreamCompletionReleaseCount = 0 + } else { + liveStreamCompletionReleaseCount = min(liveStreamCompletionReleaseCount + 1, 8) + if liveStreamCompletionObservationCount > 0 { + liveStreamCompletionObservationCount = max(liveStreamCompletionObservationCount - 1, 0) + } + } + + if nearDurationEdge || sliderAppearsComplete { + liveStreamEdgeObservationCount = min(liveStreamEdgeObservationCount + 1, 12) + } else if liveStreamEdgeObservationCount > 0 { + liveStreamEdgeObservationCount = max(liveStreamEdgeObservationCount - 1, 0) + } + + if !isLiveStream { + if liveStreamCompletionObservationCount >= 3 || liveStreamEdgeObservationCount >= 5 { + isLiveStream = true + } + } else { + let shouldClearForKnownDuration = + !isPlaying + || (duration > 10 && remaining > 5) + || (liveStreamCompletionObservationCount == 0 + && liveStreamEdgeObservationCount == 0 + && liveStreamCompletionReleaseCount >= 4) + + if shouldClearForKnownDuration { + isLiveStream = false + } + } + } else if isPlaying { + liveStreamEdgeObservationCount = max(liveStreamEdgeObservationCount - 1, 0) + liveStreamCompletionObservationCount = max(liveStreamCompletionObservationCount - 1, 0) + liveStreamCompletionReleaseCount = 0 + + liveStreamUnknownDurationCount = min(liveStreamUnknownDurationCount + 1, 8) + if liveStreamUnknownDurationCount >= 3 && !isLiveStream { + isLiveStream = true + } + } else { + liveStreamUnknownDurationCount = 0 + liveStreamEdgeObservationCount = 0 + liveStreamCompletionObservationCount = 0 + liveStreamCompletionReleaseCount = 0 + if !hasKnownDuration && isLiveStream { + isLiveStream = false + } + } + } + private func updateArtwork(_ artworkData: Data) { DispatchQueue.global(qos: .userInitiated).async { [weak self] in guard let self = self else { return } From bb2284bafc646fefaef78c5a429bedb9d48395ef Mon Sep 17 00:00:00 2001 From: Hariharan Mudaliar Date: Mon, 10 Nov 2025 01:55:29 +0530 Subject: [PATCH 3/6] Working notification --- DynamicIsland/ContentView.swift | 3 + DynamicIsland/DynamicIslandApp.swift | 15 + .../DynamicIslandViewCoordinator.swift | 1 + DynamicIsland/Localizable.xcstrings | 18 +- .../Focus/DoNotDisturbLiveActivity.swift | 119 +++++ .../Notch/DynamicIslandHeader.swift | 7 + .../components/Settings/SettingsView.swift | 50 +- .../components/UI/FocusIndicator.swift | 45 ++ .../managers/DoNotDisturbManager.swift | 479 ++++-------------- DynamicIsland/models/Constants.swift | 4 + 10 files changed, 368 insertions(+), 373 deletions(-) create mode 100644 DynamicIsland/components/Focus/DoNotDisturbLiveActivity.swift create mode 100644 DynamicIsland/components/UI/FocusIndicator.swift diff --git a/DynamicIsland/ContentView.swift b/DynamicIsland/ContentView.swift index b5dc5ee..5a85727 100644 --- a/DynamicIsland/ContentView.swift +++ b/DynamicIsland/ContentView.swift @@ -25,6 +25,7 @@ struct ContentView: View { @ObservedObject var statsManager = StatsManager.shared @ObservedObject var recordingManager = ScreenRecordingManager.shared @ObservedObject var privacyManager = PrivacyIndicatorManager.shared + @ObservedObject var doNotDisturbManager = DoNotDisturbManager.shared @ObservedObject var lockScreenManager = LockScreenManager.shared @Default(.enableStatsFeature) var enableStatsFeature @@ -412,6 +413,8 @@ struct ContentView: View { TimerLiveActivity() } else if (!coordinator.expandingView.show || coordinator.expandingView.type == .recording) && vm.notchState == .closed && (recordingManager.isRecording || !recordingManager.isRecorderIdle) && Defaults[.enableScreenRecordingDetection] && !vm.hideOnClosed { RecordingLiveActivity() + } else if (!coordinator.expandingView.show || coordinator.expandingView.type == .doNotDisturb) && vm.notchState == .closed && doNotDisturbManager.isDoNotDisturbActive && Defaults[.enableDoNotDisturbDetection] && Defaults[.showDoNotDisturbIndicator] && !vm.hideOnClosed && !lockScreenManager.isLocked { + DoNotDisturbLiveActivity() } else if (!coordinator.expandingView.show || coordinator.expandingView.type == .lockScreen) && vm.notchState == .closed && (lockScreenManager.isLocked || !lockScreenManager.isLockIdle) && Defaults[.enableLockScreenLiveActivity] && !vm.hideOnClosed { LockScreenLiveActivity() } else if (!coordinator.expandingView.show || coordinator.expandingView.type == .privacy) && vm.notchState == .closed && privacyManager.hasAnyIndicator && (Defaults[.enableCameraDetection] || Defaults[.enableMicrophoneDetection]) && !vm.hideOnClosed { diff --git a/DynamicIsland/DynamicIslandApp.swift b/DynamicIsland/DynamicIslandApp.swift index c5147c6..653b143 100644 --- a/DynamicIsland/DynamicIslandApp.swift +++ b/DynamicIsland/DynamicIslandApp.swift @@ -379,6 +379,11 @@ class AppDelegate: NSObject, NSApplicationDelegate { ScreenRecordingManager.shared.startMonitoring() } + // Setup Do Not Disturb Manager + if Defaults[.enableDoNotDisturbDetection] { + dndManager.startMonitoring() + } + // Setup Privacy Indicator Manager (camera and microphone monitoring) PrivacyIndicatorManager.shared.startMonitoring() @@ -435,6 +440,16 @@ class AppDelegate: NSObject, NSApplicationDelegate { } }.store(in: &cancellables) + Defaults.publisher(.enableDoNotDisturbDetection, options: []).sink { [weak self] _ in + guard let self else { return } + + if Defaults[.enableDoNotDisturbDetection] { + self.dndManager.startMonitoring() + } else { + self.dndManager.stopMonitoring() + } + }.store(in: &cancellables) + // Note: Polling setting removed - now uses event-driven private API detection only NotificationCenter.default.addObserver( diff --git a/DynamicIsland/DynamicIslandViewCoordinator.swift b/DynamicIsland/DynamicIslandViewCoordinator.swift index ebb402b..2eba167 100644 --- a/DynamicIsland/DynamicIslandViewCoordinator.swift +++ b/DynamicIsland/DynamicIslandViewCoordinator.swift @@ -20,6 +20,7 @@ enum SneakContentType { case download case timer case recording + case doNotDisturb case bluetoothAudio case privacy case lockScreen diff --git a/DynamicIsland/Localizable.xcstrings b/DynamicIsland/Localizable.xcstrings index 4df518e..222c4e1 100644 --- a/DynamicIsland/Localizable.xcstrings +++ b/DynamicIsland/Localizable.xcstrings @@ -1805,6 +1805,9 @@ } } } + }, + "Active - No Focus" : { + }, "Active - No Recording" : { "comment" : "A description of the recording detection feature, indicating that recording is not currently active.", @@ -6769,6 +6772,9 @@ } } } + }, + "Do Not Disturb" : { + }, "Download" : { "localizations" : { @@ -8491,6 +8497,13 @@ }, "Fit to Notch" : { + }, + "Focus mode active" : { + "comment" : "An accessibility label for the focus indicator.", + "isCommentAutoGenerated" : true + }, + "Focus Status" : { + }, "Free ยท %@" : { "comment" : "A label displaying the amount of free storage on a disk device. The argument is the number of free bytes on the disk.", @@ -10478,6 +10491,9 @@ } } } + }, + "Listens for Focus session changes via distributed notifications" : { + }, "LIVE" : { "comment" : "A label indicating that the music is live.", @@ -15692,7 +15708,7 @@ } } }, - "Screen Recording Detection" : { + "Screen Recording" : { }, "Screenshot options" : { diff --git a/DynamicIsland/components/Focus/DoNotDisturbLiveActivity.swift b/DynamicIsland/components/Focus/DoNotDisturbLiveActivity.swift new file mode 100644 index 0000000..ccfa3ba --- /dev/null +++ b/DynamicIsland/components/Focus/DoNotDisturbLiveActivity.swift @@ -0,0 +1,119 @@ +// +// DoNotDisturbLiveActivity.swift +// DynamicIsland +// +// Renders the closed-notch Focus indicator with a purple moon badge. +// + +import SwiftUI + +struct DoNotDisturbLiveActivity: View { + @EnvironmentObject var vm: DynamicIslandViewModel + @ObservedObject var manager = DoNotDisturbManager.shared + @State private var isExpanded = false + + private let focusColor = Color.purple + + var body: some View { + HStack(spacing: 0) { + leadingWing + .frame(width: leadingWingWidth, height: wingHeight) + + Rectangle() + .fill(Color.black) + .frame(width: vm.closedNotchSize.width) + + trailingWing + .frame(width: trailingWingWidth, height: wingHeight) + } + .frame(height: vm.effectiveClosedNotchHeight) + .accessibilityElement(children: .ignore) + .accessibilityLabel(accessibilityDescription) + .onAppear { + withAnimation(.smooth(duration: 0.35)) { + isExpanded = true + } + } + .onChange(of: manager.isDoNotDisturbActive) { _, isActive in + withAnimation(.smooth(duration: 0.35)) { + isExpanded = isActive + } + } + } + + private var wingHeight: CGFloat { + max(vm.effectiveClosedNotchHeight - 10, 20) + } + + private var leadingWingWidth: CGFloat { + isExpanded ? max(vm.effectiveClosedNotchHeight - 10, 20) : 0 + } + + private var trailingWingWidth: CGFloat { + guard isExpanded else { return 0 } + return max(vm.closedNotchSize.width * 0.65, 120) + } + + private var focusSymbol: String { + if let symbol = FocusModeType(rawValue: manager.currentFocusModeIdentifier)?.sfSymbol { + return symbol + } + return FocusModeType.doNotDisturb.sfSymbol + } + + private var focusLabel: String { + if !manager.currentFocusModeName.isEmpty { + return manager.currentFocusModeName + } + return FocusModeType(rawValue: manager.currentFocusModeIdentifier)?.displayName ?? "Do Not Disturb" + } + + private var accessibilityDescription: String { + "Focus mode active: \(focusLabel)" + } + + private var leadingWing: some View { + Color.clear + .background { + if isExpanded { + ZStack { + RoundedRectangle(cornerRadius: 6, style: .continuous) + .fill(focusColor.opacity(0.18)) + + Image(systemName: focusSymbol) + .font(.system(size: 12, weight: .semibold)) + .foregroundColor(focusColor) + } + .frame(width: leadingWingWidth, height: wingHeight) + } + } + } + + private var trailingWing: some View { + Color.clear + .background { + if isExpanded { + HStack { + Text(focusLabel) + .font(.system(size: 12, weight: .semibold, design: .rounded)) + .foregroundColor(focusColor) + .lineLimit(1) + .padding(.horizontal, 12) + .padding(.vertical, 6) + } + .frame(maxWidth: .infinity, alignment: .trailing) + .background( + Capsule(style: .continuous) + .fill(focusColor.opacity(0.18)) + ) + } + } + } +} + +#Preview { + DoNotDisturbLiveActivity() + .environmentObject(DynamicIslandViewModel()) + .frame(width: 320, height: 54) + .background(Color.black) +} diff --git a/DynamicIsland/components/Notch/DynamicIslandHeader.swift b/DynamicIsland/components/Notch/DynamicIslandHeader.swift index 83dc5ec..6204e9c 100644 --- a/DynamicIsland/components/Notch/DynamicIslandHeader.swift +++ b/DynamicIsland/components/Notch/DynamicIslandHeader.swift @@ -16,6 +16,7 @@ struct DynamicIslandHeader: View { @ObservedObject var clipboardManager = ClipboardManager.shared @ObservedObject var tvm = TrayDrop.shared @ObservedObject var timerManager = TimerManager.shared + @ObservedObject var doNotDisturbManager = DoNotDisturbManager.shared @State private var showClipboardPopover = false @State private var showColorPickerPopover = false @State private var showTimerPopover = false @@ -196,6 +197,12 @@ struct DynamicIslandHeader: View { RecordingIndicator() .frame(width: 30, height: 30) // Same size as other header elements } + + if Defaults[.enableDoNotDisturbDetection] && Defaults[.showDoNotDisturbIndicator] && doNotDisturbManager.isDoNotDisturbActive { + FocusIndicator() + .frame(width: 30, height: 30) + .transition(.opacity) + } if Defaults[.showBatteryIndicator] { DynamicIslandBatteryView( diff --git a/DynamicIsland/components/Settings/SettingsView.swift b/DynamicIsland/components/Settings/SettingsView.swift index 9e9135f..bfb8f4b 100644 --- a/DynamicIsland/components/Settings/SettingsView.swift +++ b/DynamicIsland/components/Settings/SettingsView.swift @@ -157,6 +157,7 @@ struct GeneralSettings: View { @ObservedObject var coordinator = DynamicIslandViewCoordinator.shared @ObservedObject var recordingManager = ScreenRecordingManager.shared @ObservedObject var privacyManager = PrivacyIndicatorManager.shared + @ObservedObject var doNotDisturbManager = DoNotDisturbManager.shared @Default(.mirrorShape) var mirrorShape @Default(.showEmojis) var showEmojis @@ -172,6 +173,8 @@ struct GeneralSettings: View { @Default(.openNotchOnHover) var openNotchOnHover @Default(.enableScreenRecordingDetection) var enableScreenRecordingDetection @Default(.showRecordingIndicator) var showRecordingIndicator + @Default(.enableDoNotDisturbDetection) var enableDoNotDisturbDetection + @Default(.showDoNotDisturbIndicator) var showDoNotDisturbIndicator @Default(.enableMinimalisticUI) var enableMinimalisticUI @Default(.lockScreenGlassStyle) var lockScreenGlassStyle @Default(.lockScreenShowAppIcon) var lockScreenShowAppIcon @@ -235,12 +238,12 @@ struct GeneralSettings: View { Section { Defaults.Toggle("Enable Screen Recording Detection", key: .enableScreenRecordingDetection) - + Defaults.Toggle("Show Recording Indicator", key: .showRecordingIndicator) .disabled(!enableScreenRecordingDetection) - + // Note: Polling removed - now uses event-driven private API detection - + if recordingManager.isMonitoring { HStack { Text("Detection Status") @@ -260,11 +263,48 @@ struct GeneralSettings: View { } } } header: { - Text("Screen Recording Detection") + Text("Screen Recording") } footer: { Text("Uses event-driven private API for real-time screen recording detection") } - + + Section { + Defaults.Toggle("Enable Focus Detection", key: .enableDoNotDisturbDetection) + + Defaults.Toggle("Show Focus Indicator", key: .showDoNotDisturbIndicator) + .disabled(!enableDoNotDisturbDetection) + + if doNotDisturbManager.isMonitoring { + HStack { + Text("Focus Status") + Spacer() + if doNotDisturbManager.isDoNotDisturbActive { + HStack(spacing: 4) { + Circle() + .fill(Color.purple) + .frame(width: 8, height: 8) + Text(doNotDisturbManager.currentFocusModeName.isEmpty ? "Focus Enabled" : doNotDisturbManager.currentFocusModeName) + .foregroundColor(.purple) + } + } else { + Text("Active - No Focus") + .foregroundColor(.green) + } + } + } else { + HStack { + Text("Focus Status") + Spacer() + Text("Disabled") + .foregroundColor(.secondary) + } + } + } header: { + Text("Do Not Disturb") + } footer: { + Text("Listens for Focus session changes via distributed notifications") + } + Section { Defaults.Toggle("Enable Camera Detection", key: .enableCameraDetection) Defaults.Toggle("Enable Microphone Detection", key: .enableMicrophoneDetection) diff --git a/DynamicIsland/components/UI/FocusIndicator.swift b/DynamicIsland/components/UI/FocusIndicator.swift new file mode 100644 index 0000000..27db505 --- /dev/null +++ b/DynamicIsland/components/UI/FocusIndicator.swift @@ -0,0 +1,45 @@ +// +// FocusIndicator.swift +// DynamicIsland +// +// Small purple moon badge shown while Focus mode is active. +// + +import SwiftUI + +struct FocusIndicator: View { + @ObservedObject var manager = DoNotDisturbManager.shared + + private let focusColor = Color.purple + + var body: some View { + Capsule() + .fill(Color.black) + .overlay { + ZStack { + Circle() + .fill(focusColor.opacity(0.2)) + .frame(width: 20, height: 20) + + Image(systemName: focusSymbol) + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(focusColor) + } + } + .accessibilityElement(children: .ignore) + .accessibilityLabel("Focus mode active") + } + + private var focusSymbol: String { + if let symbol = FocusModeType(rawValue: manager.currentFocusModeIdentifier)?.sfSymbol { + return symbol + } + return FocusModeType.doNotDisturb.sfSymbol + } +} + +#Preview { + FocusIndicator() + .frame(width: 30, height: 30) + .background(Color.gray.opacity(0.2)) +} diff --git a/DynamicIsland/managers/DoNotDisturbManager.swift b/DynamicIsland/managers/DoNotDisturbManager.swift index 355b12b..83c63e6 100644 --- a/DynamicIsland/managers/DoNotDisturbManager.swift +++ b/DynamicIsland/managers/DoNotDisturbManager.swift @@ -2,398 +2,143 @@ // DoNotDisturbManager.swift // DynamicIsland // -// Created for Do Not Disturb / Focus Mode detection -// Monitors macOS Focus Mode state changes via distributed notifications +// Replaces the legacy polling-based Focus detection with +// NSDistributedNotificationCenter-backed monitoring. // -import Foundation -import Combine import AppKit +import Combine import Defaults +import Foundation import SwiftUI -import UserNotifications // For notification permission checks -import ApplicationServices // For AXIsProcessTrusted -/// Manages detection and state tracking for macOS Focus Mode (Do Not Disturb) -class DoNotDisturbManager: ObservableObject { +final class DoNotDisturbManager: ObservableObject { static let shared = DoNotDisturbManager() - - // MARK: - Published Properties - @Published var isDoNotDisturbActive: Bool = false + + @Published private(set) var isMonitoring = false + @Published var isDoNotDisturbActive = false @Published var currentFocusModeName: String = "" @Published var currentFocusModeIdentifier: String = "" - - // MARK: - Private Properties - private var observers: [NSObjectProtocol] = [] - private var cancellables = Set() - - // MARK: - Initialization - private init() { - print("๐Ÿ”” [DoNotDisturbManager] Initializing...") - checkPermissions() - setupFocusModeObservers() - checkInitialState() - } - + + private let notificationCenter = DistributedNotificationCenter.default() + private let metadataExtractionQueue = DispatchQueue(label: "com.dynamicisland.focus.metadata", qos: .userInitiated) + private var hasLoggedPayload = false + + private init() {} + deinit { - cleanup() + stopMonitoring() } - - // MARK: - Permission Checks - - /// Checks if the app has necessary permissions for notification observation - private func checkPermissions() { - print("๐Ÿ”” [DoNotDisturbManager] ==========================================") - print("๐Ÿ”” [DoNotDisturbManager] ๐Ÿ” CHECKING PERMISSIONS & CAPABILITIES") - print("๐Ÿ”” [DoNotDisturbManager] ==========================================") - - // Check if running in sandbox - let isSandboxed = ProcessInfo.processInfo.environment["APP_SANDBOX_CONTAINER_ID"] != nil - print(" ๐Ÿ“ฆ Sandboxed: \(isSandboxed ? "YES โš ๏ธ" : "NO โœ…")") - - // Check notification center permissions - let notificationCenter = UNUserNotificationCenter.current() - notificationCenter.getNotificationSettings { settings in - print(" ๐Ÿ”” Notification Authorization: \(settings.authorizationStatus.rawValue)") - print(" - .notDetermined = 0") - print(" - .denied = 1") - print(" - .authorized = 2") - print(" - .provisional = 3") - print(" - .ephemeral = 4") - } - - // Check accessibility permissions - let trusted = AXIsProcessTrusted() - print(" โ™ฟ๏ธ Accessibility Trusted: \(trusted ? "YES โœ…" : "NO โš ๏ธ")") - if !trusted { - print(" โš ๏ธ App needs Accessibility permission!") - print(" โš ๏ธ Go to: System Settings > Privacy & Security > Accessibility") - } - - // Check entitlements - print(" ๐Ÿ“ Checking Entitlements...") - if let entitlements = Bundle.main.object(forInfoDictionaryKey: "Entitlements") as? [String: Any] { - print(" Entitlements found: \(entitlements.keys)") - } else { - print(" โš ๏ธ No entitlements in Info.plist") - } - - // Test if we can observe notifications at all - print(" ๐Ÿงช Testing notification observation capability...") - testNotificationObservation() - - print("๐Ÿ”” [DoNotDisturbManager] ==========================================") - } - - /// Test if notification observation is working at all - private func testNotificationObservation() { - // Test 1: Local NotificationCenter - let testObserver = NotificationCenter.default.addObserver( - forName: NSNotification.Name("TEST_NOTIFICATION"), + + func startMonitoring() { + guard !isMonitoring else { return } + + notificationCenter.addObserver( + self, + selector: #selector(handleFocusEnabled(_:)), + name: .focusModeEnabled, object: nil, - queue: .main, - using: { notification in - print(" โœ… Local NotificationCenter works!") - } + suspensionBehavior: .deliverImmediately ) - NotificationCenter.default.post(name: NSNotification.Name("TEST_NOTIFICATION"), object: nil) - NotificationCenter.default.removeObserver(testObserver) - - // Test 2: Distributed NotificationCenter (this is what we need) - let dnc = DistributedNotificationCenter.default() - print(" ๐ŸŒ DistributedNotificationCenter instance: \(dnc)") - print(" ๐ŸŒ Attempting to post test distributed notification...") - - let testDistributedObserver = dnc.addObserver( - forName: NSNotification.Name("com.test.distributed.notification"), + + notificationCenter.addObserver( + self, + selector: #selector(handleFocusDisabled(_:)), + name: .focusModeDisabled, object: nil, - queue: .main, - using: { notification in - print(" โœ… Distributed NotificationCenter observation works!") - } + suspensionBehavior: .deliverImmediately ) - - // Post a test distributed notification - dnc.postNotificationName( - NSNotification.Name("com.test.distributed.notification"), - object: nil, - userInfo: ["test": "data"], - deliverImmediately: true - ) - - // Wait a bit then remove observer - DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { - dnc.removeObserver(testDistributedObserver) - print(" ๐Ÿงน Test observer cleaned up") + + isMonitoring = true + } + + func stopMonitoring() { + guard isMonitoring else { return } + + notificationCenter.removeObserver(self, name: .focusModeEnabled, object: nil) + notificationCenter.removeObserver(self, name: .focusModeDisabled, object: nil) + + isMonitoring = false + + DispatchQueue.main.async { + self.isDoNotDisturbActive = false + self.currentFocusModeIdentifier = "" + self.currentFocusModeName = "" } } - - // MARK: - Setup Methods - - /// Sets up distributed notification observers for Focus Mode changes - private func setupFocusModeObservers() { - print("๐Ÿ”” [DoNotDisturbManager] ==========================================") - print("๐Ÿ”” [DoNotDisturbManager] ๐Ÿšจ SETTING UP NOTIFICATION OBSERVERS ๐Ÿšจ") - print("๐Ÿ”” [DoNotDisturbManager] ==========================================") - - // Method 1: Darwin Notifications (low-level, no permissions needed) - setupDarwinNotificationObserver() - - // Method 2: Distributed Notifications (system-wide) - setupDistributedNotificationObserver() - - // Method 3: NSWorkspace Notifications (local) - setupNSWorkspaceNotificationObserver() - - // Method 4: Polling as fallback - startPollingForDND() - - print("๐Ÿ”” [DoNotDisturbManager] โœ… All observers registered") - print("๐Ÿ”” [DoNotDisturbManager] Please toggle Do Not Disturb now...") - print("๐Ÿ”” [DoNotDisturbManager] ==========================================") + + @objc private func handleFocusEnabled(_ notification: Notification) { + apply(notification: notification, isActive: true) } - - private func setupDarwinNotificationObserver() { - print("๐Ÿ”” [DoNotDisturbManager] Setting up DARWIN notification observer...") - - // Darwin notifications - these are low-level and work without special permissions - let darwinNotificationNames = [ - "com.apple.controlcenter.modeChanged", - "com.apple.donotdisturb.changed", - "com.apple.donotdisturb.state", - "com.apple.springboard.donotdisturb" - ] - - let center = CFNotificationCenterGetDarwinNotifyCenter() - - for notificationName in darwinNotificationNames { - CFNotificationCenterAddObserver( - center, - Unmanaged.passUnretained(self).toOpaque(), - { (center, observer, name, object, userInfo) in - guard let name = name else { return } - let notificationName = name.rawValue as String - print("๐ŸŽฏ [DARWIN NOTIFICATION] \(notificationName)") - - // Get the manager instance - if let observer = observer { - let manager = Unmanaged.fromOpaque(observer).takeUnretainedValue() - manager.handleDarwinNotification(notificationName) - } - }, - notificationName as CFString, - nil, - .deliverImmediately - ) - print(" โœ… Darwin observer registered for: \(notificationName)") - } + + @objc private func handleFocusDisabled(_ notification: Notification) { + apply(notification: notification, isActive: false) } - - private func handleDarwinNotification(_ name: String) { - DispatchQueue.main.async { [weak self] in - print("๐Ÿ””๐Ÿ””๐Ÿ”” [DARWIN] Focus Mode notification received: \(name)") - // Toggle DND state - self?.isDoNotDisturbActive.toggle() - self?.showFocusModeActivatedHUD() - } - } - - private func setupDistributedNotificationObserver() { - let dnc = DistributedNotificationCenter.default() - - print("๐Ÿ”” [DoNotDisturbManager] Setting up DISTRIBUTED notification observer...") - - // Observer for ANY AND ALL distributed notifications (for discovery) - let allNotificationsObserver = dnc.addObserver( - forName: nil, // nil = listen to EVERYTHING - object: nil, - queue: .main, - using: { [weak self] notification in - let notificationName = notification.name.rawValue - - // PRINT EVERY SINGLE NOTIFICATION (no filtering) - print("๐Ÿ“ข [DISTRIBUTED] \(notificationName)") - - // Also check if it looks Focus Mode related - if notificationName.lowercased().contains("focus") || - notificationName.lowercased().contains("dnd") || - notificationName.lowercased().contains("disturb") || - notificationName.lowercased().contains("mode") || - notificationName.contains("com.apple.controlcenter") || - notificationName.contains("com.apple.donotdisturb") { - - print("๐Ÿ””๐Ÿ””๐Ÿ”” [DoNotDisturbManager] โšก๏ธโšก๏ธโšก๏ธ POTENTIAL FOCUS MODE NOTIFICATION:") - print(" Name: \(notificationName)") - print(" Object: \(String(describing: notification.object))") - print(" UserInfo Keys: \(notification.userInfo?.keys.map { String(describing: $0) } ?? [])") - print(" UserInfo: \(notification.userInfo ?? [:])") - print(" ===============================================") - - self?.handleFocusModeNotification(notification) - } - } - ) - observers.append(allNotificationsObserver) - print(" โœ… Distributed notification observer registered") - } - - private func setupNSWorkspaceNotificationObserver() { - let nc = NSWorkspace.shared.notificationCenter - - print("๐Ÿ”” [DoNotDisturbManager] Setting up NSWORKSPACE notification observer...") - - // Try to observe workspace notifications - let workspaceObserver = nc.addObserver( - forName: nil, // Listen to all workspace notifications - object: nil, - queue: .main, - using: { notification in - let notificationName = notification.name.rawValue - - // Print all workspace notifications - print("๐Ÿข [NSWORKSPACE] \(notificationName)") - - // Check for Focus Mode related - if notificationName.lowercased().contains("focus") || - notificationName.lowercased().contains("dnd") || - notificationName.lowercased().contains("disturb") { - print("๐Ÿ””๐Ÿ””๐Ÿ”” [WORKSPACE FOCUS NOTIFICATION]") - print(" Name: \(notificationName)") - print(" UserInfo: \(notification.userInfo ?? [:])") - } - } - ) - observers.append(workspaceObserver) - print(" โœ… NSWorkspace notification observer registered") - } - - private func startPollingForDND() { - print("๐Ÿ”” [DoNotDisturbManager] Starting polling fallback (every 2 seconds)...") - - // Poll every 2 seconds to check for DND state changes - Timer.scheduledTimer(withTimeInterval: 2.0, repeats: true) { [weak self] _ in - self?.checkDNDStateViaDefaults() - } - print(" โœ… Polling timer started") - } - - private func checkDNDStateViaDefaults() { - // Try to read DND state from UserDefaults or system preferences - // This is a fallback detection method - - let cfPrefs = CFPreferencesCopyAppValue("dndStart" as CFString, "com.apple.controlcenter" as CFString) - if let prefs = cfPrefs { - print("๐Ÿ” [POLLING] Found controlcenter prefs: \(prefs)") - } - - // Try reading notification center settings - let ncPrefs = CFPreferencesCopyAppValue("doNotDisturb" as CFString, "com.apple.notificationcenterui" as CFString) - if let nc = ncPrefs { - print("๐Ÿ” [POLLING] Found notification center prefs: \(nc)") - } - } - - /// Checks initial Focus Mode state on app launch - private func checkInitialState() { - // TODO: Find a way to check current Focus Mode state - // Options to explore: - // 1. NSWorkspace properties (if any exist) - // 2. Private APIs - // 3. AppleScript (last resort) - - print("๐Ÿ”” [DoNotDisturbManager] Checking initial Focus Mode state...") - print(" Note: Initial state detection not yet implemented") - } - - // MARK: - Notification Handling - - /// Handles incoming Focus Mode notifications - private func handleFocusModeNotification(_ notification: Notification) { - DispatchQueue.main.async { [weak self] in + + private func apply(notification: Notification, isActive: Bool) { + metadataExtractionQueue.async { [weak self] in guard let self = self else { return } - - // Parse notification payload - let userInfo = notification.userInfo ?? [:] - - // Try to extract Focus Mode state and identifier - // Note: Actual keys depend on the notification structure (to be discovered) - if let stateString = userInfo["state"] as? String { - self.isDoNotDisturbActive = (stateString == "enabled" || stateString == "on" || stateString == "active") - } else if let enabled = userInfo["enabled"] as? Bool { - self.isDoNotDisturbActive = enabled - } else if let active = userInfo["active"] as? Bool { - self.isDoNotDisturbActive = active - } - - // Try to extract mode name - if let modeName = userInfo["name"] as? String { - self.currentFocusModeName = modeName - } else if let mode = userInfo["mode"] as? String { - self.currentFocusModeName = mode - } - - // Try to extract mode identifier - if let identifier = userInfo["identifier"] as? String { - self.currentFocusModeIdentifier = identifier - } - - print("๐Ÿ”” [DoNotDisturbManager] State updated:") - print(" Active: \(self.isDoNotDisturbActive)") - print(" Mode Name: \(self.currentFocusModeName.isEmpty ? "N/A" : self.currentFocusModeName)") - print(" Identifier: \(self.currentFocusModeIdentifier.isEmpty ? "N/A" : self.currentFocusModeIdentifier)") - - // Trigger HUD display - if self.isDoNotDisturbActive { - self.showFocusModeActivatedHUD() - } else { - self.showFocusModeDeactivatedHUD() + + let metadata = self.extractMetadata(from: notification) + + DispatchQueue.main.async { + if let identifier = metadata.identifier, identifier != self.currentFocusModeIdentifier { + self.currentFocusModeIdentifier = identifier + } + + if let name = metadata.name { + if name != self.currentFocusModeName { + self.currentFocusModeName = name + } + } else if !self.currentFocusModeIdentifier.isEmpty { + let derivedName = FocusModeType(rawValue: self.currentFocusModeIdentifier)?.displayName ?? "" + if !derivedName.isEmpty { + self.currentFocusModeName = derivedName + } + } + + guard self.isDoNotDisturbActive != isActive else { return } + + withAnimation(.smooth(duration: 0.25)) { + self.isDoNotDisturbActive = isActive + } } } } - - // MARK: - HUD Display Methods - - /// Shows HUD when Focus Mode is activated - private func showFocusModeActivatedHUD() { - print("๐Ÿ”” [DoNotDisturbManager] ๐Ÿ“ฑ Showing Focus Mode ACTIVATED HUD") - - // TODO: Integrate with DynamicIslandViewCoordinator to show HUD - // coordinator.toggleSneakPeek(status: true, type: .doNotDisturb, value: 1) - } - - /// Shows HUD when Focus Mode is deactivated - private func showFocusModeDeactivatedHUD() { - print("๐Ÿ”” [DoNotDisturbManager] ๐Ÿ“ฑ Showing Focus Mode DEACTIVATED HUD") - - // TODO: Integrate with DynamicIslandViewCoordinator to show HUD - // coordinator.toggleSneakPeek(status: true, type: .doNotDisturb, value: 0) - } - - // MARK: - Public Methods - - /// Manually refresh Focus Mode state - func refreshState() { - print("๐Ÿ”” [DoNotDisturbManager] Manual state refresh requested") - checkInitialState() - } - - // MARK: - Cleanup - - private func cleanup() { - print("๐Ÿ”” [DoNotDisturbManager] Cleaning up observers...") - - // Remove Darwin notification observers - let center = CFNotificationCenterGetDarwinNotifyCenter() - CFNotificationCenterRemoveEveryObserver(center, Unmanaged.passUnretained(self).toOpaque()) - - // Remove other observers - let dnc = DistributedNotificationCenter.default() - for observer in observers { - dnc.removeObserver(observer) + + private func extractMetadata(from notification: Notification) -> (name: String?, identifier: String?) { + guard let userInfo = notification.userInfo else { return (nil, nil) } + + if !hasLoggedPayload { + hasLoggedPayload = true + debugPrint("[DoNotDisturbManager] focus notification payload: \(userInfo)") } - observers.removeAll() - cancellables.removeAll() + + let identifierKeys = [ + "FocusModeIdentifier", + "focusModeIdentifier", + "identifier", + "Identifier" + ] + + let nameKeys = [ + "FocusModeName", + "focusModeName", + "name", + "Name" + ] + + let identifier = identifierKeys.compactMap { userInfo[$0] as? String }.first + let name = nameKeys.compactMap { userInfo[$0] as? String }.first + + return (name, identifier) } + +} + +private extension Notification.Name { + static let focusModeEnabled = Notification.Name("_NSDoNotDisturbEnabledNotification") + static let focusModeDisabled = Notification.Name("_NSDoNotDisturbDisabledNotification") } // MARK: - Focus Mode Types diff --git a/DynamicIsland/models/Constants.swift b/DynamicIsland/models/Constants.swift index a02af29..735e990 100644 --- a/DynamicIsland/models/Constants.swift +++ b/DynamicIsland/models/Constants.swift @@ -483,6 +483,10 @@ extension Defaults.Keys { static let showRecordingIndicator = Key("showRecordingIndicator", default: true) // Polling removed - now uses event-driven private API detection (CGSIsScreenWatcherPresent) // static let enableScreenRecordingPolling = Key("enableScreenRecordingPolling", default: false) + + // MARK: Focus / Do Not Disturb Detection + static let enableDoNotDisturbDetection = Key("enableDoNotDisturbDetection", default: true) + static let showDoNotDisturbIndicator = Key("showDoNotDisturbIndicator", default: true) // MARK: Privacy Indicators (Camera & Microphone Detection) static let enableCameraDetection = Key("enableCameraDetection", default: true) From 39c135cfd793ec0fdcf708213ff12f7c84480163 Mon Sep 17 00:00:00 2001 From: Hariharan Mudaliar Date: Mon, 10 Nov 2025 02:19:31 +0530 Subject: [PATCH 4/6] Fixed the width and color --- DynamicIsland/Localizable.xcstrings | 4 - .../Focus/DoNotDisturbLiveActivity.swift | 227 +++++++++++++----- .../components/Settings/SettingsView.swift | 4 + .../components/UI/FocusIndicator.swift | 26 +- .../managers/DoNotDisturbManager.swift | 36 +++ DynamicIsland/models/Constants.swift | 1 + 6 files changed, 221 insertions(+), 77 deletions(-) diff --git a/DynamicIsland/Localizable.xcstrings b/DynamicIsland/Localizable.xcstrings index 222c4e1..2d008e7 100644 --- a/DynamicIsland/Localizable.xcstrings +++ b/DynamicIsland/Localizable.xcstrings @@ -8497,10 +8497,6 @@ }, "Fit to Notch" : { - }, - "Focus mode active" : { - "comment" : "An accessibility label for the focus indicator.", - "isCommentAutoGenerated" : true }, "Focus Status" : { diff --git a/DynamicIsland/components/Focus/DoNotDisturbLiveActivity.swift b/DynamicIsland/components/Focus/DoNotDisturbLiveActivity.swift index ccfa3ba..6f1ffb0 100644 --- a/DynamicIsland/components/Focus/DoNotDisturbLiveActivity.swift +++ b/DynamicIsland/components/Focus/DoNotDisturbLiveActivity.swift @@ -2,112 +2,211 @@ // DoNotDisturbLiveActivity.swift // DynamicIsland // -// Renders the closed-notch Focus indicator with a purple moon badge. +// Renders the closed-notch Focus indicator with per-mode colours and +// an icon-first layout that collapses gracefully when Focus ends. // +import Defaults import SwiftUI struct DoNotDisturbLiveActivity: View { @EnvironmentObject var vm: DynamicIslandViewModel @ObservedObject var manager = DoNotDisturbManager.shared - @State private var isExpanded = false + @Default(.showDoNotDisturbLabel) private var showLabelSetting - private let focusColor = Color.purple + @State private var isExpanded = false + @State private var showInactiveIcon = false + @State private var iconScale: CGFloat = 1.0 + @State private var scaleResetTask: Task? + @State private var collapseTask: Task? + @State private var cleanupTask: Task? var body: some View { HStack(spacing: 0) { - leadingWing - .frame(width: leadingWingWidth, height: wingHeight) + iconWing + .frame(width: iconWingWidth, height: wingHeight) Rectangle() .fill(Color.black) .frame(width: vm.closedNotchSize.width) - trailingWing - .frame(width: trailingWingWidth, height: wingHeight) + labelWing + .frame(width: labelWingWidth, height: wingHeight) } .frame(height: vm.effectiveClosedNotchHeight) .accessibilityElement(children: .ignore) .accessibilityLabel(accessibilityDescription) - .onAppear { - withAnimation(.smooth(duration: 0.35)) { - isExpanded = true - } - } - .onChange(of: manager.isDoNotDisturbActive) { _, isActive in - withAnimation(.smooth(duration: 0.35)) { - isExpanded = isActive - } - } + .onAppear(perform: handleInitialState) + .onChange(of: manager.isDoNotDisturbActive, handleFocusStateChange) + .onDisappear(perform: cancelPendingTasks) } + // MARK: - Layout helpers + private var wingHeight: CGFloat { max(vm.effectiveClosedNotchHeight - 10, 20) } - private var leadingWingWidth: CGFloat { - isExpanded ? max(vm.effectiveClosedNotchHeight - 10, 20) : 0 + private var iconWingWidth: CGFloat { + (isExpanded || showInactiveIcon) ? max(vm.effectiveClosedNotchHeight - 12, 24) : 0 } - private var trailingWingWidth: CGFloat { - guard isExpanded else { return 0 } - return max(vm.closedNotchSize.width * 0.65, 120) + private var labelWingWidth: CGFloat { + shouldShowLabel ? max(vm.closedNotchSize.width * 0.45, 110) : 0 } - private var focusSymbol: String { - if let symbol = FocusModeType(rawValue: manager.currentFocusModeIdentifier)?.sfSymbol { - return symbol - } - return FocusModeType.doNotDisturb.sfSymbol + private var shouldShowLabel: Bool { + showLabelSetting && isExpanded && !labelText.isEmpty } - private var focusLabel: String { - if !manager.currentFocusModeName.isEmpty { - return manager.currentFocusModeName - } - return FocusModeType(rawValue: manager.currentFocusModeIdentifier)?.displayName ?? "Do Not Disturb" + // MARK: - Focus metadata + + private var focusMode: FocusModeType { + FocusModeType(rawValue: manager.currentFocusModeIdentifier) ?? .doNotDisturb + } + + private var activeAccentColor: Color { + focusMode.accentColor + } + + private var labelText: String { + let trimmed = manager.currentFocusModeName.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? "Focus" : trimmed } private var accessibilityDescription: String { - "Focus mode active: \(focusLabel)" + if manager.isDoNotDisturbActive { + return "Focus active: \(labelText)" + } else { + return "Focus inactive" + } } - private var leadingWing: some View { - Color.clear - .background { - if isExpanded { - ZStack { - RoundedRectangle(cornerRadius: 6, style: .continuous) - .fill(focusColor.opacity(0.18)) - - Image(systemName: focusSymbol) - .font(.system(size: 12, weight: .semibold)) - .foregroundColor(focusColor) - } - .frame(width: leadingWingWidth, height: wingHeight) - } - } + private var currentIconName: String { + if manager.isDoNotDisturbActive { + return focusMode.sfSymbol + } else if showInactiveIcon { + return focusMode.inactiveSymbol + } else { + return focusMode.sfSymbol + } } - private var trailingWing: some View { + private var currentIconColor: Color { + if manager.isDoNotDisturbActive { + return activeAccentColor + } else if showInactiveIcon { + return .white + } else { + return activeAccentColor + } + } + + private var iconBackground: Color { + if manager.isDoNotDisturbActive { + return activeAccentColor.opacity(0.18) + } else if showInactiveIcon { + return Color.white.opacity(0.10) + } else { + return Color.black.opacity(0.001) + } + } + + // MARK: - Subviews + + private var iconWing: some View { Color.clear - .background { - if isExpanded { - HStack { - Text(focusLabel) - .font(.system(size: 12, weight: .semibold, design: .rounded)) - .foregroundColor(focusColor) - .lineLimit(1) - .padding(.horizontal, 12) - .padding(.vertical, 6) - } - .frame(maxWidth: .infinity, alignment: .trailing) - .background( - Capsule(style: .continuous) - .fill(focusColor.opacity(0.18)) - ) + .overlay(alignment: .center) { + if iconWingWidth > 0 { + RoundedRectangle(cornerRadius: 6, style: .continuous) + .fill(iconBackground) + .overlay { + Image(systemName: currentIconName) + .font(.system(size: 13, weight: .semibold)) + .foregroundColor(currentIconColor) + .scaleEffect(iconScale) + .animation(.none, value: iconScale) + } } } + .animation(.smooth(duration: 0.3), value: iconWingWidth) + } + + private var labelWing: some View { + Color.clear + .overlay(alignment: .trailing) { + if shouldShowLabel { + Text(labelText) + .font(.system(size: 12, weight: .semibold, design: .rounded)) + .foregroundColor(activeAccentColor) + .lineLimit(1) + .contentTransition(.opacity) + .padding(.horizontal, 4) + } + } + .animation(.smooth(duration: 0.3), value: shouldShowLabel) + } + + // MARK: - State transitions + + private func handleInitialState() { + if manager.isDoNotDisturbActive { + withAnimation(.spring(response: 0.35, dampingFraction: 0.85)) { + isExpanded = true + } + } + } + + private func handleFocusStateChange(_ oldValue: Bool, _ isActive: Bool) { + if isActive { + cancelPendingTasks() + showInactiveIcon = false + withAnimation(.spring(response: 0.35, dampingFraction: 0.85)) { + iconScale = 1.0 + isExpanded = true + } + } else { + triggerInactiveAnimation() + } + } + + private func triggerInactiveAnimation() { + showInactiveIcon = true + + withAnimation(.interpolatingSpring(stiffness: 220, damping: 12)) { + iconScale = 1.2 + } + + scaleResetTask?.cancel() + scaleResetTask = Task { @MainActor in + try? await Task.sleep(for: .milliseconds(180)) + withAnimation(.spring(response: 0.28, dampingFraction: 0.75)) { + iconScale = 1.0 + } + } + + collapseTask?.cancel() + collapseTask = Task { @MainActor in + try? await Task.sleep(for: .milliseconds(320)) + withAnimation(.smooth(duration: 0.32)) { + isExpanded = false + } + } + + cleanupTask?.cancel() + cleanupTask = Task { @MainActor in + try? await Task.sleep(for: .milliseconds(650)) + showInactiveIcon = false + } + } + + private func cancelPendingTasks() { + scaleResetTask?.cancel() + collapseTask?.cancel() + cleanupTask?.cancel() + scaleResetTask = nil + collapseTask = nil + cleanupTask = nil } } diff --git a/DynamicIsland/components/Settings/SettingsView.swift b/DynamicIsland/components/Settings/SettingsView.swift index bfb8f4b..2245fdb 100644 --- a/DynamicIsland/components/Settings/SettingsView.swift +++ b/DynamicIsland/components/Settings/SettingsView.swift @@ -175,6 +175,7 @@ struct GeneralSettings: View { @Default(.showRecordingIndicator) var showRecordingIndicator @Default(.enableDoNotDisturbDetection) var enableDoNotDisturbDetection @Default(.showDoNotDisturbIndicator) var showDoNotDisturbIndicator + @Default(.showDoNotDisturbLabel) var showDoNotDisturbLabel @Default(.enableMinimalisticUI) var enableMinimalisticUI @Default(.lockScreenGlassStyle) var lockScreenGlassStyle @Default(.lockScreenShowAppIcon) var lockScreenShowAppIcon @@ -274,6 +275,9 @@ struct GeneralSettings: View { Defaults.Toggle("Show Focus Indicator", key: .showDoNotDisturbIndicator) .disabled(!enableDoNotDisturbDetection) + Defaults.Toggle("Show Focus Label", key: .showDoNotDisturbLabel) + .disabled(!enableDoNotDisturbDetection) + if doNotDisturbManager.isMonitoring { HStack { Text("Focus Status") diff --git a/DynamicIsland/components/UI/FocusIndicator.swift b/DynamicIsland/components/UI/FocusIndicator.swift index 27db505..ec13c8a 100644 --- a/DynamicIsland/components/UI/FocusIndicator.swift +++ b/DynamicIsland/components/UI/FocusIndicator.swift @@ -10,31 +10,39 @@ import SwiftUI struct FocusIndicator: View { @ObservedObject var manager = DoNotDisturbManager.shared - private let focusColor = Color.purple - var body: some View { Capsule() .fill(Color.black) .overlay { ZStack { Circle() - .fill(focusColor.opacity(0.2)) + .fill(accentColor.opacity(0.2)) .frame(width: 20, height: 20) Image(systemName: focusSymbol) .font(.system(size: 12, weight: .semibold)) - .foregroundStyle(focusColor) + .foregroundStyle(accentColor) } } .accessibilityElement(children: .ignore) - .accessibilityLabel("Focus mode active") + .accessibilityLabel(accessibilityLabel) + } + + private var focusMode: FocusModeType { + FocusModeType(rawValue: manager.currentFocusModeIdentifier) ?? .doNotDisturb } private var focusSymbol: String { - if let symbol = FocusModeType(rawValue: manager.currentFocusModeIdentifier)?.sfSymbol { - return symbol - } - return FocusModeType.doNotDisturb.sfSymbol + focusMode.sfSymbol + } + + private var accentColor: Color { + focusMode.accentColor + } + + private var accessibilityLabel: String { + let name = manager.currentFocusModeName.isEmpty ? focusMode.displayName : manager.currentFocusModeName + return "Focus active: \(name)" } } diff --git a/DynamicIsland/managers/DoNotDisturbManager.swift b/DynamicIsland/managers/DoNotDisturbManager.swift index 83c63e6..9087389 100644 --- a/DynamicIsland/managers/DoNotDisturbManager.swift +++ b/DynamicIsland/managers/DoNotDisturbManager.swift @@ -187,4 +187,40 @@ enum FocusModeType: String { case .unknown: return "moon.zzz.fill" } } + + var accentColor: Color { + switch self { + case .doNotDisturb: + return Color(red: 0.370, green: 0.360, blue: 0.902) + case .work: + return Color(red: 0.133, green: 0.475, blue: 0.992) + case .personal: + return Color(red: 0.937, green: 0.282, blue: 0.624) + case .sleep: + return Color(red: 0.341, green: 0.384, blue: 0.980) + case .driving: + return Color(red: 0.988, green: 0.561, blue: 0.153) + case .fitness: + return Color(red: 0.176, green: 0.804, blue: 0.459) + case .gaming: + return Color(red: 0.639, green: 0.329, blue: 0.937) + case .mindfulness: + return Color(red: 0.239, green: 0.718, blue: 0.682) + case .reading: + return Color(red: 0.239, green: 0.596, blue: 0.965) + case .custom: + return Color(red: 0.513, green: 0.478, blue: 0.965) + case .unknown: + return Color(red: 0.370, green: 0.360, blue: 0.902) + } + } + + var inactiveSymbol: String { + switch self { + case .doNotDisturb: + return "moon.circle" + default: + return sfSymbol + } + } } diff --git a/DynamicIsland/models/Constants.swift b/DynamicIsland/models/Constants.swift index 735e990..e936a74 100644 --- a/DynamicIsland/models/Constants.swift +++ b/DynamicIsland/models/Constants.swift @@ -487,6 +487,7 @@ extension Defaults.Keys { // MARK: Focus / Do Not Disturb Detection static let enableDoNotDisturbDetection = Key("enableDoNotDisturbDetection", default: true) static let showDoNotDisturbIndicator = Key("showDoNotDisturbIndicator", default: true) + static let showDoNotDisturbLabel = Key("showDoNotDisturbLabel", default: true) // MARK: Privacy Indicators (Camera & Microphone Detection) static let enableCameraDetection = Key("enableCameraDetection", default: true) From 4444df4cb4e4a8eb3d442e65d12638e53fc70f17 Mon Sep 17 00:00:00 2001 From: Hariharan Mudaliar Date: Mon, 10 Nov 2025 03:00:10 +0530 Subject: [PATCH 5/6] Somewhat working --- .../Focus/DoNotDisturbLiveActivity.swift | 90 +++++++++----- .../components/UI/FocusIndicator.swift | 21 ++-- .../managers/DoNotDisturbManager.swift | 110 +++++++++++++++--- 3 files changed, 164 insertions(+), 57 deletions(-) diff --git a/DynamicIsland/components/Focus/DoNotDisturbLiveActivity.swift b/DynamicIsland/components/Focus/DoNotDisturbLiveActivity.swift index 6f1ffb0..aaeb179 100644 --- a/DynamicIsland/components/Focus/DoNotDisturbLiveActivity.swift +++ b/DynamicIsland/components/Focus/DoNotDisturbLiveActivity.swift @@ -20,6 +20,7 @@ struct DoNotDisturbLiveActivity: View { @State private var scaleResetTask: Task? @State private var collapseTask: Task? @State private var cleanupTask: Task? + @State private var labelIntrinsicWidth: CGFloat = 0 var body: some View { HStack(spacing: 0) { @@ -48,11 +49,24 @@ struct DoNotDisturbLiveActivity: View { } private var iconWingWidth: CGFloat { - (isExpanded || showInactiveIcon) ? max(vm.effectiveClosedNotchHeight - 12, 24) : 0 + (isExpanded || showInactiveIcon) ? minimalWingWidth : 0 } private var labelWingWidth: CGFloat { - shouldShowLabel ? max(vm.closedNotchSize.width * 0.45, 110) : 0 + if shouldShowLabel { + return max(desiredLabelWidth, minimalWingWidth) + } + return (isExpanded || showInactiveIcon) ? minimalWingWidth : 0 + } + + private var minimalWingWidth: CGFloat { + max(vm.effectiveClosedNotchHeight - 12, 24) + } + + private var desiredLabelWidth: CGFloat { + let measuredWidth = labelIntrinsicWidth + 8 // horizontal padding inside the label + let fallbackWidth = max(vm.closedNotchSize.width * 0.52, 136) + return max(measuredWidth, fallbackWidth) } private var shouldShowLabel: Bool { @@ -62,7 +76,10 @@ struct DoNotDisturbLiveActivity: View { // MARK: - Focus metadata private var focusMode: FocusModeType { - FocusModeType(rawValue: manager.currentFocusModeIdentifier) ?? .doNotDisturb + FocusModeType.resolve( + identifier: manager.currentFocusModeIdentifier, + name: manager.currentFocusModeName + ) } private var activeAccentColor: Color { @@ -71,7 +88,12 @@ struct DoNotDisturbLiveActivity: View { private var labelText: String { let trimmed = manager.currentFocusModeName.trimmingCharacters(in: .whitespacesAndNewlines) - return trimmed.isEmpty ? "Focus" : trimmed + if !trimmed.isEmpty { + return trimmed + } + + let fallback = focusMode.displayName + return fallback.isEmpty ? "Focus" : fallback } private var accessibilityDescription: String { @@ -102,31 +124,18 @@ struct DoNotDisturbLiveActivity: View { } } - private var iconBackground: Color { - if manager.isDoNotDisturbActive { - return activeAccentColor.opacity(0.18) - } else if showInactiveIcon { - return Color.white.opacity(0.10) - } else { - return Color.black.opacity(0.001) - } - } - // MARK: - Subviews private var iconWing: some View { Color.clear .overlay(alignment: .center) { if iconWingWidth > 0 { - RoundedRectangle(cornerRadius: 6, style: .continuous) - .fill(iconBackground) - .overlay { - Image(systemName: currentIconName) - .font(.system(size: 13, weight: .semibold)) - .foregroundColor(currentIconColor) - .scaleEffect(iconScale) - .animation(.none, value: iconScale) - } + Image(systemName: currentIconName) + .font(.system(size: 13, weight: .semibold)) + .foregroundColor(currentIconColor) + .contentTransition(.opacity) + .scaleEffect(iconScale) + .animation(.none, value: iconScale) } } .animation(.smooth(duration: 0.3), value: iconWingWidth) @@ -141,10 +150,19 @@ struct DoNotDisturbLiveActivity: View { .foregroundColor(activeAccentColor) .lineLimit(1) .contentTransition(.opacity) + .background( + GeometryReader { proxy in + Color.clear + .preference(key: FocusLabelWidthPreferenceKey.self, value: proxy.size.width) + } + ) .padding(.horizontal, 4) } } .animation(.smooth(duration: 0.3), value: shouldShowLabel) + .onPreferenceChange(FocusLabelWidthPreferenceKey.self) { value in + labelIntrinsicWidth = value + } } // MARK: - State transitions @@ -160,7 +178,9 @@ struct DoNotDisturbLiveActivity: View { private func handleFocusStateChange(_ oldValue: Bool, _ isActive: Bool) { if isActive { cancelPendingTasks() - showInactiveIcon = false + withAnimation(.smooth(duration: 0.2)) { + showInactiveIcon = false + } withAnimation(.spring(response: 0.35, dampingFraction: 0.85)) { iconScale = 1.0 isExpanded = true @@ -171,7 +191,9 @@ struct DoNotDisturbLiveActivity: View { } private func triggerInactiveAnimation() { - showInactiveIcon = true + withAnimation(.smooth(duration: 0.2)) { + showInactiveIcon = true + } withAnimation(.interpolatingSpring(stiffness: 220, damping: 12)) { iconScale = 1.2 @@ -179,9 +201,9 @@ struct DoNotDisturbLiveActivity: View { scaleResetTask?.cancel() scaleResetTask = Task { @MainActor in - try? await Task.sleep(for: .milliseconds(180)) - withAnimation(.spring(response: 0.28, dampingFraction: 0.75)) { - iconScale = 1.0 + try? await Task.sleep(for: .milliseconds(650)) + withAnimation(.smooth(duration: 0.2)) { + showInactiveIcon = false } } @@ -196,7 +218,9 @@ struct DoNotDisturbLiveActivity: View { cleanupTask?.cancel() cleanupTask = Task { @MainActor in try? await Task.sleep(for: .milliseconds(650)) - showInactiveIcon = false + withAnimation(.smooth(duration: 0.2)) { + showInactiveIcon = false + } } } @@ -216,3 +240,11 @@ struct DoNotDisturbLiveActivity: View { .frame(width: 320, height: 54) .background(Color.black) } + +private struct FocusLabelWidthPreferenceKey: PreferenceKey { + static var defaultValue: CGFloat = 0 + + static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { + value = max(value, nextValue()) + } +} diff --git a/DynamicIsland/components/UI/FocusIndicator.swift b/DynamicIsland/components/UI/FocusIndicator.swift index ec13c8a..4bcb5e8 100644 --- a/DynamicIsland/components/UI/FocusIndicator.swift +++ b/DynamicIsland/components/UI/FocusIndicator.swift @@ -14,22 +14,20 @@ struct FocusIndicator: View { Capsule() .fill(Color.black) .overlay { - ZStack { - Circle() - .fill(accentColor.opacity(0.2)) - .frame(width: 20, height: 20) - - Image(systemName: focusSymbol) - .font(.system(size: 12, weight: .semibold)) - .foregroundStyle(accentColor) - } + Image(systemName: focusSymbol) + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(accentColor) + .frame(width: 24, height: 24) } .accessibilityElement(children: .ignore) .accessibilityLabel(accessibilityLabel) } private var focusMode: FocusModeType { - FocusModeType(rawValue: manager.currentFocusModeIdentifier) ?? .doNotDisturb + FocusModeType.resolve( + identifier: manager.currentFocusModeIdentifier, + name: manager.currentFocusModeName + ) } private var focusSymbol: String { @@ -41,7 +39,8 @@ struct FocusIndicator: View { } private var accessibilityLabel: String { - let name = manager.currentFocusModeName.isEmpty ? focusMode.displayName : manager.currentFocusModeName + let trimmedName = manager.currentFocusModeName.trimmingCharacters(in: .whitespacesAndNewlines) + let name = trimmedName.isEmpty ? focusMode.displayName : trimmedName return "Focus active: \(name)" } } diff --git a/DynamicIsland/managers/DoNotDisturbManager.swift b/DynamicIsland/managers/DoNotDisturbManager.swift index 9087389..f659e93 100644 --- a/DynamicIsland/managers/DoNotDisturbManager.swift +++ b/DynamicIsland/managers/DoNotDisturbManager.swift @@ -82,19 +82,34 @@ final class DoNotDisturbManager: ObservableObject { let metadata = self.extractMetadata(from: notification) DispatchQueue.main.async { - if let identifier = metadata.identifier, identifier != self.currentFocusModeIdentifier { - self.currentFocusModeIdentifier = identifier + let trimmedIdentifier = metadata.identifier?.trimmingCharacters(in: .whitespacesAndNewlines) + let trimmedName = metadata.name?.trimmingCharacters(in: .whitespacesAndNewlines) + + let incomingIdentifier = (trimmedIdentifier?.isEmpty == false ? trimmedIdentifier! : self.currentFocusModeIdentifier) + let incomingName = (trimmedName?.isEmpty == false ? trimmedName! : self.currentFocusModeName) + + let resolvedMode = FocusModeType.resolve(identifier: incomingIdentifier, name: incomingName) + + debugPrint("[DoNotDisturbManager] Focus update -> identifier: \(trimmedIdentifier ?? "") (incoming: \(incomingIdentifier.isEmpty ? "" : incomingIdentifier)) | name: \(trimmedName ?? "") | resolved: \(resolvedMode.rawValue)") + + let finalIdentifier = resolvedMode.rawValue + if finalIdentifier != self.currentFocusModeIdentifier { + self.currentFocusModeIdentifier = finalIdentifier } - if let name = metadata.name { - if name != self.currentFocusModeName { - self.currentFocusModeName = name - } - } else if !self.currentFocusModeIdentifier.isEmpty { - let derivedName = FocusModeType(rawValue: self.currentFocusModeIdentifier)?.displayName ?? "" - if !derivedName.isEmpty { - self.currentFocusModeName = derivedName - } + let finalName: String + if let name = trimmedName, !name.isEmpty { + finalName = name + } else if !resolvedMode.displayName.isEmpty { + finalName = resolvedMode.displayName + } else if !incomingName.isEmpty { + finalName = incomingName + } else { + finalName = "Focus" + } + + if finalName != self.currentFocusModeName { + self.currentFocusModeName = finalName } guard self.isDoNotDisturbActive != isActive else { return } @@ -117,6 +132,10 @@ final class DoNotDisturbManager: ObservableObject { let identifierKeys = [ "FocusModeIdentifier", "focusModeIdentifier", + "FocusModeUUID", + "focusModeUUID", + "UUID", + "uuid", "identifier", "Identifier" ] @@ -124,12 +143,21 @@ final class DoNotDisturbManager: ObservableObject { let nameKeys = [ "FocusModeName", "focusModeName", + "FocusMode", + "focusMode", "name", "Name" ] - let identifier = identifierKeys.compactMap { userInfo[$0] as? String }.first - let name = nameKeys.compactMap { userInfo[$0] as? String }.first + let identifier = identifierKeys + .compactMap { userInfo[$0] as? String } + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .first { !$0.isEmpty } + + let name = nameKeys + .compactMap { userInfo[$0] as? String } + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .first { !$0.isEmpty } return (name, identifier) } @@ -143,7 +171,7 @@ private extension Notification.Name { // MARK: - Focus Mode Types -enum FocusModeType: String { +enum FocusModeType: String, CaseIterable { case doNotDisturb = "com.apple.donotdisturb.mode" case work = "com.apple.focus.work" case personal = "com.apple.focus.personal" @@ -158,7 +186,7 @@ enum FocusModeType: String { var displayName: String { switch self { - case .doNotDisturb: return "Do Not Disturb" + case .doNotDisturb: return "DnD" case .work: return "Work" case .personal: return "Personal" case .sleep: return "Sleep" @@ -184,7 +212,7 @@ enum FocusModeType: String { case .mindfulness: return "brain.head.profile" case .reading: return "book.fill" case .custom: return "app.badge" - case .unknown: return "moon.zzz.fill" + case .unknown: return "moon.fill" } } @@ -218,9 +246,57 @@ enum FocusModeType: String { var inactiveSymbol: String { switch self { case .doNotDisturb: - return "moon.circle" + return "moon.circle.fill" default: return sfSymbol } } } + +extension FocusModeType { + init(identifier: String) { + let normalized = identifier.trimmingCharacters(in: .whitespacesAndNewlines) + let normalizedLowercased = normalized.lowercased() + + guard !normalized.isEmpty else { + self = .doNotDisturb + return + } + + if let direct = FocusModeType(rawValue: normalized) ?? FocusModeType(rawValue: normalizedLowercased) { + self = direct + return + } + + if let resolved = FocusModeType.allCases.first(where: { + guard !$0.rawValue.isEmpty else { return false } + return normalized.hasPrefix($0.rawValue) || normalizedLowercased.hasPrefix($0.rawValue) + }) { + self = resolved + return + } + + if normalizedLowercased.hasPrefix("com.apple.focus.") { + self = .custom + return + } + + self = .doNotDisturb + } + + static func resolve(identifier: String?, name: String?) -> FocusModeType { + let trimmedIdentifier = identifier?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + if !trimmedIdentifier.isEmpty { + return FocusModeType(identifier: trimmedIdentifier) + } + + let trimmedName = name?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + if !trimmedName.isEmpty { + if let match = FocusModeType.allCases.first(where: { !$0.displayName.isEmpty && $0.displayName.compare(trimmedName, options: [.caseInsensitive, .diacriticInsensitive]) == .orderedSame }) { + return match + } + } + + return .doNotDisturb + } +} From b643f1d9053e5e2117b7961a99c3adbac24e5642 Mon Sep 17 00:00:00 2001 From: Hariharan Mudaliar Date: Mon, 10 Nov 2025 03:15:28 +0530 Subject: [PATCH 6/6] Working Focus mode - focus type not yet supported --- .../Focus/DoNotDisturbLiveActivity.swift | 6 + .../components/UI/FocusIndicator.swift | 11 +- .../managers/DoNotDisturbManager.swift | 111 +++++++++++++----- 3 files changed, 97 insertions(+), 31 deletions(-) diff --git a/DynamicIsland/components/Focus/DoNotDisturbLiveActivity.swift b/DynamicIsland/components/Focus/DoNotDisturbLiveActivity.swift index aaeb179..c01aca0 100644 --- a/DynamicIsland/components/Focus/DoNotDisturbLiveActivity.swift +++ b/DynamicIsland/components/Focus/DoNotDisturbLiveActivity.swift @@ -89,10 +89,16 @@ struct DoNotDisturbLiveActivity: View { private var labelText: String { let trimmed = manager.currentFocusModeName.trimmingCharacters(in: .whitespacesAndNewlines) if !trimmed.isEmpty { + if focusMode == .doNotDisturb { + return "Focus" + } return trimmed } let fallback = focusMode.displayName + if focusMode == .doNotDisturb { + return "Focus" + } return fallback.isEmpty ? "Focus" : fallback } diff --git a/DynamicIsland/components/UI/FocusIndicator.swift b/DynamicIsland/components/UI/FocusIndicator.swift index 4bcb5e8..f887d13 100644 --- a/DynamicIsland/components/UI/FocusIndicator.swift +++ b/DynamicIsland/components/UI/FocusIndicator.swift @@ -40,8 +40,15 @@ struct FocusIndicator: View { private var accessibilityLabel: String { let trimmedName = manager.currentFocusModeName.trimmingCharacters(in: .whitespacesAndNewlines) - let name = trimmedName.isEmpty ? focusMode.displayName : trimmedName - return "Focus active: \(name)" + let baseName: String + if trimmedName.isEmpty { + baseName = focusMode.displayName + } else { + baseName = trimmedName + } + + let finalName = focusMode == .doNotDisturb ? "Focus" : baseName + return "Focus active: \(finalName)" } } diff --git a/DynamicIsland/managers/DoNotDisturbManager.swift b/DynamicIsland/managers/DoNotDisturbManager.swift index f659e93..ce3ad64 100644 --- a/DynamicIsland/managers/DoNotDisturbManager.swift +++ b/DynamicIsland/managers/DoNotDisturbManager.swift @@ -22,7 +22,6 @@ final class DoNotDisturbManager: ObservableObject { private let notificationCenter = DistributedNotificationCenter.default() private let metadataExtractionQueue = DispatchQueue(label: "com.dynamicisland.focus.metadata", qos: .userInitiated) - private var hasLoggedPayload = false private init() {} @@ -85,14 +84,17 @@ final class DoNotDisturbManager: ObservableObject { let trimmedIdentifier = metadata.identifier?.trimmingCharacters(in: .whitespacesAndNewlines) let trimmedName = metadata.name?.trimmingCharacters(in: .whitespacesAndNewlines) - let incomingIdentifier = (trimmedIdentifier?.isEmpty == false ? trimmedIdentifier! : self.currentFocusModeIdentifier) - let incomingName = (trimmedName?.isEmpty == false ? trimmedName! : self.currentFocusModeName) + let resolvedMode = FocusModeType.resolve(identifier: trimmedIdentifier, name: trimmedName) - let resolvedMode = FocusModeType.resolve(identifier: incomingIdentifier, name: incomingName) + debugPrint("[DoNotDisturbManager] Focus update -> notification: \(notification.name.rawValue) | identifier: \(trimmedIdentifier ?? "") | name: \(trimmedName ?? "") | resolved: \(resolvedMode.rawValue)") - debugPrint("[DoNotDisturbManager] Focus update -> identifier: \(trimmedIdentifier ?? "") (incoming: \(incomingIdentifier.isEmpty ? "" : incomingIdentifier)) | name: \(trimmedName ?? "") | resolved: \(resolvedMode.rawValue)") + let finalIdentifier: String + if let identifier = trimmedIdentifier, !identifier.isEmpty { + finalIdentifier = identifier + } else { + finalIdentifier = resolvedMode.rawValue + } - let finalIdentifier = resolvedMode.rawValue if finalIdentifier != self.currentFocusModeIdentifier { self.currentFocusModeIdentifier = finalIdentifier } @@ -102,8 +104,8 @@ final class DoNotDisturbManager: ObservableObject { finalName = name } else if !resolvedMode.displayName.isEmpty { finalName = resolvedMode.displayName - } else if !incomingName.isEmpty { - finalName = incomingName + } else if let identifier = trimmedIdentifier, !identifier.isEmpty { + finalName = identifier } else { finalName = "Focus" } @@ -124,10 +126,7 @@ final class DoNotDisturbManager: ObservableObject { private func extractMetadata(from notification: Notification) -> (name: String?, identifier: String?) { guard let userInfo = notification.userInfo else { return (nil, nil) } - if !hasLoggedPayload { - hasLoggedPayload = true - debugPrint("[DoNotDisturbManager] focus notification payload: \(userInfo)") - } + debugPrint("[DoNotDisturbManager] raw focus payload -> name: \(notification.name.rawValue), object: \(String(describing: notification.object)), userInfo: \(userInfo)") let identifierKeys = [ "FocusModeIdentifier", @@ -149,15 +148,8 @@ final class DoNotDisturbManager: ObservableObject { "Name" ] - let identifier = identifierKeys - .compactMap { userInfo[$0] as? String } - .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } - .first { !$0.isEmpty } - - let name = nameKeys - .compactMap { userInfo[$0] as? String } - .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } - .first { !$0.isEmpty } + let identifier = firstMatch(for: identifierKeys, in: userInfo) + let name = firstMatch(for: nameKeys, in: userInfo) return (name, identifier) } @@ -186,7 +178,7 @@ enum FocusModeType: String, CaseIterable { var displayName: String { switch self { - case .doNotDisturb: return "DnD" + case .doNotDisturb: return "Do Not Disturb" case .work: return "Work" case .personal: return "Personal" case .sleep: return "Sleep" @@ -285,18 +277,79 @@ extension FocusModeType { } static func resolve(identifier: String?, name: String?) -> FocusModeType { + let trimmedName = name?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + if !trimmedName.isEmpty { + if let match = FocusModeType.allCases.first(where: { + guard !$0.displayName.isEmpty else { return false } + return $0.displayName.compare(trimmedName, options: [.caseInsensitive, .diacriticInsensitive]) == .orderedSame + }) { + return match + } + } + let trimmedIdentifier = identifier?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" if !trimmedIdentifier.isEmpty { return FocusModeType(identifier: trimmedIdentifier) } - let trimmedName = name?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - if !trimmedName.isEmpty { - if let match = FocusModeType.allCases.first(where: { !$0.displayName.isEmpty && $0.displayName.compare(trimmedName, options: [.caseInsensitive, .diacriticInsensitive]) == .orderedSame }) { - return match - } - } - return .doNotDisturb } } + +// MARK: - Metadata helpers + +private extension DoNotDisturbManager { + func firstMatch(for keys: [String], in value: Any) -> String? { + if let dictionary = value as? [AnyHashable: Any] { + for key in keys { + if let candidate = dictionary[key], let string = normalizedString(from: candidate) { + return string + } + } + + for nestedValue in dictionary.values { + if let nestedMatch = firstMatch(for: keys, in: nestedValue) { + return nestedMatch + } + } + } else if let array = value as? [Any] { + for element in array { + if let nestedMatch = firstMatch(for: keys, in: element) { + return nestedMatch + } + } + } + + return nil + } + + func normalizedString(from value: Any) -> String? { + switch value { + case let string as String: + let trimmed = string.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + case let number as NSNumber: + return number.stringValue + case let uuid as UUID: + return uuid.uuidString + case let uuid as NSUUID: + return uuid.uuidString + case let data as Data: + if let string = String(data: data, encoding: .utf8) { + return string.trimmingCharacters(in: .whitespacesAndNewlines) + } + return nil + case let dict as [AnyHashable: Any]: + // Attempt to pull common keys from nested dictionaries + if let nested = firstMatch(for: ["identifier", "Identifier", "uuid", "UUID"], in: dict) { + return nested + } + if let name = firstMatch(for: ["name", "Name", "displayName", "display_name"], in: dict) { + return name + } + return nil + default: + return nil + } + } +}