diff --git a/Info.plist b/Info.plist index 3ee0769..2fbc048 100644 --- a/Info.plist +++ b/Info.plist @@ -15,9 +15,9 @@ CFBundlePackageType APPL CFBundleShortVersionString - 0.8.5 + 0.8.6 CFBundleVersion - 41 + 42 LSMinimumSystemVersion 14.0 LSUIElement diff --git a/README.md b/README.md index 95bbe01..c543f71 100644 --- a/README.md +++ b/README.md @@ -85,6 +85,7 @@ Cue를 실행한 뒤 받은 알림은 원래 대화 주소를 확인할 수 있 소스 관리에서 **앱 추가**를 누르고 설치된 앱을 선택하면 됩니다. Cue는 macOS 알림 센터에 저장된 알림 제목과 내용을 보여줍니다. 앱의 공식 API는 사용하지 않기 때문에, 앱 안에서 진행 중인 작업 상태까지 알 수는 없습니다. +알림 센터의 변경을 로컬에서 감지해 새 알림을 바로 반영합니다. ## 설치 @@ -168,6 +169,7 @@ swift run Cue --preview-regular-app --preview-many-platforms \ - `CueModel`: 소스 설정, 상태 정리, 로컬 저장 - `LocalActivityMonitor`: Codex와 macOS 알림 센터 읽기 +- `NotificationDatabaseWatcher`: 새 macOS 알림을 감지해 목록 갱신 - `MattermostBridgeSupervisor`: Mattermost의 원래 대화로 이동하기 위한 로컬 연결 - `CueNotchView`: 노치, 활동 목록, 소스 관리, 순서 변경 - `NotchPanelController`: 노치 위치와 멀티 디스플레이 동작 diff --git a/Sources/Cue/CueModel.swift b/Sources/Cue/CueModel.swift index 35da6d8..21263b3 100644 --- a/Sources/Cue/CueModel.swift +++ b/Sources/Cue/CueModel.swift @@ -85,6 +85,8 @@ final class CueModel: ObservableObject { private var hoverTask: Task? private var monitorTask: Task? private var refreshTask: Task? + private var notificationRefreshTask: Task? + private var notificationDatabaseWatcher: NotificationDatabaseWatcher? private let localMonitor = LocalActivityMonitor() private let mattermostBridge = MattermostBridgeSupervisor() private let locksExpandedState: Bool @@ -319,6 +321,7 @@ final class CueModel: ObservableObject { mattermostBridge.start() } startMonitoring() + updateNotificationDatabaseWatcher() } } @@ -725,6 +728,7 @@ final class CueModel: ObservableObject { notificationSeenAt[notificationSources[index].id] = Date() .timeIntervalSince1970 persistNotificationConfiguration() + updateNotificationDatabaseWatcher() refreshNow() return } @@ -751,6 +755,7 @@ final class CueModel: ObservableObject { } notificationSeenAt[configuration.id] = Date().timeIntervalSince1970 persistNotificationConfiguration() + updateNotificationDatabaseWatcher() refreshNow() } @@ -777,6 +782,7 @@ final class CueModel: ObservableObject { } persistBuiltInSourceConfiguration() + updateNotificationDatabaseWatcher() refreshNow() } @@ -802,6 +808,7 @@ final class CueModel: ObservableObject { persistBuiltInSourceConfiguration() persistPlatformOrder() persistCollapsedPlatformIDs() + updateNotificationDatabaseWatcher() } func toggleNotificationSource(_ sourceID: String) { @@ -816,6 +823,7 @@ final class CueModel: ObservableObject { notificationSeenAt[sourceID] = Date().timeIntervalSince1970 notificationPreviews.removeAll { $0.sourceID == sourceID } persistNotificationConfiguration() + updateNotificationDatabaseWatcher() refreshNow() } @@ -830,6 +838,7 @@ final class CueModel: ObservableObject { persistNotificationConfiguration() persistPlatformOrder() persistCollapsedPlatformIDs() + updateNotificationDatabaseWatcher() } func showWorkingDemo() { @@ -860,6 +869,9 @@ final class CueModel: ObservableObject { hoverTask?.cancel() monitorTask?.cancel() refreshTask?.cancel() + notificationRefreshTask?.cancel() + notificationDatabaseWatcher?.stop() + notificationDatabaseWatcher = nil mattermostBridge.stop() } @@ -878,6 +890,45 @@ final class CueModel: ObservableObject { } } + private var needsNotificationDatabaseWatcher: Bool { + isMattermostSourceEnabled + || notificationSources.contains(where: \.isEnabled) + } + + private func updateNotificationDatabaseWatcher() { + guard !usesDemoData else { return } + + guard needsNotificationDatabaseWatcher else { + notificationRefreshTask?.cancel() + notificationRefreshTask = nil + notificationDatabaseWatcher?.stop() + notificationDatabaseWatcher = nil + return + } + + guard notificationDatabaseWatcher == nil else { return } + let watcher = NotificationDatabaseWatcher { [weak self] in + Task { @MainActor [weak self] in + self?.scheduleNotificationDatabaseRefresh() + } + } + notificationDatabaseWatcher = watcher + watcher.start() + } + + private func scheduleNotificationDatabaseRefresh() { + guard !usesDemoData, needsNotificationDatabaseWatcher else { return } + notificationRefreshTask?.cancel() + notificationRefreshTask = Task { @MainActor [weak self] in + do { + try await Task.sleep(for: .milliseconds(20)) + } catch { + return + } + await self?.refreshLocalState() + } + } + private func refreshLocalState() async { let frontmostIdentifier = NSWorkspace.shared.frontmostApplication? .bundleIdentifier @@ -1073,6 +1124,7 @@ final class CueModel: ObservableObject { } persistBuiltInSourceConfiguration() + updateNotificationDatabaseWatcher() refreshNow() } diff --git a/Sources/Cue/NotificationDatabaseWatcher.swift b/Sources/Cue/NotificationDatabaseWatcher.swift new file mode 100644 index 0000000..1177cfc --- /dev/null +++ b/Sources/Cue/NotificationDatabaseWatcher.swift @@ -0,0 +1,202 @@ +import Darwin +import Dispatch +import Foundation + +/// Watches the local macOS Notification Center database for changes. +/// +/// SQLite writes normally land in the WAL, but macOS can create, remove, or +/// replace any of the database files. Watching the containing directory as well +/// as the individual files keeps the watcher attached across those transitions. +final class NotificationDatabaseWatcher: @unchecked Sendable { + private final class WatchToken { + let source: DispatchSourceFileSystemObject + + init(source: DispatchSourceFileSystemObject) { + self.source = source + } + } + + private static let directoryKey = "directory" + private static let databaseFileNames = ["db", "db-wal", "db-shm"] + + private let queue = DispatchQueue( + label: "app.cue.notification-database-watcher", + qos: .utility + ) + private let databaseDirectoryURL: URL + private let debounceInterval: DispatchTimeInterval + private let onChange: @Sendable () -> Void + + private var isRunning = false + private var watchTokens: [String: WatchToken] = [:] + private var debounceWorkItem: DispatchWorkItem? + private var retryWorkItem: DispatchWorkItem? + + init( + databaseDirectoryURL: URL = FileManager.default + .homeDirectoryForCurrentUser + .appendingPathComponent( + "Library/Group Containers/group.com.apple.usernoted/db2", + isDirectory: true + ), + debounceInterval: DispatchTimeInterval = .milliseconds(80), + onChange: @escaping @Sendable () -> Void + ) { + self.databaseDirectoryURL = databaseDirectoryURL + self.debounceInterval = debounceInterval + self.onChange = onChange + } + + func start() { + queue.sync { + guard !isRunning else { return } + isRunning = true + reconcileWatchers() + } + } + + func stop() { + queue.sync { + guard isRunning else { return } + isRunning = false + debounceWorkItem?.cancel() + debounceWorkItem = nil + retryWorkItem?.cancel() + retryWorkItem = nil + + for token in watchTokens.values { + token.source.cancel() + } + watchTokens.removeAll() + } + } + + private func reconcileWatchers() { + guard isRunning else { return } + + if watchTokens[Self.directoryKey] == nil { + installWatcher( + key: Self.directoryKey, + url: databaseDirectoryURL, + eventMask: [ + .write, + .attrib, + .rename, + .delete, + .revoke + ] + ) + } + + for fileName in Self.databaseFileNames { + let fileURL = databaseDirectoryURL.appendingPathComponent(fileName) + let fileExists = FileManager.default.fileExists( + atPath: fileURL.path + ) + + if fileExists, watchTokens[fileName] == nil { + installWatcher( + key: fileName, + url: fileURL, + eventMask: [ + .write, + .extend, + .attrib, + .rename, + .delete, + .revoke + ] + ) + } else if !fileExists, + let token = watchTokens.removeValue(forKey: fileName) { + token.source.cancel() + } + } + + if watchTokens[Self.directoryKey] == nil + || watchTokens["db"] == nil { + scheduleRetry() + } else { + retryWorkItem?.cancel() + retryWorkItem = nil + } + } + + private func installWatcher( + key: String, + url: URL, + eventMask: DispatchSource.FileSystemEvent + ) { + let fileDescriptor = url.withUnsafeFileSystemRepresentation { path in + guard let path else { return Int32(-1) } + return open(path, O_EVTONLY) + } + guard fileDescriptor >= 0 else { return } + + let source = DispatchSource.makeFileSystemObjectSource( + fileDescriptor: fileDescriptor, + eventMask: eventMask, + queue: queue + ) + source.setEventHandler { [weak self] in + self?.handleEvent(for: key) + } + source.setCancelHandler { + close(fileDescriptor) + } + watchTokens[key] = WatchToken(source: source) + source.resume() + } + + private func handleEvent(for key: String) { + guard isRunning, let token = watchTokens[key] else { return } + let events = token.source.data + scheduleChangeCallback() + + if key == Self.directoryKey || events.contains(.write) { + reconcileWatchers() + } + + let invalidationEvents: DispatchSource.FileSystemEvent = [ + .rename, + .delete, + .revoke + ] + if !events.intersection(invalidationEvents).isEmpty { + watchTokens.removeValue(forKey: key)?.source.cancel() + scheduleReconciliation() + } + } + + private func scheduleChangeCallback() { + debounceWorkItem?.cancel() + + let workItem = DispatchWorkItem { [weak self] in + guard let self, self.isRunning else { return } + self.onChange() + } + debounceWorkItem = workItem + queue.asyncAfter( + deadline: .now() + debounceInterval, + execute: workItem + ) + } + + private func scheduleReconciliation() { + queue.asyncAfter(deadline: .now() + .milliseconds(100)) { [weak self] in + self?.reconcileWatchers() + } + } + + private func scheduleRetry() { + guard retryWorkItem == nil else { return } + + let workItem = DispatchWorkItem { [weak self] in + guard let self, self.isRunning else { return } + self.retryWorkItem = nil + self.reconcileWatchers() + } + retryWorkItem = workItem + queue.asyncAfter(deadline: .now() + .seconds(2), execute: workItem) + } +}