diff --git a/DynamicIsland/ContentView.swift b/DynamicIsland/ContentView.swift index df350e4..37c8bbc 100644 --- a/DynamicIsland/ContentView.swift +++ b/DynamicIsland/ContentView.swift @@ -427,6 +427,11 @@ struct ContentView: View { if newState == .closed { removeStickyTerminalClickMonitor() } + #if os(macOS) + if newState == .open { + TimerControlWindowManager.shared.hide() + } + #endif } .onChange(of: vm.isBatteryPopoverActive) { _, newPopoverState in runAfter(0.1) { diff --git a/DynamicIsland/components/LockScreen/LockScreenTimerWidget.swift b/DynamicIsland/components/LockScreen/LockScreenTimerWidget.swift index e13289d..362c853 100644 --- a/DynamicIsland/components/LockScreen/LockScreenTimerWidget.swift +++ b/DynamicIsland/components/LockScreen/LockScreenTimerWidget.swift @@ -46,7 +46,7 @@ struct LockScreenTimerWidget: View { } private func displayFont(size: CGFloat) -> Font { - .custom("SF Pro Display", size: size) + .system(size: size, weight: .semibold, design: .rounded) } private var hasHoursComponent: Bool { @@ -58,20 +58,20 @@ struct LockScreenTimerWidget: View { } private var titleFrameWidth: CGFloat { - if hasDoubleDigitHours { return 78 } - if hasHoursComponent { return 90 } - return 130 + if hasDoubleDigitHours { return 60 } + if hasHoursComponent { return 80 } + return 110 } private var countdownFrameWidth: CGFloat { - if hasDoubleDigitHours { return 248 } - if hasHoursComponent { return 235 } - return 205 + if hasDoubleDigitHours { return 200 } + if hasHoursComponent { return 185 } + return 160 } private var countdownFont: Font { - let baseSize: CGFloat = hasDoubleDigitHours ? 52 : 56 - return displayFont(size: baseSize) + let baseSize: CGFloat = hasDoubleDigitHours ? 42 : 46 + return .system(size: baseSize, weight: .bold, design: .rounded) } private var timerLabel: String { @@ -191,17 +191,19 @@ struct LockScreenTimerWidget: View { var body: some View { VStack(spacing: 16) { - HStack(alignment: .center, spacing: 8) { + HStack(alignment: .center, spacing: 0) { controlButtons + .padding(.trailing, 12) titleSection .frame(maxWidth: .infinity) countdownSection + .padding(.leading, 12) } } .padding(.horizontal, 12) - .padding(.vertical, 18) + .padding(.vertical, 12) .frame(width: widgetSize.width, height: widgetSize.height) .background(widgetBackground) .clipShape(RoundedRectangle(cornerRadius: Self.cornerRadius, style: .continuous)) @@ -260,18 +262,15 @@ struct LockScreenTimerWidget: View { } private var titleSection: some View { - VStack(alignment: .center, spacing: 0) { - MarqueeText( - .constant(timerLabel), - font: displayFont(size: 18), - nsFont: .title3, - textColor: accentColor, - minDuration: 0.16, - frameWidth: titleFrameWidth - ) - .frame(maxWidth: titleFrameWidth) - } - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) + MarqueeText( + .constant(timerLabel), + font: displayFont(size: 15), + nsFont: .title3, + textColor: accentColor, + minDuration: 0.16, + frameWidth: titleFrameWidth + ) + .frame(maxWidth: .infinity, alignment: .center) .layoutPriority(0) } diff --git a/DynamicIsland/components/Timer/TimerLiveActivity.swift b/DynamicIsland/components/Timer/TimerLiveActivity.swift index 88a5a51..9b740ca 100644 --- a/DynamicIsland/components/Timer/TimerLiveActivity.swift +++ b/DynamicIsland/components/Timer/TimerLiveActivity.swift @@ -207,7 +207,7 @@ struct TimerLiveActivity: View { private var shouldShowControlWindow: Bool { if !controlWindowEnabled { return false } - if shouldDisplayLabel { return false } + if showsLabel { return false } let timerEligible = timerManager.isTimerActive && !timerManager.isExternalTimerActive if !timerEligible { return false } @@ -253,9 +253,6 @@ struct TimerLiveActivity: View { } .onAppear { requestControlWindowSync(forceRefresh: true) - if timerManager.isTimerActive && !timerManager.isFinished && !timerManager.isOvertime { - triggerTransientLabel() - } } .onDisappear { hideControlWindow() @@ -528,23 +525,24 @@ struct TimerLiveActivity: View { let syncDelay = max(0, delay) - pendingControlWindowTask = Task.detached(priority: .userInitiated) { [syncDelay, forceRefresh] in - if syncDelay > 0 { - let nanoseconds = UInt64(syncDelay * 1_000_000_000) - try? await Task.sleep(nanoseconds: nanoseconds) - } + if syncDelay <= 0 { + syncControlWindow(forceRefresh: forceRefresh) + return + } + + pendingControlWindowTask = Task { @MainActor [forceRefresh] in + let nanoseconds = UInt64(syncDelay * 1_000_000_000) + try? await Task.sleep(nanoseconds: nanoseconds) guard !Task.isCancelled else { return } - await MainActor.run { - if shouldShowControlWindow { - syncControlWindow(forceRefresh: forceRefresh) - } else { - hideControlWindow() - } - - pendingControlWindowTask = nil + if shouldShowControlWindow { + syncControlWindow(forceRefresh: forceRefresh) + } else { + hideControlWindow() } + + pendingControlWindowTask = nil } } diff --git a/DynamicIsland/managers/DoNotDisturbManager.swift b/DynamicIsland/managers/DoNotDisturbManager.swift index 518f37c..6afdec3 100644 --- a/DynamicIsland/managers/DoNotDisturbManager.swift +++ b/DynamicIsland/managers/DoNotDisturbManager.swift @@ -516,12 +516,59 @@ private extension DoNotDisturbManager { if mode == .useDevTools { stopAssertionsPolling() focusLogStream.start() + checkInitialFocusStateViaLog() } else { focusLogStream.stop() startAssertionsPolling() } } + /// When using the log-stream mode, `log stream` only delivers future events — it misses any + /// focus mode that was already active when Atoll launched. This one-shot `log show` reads + /// recent duetexpertd debug logs to find the most recent `semanticModeIdentifier` event and + /// seeds the initial focus state from it. Tries progressively larger windows so the common + /// case (focus toggled recently) resolves in ~1-2s without scanning a full day of logs. + private func checkInitialFocusStateViaLog() { + metadataExtractionQueue.async { [weak self] in + guard let self else { return } + + for window in ["5m", "1h", "24h"] { + let task = Process() + task.executableURL = URL(fileURLWithPath: "/usr/bin/log") + task.arguments = [ + "show", + "--last", window, + "--debug", + "--style", "compact", + "--predicate", "process == \"duetexpertd\" AND eventMessage CONTAINS \"semanticModeIdentifier\"" + ] + let pipe = Pipe() + task.standardOutput = pipe + task.standardError = Pipe() + + guard (try? task.run()) != nil else { return } + task.waitUntilExit() + + let output = String(data: pipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? "" + let lines = output.components(separatedBy: "\n").filter { + $0.contains("semanticModeIdentifier") && !$0.hasPrefix("Filtering") + } + + guard let lastLine = lines.last(where: { !$0.isEmpty }) else { continue } + + // starting: 0 means focus ended — nothing to activate. + guard !lastLine.contains("starting: 0") else { return } + + let identifier = FocusMetadataDecoder.extractIdentifier(from: lastLine) + let name = FocusMetadataDecoder.extractName(from: lastLine) + guard identifier != nil || name != nil else { return } + + self.publishMetadata(identifier: identifier, name: name, isActive: true, source: "log-initial") + return + } + } + } + func startAssertionsPolling() { stopAssertionsPolling() lastAssertionsModificationDate = nil @@ -740,7 +787,14 @@ extension FocusModeType { return } - // 2. macOS 26.3 custom Focus modes use `com.apple.donotdisturb.mode.`. + // 2. Sleep focus is reported as `com.apple.sleep.sleep-mode` by duetexpertd, + // not as the rawValue `com.apple.focus.sleep`, so handle it explicitly here. + if normalizedLowercased == "com.apple.sleep.sleep-mode" { + self = .sleep + return + } + + // 3. macOS custom Focus modes use `com.apple.donotdisturb.mode.`. // Must be checked BEFORE the generic prefix match, otherwise the prefix // `com.apple.donotdisturb.mode` would incorrectly resolve to .doNotDisturb. if normalizedLowercased.hasPrefix("com.apple.donotdisturb.mode.") { @@ -751,7 +805,7 @@ extension FocusModeType { } } - // 3. Prefix match for known built-in modes (e.g., com.apple.focus.personal-time -> .personal). + // 4. Prefix match for known built-in modes (e.g., com.apple.focus.personal-time -> .personal). if let resolved = FocusModeType.allCases.first(where: { guard !$0.rawValue.isEmpty else { return false } return normalized.hasPrefix($0.rawValue) || normalizedLowercased.hasPrefix($0.rawValue) @@ -760,7 +814,7 @@ extension FocusModeType { return } - // 4. Anything else under com.apple.focus is custom. + // 5. Anything else under com.apple.focus is custom. if normalizedLowercased.hasPrefix("com.apple.focus") { self = .custom return @@ -1276,6 +1330,7 @@ private enum FocusNotificationParsing { "(?i)(?:focusModeName|focusMode|displayName|name)\\s*=\\s*\"([^\"]+)\"", "(?i)(?:focusModeName|focusMode|displayName|name)\\s*=\\s*([^;\\n]+)", "activityDisplayName:\\s*([^;>\\n]+)", + "semanticType:\\s*([A-Za-z][A-Za-z0-9 _-]+)", "modeIdentifier:\\s*'com\\.apple\\.focus\\.([A-Za-z0-9._-]+)'" ] return patterns.compactMap { try? NSRegularExpression(pattern: $0, options: []) } diff --git a/DynamicIsland/managers/LockScreenPanelManager.swift b/DynamicIsland/managers/LockScreenPanelManager.swift index 3dbe0c8..5c5dfa7 100644 --- a/DynamicIsland/managers/LockScreenPanelManager.swift +++ b/DynamicIsland/managers/LockScreenPanelManager.swift @@ -202,6 +202,11 @@ class LockScreenPanelManager { currentAdditionalHeight = additionalHeight } + func notifyTimerWidgetFrameChanged(animated: Bool) { + guard panelWindow?.isVisible == true || panelAnimator.isPresented else { return } + applyOffsetAdjustment(animated: animated) + } + func applyOffsetAdjustment(animated: Bool = true) { guard let screen = currentScreen() else { return } let screenFrame = screen.frame @@ -266,7 +271,14 @@ class LockScreenPanelManager { let defaultLowering: CGFloat = -28 let userOffset = CGFloat(Defaults[.lockScreenMusicVerticalOffset]) let clampedOffset = min(max(userOffset, -160), 160) - let originY = baseOriginY + defaultLowering + clampedOffset + var originY = baseOriginY + defaultLowering + clampedOffset + + if let timerFrame = LockScreenTimerWidgetPanelManager.shared.latestFrame { + let maxAllowedTop = timerFrame.minY - 12 + let maxOriginY = maxAllowedTop - collapsedSize.height + originY = min(originY, maxOriginY) + } + return NSRect(x: originX, y: originY, width: collapsedSize.width, height: collapsedSize.height) } diff --git a/DynamicIsland/managers/LockScreenTimerWidgetManager.swift b/DynamicIsland/managers/LockScreenTimerWidgetManager.swift index ff672f4..0337d89 100644 --- a/DynamicIsland/managers/LockScreenTimerWidgetManager.swift +++ b/DynamicIsland/managers/LockScreenTimerWidgetManager.swift @@ -156,6 +156,7 @@ final class LockScreenTimerWidgetPanelManager { hideTask?.cancel() hideTask = nil animator.isPresented = true + LockScreenPanelManager.shared.notifyTimerWidgetFrameChanged(animated: false) LockScreenReminderWidgetPanelManager.shared.refreshPosition(animated: true) } @@ -169,6 +170,7 @@ final class LockScreenTimerWidgetPanelManager { window.orderOut(nil) hideTask = nil latestFrame = nil + LockScreenPanelManager.shared.notifyTimerWidgetFrameChanged(animated: true) LockScreenReminderWidgetPanelManager.shared.refreshPosition(animated: true) return } @@ -179,6 +181,7 @@ final class LockScreenTimerWidgetPanelManager { window?.orderOut(nil) self?.hideTask = nil self?.latestFrame = nil + LockScreenPanelManager.shared.notifyTimerWidgetFrameChanged(animated: true) LockScreenReminderWidgetPanelManager.shared.refreshPosition(animated: true) } } @@ -264,19 +267,11 @@ final class LockScreenTimerWidgetPanelManager { let size = LockScreenTimerWidget.preferredSize let originX = screen.frame.midX - (size.width / 2) let defaultLowering: CGFloat = -18 - var baseY = screen.frame.midY + 24 + defaultLowering - - if let musicFrame = LockScreenPanelManager.shared.latestFrame { - baseY = musicFrame.maxY + 28 + defaultLowering - } + let baseY = screen.frame.midY + 24 + defaultLowering let offset = CGFloat(clampedTimerOffset()) var originY = baseY + offset - if let musicFrame = LockScreenPanelManager.shared.latestFrame { - originY = max(originY, musicFrame.maxY + 12) - } - if let weatherFrame = LockScreenWeatherPanelManager.shared.latestFrame { originY = min(originY, weatherFrame.minY - size.height - 20) } else { diff --git a/DynamicIsland/managers/LockScreenWidgetPreviewManager.swift b/DynamicIsland/managers/LockScreenWidgetPreviewManager.swift index f5fc25d..9ac2c2a 100644 --- a/DynamicIsland/managers/LockScreenWidgetPreviewManager.swift +++ b/DynamicIsland/managers/LockScreenWidgetPreviewManager.swift @@ -444,7 +444,14 @@ private struct LockScreenPreviewScene: View { let defaultLowering: CGFloat = -28 let userOffset = CGFloat(Defaults[.lockScreenMusicVerticalOffset]) let clampedOffset = min(max(userOffset, -160), 160) - let originY = baseOriginY + defaultLowering + clampedOffset + var originY = baseOriginY + defaultLowering + clampedOffset + + if showsTimer { + let maxAllowedTop = timerFrame.minY - 12 + let maxOriginY = maxAllowedTop - size.height + originY = min(originY, maxOriginY) + } + return CGRect(x: originX, y: originY, width: size.width, height: size.height) } @@ -453,19 +460,11 @@ private struct LockScreenPreviewScene: View { let screenFrame = CGRect(origin: .zero, size: screenSize) let originX = screenFrame.midX - (size.width / 2) let defaultLowering: CGFloat = -18 - var baseY = screenFrame.midY + 24 + defaultLowering - - if showsMediaPanel { - baseY = mediaFrame.maxY + 28 + defaultLowering - } + let baseY = screenFrame.midY + 24 + defaultLowering let offset = CGFloat(min(max(Defaults[.lockScreenTimerVerticalOffset], -160), 160)) var originY = baseY + offset - if showsMediaPanel { - originY = max(originY, mediaFrame.maxY + 12) - } - if showsWeather { originY = min(originY, weatherFrame.minY - size.height - 20) } else {