mirror of
https://github.com/FBukovina/Atoll.git
synced 2026-10-27 15:14:33 +00:00
Create Working Focus Detection for all types
This commit is contained in:
parent
583e244e8a
commit
c3dff3a950
2 changed files with 890 additions and 42 deletions
|
|
@ -22,8 +22,14 @@ final class DoNotDisturbManager: ObservableObject {
|
||||||
|
|
||||||
private let notificationCenter = DistributedNotificationCenter.default()
|
private let notificationCenter = DistributedNotificationCenter.default()
|
||||||
private let metadataExtractionQueue = DispatchQueue(label: "com.dynamicisland.focus.metadata", qos: .userInitiated)
|
private let metadataExtractionQueue = DispatchQueue(label: "com.dynamicisland.focus.metadata", qos: .userInitiated)
|
||||||
|
private let focusStateResolver = FocusStateResolver()
|
||||||
|
private let focusLogStream = FocusLogStream()
|
||||||
|
|
||||||
private init() {}
|
private init() {
|
||||||
|
focusLogStream.onMetadataUpdate = { [weak self] identifier, name in
|
||||||
|
self?.handleLogMetadataUpdate(identifier: identifier, name: name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
deinit {
|
deinit {
|
||||||
stopMonitoring()
|
stopMonitoring()
|
||||||
|
|
@ -48,6 +54,7 @@ final class DoNotDisturbManager: ObservableObject {
|
||||||
suspensionBehavior: .deliverImmediately
|
suspensionBehavior: .deliverImmediately
|
||||||
)
|
)
|
||||||
|
|
||||||
|
focusLogStream.start()
|
||||||
isMonitoring = true
|
isMonitoring = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -57,6 +64,7 @@ final class DoNotDisturbManager: ObservableObject {
|
||||||
notificationCenter.removeObserver(self, name: .focusModeEnabled, object: nil)
|
notificationCenter.removeObserver(self, name: .focusModeEnabled, object: nil)
|
||||||
notificationCenter.removeObserver(self, name: .focusModeDisabled, object: nil)
|
notificationCenter.removeObserver(self, name: .focusModeDisabled, object: nil)
|
||||||
|
|
||||||
|
focusLogStream.stop()
|
||||||
isMonitoring = false
|
isMonitoring = false
|
||||||
|
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
|
|
@ -79,54 +87,83 @@ final class DoNotDisturbManager: ObservableObject {
|
||||||
guard let self = self else { return }
|
guard let self = self else { return }
|
||||||
|
|
||||||
let metadata = self.extractMetadata(from: notification)
|
let metadata = self.extractMetadata(from: notification)
|
||||||
|
self.publishMetadata(identifier: metadata.identifier, name: metadata.name, isActive: isActive, source: notification.name.rawValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
DispatchQueue.main.async {
|
private func publishMetadata(identifier: String?, name: String?, isActive: Bool?, source: String) {
|
||||||
let trimmedIdentifier = metadata.identifier?.trimmingCharacters(in: .whitespacesAndNewlines)
|
DispatchQueue.main.async { [weak self] in
|
||||||
let trimmedName = metadata.name?.trimmingCharacters(in: .whitespacesAndNewlines)
|
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 ?? "<nil>") | name: \(trimmedName ?? "<nil>") | resolved: \(resolvedMode.rawValue)")
|
let resolvedMode = FocusModeType.resolve(identifier: trimmedIdentifier, name: trimmedName)
|
||||||
|
|
||||||
let finalIdentifier: String
|
let finalIdentifier: String
|
||||||
if let identifier = trimmedIdentifier, !identifier.isEmpty {
|
if let identifier = trimmedIdentifier, !identifier.isEmpty {
|
||||||
finalIdentifier = identifier
|
finalIdentifier = identifier
|
||||||
} else {
|
} else {
|
||||||
finalIdentifier = resolvedMode.rawValue
|
finalIdentifier = resolvedMode.rawValue
|
||||||
}
|
}
|
||||||
|
|
||||||
if finalIdentifier != self.currentFocusModeIdentifier {
|
let finalName: String
|
||||||
self.currentFocusModeIdentifier = finalIdentifier
|
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
|
let previousIdentifier = self.currentFocusModeIdentifier
|
||||||
if let name = trimmedName, !name.isEmpty {
|
let previousName = self.currentFocusModeName
|
||||||
finalName = name
|
let previousActive = self.isDoNotDisturbActive
|
||||||
} else if !resolvedMode.displayName.isEmpty {
|
|
||||||
finalName = resolvedMode.displayName
|
|
||||||
} else if let identifier = trimmedIdentifier, !identifier.isEmpty {
|
|
||||||
finalName = identifier
|
|
||||||
} else {
|
|
||||||
finalName = "Focus"
|
|
||||||
}
|
|
||||||
|
|
||||||
if finalName != self.currentFocusModeName {
|
let identifierChanged = finalIdentifier != previousIdentifier
|
||||||
self.currentFocusModeName = finalName
|
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)) {
|
if nameChanged {
|
||||||
self.isDoNotDisturbActive = isActive
|
self.currentFocusModeName = finalName
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if identifierChanged || nameChanged || shouldToggleActive {
|
||||||
|
debugPrint("[DoNotDisturbManager] Focus update -> source: \(source) | identifier: \(trimmedIdentifier ?? "<nil>") | name: \(trimmedName ?? "<nil>") | 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?) {
|
private func handleLogMetadataUpdate(identifier: String?, name: String?) {
|
||||||
guard let userInfo = notification.userInfo else { return (nil, nil) }
|
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 = [
|
let identifierKeys = [
|
||||||
"FocusModeIdentifier",
|
"FocusModeIdentifier",
|
||||||
|
|
@ -144,12 +181,121 @@ final class DoNotDisturbManager: ObservableObject {
|
||||||
"focusModeName",
|
"focusModeName",
|
||||||
"FocusMode",
|
"FocusMode",
|
||||||
"focusMode",
|
"focusMode",
|
||||||
|
"displayName",
|
||||||
|
"display_name",
|
||||||
"name",
|
"name",
|
||||||
"Name"
|
"Name"
|
||||||
]
|
]
|
||||||
|
|
||||||
let identifier = firstMatch(for: identifierKeys, in: userInfo)
|
var candidates: [Any] = []
|
||||||
let name = firstMatch(for: nameKeys, in: userInfo)
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if identifier == nil || name == nil {
|
||||||
|
if let fallback = focusStateResolver.loadActiveFocusState(identifierMissing: identifier == nil, nameMissing: name == nil) {
|
||||||
|
if identifier == nil {
|
||||||
|
identifier = fallback.identifier
|
||||||
|
}
|
||||||
|
if name == nil {
|
||||||
|
name = fallback.name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (name, identifier)
|
return (name, identifier)
|
||||||
}
|
}
|
||||||
|
|
@ -326,17 +472,26 @@ private extension DoNotDisturbManager {
|
||||||
func normalizedString(from value: Any) -> String? {
|
func normalizedString(from value: Any) -> String? {
|
||||||
switch value {
|
switch value {
|
||||||
case let string as String:
|
case let string as String:
|
||||||
let trimmed = string.trimmingCharacters(in: .whitespacesAndNewlines)
|
let cleaned = FocusMetadataDecoder.cleanedString(string)
|
||||||
return trimmed.isEmpty ? nil : trimmed
|
return cleaned.isEmpty ? nil : cleaned
|
||||||
case let number as NSNumber:
|
case let number as NSNumber:
|
||||||
return number.stringValue
|
return FocusMetadataDecoder.cleanedString(number.stringValue)
|
||||||
case let uuid as UUID:
|
case let uuid as UUID:
|
||||||
return uuid.uuidString
|
return uuid.uuidString
|
||||||
case let uuid as NSUUID:
|
case let uuid as NSUUID:
|
||||||
return uuid.uuidString
|
return uuid.uuidString
|
||||||
case let data as Data:
|
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) {
|
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
|
return nil
|
||||||
case let dict as [AnyHashable: Any]:
|
case let dict as [AnyHashable: Any]:
|
||||||
|
|
@ -352,4 +507,697 @@ private extension DoNotDisturbManager {
|
||||||
return nil
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Filesystem fallback for Focus metadata
|
||||||
|
|
||||||
|
private final class FocusStateResolver {
|
||||||
|
private struct CacheKey: Hashable { let url: URL }
|
||||||
|
private struct FocusRecord { let identifier: String?; let name: String? }
|
||||||
|
|
||||||
|
private let fileManager = FileManager.default
|
||||||
|
|
||||||
|
private let identifierKeys = [
|
||||||
|
"FocusModeIdentifier",
|
||||||
|
"focusModeIdentifier",
|
||||||
|
"FocusModeUUID",
|
||||||
|
"focusModeUUID",
|
||||||
|
"UUID",
|
||||||
|
"uuid",
|
||||||
|
"identifier",
|
||||||
|
"Identifier",
|
||||||
|
"activeModeIdentifier",
|
||||||
|
"activeModeUUID",
|
||||||
|
"modeIdentifier",
|
||||||
|
"modeUUID"
|
||||||
|
]
|
||||||
|
|
||||||
|
private let nameKeys = [
|
||||||
|
"FocusModeName",
|
||||||
|
"focusModeName",
|
||||||
|
"FocusMode",
|
||||||
|
"focusMode",
|
||||||
|
"displayName",
|
||||||
|
"display_name",
|
||||||
|
"name",
|
||||||
|
"Name",
|
||||||
|
"modeName"
|
||||||
|
]
|
||||||
|
|
||||||
|
private var cache: [CacheKey: (modified: Date, records: [FocusRecord])] = [:]
|
||||||
|
|
||||||
|
private lazy var activeStateURLs: [URL] = {
|
||||||
|
let home = fileManager.homeDirectoryForCurrentUser
|
||||||
|
return [
|
||||||
|
"Library/DoNotDisturb/ActiveState.plist",
|
||||||
|
"Library/DoNotDisturb/DB/ActiveState.plist",
|
||||||
|
"Library/Focus/ActiveState.plist"
|
||||||
|
].map { home.appendingPathComponent($0) }
|
||||||
|
}()
|
||||||
|
|
||||||
|
private lazy var catalogURLs: [URL] = {
|
||||||
|
let home = fileManager.homeDirectoryForCurrentUser
|
||||||
|
return [
|
||||||
|
"Library/DoNotDisturb/DB/FocusPreferences.plist",
|
||||||
|
"Library/DoNotDisturb/DB/FocusModeDefaults.plist",
|
||||||
|
"Library/DoNotDisturb/DB/FocusModes.plist",
|
||||||
|
"Library/Focus/FocusModeDefaults.plist",
|
||||||
|
"Library/Preferences/com.apple.controlcenter.plist"
|
||||||
|
].map { home.appendingPathComponent($0) }
|
||||||
|
}()
|
||||||
|
|
||||||
|
func loadActiveFocusState(identifierMissing: Bool, nameMissing: Bool) -> (identifier: String?, name: String?)? {
|
||||||
|
guard identifierMissing || nameMissing else { return nil }
|
||||||
|
|
||||||
|
let activeRecords = loadRecords(from: activeStateURLs)
|
||||||
|
let catalogRecords = loadRecords(from: catalogURLs)
|
||||||
|
let allRecords = activeRecords + catalogRecords
|
||||||
|
|
||||||
|
guard !allRecords.isEmpty else { return nil }
|
||||||
|
|
||||||
|
var identifier: String?
|
||||||
|
var name: String?
|
||||||
|
|
||||||
|
if let record = activeRecords.first(where: { record in
|
||||||
|
guard let id = record.identifier, !id.isEmpty else { return false }
|
||||||
|
guard let name = record.name, !name.isEmpty else { return false }
|
||||||
|
return true
|
||||||
|
}) {
|
||||||
|
identifier = record.identifier
|
||||||
|
name = record.name
|
||||||
|
}
|
||||||
|
|
||||||
|
if identifierMissing && (identifier == nil || identifier?.isEmpty == true) {
|
||||||
|
identifier = activeRecords.compactMap { $0.identifier?.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||||
|
.first(where: { !$0.isEmpty })
|
||||||
|
|
||||||
|
if identifier == nil {
|
||||||
|
identifier = catalogRecords.compactMap { $0.identifier?.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||||
|
.first(where: { !$0.isEmpty })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if nameMissing && (name == nil || name?.isEmpty == true) {
|
||||||
|
if let id = identifier {
|
||||||
|
name = allRecords.first(where: {
|
||||||
|
guard let candidateIdentifier = $0.identifier else { return false }
|
||||||
|
return candidateIdentifier.compare(id, options: [.caseInsensitive, .diacriticInsensitive]) == .orderedSame && ($0.name?.isEmpty == false)
|
||||||
|
})?.name
|
||||||
|
}
|
||||||
|
|
||||||
|
if name == nil {
|
||||||
|
name = activeRecords.compactMap { $0.name?.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||||
|
.first(where: { !$0.isEmpty })
|
||||||
|
}
|
||||||
|
|
||||||
|
if name == nil {
|
||||||
|
name = catalogRecords.compactMap { $0.name?.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||||
|
.first(where: { !$0.isEmpty })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if identifier == nil && name == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return (identifierMissing ? identifier : nil, nameMissing ? name : nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func loadRecords(from urls: [URL]) -> [FocusRecord] {
|
||||||
|
var records: [FocusRecord] = []
|
||||||
|
|
||||||
|
for url in urls {
|
||||||
|
guard fileManager.fileExists(atPath: url.path) else { continue }
|
||||||
|
|
||||||
|
if let cached = cachedRecords(for: url) {
|
||||||
|
records.append(contentsOf: cached)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let attributes = try? fileManager.attributesOfItem(atPath: url.path),
|
||||||
|
let modified = attributes[.modificationDate] as? Date,
|
||||||
|
let data = try? Data(contentsOf: url) else {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
let decoded = decodeData(data)
|
||||||
|
let extracted = extractRecords(from: decoded)
|
||||||
|
|
||||||
|
cache[CacheKey(url: url)] = (modified, extracted)
|
||||||
|
records.append(contentsOf: extracted)
|
||||||
|
}
|
||||||
|
|
||||||
|
return records
|
||||||
|
}
|
||||||
|
|
||||||
|
private func cachedRecords(for url: URL) -> [FocusRecord]? {
|
||||||
|
let key = CacheKey(url: url)
|
||||||
|
guard let cached = cache[key],
|
||||||
|
let attributes = try? fileManager.attributesOfItem(atPath: url.path),
|
||||||
|
let modified = attributes[.modificationDate] as? Date,
|
||||||
|
cached.modified == modified else {
|
||||||
|
cache.removeValue(forKey: key)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return cached.records
|
||||||
|
}
|
||||||
|
|
||||||
|
private func decodeData(_ data: Data) -> Any {
|
||||||
|
if let propertyList = try? PropertyListSerialization.propertyList(from: data, options: [], format: nil) {
|
||||||
|
return propertyList
|
||||||
|
}
|
||||||
|
|
||||||
|
if let unarchived = try? NSKeyedUnarchiver.unarchivedObject(ofClasses: [NSDictionary.self, NSArray.self, NSString.self, NSNumber.self, NSData.self], from: data) {
|
||||||
|
return unarchived as Any
|
||||||
|
}
|
||||||
|
|
||||||
|
if let unarchived = try? NSKeyedUnarchiver.unarchiveTopLevelObjectWithData(data) {
|
||||||
|
return unarchived as Any
|
||||||
|
}
|
||||||
|
|
||||||
|
if let jsonObject = try? JSONSerialization.jsonObject(with: data, options: [.fragmentsAllowed]) {
|
||||||
|
return jsonObject
|
||||||
|
}
|
||||||
|
|
||||||
|
if let string = String(data: data, encoding: .utf8) {
|
||||||
|
return string
|
||||||
|
}
|
||||||
|
|
||||||
|
return [:]
|
||||||
|
}
|
||||||
|
|
||||||
|
private func extractRecords(from value: Any) -> [FocusRecord] {
|
||||||
|
var results: [FocusRecord] = []
|
||||||
|
|
||||||
|
if let dictionary = value as? [AnyHashable: Any] {
|
||||||
|
let identifier = directMatch(in: dictionary, keys: identifierKeys)
|
||||||
|
let name = directMatch(in: dictionary, keys: nameKeys)
|
||||||
|
|
||||||
|
if identifier != nil || name != nil {
|
||||||
|
results.append(FocusRecord(identifier: identifier, name: name))
|
||||||
|
}
|
||||||
|
|
||||||
|
for nested in dictionary.values {
|
||||||
|
results.append(contentsOf: extractRecords(from: nested))
|
||||||
|
}
|
||||||
|
} else if let array = value as? [Any] {
|
||||||
|
for element in array {
|
||||||
|
results.append(contentsOf: extractRecords(from: element))
|
||||||
|
}
|
||||||
|
} else if let data = value as? Data {
|
||||||
|
let decoded = decodeData(data)
|
||||||
|
results.append(contentsOf: extractRecords(from: decoded))
|
||||||
|
} else if let data = value as? NSData {
|
||||||
|
let decoded = decodeData(data as Data)
|
||||||
|
results.append(contentsOf: extractRecords(from: decoded))
|
||||||
|
} else if let string = value as? String {
|
||||||
|
let cleaned = FocusMetadataDecoder.cleanedString(string)
|
||||||
|
if !cleaned.isEmpty {
|
||||||
|
let identifier = matchIdentifier(in: cleaned)
|
||||||
|
let name = identifier == nil ? matchName(in: cleaned) : matchName(in: cleaned, matching: identifier)
|
||||||
|
|
||||||
|
if identifier != nil || name != nil {
|
||||||
|
results.append(FocusRecord(identifier: identifier, name: name))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
|
||||||
|
private func directMatch(in dictionary: [AnyHashable: Any], keys: [String]) -> String? {
|
||||||
|
for key in keys {
|
||||||
|
if let candidate = dictionary[key], let string = normalizedString(from: candidate) {
|
||||||
|
return string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private func normalizedString(from value: Any) -> String? {
|
||||||
|
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)
|
||||||
|
case let data as Data:
|
||||||
|
if let string = String(data: data, encoding: .utf8) {
|
||||||
|
return FocusMetadataDecoder.cleanedString(string)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
case let data as NSData:
|
||||||
|
return normalizedString(from: data as Data)
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func matchIdentifier(in string: String) -> String? {
|
||||||
|
FocusMetadataDecoder.extractIdentifier(from: string)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func matchName(in string: String, matching identifier: String? = nil) -> String? {
|
||||||
|
if let name = FocusMetadataDecoder.extractName(from: string) {
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
|
||||||
|
if let identifier = identifier {
|
||||||
|
let components = identifier.split(separator: ".")
|
||||||
|
if let last = components.last {
|
||||||
|
let derived = FocusMetadataDecoder.cleanedString(String(last).replacingOccurrences(of: "_", with: " "))
|
||||||
|
if !derived.isEmpty {
|
||||||
|
return derived.capitalized
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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..<description.endIndex, in: description)
|
||||||
|
|
||||||
|
if let regex = FocusNotificationParsing.identifierPattern,
|
||||||
|
let match = regex.firstMatch(in: description, options: [], range: fullRange),
|
||||||
|
match.numberOfRanges > 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..<description.endIndex, in: description)
|
||||||
|
|
||||||
|
for regex in FocusNotificationParsing.namePatterns {
|
||||||
|
if let match = regex.firstMatch(in: description, options: [], range: fullRange),
|
||||||
|
match.numberOfRanges > 1,
|
||||||
|
let nameRange = Range(match.range(at: 1), in: description) {
|
||||||
|
let candidate = cleanedString(String(description[nameRange]))
|
||||||
|
if !candidate.isEmpty {
|
||||||
|
return candidate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@
|
||||||
<img src="https://img.shields.io/github/downloads/Ebullioscopic/Atoll/total?label=Downloads" alt="GitHub downloads"/>
|
<img src="https://img.shields.io/github/downloads/Ebullioscopic/Atoll/total?label=Downloads" alt="GitHub downloads"/>
|
||||||
</a>
|
</a>
|
||||||
<a href="https://discord.gg/zvjB4tJBkt">
|
<a href="https://discord.gg/zvjB4tJBkt">
|
||||||
<img src="https://img.shields.io/discord/1120176554840510494?label=Discord&logo=discord&color=7289da" alt="Discord server"/>
|
<img src="https://img.shields.io/discord/1429481472942669896?label=Discord&logo=discord&color=7289da" alt="Discord server"/>
|
||||||
</a>
|
</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue