From 19dcb9748022a98bd70785bde732ef4aa2859787 Mon Sep 17 00:00:00 2001
From: exemchs <185463904+exemchs@users.noreply.github.com>
Date: Mon, 27 Jul 2026 17:08:56 +0900
Subject: [PATCH] Refresh notification sources immediately
---
Info.plist | 4 +-
README.md | 2 +
Sources/Cue/CueModel.swift | 52 +++++
Sources/Cue/NotificationDatabaseWatcher.swift | 202 ++++++++++++++++++
4 files changed, 258 insertions(+), 2 deletions(-)
create mode 100644 Sources/Cue/NotificationDatabaseWatcher.swift
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)
+ }
+}