Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,9 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>0.8.5</string>
<string>0.8.6</string>
<key>CFBundleVersion</key>
<string>41</string>
<string>42</string>
<key>LSMinimumSystemVersion</key>
<string>14.0</string>
<key>LSUIElement</key>
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ Cue를 실행한 뒤 받은 알림은 원래 대화 주소를 확인할 수 있
소스 관리에서 **앱 추가**를 누르고 설치된 앱을 선택하면 됩니다. Cue는
macOS 알림 센터에 저장된 알림 제목과 내용을 보여줍니다. 앱의 공식 API는
사용하지 않기 때문에, 앱 안에서 진행 중인 작업 상태까지 알 수는 없습니다.
알림 센터의 변경을 로컬에서 감지해 새 알림을 바로 반영합니다.

## 설치

Expand Down Expand Up @@ -168,6 +169,7 @@ swift run Cue --preview-regular-app --preview-many-platforms \

- `CueModel`: 소스 설정, 상태 정리, 로컬 저장
- `LocalActivityMonitor`: Codex와 macOS 알림 센터 읽기
- `NotificationDatabaseWatcher`: 새 macOS 알림을 감지해 목록 갱신
- `MattermostBridgeSupervisor`: Mattermost의 원래 대화로 이동하기 위한 로컬 연결
- `CueNotchView`: 노치, 활동 목록, 소스 관리, 순서 변경
- `NotchPanelController`: 노치 위치와 멀티 디스플레이 동작
Expand Down
52 changes: 52 additions & 0 deletions Sources/Cue/CueModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,8 @@ final class CueModel: ObservableObject {
private var hoverTask: Task<Void, Never>?
private var monitorTask: Task<Void, Never>?
private var refreshTask: Task<Void, Never>?
private var notificationRefreshTask: Task<Void, Never>?
private var notificationDatabaseWatcher: NotificationDatabaseWatcher?
private let localMonitor = LocalActivityMonitor()
private let mattermostBridge = MattermostBridgeSupervisor()
private let locksExpandedState: Bool
Expand Down Expand Up @@ -319,6 +321,7 @@ final class CueModel: ObservableObject {
mattermostBridge.start()
}
startMonitoring()
updateNotificationDatabaseWatcher()
}

}
Expand Down Expand Up @@ -725,6 +728,7 @@ final class CueModel: ObservableObject {
notificationSeenAt[notificationSources[index].id] = Date()
.timeIntervalSince1970
persistNotificationConfiguration()
updateNotificationDatabaseWatcher()
refreshNow()
return
}
Expand All @@ -751,6 +755,7 @@ final class CueModel: ObservableObject {
}
notificationSeenAt[configuration.id] = Date().timeIntervalSince1970
persistNotificationConfiguration()
updateNotificationDatabaseWatcher()
refreshNow()
}

Expand All @@ -777,6 +782,7 @@ final class CueModel: ObservableObject {
}

persistBuiltInSourceConfiguration()
updateNotificationDatabaseWatcher()
refreshNow()
}

Expand All @@ -802,6 +808,7 @@ final class CueModel: ObservableObject {
persistBuiltInSourceConfiguration()
persistPlatformOrder()
persistCollapsedPlatformIDs()
updateNotificationDatabaseWatcher()
}

func toggleNotificationSource(_ sourceID: String) {
Expand All @@ -816,6 +823,7 @@ final class CueModel: ObservableObject {
notificationSeenAt[sourceID] = Date().timeIntervalSince1970
notificationPreviews.removeAll { $0.sourceID == sourceID }
persistNotificationConfiguration()
updateNotificationDatabaseWatcher()
refreshNow()
}

Expand All @@ -830,6 +838,7 @@ final class CueModel: ObservableObject {
persistNotificationConfiguration()
persistPlatformOrder()
persistCollapsedPlatformIDs()
updateNotificationDatabaseWatcher()
}

func showWorkingDemo() {
Expand Down Expand Up @@ -860,6 +869,9 @@ final class CueModel: ObservableObject {
hoverTask?.cancel()
monitorTask?.cancel()
refreshTask?.cancel()
notificationRefreshTask?.cancel()
notificationDatabaseWatcher?.stop()
notificationDatabaseWatcher = nil
mattermostBridge.stop()
}

Expand All @@ -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
Expand Down Expand Up @@ -1073,6 +1124,7 @@ final class CueModel: ObservableObject {
}

persistBuiltInSourceConfiguration()
updateNotificationDatabaseWatcher()
refreshNow()
}

Expand Down
202 changes: 202 additions & 0 deletions Sources/Cue/NotificationDatabaseWatcher.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}