diff --git a/DynamicIsland/components/Focus/DoNotDisturbLiveActivity.swift b/DynamicIsland/components/Focus/DoNotDisturbLiveActivity.swift index c01aca0..a4b3f9e 100644 --- a/DynamicIsland/components/Focus/DoNotDisturbLiveActivity.swift +++ b/DynamicIsland/components/Focus/DoNotDisturbLiveActivity.swift @@ -66,7 +66,13 @@ struct DoNotDisturbLiveActivity: View { 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) + var width = max(measuredWidth, fallbackWidth) + + if focusMode == .doNotDisturb && shouldShowLabel { + width = max(width, 164) + } + + return width } private var shouldShowLabel: Bool { @@ -89,15 +95,12 @@ 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 "Do Not Disturb" } return fallback.isEmpty ? "Focus" : fallback } @@ -110,13 +113,13 @@ struct DoNotDisturbLiveActivity: View { } } - private var currentIconName: String { + private var currentIcon: Image { if manager.isDoNotDisturbActive { - return focusMode.sfSymbol + return focusMode.activeIcon } else if showInactiveIcon { - return focusMode.inactiveSymbol + return Image(systemName: focusMode.inactiveSymbol) } else { - return focusMode.sfSymbol + return focusMode.activeIcon } } @@ -136,7 +139,7 @@ struct DoNotDisturbLiveActivity: View { Color.clear .overlay(alignment: .center) { if iconWingWidth > 0 { - Image(systemName: currentIconName) + currentIcon .font(.system(size: 13, weight: .semibold)) .foregroundColor(currentIconColor) .contentTransition(.opacity) diff --git a/DynamicIsland/components/Privacy/PrivacyLiveActivity.swift b/DynamicIsland/components/Privacy/PrivacyLiveActivity.swift index 380c6f6..a28b992 100644 --- a/DynamicIsland/components/Privacy/PrivacyLiveActivity.swift +++ b/DynamicIsland/components/Privacy/PrivacyLiveActivity.swift @@ -110,8 +110,7 @@ struct PrivacyLiveActivity: View { // Individual privacy icon component struct PrivacyIcon: View { let type: PrivacyIndicatorType - @State private var isPulsing = false - + // Color based on type: camera = #26CC41, mic = #FF9402 private var iconColor: Color { type == .camera ? Color(red: 0.152, green: 0.804, blue: 0.256) : Color(red: 1.000, green: 0.584, blue: 0.010) @@ -123,12 +122,7 @@ struct PrivacyIcon: View { .font(.system(size: 13, weight: .semibold)) .foregroundColor(iconColor) .frame(width: 24, height: 24) - .scaleEffect(isPulsing ? 1.1 : 1.0) - .opacity(isPulsing ? 1.0 : 0.85) - .onAppear { - withAnimation(.easeInOut(duration: 1.5).repeatForever(autoreverses: true)) { - isPulsing = true - } - } + .scaleEffect(1.0) + .opacity(1.0) } } diff --git a/DynamicIsland/components/UI/FocusIndicator.swift b/DynamicIsland/components/UI/FocusIndicator.swift index f887d13..0430cb9 100644 --- a/DynamicIsland/components/UI/FocusIndicator.swift +++ b/DynamicIsland/components/UI/FocusIndicator.swift @@ -14,7 +14,7 @@ struct FocusIndicator: View { Capsule() .fill(Color.black) .overlay { - Image(systemName: focusSymbol) + focusIcon .font(.system(size: 12, weight: .semibold)) .foregroundStyle(accentColor) .frame(width: 24, height: 24) @@ -30,8 +30,8 @@ struct FocusIndicator: View { ) } - private var focusSymbol: String { - focusMode.sfSymbol + private var focusIcon: Image { + focusMode.activeIcon } private var accentColor: Color { diff --git a/DynamicIsland/extensions/Image+InternalSystemName.swift b/DynamicIsland/extensions/Image+InternalSystemName.swift new file mode 100644 index 0000000..1fccf44 --- /dev/null +++ b/DynamicIsland/extensions/Image+InternalSystemName.swift @@ -0,0 +1,14 @@ +import SwiftUI + +@_silgen_name("$s7SwiftUI5ImageV19_internalSystemNameACSS_tcfC") +private func _swiftUI_image(internalSystemName: String) -> Image? + +extension Image { + init?(internalSystemName systemName: String) { + guard let systemImage = _swiftUI_image(internalSystemName: systemName) else { + return nil + } + + self = systemImage + } +} diff --git a/DynamicIsland/managers/DoNotDisturbManager.swift b/DynamicIsland/managers/DoNotDisturbManager.swift index ce3ad64..e323fed 100644 --- a/DynamicIsland/managers/DoNotDisturbManager.swift +++ b/DynamicIsland/managers/DoNotDisturbManager.swift @@ -22,8 +22,13 @@ final class DoNotDisturbManager: ObservableObject { private let notificationCenter = DistributedNotificationCenter.default() private let metadataExtractionQueue = DispatchQueue(label: "com.dynamicisland.focus.metadata", qos: .userInitiated) + private let focusLogStream = FocusLogStream() - private init() {} + private init() { + focusLogStream.onMetadataUpdate = { [weak self] identifier, name in + self?.handleLogMetadataUpdate(identifier: identifier, name: name) + } + } deinit { stopMonitoring() @@ -48,6 +53,7 @@ final class DoNotDisturbManager: ObservableObject { suspensionBehavior: .deliverImmediately ) + focusLogStream.start() isMonitoring = true } @@ -57,6 +63,7 @@ final class DoNotDisturbManager: ObservableObject { notificationCenter.removeObserver(self, name: .focusModeEnabled, object: nil) notificationCenter.removeObserver(self, name: .focusModeDisabled, object: nil) + focusLogStream.stop() isMonitoring = false DispatchQueue.main.async { @@ -79,54 +86,83 @@ final class DoNotDisturbManager: ObservableObject { guard let self = self else { return } let metadata = self.extractMetadata(from: notification) + self.publishMetadata(identifier: metadata.identifier, name: metadata.name, isActive: isActive, source: notification.name.rawValue) + } + } - DispatchQueue.main.async { - let trimmedIdentifier = metadata.identifier?.trimmingCharacters(in: .whitespacesAndNewlines) - let trimmedName = metadata.name?.trimmingCharacters(in: .whitespacesAndNewlines) + private func publishMetadata(identifier: String?, name: String?, isActive: Bool?, source: String) { + DispatchQueue.main.async { [weak self] in + guard let self = self else { return } - let resolvedMode = FocusModeType.resolve(identifier: trimmedIdentifier, name: trimmedName) + let trimmedIdentifier = identifier?.trimmingCharacters(in: .whitespacesAndNewlines) + let trimmedName = name?.trimmingCharacters(in: .whitespacesAndNewlines) - debugPrint("[DoNotDisturbManager] Focus update -> notification: \(notification.name.rawValue) | identifier: \(trimmedIdentifier ?? "") | name: \(trimmedName ?? "") | resolved: \(resolvedMode.rawValue)") + let resolvedMode = FocusModeType.resolve(identifier: trimmedIdentifier, name: trimmedName) - let finalIdentifier: String - if let identifier = trimmedIdentifier, !identifier.isEmpty { - finalIdentifier = identifier - } else { - finalIdentifier = resolvedMode.rawValue - } + let finalIdentifier: String + if let identifier = trimmedIdentifier, !identifier.isEmpty { + finalIdentifier = identifier + } else { + finalIdentifier = resolvedMode.rawValue + } - if finalIdentifier != self.currentFocusModeIdentifier { - self.currentFocusModeIdentifier = finalIdentifier - } + let finalName: String + if let name = trimmedName, !name.isEmpty { + finalName = name + } else if !resolvedMode.displayName.isEmpty { + finalName = resolvedMode.displayName + } else if let identifier = trimmedIdentifier, !identifier.isEmpty { + finalName = identifier + } else { + finalName = "Focus" + } - let finalName: String - if let name = trimmedName, !name.isEmpty { - finalName = name - } else if !resolvedMode.displayName.isEmpty { - finalName = resolvedMode.displayName - } else if let identifier = trimmedIdentifier, !identifier.isEmpty { - finalName = identifier - } else { - finalName = "Focus" - } + let previousIdentifier = self.currentFocusModeIdentifier + let previousName = self.currentFocusModeName + let previousActive = self.isDoNotDisturbActive - if finalName != self.currentFocusModeName { - self.currentFocusModeName = finalName - } + let identifierChanged = finalIdentifier != previousIdentifier + let nameChanged = finalName != previousName + let shouldToggleActive = isActive.map { $0 != previousActive } ?? false - guard self.isDoNotDisturbActive != isActive else { return } + if identifierChanged { + self.currentFocusModeIdentifier = finalIdentifier + } - withAnimation(.smooth(duration: 0.25)) { - self.isDoNotDisturbActive = isActive - } + if nameChanged { + self.currentFocusModeName = finalName + } + + if identifierChanged || nameChanged || shouldToggleActive { + debugPrint("[DoNotDisturbManager] Focus update -> source: \(source) | identifier: \(trimmedIdentifier ?? "") | name: \(trimmedName ?? "") | resolved: \(resolvedMode.rawValue)") + } + + guard let isActive = isActive, shouldToggleActive else { return } + + withAnimation(.smooth(duration: 0.25)) { + self.isDoNotDisturbActive = isActive } } } - private func extractMetadata(from notification: Notification) -> (name: String?, identifier: String?) { - guard let userInfo = notification.userInfo else { return (nil, nil) } + private func handleLogMetadataUpdate(identifier: String?, name: String?) { + metadataExtractionQueue.async { [weak self] in + guard let self = self else { return } + let trimmedIdentifier = identifier?.trimmingCharacters(in: .whitespacesAndNewlines) + let trimmedName = name?.trimmingCharacters(in: .whitespacesAndNewlines) - debugPrint("[DoNotDisturbManager] raw focus payload -> name: \(notification.name.rawValue), object: \(String(describing: notification.object)), userInfo: \(userInfo)") + let hasIdentifier = (trimmedIdentifier?.isEmpty == false) + let hasName = (trimmedName?.isEmpty == false) + + guard hasIdentifier || hasName else { return } + + self.publishMetadata(identifier: trimmedIdentifier, name: trimmedName, isActive: nil, source: "log-stream") + } + } + + private func extractMetadata(from notification: Notification) -> (name: String?, identifier: String?) { + var identifier: String? + var name: String? let identifierKeys = [ "FocusModeIdentifier", @@ -144,12 +180,110 @@ final class DoNotDisturbManager: ObservableObject { "focusModeName", "FocusMode", "focusMode", + "displayName", + "display_name", "name", "Name" ] - let identifier = firstMatch(for: identifierKeys, in: userInfo) - let name = firstMatch(for: nameKeys, in: userInfo) + var candidates: [Any] = [] + if let userInfo = notification.userInfo { + candidates.append(userInfo) + } + + if let object = notification.object { + candidates.append(object) + } + + debugPrint("[DoNotDisturbManager] raw focus payload -> name: \(notification.name.rawValue), object: \(String(describing: notification.object)), userInfo: \(String(describing: notification.userInfo))") + + for candidate in candidates { + if identifier == nil { + identifier = firstMatch(for: identifierKeys, in: candidate) + } + + if name == nil { + name = firstMatch(for: nameKeys, in: candidate) + } + + if identifier != nil && name != nil { + break + } + } + + if identifier == nil || name == nil { + for candidate in candidates { + if let decoded = decodeFocusPayloadIfNeeded(candidate) { + if identifier == nil { + identifier = firstMatch(for: identifierKeys, in: decoded) + } + + if name == nil { + name = firstMatch(for: nameKeys, in: decoded) + } + + if identifier != nil && name != nil { + break + } + } + } + } + + if identifier == nil || name == nil { + for candidate in candidates { + if let object = candidate as? NSObject { + if identifier == nil, let extractedIdentifier = extractIdentifier(fromFocusObject: object) { + identifier = extractedIdentifier + } + + if name == nil, let extractedName = extractDisplayName(fromFocusObject: object) { + name = extractedName + } + + if identifier != nil && name != nil { + break + } + } + } + } + + if identifier == nil || name == nil { + var descriptionSources: [Any] = candidates + + for candidate in candidates { + if let decoded = decodeFocusPayloadIfNeeded(candidate) { + descriptionSources.append(decoded) + } + } + + for candidate in descriptionSources { + let description = String(describing: candidate) + + if identifier == nil, let inferredIdentifier = FocusMetadataDecoder.extractIdentifier(from: description) { + identifier = inferredIdentifier + } + + if name == nil, let inferredName = FocusMetadataDecoder.extractName(from: description) { + name = inferredName + } + + if identifier != nil && name != nil { + break + } + } + } + + if identifier == nil || name == nil { + if let logMetadata = focusLogStream.latestMetadata() { + if identifier == nil { + identifier = logMetadata.identifier + } + + if name == nil { + name = logMetadata.name + } + } + } return (name, identifier) } @@ -201,21 +335,39 @@ enum FocusModeType: String, CaseIterable { case .driving: return "car.fill" case .fitness: return "figure.run" case .gaming: return "gamecontroller.fill" - case .mindfulness: return "brain.head.profile" - case .reading: return "book.fill" + case .mindfulness: return "circle.hexagongrid" + case .reading: return "book.closed.fill" case .custom: return "app.badge" case .unknown: return "moon.fill" } } + var internalSymbolName: String? { + switch self { + case .work: return "person.lanyardcard.fill" + case .mindfulness: return "apple.mindfulness" + case .gaming: return "rocket.fill" + default: return nil + } + } + + var activeIcon: Image { + if let internalSymbolName, + let image = Image(internalSystemName: internalSymbolName) { + return image + } + + return Image(systemName: sfSymbol) + } + 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) + return Color(red: 0.414, green: 0.769, blue: 0.863, opacity: 1.0) case .personal: - return Color(red: 0.937, green: 0.282, blue: 0.624) + return Color(red: 0.748, green: 0.354, blue: 0.948, opacity: 1.0) case .sleep: return Color(red: 0.341, green: 0.384, blue: 0.980) case .driving: @@ -223,11 +375,11 @@ enum FocusModeType: String, CaseIterable { 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) + return Color(red: 0.043, green: 0.518, blue: 1.000, opacity: 1.0) case .mindfulness: - return Color(red: 0.239, green: 0.718, blue: 0.682) + return Color(red: 0.361, green: 0.898, blue: 0.883, opacity: 1.0) case .reading: - return Color(red: 0.239, green: 0.596, blue: 0.965) + return Color(red: 1.000, green: 0.622, blue: 0.044, opacity: 1.0) case .custom: return Color(red: 0.513, green: 0.478, blue: 0.965) case .unknown: @@ -326,17 +478,26 @@ private extension DoNotDisturbManager { func normalizedString(from value: Any) -> String? { switch value { case let string as String: - let trimmed = string.trimmingCharacters(in: .whitespacesAndNewlines) - return trimmed.isEmpty ? nil : trimmed + let cleaned = FocusMetadataDecoder.cleanedString(string) + return cleaned.isEmpty ? nil : cleaned case let number as NSNumber: - return number.stringValue + return FocusMetadataDecoder.cleanedString(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 decoded = decodeFocusPayload(from: data) { + if let nested = firstMatch(for: ["identifier", "Identifier", "uuid", "UUID"], in: decoded) { + return nested + } + if let name = firstMatch(for: ["name", "Name", "displayName", "display_name"], in: decoded) { + return name + } + } if let string = String(data: data, encoding: .utf8) { - return string.trimmingCharacters(in: .whitespacesAndNewlines) + let cleaned = FocusMetadataDecoder.cleanedString(string) + return cleaned.isEmpty ? nil : cleaned } return nil case let dict as [AnyHashable: Any]: @@ -352,4 +513,428 @@ private extension DoNotDisturbManager { return nil } } + + func decodeFocusPayloadIfNeeded(_ value: Any) -> Any? { + switch value { + case let data as Data: + return decodeFocusPayload(from: data) + case let data as NSData: + return decodeFocusPayload(from: data as Data) + default: + return nil + } + } + + func decodeFocusPayload(from data: Data) -> Any? { + guard !data.isEmpty else { return nil } + + if let propertyList = try? PropertyListSerialization.propertyList(from: data, options: [], format: nil) { + return propertyList + } + + if let jsonObject = try? JSONSerialization.jsonObject(with: data, options: [.fragmentsAllowed]) { + return jsonObject + } + + if let string = String(data: data, encoding: .utf8) { + let trimmed = string.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } + + return nil + } + + func extractIdentifier(fromFocusObject object: NSObject) -> String? { + if let array = object as? [Any] { + for element in array { + if let nested = element as? NSObject, let identifier = extractIdentifier(fromFocusObject: nested) { + return identifier + } + } + return nil + } + + if let identifier = focusString(object, selector: "modeIdentifier") { + return identifier + } + + if let identifier = focusString(object, selector: "identifier") { + return identifier + } + + if let details = focusObject(object, selector: "details"), let identifier = extractIdentifier(fromFocusObject: details) { + return identifier + } + + if let metadata = focusObject(object, selector: "activeModeAssertionMetadata"), let identifier = extractIdentifier(fromFocusObject: metadata) { + return identifier + } + + if let configuration = focusObject(object, selector: "activeModeConfiguration"), let identifier = extractIdentifier(fromFocusObject: configuration) { + return identifier + } + + if let modeConfiguration = focusObject(object, selector: "modeConfiguration"), let identifier = extractIdentifier(fromFocusObject: modeConfiguration) { + return identifier + } + + if let mode = focusObject(object, selector: "mode") { + return extractIdentifier(fromFocusObject: mode) + } + + if let identifiers = focusObject(object, selector: "activeModeIdentifiers") { + if let stringArray = identifiers as? [String] { + if let first = stringArray.compactMap({ FocusMetadataDecoder.cleanedString($0) }).first(where: { !$0.isEmpty }) { + return first + } + } else if let array = identifiers as? NSArray { + for case let string as String in array { + let trimmed = FocusMetadataDecoder.cleanedString(string) + if !trimmed.isEmpty { + return trimmed + } + } + } + } + + return nil + } + + func extractDisplayName(fromFocusObject object: NSObject) -> String? { + if let array = object as? [Any] { + for element in array { + if let nested = element as? NSObject, let name = extractDisplayName(fromFocusObject: nested) { + return name + } + } + return nil + } + + if let name = focusString(object, selector: "name") { + return name + } + + if let name = focusString(object, selector: "displayName") { + return name + } + + if let name = focusString(object, selector: "activityDisplayName") { + return name + } + + if let descriptor = focusObject(object, selector: "symbolDescriptor"), let name = focusString(descriptor, selector: "name") { + return name + } + + if let mode = focusObject(object, selector: "mode"), let name = extractDisplayName(fromFocusObject: mode) { + return name + } + + if let details = focusObject(object, selector: "details"), let name = extractDisplayName(fromFocusObject: details) { + return name + } + + if let configuration = focusObject(object, selector: "modeConfiguration"), let name = extractDisplayName(fromFocusObject: configuration) { + return name + } + + return nil + } + + func focusObject(_ object: NSObject, selector selectorName: String) -> NSObject? { + let selector = NSSelectorFromString(selectorName) + guard object.responds(to: selector) else { return nil } + guard let value = object.perform(selector)?.takeUnretainedValue() else { return nil } + return value as? NSObject + } + + func focusString(_ object: NSObject, selector selectorName: String) -> String? { + let selector = NSSelectorFromString(selectorName) + guard object.responds(to: selector) else { return nil } + guard let value = object.perform(selector)?.takeUnretainedValue() else { return nil } + + switch value { + case let string as String: + return FocusMetadataDecoder.cleanedString(string) + case let string as NSString: + return FocusMetadataDecoder.cleanedString(string as String) + case let number as NSNumber: + return FocusMetadataDecoder.cleanedString(number.stringValue) + default: + return nil + } + } + +} + +private final class FocusLogStream { + private let queue = DispatchQueue(label: "com.dynamicisland.focus.logstream", qos: .utility) + private var process: Process? + private var pipe: Pipe? + private var buffer = Data() + private var isRunning = false + + private let metadataLock = NSLock() + private var lastIdentifier: String? + private var lastName: String? + + var onMetadataUpdate: ((String?, String?) -> Void)? + + func start() { + queue.async { [weak self] in + guard let self = self else { return } + guard !self.isRunning else { return } + + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/log") + process.arguments = [ + "stream", + "--style", + "compact", + "--level", + "info", + "--predicate", + "subsystem == \"com.apple.focus\"" + ] + + let pipe = Pipe() + process.standardOutput = pipe + process.standardError = pipe + + pipe.fileHandleForReading.readabilityHandler = { [weak self] handle in + guard let self else { return } + let data = handle.availableData + + if data.isEmpty { + self.queue.async { [weak self] in + self?.handleTermination() + } + return + } + + self.queue.async { [weak self] in + self?.handleIncomingData(data) + } + } + + process.terminationHandler = { [weak self] _ in + self?.queue.async { + self?.handleTermination() + } + } + + do { + try process.run() + self.process = process + self.pipe = pipe + self.isRunning = true + debugPrint("[FocusLogStream] Started unified log tail for com.apple.focus") + } catch { + debugPrint("[FocusLogStream] Failed to start log stream: \(error)") + pipe.fileHandleForReading.readabilityHandler = nil + self.process = nil + self.pipe = nil + } + } + } + + func stop() { + queue.async { [weak self] in + guard let self else { return } + guard self.isRunning else { return } + self.handleTermination(terminateProcess: true) + } + } + + func latestMetadata() -> (identifier: String?, name: String?)? { + metadataLock.lock() + let identifier = lastIdentifier?.trimmingCharacters(in: .whitespacesAndNewlines) + let name = lastName?.trimmingCharacters(in: .whitespacesAndNewlines) + metadataLock.unlock() + + let normalizedIdentifier = (identifier?.isEmpty == false) ? identifier : nil + let normalizedName = (name?.isEmpty == false) ? name : nil + + if normalizedIdentifier == nil && normalizedName == nil { + return nil + } + + return (normalizedIdentifier, normalizedName) + } + + private func handleIncomingData(_ data: Data) { + buffer.append(data) + + let newline: UInt8 = 0x0A + + while let newlineIndex = buffer.firstIndex(of: newline) { + let lineData = buffer.prefix(upTo: newlineIndex) + buffer.removeSubrange(buffer.startIndex...newlineIndex) + + let trimmedLineData: Data + if let lastByte = lineData.last, lastByte == 0x0D { + trimmedLineData = lineData.dropLast() + } else { + trimmedLineData = lineData + } + + guard !trimmedLineData.isEmpty, + let line = String(data: trimmedLineData, encoding: .utf8) else { + continue + } + + processLine(line) + } + } + + private func processLine(_ line: String) { + let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return } + + if trimmed.hasPrefix("Filtering the log data") || trimmed.hasPrefix("Timestamp") { + return + } + + if trimmed.contains("active mode assertion: (null)") || trimmed.contains("active activity: (null)") { + clearMetadata() + return + } + + var updatedIdentifier: String? + var updatedName: String? + + if let identifier = FocusMetadataDecoder.extractIdentifier(from: trimmed), !identifier.isEmpty { + updatedIdentifier = identifier + } + + if let name = FocusMetadataDecoder.extractName(from: trimmed), !name.isEmpty { + updatedName = name + } + + guard updatedIdentifier != nil || updatedName != nil else { return } + + var identifierToSend: String? + var nameToSend: String? + + metadataLock.lock() + if let identifier = updatedIdentifier, !identifier.isEmpty { + lastIdentifier = identifier + } + + if let name = updatedName, !name.isEmpty { + lastName = name + } + + identifierToSend = lastIdentifier + nameToSend = lastName + metadataLock.unlock() + + notifyMetadataUpdate(identifier: identifierToSend, name: nameToSend) + } + + private func clearMetadata() { + metadataLock.lock() + lastIdentifier = nil + lastName = nil + metadataLock.unlock() + notifyMetadataUpdate(identifier: nil, name: nil) + } + + private func handleTermination(terminateProcess: Bool = false) { + if terminateProcess, let process, process.isRunning { + process.terminate() + } + + pipe?.fileHandleForReading.readabilityHandler = nil + pipe?.fileHandleForReading.closeFile() + pipe = nil + + process = nil + buffer.removeAll(keepingCapacity: false) + isRunning = false + clearMetadata() + debugPrint("[FocusLogStream] Stopped unified log tail for com.apple.focus") + } + + private func notifyMetadataUpdate(identifier: String?, name: String?) { + guard let handler = onMetadataUpdate else { return } + handler(identifier, name) + } +} + +private enum FocusNotificationParsing { + static let identifierPattern: NSRegularExpression? = { + let pattern = "com\\.apple\\.(?:focus|donotdisturb)[A-Za-z0-9_.-]*" + return try? NSRegularExpression(pattern: pattern, options: []) + }() + + static let identifierDetailPatterns: [NSRegularExpression] = { + let patterns = [ + "modeIdentifier:\\s*'([^'\\s]+)'", + "activityIdentifier:\\s*([A-Za-z0-9._-]+)" + ] + return patterns.compactMap { try? NSRegularExpression(pattern: $0, options: []) } + }() + + static let namePatterns: [NSRegularExpression] = { + let patterns = [ + "(?i)(?:focusModeName|focusMode|displayName|name)\\s*=\\s*\"([^\"]+)\"", + "(?i)(?:focusModeName|focusMode|displayName|name)\\s*=\\s*([^;\\n]+)", + "activityDisplayName:\\s*([^;>\\n]+)", + "modeIdentifier:\\s*'com\\.apple\\.focus\\.([A-Za-z0-9._-]+)'" + ] + return patterns.compactMap { try? NSRegularExpression(pattern: $0, options: []) } + }() +} + +private enum FocusMetadataDecoder { + static func cleanedString(_ string: String) -> String { + var trimmed = string.trimmingCharacters(in: .whitespacesAndNewlines) + trimmed = trimmed.trimmingCharacters(in: CharacterSet(charactersIn: "'\"")) + return trimmed + } + + static func extractIdentifier(from description: String) -> String? { + let fullRange = NSRange(description.startIndex.. 0, + let identifierRange = Range(match.range(at: 0), in: description) { + let candidate = cleanedString(String(description[identifierRange])) + if !candidate.isEmpty { + return candidate + } + } + + for regex in FocusNotificationParsing.identifierDetailPatterns { + if let match = regex.firstMatch(in: description, options: [], range: fullRange), + match.numberOfRanges > 1, + let identifierRange = Range(match.range(at: 1), in: description) { + let candidate = cleanedString(String(description[identifierRange])) + if !candidate.isEmpty { + return candidate + } + } + } + + return nil + } + + static func extractName(from description: String) -> String? { + let fullRange = NSRange(description.startIndex.. 1, + let nameRange = Range(match.range(at: 1), in: description) { + let candidate = cleanedString(String(description[nameRange])) + if !candidate.isEmpty { + return candidate + } + } + } + + return nil + } } diff --git a/ReadMe.md b/ReadMe.md index 031788d..0ae4746 100644 --- a/ReadMe.md +++ b/ReadMe.md @@ -13,7 +13,7 @@ GitHub downloads - Discord server + Discord server