From 91dc22acf4233fbc6feda8c3f994bfc632688b6f Mon Sep 17 00:00:00 2001 From: Hariharan Mudaliar Date: Sat, 15 Nov 2025 21:58:44 +0530 Subject: [PATCH] Able to detectgit add . --- DynamicIsland/DynamicIslandApp.swift | 1 + DynamicIsland/Localizable.xcstrings | 19 +- .../components/Notch/NotchTimerView.swift | 120 +-- .../components/Settings/SettingsView.swift | 5 +- .../Timer/TimerControlOverlay.swift | 7 + .../components/Timer/TimerPopover.swift | 7 + .../managers/SystemTimerBridge.swift | 938 ++++++++++++++++++ DynamicIsland/managers/TimerManager.swift | 110 +- DynamicIsland/models/Constants.swift | 1 + 9 files changed, 1144 insertions(+), 64 deletions(-) create mode 100644 DynamicIsland/managers/SystemTimerBridge.swift diff --git a/DynamicIsland/DynamicIslandApp.swift b/DynamicIsland/DynamicIslandApp.swift index c259832..1e08434 100644 --- a/DynamicIsland/DynamicIslandApp.swift +++ b/DynamicIsland/DynamicIslandApp.swift @@ -100,6 +100,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { let bluetoothAudioManager = BluetoothAudioManager.shared // NEW: Bluetooth audio detection let idleAnimationManager = IdleAnimationManager.shared // NEW: Custom idle animations let lockScreenPanelManager = LockScreenPanelManager.shared // NEW: Lock screen music panel + let systemTimerBridge = SystemTimerBridge.shared var closeNotchWorkItem: DispatchWorkItem? private var previousScreens: [NSScreen]? private var onboardingWindowController: NSWindowController? diff --git a/DynamicIsland/Localizable.xcstrings b/DynamicIsland/Localizable.xcstrings index c7de981..90670fb 100644 --- a/DynamicIsland/Localizable.xcstrings +++ b/DynamicIsland/Localizable.xcstrings @@ -5603,9 +5603,8 @@ } } }, - "Control timer availability and whether progress appears as a live activity while the notch is closed." : { - "comment" : "A footer description for the timer feature settings.", - "isCommentAutoGenerated" : true + "Control timer availability, live activity behaviour, and whether the app mirrors timers started from the macOS Clock app." : { + }, "Controls" : { "localizations" : { @@ -9319,6 +9318,10 @@ } } }, + "Hide from Notch" : { + "comment" : "A button label that hides the Clock timer from the Notch app.", + "isCommentAutoGenerated" : true + }, "Hide only when NowPlaying app is in fullscreen" : { "localizations" : { "ar" : { @@ -11001,6 +11004,9 @@ } } } + }, + "Manage the Clock timer from the Clock app." : { + }, "Match menubar height" : { "localizations" : { @@ -14887,6 +14893,10 @@ }, "Quick Presets" : { + }, + "Quick Start" : { + "comment" : "A label displayed above the quick start options in the timer tab.", + "isCommentAutoGenerated" : true }, "Quit" : { "localizations" : { @@ -17103,6 +17113,9 @@ } } } + }, + "Shows the system Clock timer in the notch when available. Requires Accessibility permission to read the status item." : { + }, "Size & Scale" : { "localizations" : { diff --git a/DynamicIsland/components/Notch/NotchTimerView.swift b/DynamicIsland/components/Notch/NotchTimerView.swift index 2650198..e17d028 100644 --- a/DynamicIsland/components/Notch/NotchTimerView.swift +++ b/DynamicIsland/components/Notch/NotchTimerView.swift @@ -96,21 +96,20 @@ struct NotchTimerView: View { } private var controlsSection: some View { - VStack(spacing: 20) { - if timerManager.isTimerActive { - // Active timer controls - activeTimerControls - } else { - // Quick timer setup + VStack(alignment: .leading, spacing: 20) { + if timerManager.allowsManualInteraction { + manualControlButtons quickTimerControls + } else { + externalTimerNotice } } + .frame(maxWidth: .infinity, alignment: .leading) } - - private var activeTimerControls: some View { - VStack(spacing: 16) { + + private var manualControlButtons: some View { + VStack(alignment: .leading, spacing: 16) { if timerManager.isOvertime { - // Overtime controls - only show stop button Button(action: { timerManager.forceStopTimer() }) { @@ -128,17 +127,9 @@ struct NotchTimerView: View { .clipShape(RoundedRectangle(cornerRadius: 12)) } .buttonStyle(PlainButtonStyle()) - .onHover { isHovering in - if isHovering { - NSCursor.pointingHand.push() - } else { - NSCursor.pop() - } - } + .onHover(perform: cursorHover) } else { - // Normal timer controls HStack(spacing: 16) { - // Pause/Resume button Button(action: { if timerManager.isPaused { timerManager.resumeTimer() @@ -159,15 +150,8 @@ struct NotchTimerView: View { .clipShape(RoundedRectangle(cornerRadius: 12)) } .buttonStyle(PlainButtonStyle()) - .onHover { isHovering in - if isHovering { - NSCursor.pointingHand.push() - } else { - NSCursor.pop() - } - } - - // Stop button + .onHover(perform: cursorHover) + Button(action: { timerManager.stopTimer() }) { @@ -180,34 +164,30 @@ struct NotchTimerView: View { .foregroundStyle(.white) .frame(height: 40) .frame(minWidth: 100) - .background(.white.opacity(0.15)) + .background(.white.opacity(0.1)) .clipShape(RoundedRectangle(cornerRadius: 12)) } .buttonStyle(PlainButtonStyle()) - .onHover { isHovering in - if isHovering { - NSCursor.pointingHand.push() - } else { - NSCursor.pop() - } - } + .onHover(perform: cursorHover) } } } } - + private var quickTimerControls: some View { - VStack(spacing: 16) { - // Quick timer grid + VStack(alignment: .leading, spacing: 16) { + Text("Quick Start") + .font(.system(size: 14, weight: .medium)) + .foregroundStyle(.secondary) + VStack(spacing: 12) { HStack(spacing: 12) { - quickTimerButton(minutes: 1, title: "1 min") - quickTimerButton(minutes: 5, title: "5 min") - quickTimerButton(minutes: 15, title: "15 min") + quickTimerButton(minutes: 1) + quickTimerButton(minutes: 5) + quickTimerButton(minutes: 15) } - + HStack(spacing: 12) { - // Custom timer button Button(action: { timerManager.startTimer(duration: customTimerDuration, name: "Custom Timer") }) { @@ -229,17 +209,37 @@ struct NotchTimerView: View { ) } .buttonStyle(PlainButtonStyle()) - .onHover { isHovering in - if isHovering { - NSCursor.pointingHand.push() - } else { - NSCursor.pop() - } - } + .onHover(perform: cursorHover) } } } + } + + private var externalTimerNotice: some View { + VStack(alignment: .leading, spacing: 12) { + Text("Manage the Clock timer from the Clock app.") + .font(.system(size: 14, weight: .medium)) + .foregroundStyle(.secondary) + + Button(action: { + timerManager.endExternalTimer(triggerSmoothClose: false) + }) { + HStack(spacing: 8) { + Image(systemName: "eye.slash") + .font(.system(size: 16, weight: .medium)) + Text("Hide from Notch") + .font(.system(size: 16, weight: .medium)) + } + .foregroundStyle(.white) + .frame(height: 40) + .frame(minWidth: 140) + .background(.white.opacity(0.12)) + .clipShape(RoundedRectangle(cornerRadius: 12)) + } + .buttonStyle(PlainButtonStyle()) + .onHover(perform: cursorHover) } + } private var customTimerDisplayText: String { let totalMinutes = Int(customTimerDuration) / 60 @@ -256,7 +256,7 @@ struct NotchTimerView: View { } } - private func quickTimerButton(minutes: Int, title: String) -> some View { + private func quickTimerButton(minutes: Int) -> some View { Button(action: { timerManager.startTimer(duration: TimeInterval(minutes * 60), name: "\(minutes) Min Timer") }) { @@ -277,13 +277,7 @@ struct NotchTimerView: View { ) } .buttonStyle(PlainButtonStyle()) - .onHover { isHovering in - if isHovering { - NSCursor.pointingHand.push() - } else { - NSCursor.pop() - } - } + .onHover(perform: cursorHover) } } @@ -292,6 +286,14 @@ private extension NotchTimerView { guard timerManager.isTimerActive && timerManager.isRunning else { return .white.opacity(0.9) } return timerManager.currentColor } + + private func cursorHover(_ isHovering: Bool) { + if isHovering { + NSCursor.pointingHand.push() + } else { + NSCursor.pop() + } + } } #Preview { diff --git a/DynamicIsland/components/Settings/SettingsView.swift b/DynamicIsland/components/Settings/SettingsView.swift index bda6dff..b2735e4 100644 --- a/DynamicIsland/components/Settings/SettingsView.swift +++ b/DynamicIsland/components/Settings/SettingsView.swift @@ -1843,6 +1843,7 @@ struct TimerSettings: View { @Default(.timerShowsProgress) private var showsProgress @Default(.timerProgressStyle) private var progressStyle @Default(.timerControlWindowEnabled) private var controlWindowEnabled + @Default(.mirrorSystemTimer) private var mirrorSystemTimer @AppStorage("customTimerDuration") private var customTimerDuration: Double = 600 @State private var customHours: Int = 0 @State private var customMinutes: Int = 10 @@ -1857,11 +1858,13 @@ struct TimerSettings: View { if enableTimerFeature { Toggle("Enable timer live activity", isOn: $coordinator.timerLiveActivityEnabled) .animation(.easeInOut, value: coordinator.timerLiveActivityEnabled) + Defaults.Toggle("Mirror macOS Clock timers", key: .mirrorSystemTimer) + .help("Shows the system Clock timer in the notch when available. Requires Accessibility permission to read the status item.") } } header: { Text("Timer Feature") } footer: { - Text("Control timer availability and whether progress appears as a live activity while the notch is closed.") + Text("Control timer availability, live activity behaviour, and whether the app mirrors timers started from the macOS Clock app.") } if enableTimerFeature { diff --git a/DynamicIsland/components/Timer/TimerControlOverlay.swift b/DynamicIsland/components/Timer/TimerControlOverlay.swift index 86453e6..173648f 100644 --- a/DynamicIsland/components/Timer/TimerControlOverlay.swift +++ b/DynamicIsland/components/Timer/TimerControlOverlay.swift @@ -41,6 +41,7 @@ struct TimerControlOverlay: View { .buttonStyle(.plain) .contentShape(RoundedRectangle(cornerRadius: 10, style: .continuous)) .help(helpText) + .disabled(!timerManager.allowsManualInteraction) Button(action: stopTimer) { Image(systemName: "stop.fill") @@ -51,6 +52,7 @@ struct TimerControlOverlay: View { .buttonStyle(.plain) .contentShape(RoundedRectangle(cornerRadius: 10, style: .continuous)) .help("Stop") + .disabled(!timerManager.allowsManualInteraction) } .padding(.horizontal, 12) .frame(height: notchHeight) @@ -66,6 +68,7 @@ struct TimerControlOverlay: View { } private func togglePause() { + guard timerManager.allowsManualInteraction else { return } if timerManager.isPaused { timerManager.resumeTimer() } else { @@ -74,6 +77,10 @@ struct TimerControlOverlay: View { } private func stopTimer() { + guard timerManager.allowsManualInteraction else { + timerManager.endExternalTimer(triggerSmoothClose: false) + return + } #if os(macOS) TimerControlWindowManager.shared.hide(animated: true) #endif diff --git a/DynamicIsland/components/Timer/TimerPopover.swift b/DynamicIsland/components/Timer/TimerPopover.swift index c5dcbfc..d6930ea 100644 --- a/DynamicIsland/components/Timer/TimerPopover.swift +++ b/DynamicIsland/components/Timer/TimerPopover.swift @@ -158,6 +158,7 @@ private struct ActiveTimerSection: View { .labelStyle(.titleAndIcon) } .buttonStyle(.bordered) + .disabled(!timerManager.allowsManualInteraction) Button(role: .destructive, action: stopTimer) { Label("Stop", systemImage: "stop.fill") @@ -165,6 +166,7 @@ private struct ActiveTimerSection: View { .labelStyle(.titleAndIcon) } .buttonStyle(.borderedProminent) + .disabled(!timerManager.allowsManualInteraction) } } .frame(maxWidth: .infinity, alignment: .leading) @@ -175,6 +177,7 @@ private struct ActiveTimerSection: View { } private func togglePause() { + guard timerManager.allowsManualInteraction else { return } withAnimation(.smooth) { if timerManager.isPaused { timerManager.resumeTimer() @@ -185,6 +188,10 @@ private struct ActiveTimerSection: View { } private func stopTimer() { + guard timerManager.allowsManualInteraction else { + timerManager.endExternalTimer(triggerSmoothClose: false) + return + } withAnimation(.smooth) { timerManager.stopTimer() } diff --git a/DynamicIsland/managers/SystemTimerBridge.swift b/DynamicIsland/managers/SystemTimerBridge.swift new file mode 100644 index 0000000..f2077c3 --- /dev/null +++ b/DynamicIsland/managers/SystemTimerBridge.swift @@ -0,0 +1,938 @@ +// +// SystemTimerBridge.swift +// DynamicIsland +// +// Mirrors the macOS Clock timer into the custom timer live activity. +// + +import AppKit +import ApplicationServices +import Combine +import Defaults +import Foundation +import OSLog +import os + +final class SystemTimerBridge { + static let shared = SystemTimerBridge() + + private let logger = os.Logger(subsystem: "com.Ebullioscopic.Atoll", category: "SystemTimerBridge") + + private struct TimerMetadata: Equatable { + enum State: Int { + case stopped = 1 + case running = 2 + case paused = 3 + case fired = 4 + case unknown = 0 + + var isActive: Bool { + switch self { + case .running, .paused: + return true + default: + return false + } + } + } + + let identifier: String + let title: String + let duration: TimeInterval + let lastModified: Date? + let firedDate: Date? + let state: State + } + + private struct ParsedTimerString { + let raw: String + let remaining: TimeInterval + let paused: Bool + } + + private let domain = "com.apple.mobiletimerd" as CFString + private let preferencesPath = (NSHomeDirectory() as NSString) + .appendingPathComponent("Library/Preferences/com.apple.mobiletimerd.plist") + private let queue = DispatchQueue(label: "com.dynamicisland.systemtimer.bridge", qos: .userInitiated) + + private var metadata: TimerMetadata? + private var initialTotalDuration: TimeInterval? + private var latestRemaining: TimeInterval? + private var latestPaused: Bool = false + + private var logProcess: Process? + private var logPipe: Pipe? + private var logBuffer = Data() + private var logRestartWorkItem: DispatchWorkItem? + private var logPreferredName: String? + private var logPreferredDuration: TimeInterval? + private var logIdentifier: String? + private var logDidCompleteActiveTimer = false + private var nonJSONLogLineCount = 0 + + private var menuExtra: AXUIElement? + private var fileDescriptor: CInt = -1 + private var fileMonitor: DispatchSourceFileSystemObject? + private var ticker: DispatchSourceTimer? + private var defaultsCancellable: AnyCancellable? + + private var didWarnAboutAccessibility = false + + private init() { + logDebug("Initializing SystemTimerBridge (mirror enabled: \(Defaults[.mirrorSystemTimer]))") + defaultsCancellable = Defaults.publisher(.mirrorSystemTimer, options: []) + .sink { [weak self] change in + if change.newValue { + self?.logDebug("mirrorSystemTimer enabled via Defaults change") + self?.startIfNeeded() + } else { + self?.logDebug("mirrorSystemTimer disabled via Defaults change; stopping monitor") + self?.stopMonitoring(clearTimer: true) + } + } + + if Defaults[.mirrorSystemTimer] { + startIfNeeded() + } + } + + private func logDebug(_ message: String) { + logger.debug("\(message, privacy: OSLogPrivacy.public)") + } + + private func logInfo(_ message: String) { + logger.info("\(message, privacy: OSLogPrivacy.public)") + } + + private func logError(_ message: String) { + logger.error("\(message, privacy: OSLogPrivacy.public)") + } + + private func startIfNeeded() { + queue.async { [weak self] in + guard let self else { return } + guard !self.isMonitoring else { return } + self.logInfo("Starting monitoring pipeline") + + self.refreshMetadata() + self.startFileMonitor() + + let logStarted = self.startLogStream() + let hasAccessibility = AXIsProcessTrusted() + + self.logDebug("Log stream started: \(logStarted), AX trusted: \(hasAccessibility)") + + if !logStarted { + if hasAccessibility { + self.logInfo("Falling back to Accessibility polling ticker") + self.setupTicker() + } else { + self.logError("Unable to start log stream and missing Accessibility permission; timer mirroring inactive") + self.postAccessibilityWarningIfNeeded() + } + } else if !hasAccessibility { + self.logDebug("Log stream active but Accessibility not trusted; AX fallback disabled") + self.postAccessibilityWarningIfNeeded() + } + } + } + + private var isMonitoring: Bool { + logProcess != nil || ticker != nil || fileMonitor != nil + } + + private func stopMonitoring(clearTimer: Bool) { + queue.async { [weak self] in + guard let self else { return } + self.logInfo("Stopping monitoring pipeline (clearTimer: \(clearTimer))") + + self.stopLogStream() + + self.ticker?.cancel() + self.ticker = nil + + self.fileMonitor?.cancel() + self.fileMonitor = nil + + if self.fileDescriptor != -1 { + close(self.fileDescriptor) + self.fileDescriptor = -1 + } + + self.menuExtra = nil + self.initialTotalDuration = nil + self.metadata = nil + self.latestRemaining = nil + self.latestPaused = false + self.logPreferredName = nil + self.logPreferredDuration = nil + self.logIdentifier = nil + self.logDidCompleteActiveTimer = false + self.logRestartWorkItem?.cancel() + self.logRestartWorkItem = nil + + if clearTimer { + self.logDebug("Clearing external timer state via TimerManager") + DispatchQueue.main.async { + TimerManager.shared.endExternalTimer(triggerSmoothClose: false) + } + } + } + } + + private func setupTicker() { + let timer = DispatchSource.makeTimerSource(queue: queue) + timer.schedule(deadline: .now(), repeating: .seconds(1), leeway: .milliseconds(150)) + timer.setEventHandler { [weak self] in + self?.pollMenuExtra() + } + timer.resume() + ticker = timer + logInfo("Accessibility ticker active") + } + + @discardableResult + private func startLogStream() -> Bool { + if let process = logProcess, process.isRunning { + return true + } + + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/log") + let predicate = "subsystem == \"com.apple.mobiletimer.logging\"" + process.arguments = [ + "stream", + "--style", + "ndjson", + "--predicate", + predicate, + "--level", + "debug" + ] + + let outputPipe = Pipe() + process.standardOutput = outputPipe + process.standardError = Pipe() + + logBuffer.removeAll(keepingCapacity: false) + nonJSONLogLineCount = 0 + logPipe = outputPipe + + outputPipe.fileHandleForReading.readabilityHandler = { [weak self] handle in + let data = handle.availableData + guard !data.isEmpty else { return } + self?.queue.async { [weak self] in + self?.consumeLogData(data) + } + } + + process.terminationHandler = { [weak self] _ in + self?.queue.async { [weak self] in + self?.handleLogStreamTermination() + } + } + + do { + try process.run() + logProcess = process + logInfo("Log stream started (pid: \(process.processIdentifier))") + return true + } catch { + logError("Failed to start log stream: \(error.localizedDescription)") + outputPipe.fileHandleForReading.readabilityHandler = nil + outputPipe.fileHandleForReading.closeFile() + logPipe = nil + return false + } + } + + private func stopLogStream() { + logRestartWorkItem?.cancel() + logRestartWorkItem = nil + + logPipe?.fileHandleForReading.readabilityHandler = nil + logPipe?.fileHandleForReading.closeFile() + logPipe = nil + + if let process = logProcess { + process.terminationHandler = nil + if process.isRunning { + process.terminate() + process.waitUntilExit() + logInfo("Log stream terminated (pid: \(process.processIdentifier))") + } + } + + logProcess = nil + logBuffer.removeAll(keepingCapacity: false) + } + + private func handleLogStreamTermination() { + logPipe?.fileHandleForReading.readabilityHandler = nil + logPipe?.fileHandleForReading.closeFile() + logPipe = nil + logProcess = nil + logBuffer.removeAll(keepingCapacity: false) + logError("Log stream exited unexpectedly; scheduling restart") + + guard Defaults[.mirrorSystemTimer] else { return } + + if AXIsProcessTrusted(), ticker == nil { + setupTicker() + } + + scheduleLogStreamRestart() + } + + private func scheduleLogStreamRestart() { + logRestartWorkItem?.cancel() + + let workItem = DispatchWorkItem { [weak self] in + guard let self else { return } + self.logRestartWorkItem = nil + let started = self.startLogStream() + if !started, AXIsProcessTrusted(), self.ticker == nil { + self.setupTicker() + } + } + + logRestartWorkItem = workItem + queue.asyncAfter(deadline: .now() + 2.0, execute: workItem) + logDebug("Scheduled log stream restart in 2s") + } + + private func consumeLogData(_ data: Data) { + logBuffer.append(data) + + while let newlineIndex = logBuffer.firstIndex(of: UInt8(10)) { + let lineData = Data(logBuffer[.. 0, (initialTotalDuration ?? 0) < durationFromMetadata { + initialTotalDuration = durationFromMetadata + } + } + + if initialTotalDuration == nil { + initialTotalDuration = max(durationFromMetadata, remaining) + } else if remaining > (initialTotalDuration ?? 0) { + initialTotalDuration = remaining + } + + let baseTotal = max(durationFromMetadata, remaining > 0 ? remaining : 0) + let total = initialTotalDuration ?? baseTotal + + let trimmedLogName = logPreferredName?.trimmingCharacters(in: .whitespacesAndNewlines) + let trimmedMetadataName = metadata?.title.trimmingCharacters(in: .whitespacesAndNewlines) + let displayName: String + + if let name = trimmedLogName, !name.isEmpty { + displayName = name + } else if let name = trimmedMetadataName, !name.isEmpty { + displayName = name + } else { + displayName = "Clock Timer" + } + + DispatchQueue.main.async { [weak self] in + guard let self else { return } + if TimerManager.shared.isExternalTimerActive { + self.logDebug("Updating existing external timer (total: \(total))") + TimerManager.shared.updateExternalTimer( + remaining: remaining, + totalDuration: total, + isPaused: paused, + name: displayName + ) + } else { + self.logInfo("Adopting system timer as external source (name: \(displayName))") + TimerManager.shared.adoptExternalTimer( + name: displayName, + totalDuration: total, + remaining: remaining, + isPaused: paused + ) + } + + if remaining <= 0 { + self.logDidCompleteActiveTimer = true + self.logInfo("System timer reported completion") + TimerManager.shared.completeExternalTimer() + } else { + self.logDidCompleteActiveTimer = false + } + } + } + + private func handleMissingMenuExtra() { + guard logIdentifier == nil else { return } + guard TimerManager.shared.isExternalTimerActive else { return } + + logDebug("Timer menu extra missing; ending external timer") + DispatchQueue.main.async { + TimerManager.shared.endExternalTimer(triggerSmoothClose: true) + } + } + + private func handleLogEvent(_ payload: [String: Any]) { + guard Defaults[.mirrorSystemTimer] else { return } + guard !TimerManager.shared.hasManualTimerRunning else { + logDebug("Ignoring log event while manual timer active") + return + } + guard let message = payload["eventMessage"] as? String, !message.isEmpty else { return } + + logDebug("Log event: \(message)") + + if message.contains("scheduled timers:") { + handleScheduledTimersMessage(message) + } + + if message.contains("Timer will fire") { + handleTimerWillFireMessage(message) + } + + if message.contains("remainingTime:") { + handleRemainingTimeMessage(message) + } + + if message.contains("next timer changed:") { + handleNextTimerChangedMessage(message) + } + + if message.contains("started timer:") { + handleTimerStartedMessage(message) + } + } + + private func handleScheduledTimersMessage(_ message: String) { + guard let identifier = captureFirstMatch(pattern: "TimerID:\\s*([A-F0-9\\-]+)", in: message) else { + logDebug("Scheduled timers message without identifier; clearing state") + clearLogState(triggerSmoothClose: true) + return + } + + logDebug("Scheduled timers message for ID: \(identifier)") + setLogIdentifier(identifier) + + if let title = captureFirstMatch(pattern: "Title:\\s*([^,]+)", in: message) { + let trimmed = title.trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmed.isEmpty, trimmed != "(null)" { + logPreferredName = trimmed + logDebug("Updated timer name from log: \(trimmed)") + } + } + + if let durationString = captureFirstMatch(pattern: "duration:([0-9]+(?:\\.[0-9]+)?)", in: message), + let duration = Double(durationString) { + if let existing = logPreferredDuration { + logPreferredDuration = max(existing, duration) + } else { + logPreferredDuration = duration + } + + if (initialTotalDuration ?? 0) < duration { + initialTotalDuration = duration + } + + logDebug("Recorded duration from log: \(duration)") + } + + if let stateString = captureFirstMatch(pattern: "state:([A-Za-z]+)", in: message) { + let normalized = stateString.lowercased() + logDebug("Timer state from log: \(normalized)") + switch normalized { + case "paused": + latestPaused = true + if let remaining = latestRemaining { + applyLogDrivenUpdate(remaining: remaining, isPaused: true) + } + case "running": + latestPaused = false + if let remaining = latestRemaining { + applyLogDrivenUpdate(remaining: remaining, isPaused: false) + } + case "fired": + latestPaused = false + applyLogDrivenUpdate(remaining: 0, isPaused: false) + return + case "stopped": + latestPaused = false + clearLogState(triggerSmoothClose: true) + return + default: + break + } + } + } + + private func handleTimerWillFireMessage(_ message: String) { + guard logIdentifier != nil else { return } + guard let minutesString = captureFirstMatch(pattern: "Timer will fire\\s+([0-9.]+)\\s+minutes?", in: message), + let minutes = Double(minutesString) else { return } + + let seconds = minutes * 60 + logDebug("Timer will fire in \(seconds) seconds") + applyLogDrivenUpdate(remaining: seconds, isPaused: latestPaused) + } + + private func handleRemainingTimeMessage(_ message: String) { + guard let valueString = captureFirstMatch(pattern: "remainingTime:\\s*([-0-9.]+)", in: message), + let remaining = Double(valueString) else { return } + + logDebug("Remaining time from log: \(remaining)") + applyLogDrivenUpdate(remaining: remaining, isPaused: latestPaused) + } + + private func handleNextTimerChangedMessage(_ message: String) { + guard let token = captureFirstMatch(pattern: "next timer changed:\\s*([^\n]+)", in: message) else { return } + let trimmed = token.trimmingCharacters(in: CharacterSet(charactersIn: " <>")) + if trimmed.isEmpty { return } + + logDebug("Next timer changed token: \(trimmed)") + + if trimmed.lowercased().contains("null") { + clearLogState(triggerSmoothClose: true) + } else { + setLogIdentifier(trimmed) + } + } + + private func handleTimerStartedMessage(_ message: String) { + guard let identifier = captureFirstMatch(pattern: "started timer:\\s*([A-F0-9\\-]+)", in: message) else { return } + logDebug("Timer started for ID: \(identifier)") + setLogIdentifier(identifier) + } + + private func applyLogDrivenUpdate(remaining: TimeInterval, isPaused: Bool, nameOverride: String? = nil, durationOverride: TimeInterval? = nil) { + guard Defaults[.mirrorSystemTimer] else { return } + guard !TimerManager.shared.hasManualTimerRunning else { return } + guard logIdentifier != nil else { return } + + if let nameOverride, !nameOverride.isEmpty { + logPreferredName = nameOverride + logDebug("Applying name override: \(nameOverride)") + } + + if let durationOverride { + if let existing = logPreferredDuration { + logPreferredDuration = max(existing, durationOverride) + } else { + logPreferredDuration = durationOverride + } + logDebug("Applying duration override: \(durationOverride)") + } + + latestPaused = isPaused + latestRemaining = remaining + logDebug("Log-driven update (remaining: \(remaining), paused: \(isPaused))") + + if let duration = logPreferredDuration, (initialTotalDuration ?? 0) < duration { + initialTotalDuration = duration + } + + if remaining > (initialTotalDuration ?? 0) { + initialTotalDuration = remaining + } + + applyTimerUpdate(remaining: remaining, paused: isPaused) + } + + private func setLogIdentifier(_ identifier: String) { + let cleaned = identifier.trimmingCharacters(in: CharacterSet(charactersIn: " <>")) + guard !cleaned.isEmpty else { return } + + if logIdentifier != cleaned { + logInfo("Tracking system timer ID: \(cleaned)") + logIdentifier = cleaned + logDidCompleteActiveTimer = false + initialTotalDuration = nil + latestRemaining = nil + logPreferredDuration = nil + logPreferredName = nil + } + } + + private func clearLogState(triggerSmoothClose: Bool) { + logInfo("Clearing log-derived timer state (smooth close: \(triggerSmoothClose))") + let didComplete = logDidCompleteActiveTimer + + logIdentifier = nil + logPreferredName = nil + logPreferredDuration = nil + initialTotalDuration = nil + latestRemaining = nil + latestPaused = false + logDidCompleteActiveTimer = false + + guard triggerSmoothClose else { return } + + DispatchQueue.main.async { + guard TimerManager.shared.isExternalTimerActive else { return } + if didComplete { + self.logDebug("Timer finished; scheduling delayed cleanup") + DispatchQueue.main.asyncAfter(deadline: .now() + 3.2) { + if TimerManager.shared.isExternalTimerActive { + self.logDebug("Delayed cleanup firing; ending external timer") + TimerManager.shared.endExternalTimer(triggerSmoothClose: false) + } + } + } else { + self.logDebug("Ending external timer immediately") + TimerManager.shared.endExternalTimer(triggerSmoothClose: true) + } + } + } + + private func captureFirstMatch(pattern: String, in text: String) -> String? { + guard let regex = try? NSRegularExpression(pattern: pattern, options: []) else { return nil } + let range = NSRange(text.startIndex..= 2, + let swiftRange = Range(match.range(at: 1), in: text) else { + return nil + } + + return String(text[swiftRange]) + } + + private func refreshMetadata() { + let previous = metadata + metadata = fetchMetadata() + + if metadata == nil { + initialTotalDuration = nil + logDebug("No active metadata found in preferences") + } + + guard let metadata, metadata != previous else { return } + + logDebug("Metadata updated from preferences (title: \(metadata.title), duration: \(metadata.duration))") + + if metadata.duration > 0, (initialTotalDuration ?? 0) < metadata.duration { + initialTotalDuration = metadata.duration + } + + if TimerManager.shared.isExternalTimerActive { + let remaining = latestRemaining ?? metadata.duration + DispatchQueue.main.async { + self.logDebug("Syncing metadata update with TimerManager (remaining: \(remaining))") + TimerManager.shared.updateExternalTimer( + remaining: remaining, + totalDuration: metadata.duration, + isPaused: self.latestPaused, + name: metadata.title + ) + } + } + } + + private func fetchMetadata() -> TimerMetadata? { + CFPreferencesAppSynchronize(domain) + guard let container = CFPreferencesCopyAppValue("MTTimers" as CFString, domain) as? [String: Any], + let rawTimers = container["MTTimers"] as? [[String: Any]] else { + return nil + } + + let records: [TimerMetadata] = rawTimers.compactMap { entry in + guard let timer = entry["$MTTimer"] as? [String: Any] else { return nil } + let identifier = timer["MTTimerID"] as? String ?? UUID().uuidString + let rawTitle = (timer["MTTimerTitle"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) + let title = (rawTitle?.isEmpty == false) ? rawTitle! : "Clock Timer" + let duration = timer["MTTimerDuration"] as? TimeInterval ?? 0 + let lastModified = timer["MTTimerLastModifiedDate"] as? Date + let firedDate = timer["MTTimerFiredDate"] as? Date + let stateRaw = timer["MTTimerState"] as? Int ?? 0 + let state = TimerMetadata.State(rawValue: stateRaw) ?? .unknown + + return TimerMetadata( + identifier: identifier, + title: title, + duration: duration, + lastModified: lastModified, + firedDate: firedDate, + state: state + ) + } + + if let active = records.first(where: { $0.state.isActive }) { + logDebug("Found active timer in preferences (state: \(active.state.rawValue))") + return active + } + + return records.sorted { (lhs, rhs) -> Bool in + (lhs.lastModified ?? .distantPast) > (rhs.lastModified ?? .distantPast) + }.first + } + + private func locateTimerMenuExtra() -> AXUIElement? { + let systemWide = AXUIElementCreateSystemWide() + guard let menuBar: AXUIElement = copyAttribute(kAXMenuBarAttribute as CFString, from: systemWide) else { + return nil + } + + guard let items: [AXUIElement] = copyAttribute(kAXChildrenAttribute as CFString, from: menuBar) else { + return nil + } + + for item in items { + if isTimerMenuExtra(item) { + return item + } + } + + return nil + } + + private func isTimerMenuExtra(_ element: AXUIElement) -> Bool { + if let identifier: String = copyAttribute(kAXIdentifierAttribute as CFString, from: element), + identifier.lowercased().contains("timer") { + return true + } + + if let title: String = copyAttribute(kAXTitleAttribute as CFString, from: element), + matchesTimerText(title) { + return true + } + + if let value: String = copyAttribute(kAXValueAttribute as CFString, from: element), + matchesTimerText(value) { + return true + } + + if let children: [AXUIElement] = copyAttribute(kAXChildrenAttribute as CFString, from: element) { + for child in children { + if let value: String = copyAttribute(kAXValueAttribute as CFString, from: child), matchesTimerText(value) { + return true + } + if let title: String = copyAttribute(kAXTitleAttribute as CFString, from: child), matchesTimerText(title) { + return true + } + } + } + + return false + } + + private func extractTimerString(from element: AXUIElement) -> ParsedTimerString? { + if let value: String = copyAttribute(kAXValueAttribute as CFString, from: element), + let parsed = parseTimerString(value) { + return parsed + } + + if let title: String = copyAttribute(kAXTitleAttribute as CFString, from: element), + let parsed = parseTimerString(title) { + return parsed + } + + if let children: [AXUIElement] = copyAttribute(kAXChildrenAttribute as CFString, from: element) { + for child in children { + if let value: String = copyAttribute(kAXValueAttribute as CFString, from: child), + let parsed = parseTimerString(value) { + return parsed + } + if let title: String = copyAttribute(kAXTitleAttribute as CFString, from: child), + let parsed = parseTimerString(title) { + return parsed + } + } + } + + return nil + } + + private func parseTimerString(_ raw: String) -> ParsedTimerString? { + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + + let lower = trimmed.lowercased() + let paused = lower.contains("pause") || lower.contains("stopped") + + if let seconds = extractSeconds(from: trimmed) { + return ParsedTimerString(raw: trimmed, remaining: seconds, paused: paused) + } + + return nil + } + + private func extractSeconds(from text: String) -> TimeInterval? { + let numericPattern = "[0-9]+(?::[0-9]{2}){0,2}" + if let match = text.range(of: numericPattern, options: .regularExpression) { + let token = String(text[match]) + let parts = token.split(separator: ":").compactMap { Double($0) } + switch parts.count { + case 3: + return parts[0] * 3600 + parts[1] * 60 + parts[2] + case 2: + return parts[0] * 60 + parts[1] + case 1: + return parts[0] + default: + break + } + } + + let suffixPattern = "([0-9]+)([hms])" + let regex = try? NSRegularExpression(pattern: suffixPattern, options: []) + let range = NSRange(text.startIndex.. 0 ? total : nil + } + + private func matchesTimerText(_ text: String) -> Bool { + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return false } + + if trimmed.lowercased().contains("timer") { + return true + } + + let pattern = "[0-9]+(?::[0-9]{2}){0,2}" + return trimmed.range(of: pattern, options: .regularExpression) != nil + } + + private func copyAttribute(_ attribute: CFString, from element: AXUIElement) -> T? { + var value: CFTypeRef? + let status = AXUIElementCopyAttributeValue(element, attribute, &value) + guard status == .success, let typed = value as? T else { + return nil + } + return typed + } + + private func postAccessibilityWarningIfNeeded() { + guard !didWarnAboutAccessibility else { return } + didWarnAboutAccessibility = true + logError("Accessibility permission missing; prompt user to enable it") + DispatchQueue.main.async { + debugPrint("[SystemTimerBridge] Accessibility permission is required to mirror Clock timers. Grant access in System Settings → Privacy & Security → Accessibility.") + } + } +} diff --git a/DynamicIsland/managers/TimerManager.swift b/DynamicIsland/managers/TimerManager.swift index f133c5d..b49654e 100644 --- a/DynamicIsland/managers/TimerManager.swift +++ b/DynamicIsland/managers/TimerManager.swift @@ -26,6 +26,7 @@ class TimerManager: ObservableObject { @Published var isOvertime: Bool = false // Timer has gone past 0 and is counting negative @Published var lastUpdated: Date = .distantPast @Published var activePresetId: UUID? + @Published private(set) var activeSource: TimerSource = .none // Timer progress (0.0 to 1.0, or >1.0 for overtime) var progress: Double { @@ -113,7 +114,6 @@ class TimerManager: ObservableObject { private var timerInstance: Timer? private var cancellables = Set() private var soundPlayer: AVAudioPlayer? - // MARK: - Initialization private init() { // Simple initialization @@ -127,6 +127,10 @@ class TimerManager: ObservableObject { // MARK: - Timer Methods func startTimer(duration: TimeInterval, name: String = "Timer", preset: TimerPreset? = nil) { + if activeSource == .external { + endExternalTimer(triggerSmoothClose: false) + } + // Stop any existing timer timerInstance?.invalidate() @@ -134,6 +138,7 @@ class TimerManager: ObservableObject { withAnimation(.smooth) { isTimerActive = true } + activeSource = .manual isFinished = false isOvertime = false timerName = name @@ -178,6 +183,11 @@ class TimerManager: ObservableObject { } func stopTimer() { + if activeSource == .external { + endExternalTimer(triggerSmoothClose: true) + return + } + timerInstance?.invalidate() timerInstance = nil soundPlayer?.stop() @@ -191,6 +201,11 @@ class TimerManager: ObservableObject { } func forceStopTimer() { + if activeSource == .external { + endExternalTimer(triggerSmoothClose: false) + return + } + // Immediate stop for user action (stop button) timerInstance?.invalidate() timerInstance = nil @@ -202,6 +217,7 @@ class TimerManager: ObservableObject { } func pauseTimer() { + guard activeSource == .manual else { return } guard isTimerActive && !isPaused else { return } isPaused = true timerInstance?.invalidate() @@ -209,6 +225,7 @@ class TimerManager: ObservableObject { } func resumeTimer() { + guard activeSource == .manual else { return } guard isTimerActive && isPaused else { return } isPaused = false lastUpdated = Date() @@ -240,6 +257,78 @@ class TimerManager: ObservableObject { } } } + + func adoptExternalTimer(name: String, totalDuration: TimeInterval, remaining: TimeInterval, isPaused: Bool) { + guard activeSource != .manual else { return } + + timerInstance?.invalidate() + timerInstance = nil + soundPlayer?.stop() + + activeSource = .external + + let clampedTotal = totalDuration > 0 ? totalDuration : max(totalDuration, remaining) + + withAnimation(.smooth) { + isTimerActive = true + } + + timerName = name.isEmpty ? "Clock Timer" : name + self.totalDuration = clampedTotal + remainingTime = remaining + elapsedTime = max(0, clampedTotal - remaining) + self.isPaused = isPaused + isFinished = false + isOvertime = remaining < 0 + activePresetId = nil + lastUpdated = Date() + } + + func updateExternalTimer(remaining: TimeInterval, totalDuration: TimeInterval?, isPaused paused: Bool, name: String? = nil) { + guard activeSource == .external else { return } + + if let name, !name.isEmpty, name != timerName { + timerName = name + } + + if let totalDuration, totalDuration > 0 { + self.totalDuration = max(totalDuration, self.totalDuration) + } else if self.totalDuration <= 0 { + self.totalDuration = max(self.totalDuration, remaining) + } + + remainingTime = remaining + elapsedTime = max(0, self.totalDuration - remaining) + isOvertime = remaining < 0 + isPaused = paused + isFinished = remaining <= 0 && !isOvertime + lastUpdated = Date() + } + + func completeExternalTimer() { + guard activeSource == .external else { return } + + remainingTime = 0 + elapsedTime = totalDuration + isOvertime = false + isFinished = true + lastUpdated = Date() + scheduleSmoothClose() + } + + func endExternalTimer(triggerSmoothClose: Bool) { + guard activeSource == .external else { return } + + if triggerSmoothClose && isTimerActive { + scheduleSmoothClose() + } else { + withAnimation(.smooth) { + isTimerActive = false + } + } + + resetTimer() + } private func resetTimer() { withAnimation(.smooth) { @@ -253,6 +342,7 @@ class TimerManager: ObservableObject { isFinished = false isOvertime = false activePresetId = nil + activeSource = .none } // MARK: - Derived State @@ -261,6 +351,24 @@ class TimerManager: ObservableObject { return Defaults[.timerPresets].first { $0.id == presetId } } + var isExternalTimerActive: Bool { + activeSource == .external && isTimerActive + } + + var hasManualTimerRunning: Bool { + activeSource == .manual && isTimerActive + } + + var allowsManualInteraction: Bool { + activeSource != .external + } + + enum TimerSource: String { + case none + case manual + case external + } + private func scheduleSmoothClose() { // Wait 3 seconds then smoothly close the live activity DispatchQueue.main.asyncAfter(deadline: .now() + 3.0) { diff --git a/DynamicIsland/models/Constants.swift b/DynamicIsland/models/Constants.swift index 895bafd..e5238a9 100644 --- a/DynamicIsland/models/Constants.swift +++ b/DynamicIsland/models/Constants.swift @@ -463,6 +463,7 @@ extension Defaults.Keys { static let timerShowsLabel = Key("timerShowsLabel", default: true) static let timerShowsProgress = Key("timerShowsProgress", default: true) static let timerProgressStyle = Key("timerProgressStyle", default: .bar) + static let mirrorSystemTimer = Key("mirrorSystemTimer", default: true) // MARK: Reminder Live Activity static let enableReminderLiveActivity = Key("enableReminderLiveActivity", default: true)