From 86b7442f3c1d19cd701416a563b365db6edb8a02 Mon Sep 17 00:00:00 2001 From: Gregory Anders Date: Mon, 13 Nov 2023 21:52:45 -0600 Subject: [PATCH] macos: implement desktop notifications --- include/ghostty.h | 2 + macos/Sources/AppDelegate.swift | 42 ++++++++++++++++++- macos/Sources/Ghostty/AppState.swift | 54 +++++++++++++++++++++++- macos/Sources/Ghostty/Package.swift | 6 +++ macos/Sources/Ghostty/SurfaceView.swift | 56 ++++++++++++++++++++++++- 5 files changed, 156 insertions(+), 4 deletions(-) diff --git a/include/ghostty.h b/include/ghostty.h index 0546115a2..f66f92c47 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -362,6 +362,7 @@ typedef void (*ghostty_runtime_toggle_fullscreen_cb)(void *, ghostty_non_native_ typedef void (*ghostty_runtime_set_initial_window_size_cb)(void *, uint32_t, uint32_t); typedef void (*ghostty_runtime_render_inspector_cb)(void *); typedef void (*ghostty_runtime_set_cell_size_cb)(void *, uint32_t, uint32_t); +typedef void (*ghostty_runtime_show_desktop_notification_cb)(void *, const char *, const char *); typedef struct { void *userdata; @@ -388,6 +389,7 @@ typedef struct { ghostty_runtime_set_initial_window_size_cb set_initial_window_size_cb; ghostty_runtime_render_inspector_cb render_inspector_cb; ghostty_runtime_set_cell_size_cb set_cell_size_cb; + ghostty_runtime_show_desktop_notification_cb show_desktop_notification_cb; } ghostty_runtime_config_s; //------------------------------------------------------------------- diff --git a/macos/Sources/AppDelegate.swift b/macos/Sources/AppDelegate.swift index 00cfd8479..600038963 100644 --- a/macos/Sources/AppDelegate.swift +++ b/macos/Sources/AppDelegate.swift @@ -1,8 +1,9 @@ import AppKit +import UserNotifications import OSLog import GhosttyKit -class AppDelegate: NSObject, ObservableObject, NSApplicationDelegate, GhosttyAppStateDelegate { +class AppDelegate: NSObject, ObservableObject, NSApplicationDelegate, UNUserNotificationCenterDelegate, GhosttyAppStateDelegate { // The application logger. We should probably move this at some point to a dedicated // class/struct but for now it lives here! 🤷‍♂️ static let logger = Logger( @@ -87,6 +88,22 @@ class AppDelegate: NSObject, ObservableObject, NSApplicationDelegate, GhosttyApp // Register our service provider. This must happen after everything // else is initialized. NSApp.servicesProvider = ServiceProvider() + + // Configure user notifications + let actions = [ + UNNotificationAction(identifier: Ghostty.userNotificationActionShow, title: "Show") + ] + + let center = UNUserNotificationCenter.current() + center.setNotificationCategories([ + UNNotificationCategory( + identifier: Ghostty.userNotificationCategory, + actions: actions, + intentIdentifiers: [], + options: [.customDismissAction] + ) + ]) + center.delegate = self } func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { @@ -248,7 +265,28 @@ class AppDelegate: NSObject, ObservableObject, NSApplicationDelegate, GhosttyApp private func focusedSurface() -> ghostty_surface_t? { return terminalManager.focusedSurface?.surface } - + + //MARK: - UNUserNotificationCenterDelegate + + func userNotificationCenter( + _ center: UNUserNotificationCenter, + didReceive: UNNotificationResponse, + withCompletionHandler: () -> Void + ) { + ghostty.handleUserNotification(response: didReceive) + withCompletionHandler() + } + + func userNotificationCenter( + _ center: UNUserNotificationCenter, + willPresent: UNNotification, + withCompletionHandler: (UNNotificationPresentationOptions) -> Void + ) { + let shouldPresent = ghostty.shouldPresentNotification(notification: willPresent) + let options: UNNotificationPresentationOptions = shouldPresent ? [.banner, .sound] : [] + withCompletionHandler(options) + } + //MARK: - GhosttyAppStateDelegate func configDidReload(_ state: Ghostty.AppState) { diff --git a/macos/Sources/Ghostty/AppState.swift b/macos/Sources/Ghostty/AppState.swift index 0dfa2e216..f9f4da8b8 100644 --- a/macos/Sources/Ghostty/AppState.swift +++ b/macos/Sources/Ghostty/AppState.swift @@ -1,4 +1,5 @@ import SwiftUI +import UserNotifications import GhosttyKit protocol GhosttyAppStateDelegate: AnyObject { @@ -167,7 +168,10 @@ extension Ghostty { toggle_fullscreen_cb: { userdata, nonNativeFullscreen in AppState.toggleFullscreen(userdata, nonNativeFullscreen: nonNativeFullscreen) }, set_initial_window_size_cb: { userdata, width, height in AppState.setInitialWindowSize(userdata, width: width, height: height) }, render_inspector_cb: { userdata in AppState.renderInspector(userdata) }, - set_cell_size_cb: { userdata, width, height in AppState.setCellSize(userdata, width: width, height: height) } + set_cell_size_cb: { userdata, width, height in AppState.setCellSize(userdata, width: width, height: height) }, + show_desktop_notification_cb: { userdata, title, body in + AppState.showUserNotification(userdata, title: title, body: body) + } ) // Create the ghostty app. @@ -563,6 +567,54 @@ extension Ghostty { surfaceView.cellSize = surfaceView.convertFromBacking(backingSize) } + static func showUserNotification(_ userdata: UnsafeMutableRawPointer?, title: UnsafePointer?, body: UnsafePointer?) { + let surfaceView = self.surfaceUserdata(from: userdata) + guard let title = String(cString: title!, encoding: .utf8) else { return } + guard let body = String(cString: body!, encoding: .utf8) else { return } + + let center = UNUserNotificationCenter.current() + center.requestAuthorization(options: [.alert, .sound]) { _, error in + if let error = error { + AppDelegate.logger.error("Error while requesting notification authorization: \(error)") + } + } + + center.getNotificationSettings() { settings in + guard settings.authorizationStatus == .authorized else { return } + surfaceView.showUserNotification(title: title, body: body) + } + } + + /// Handle a received user notification. This is called when a user notification is clicked or dismissed by the user + func handleUserNotification(response: UNNotificationResponse) { + let userInfo = response.notification.request.content.userInfo + guard let address = userInfo["address"] as? Int else { return } + guard let userdata = UnsafeMutableRawPointer(bitPattern: address) else { return } + let surface = Ghostty.AppState.surfaceUserdata(from: userdata) + + switch (response.actionIdentifier) { + case UNNotificationDefaultActionIdentifier, Ghostty.userNotificationActionShow: + // The user clicked on a notification + surface.handleUserNotification(notification: response.notification, focus: true) + case UNNotificationDismissActionIdentifier: + // The user dismissed the notification + surface.handleUserNotification(notification: response.notification, focus: false) + default: + break + } + } + + /// Determine if a given notification should be presented to the user when Ghostty is running in the foreground. + func shouldPresentNotification(notification: UNNotification) -> Bool { + let userInfo = notification.request.content.userInfo + guard let address = userInfo["address"] as? Int else { return false } + guard let userdata = UnsafeMutableRawPointer(bitPattern: address) else { return false } + let surface = Ghostty.AppState.surfaceUserdata(from: userdata) + + guard let window = surface.window else { return false } + return !window.isKeyWindow || !surface.focused + } + static func newTab(_ userdata: UnsafeMutableRawPointer?, config: ghostty_surface_config_s) { let surface = self.surfaceUserdata(from: userdata) diff --git a/macos/Sources/Ghostty/Package.swift b/macos/Sources/Ghostty/Package.swift index 49ab84ab6..e1f3f5e99 100644 --- a/macos/Sources/Ghostty/Package.swift +++ b/macos/Sources/Ghostty/Package.swift @@ -4,6 +4,12 @@ import GhosttyKit struct Ghostty { // All the notifications that will be emitted will be put here. struct Notification {} + + // The user notification category identifier + static let userNotificationCategory = "com.mitchellh.ghostty.userNotification" + + // The user notification "Show" action + static let userNotificationActionShow = "com.mitchellh.ghostty.userNotification.Show" } // MARK: Surface Notifications diff --git a/macos/Sources/Ghostty/SurfaceView.swift b/macos/Sources/Ghostty/SurfaceView.swift index b1a8a69ab..51e9e5eec 100644 --- a/macos/Sources/Ghostty/SurfaceView.swift +++ b/macos/Sources/Ghostty/SurfaceView.swift @@ -1,4 +1,5 @@ import SwiftUI +import UserNotifications import GhosttyKit extension Ghostty { @@ -275,13 +276,16 @@ extension Ghostty { } } } + + // Notification identifiers associated with this surface + var notificationIdentifiers: Set = [] private(set) var surface: ghostty_surface_t? var error: Error? = nil private var markedText: NSMutableAttributedString private var mouseEntered: Bool = false - private var focused: Bool = true + private(set) var focused: Bool = true private var cursor: NSCursor = .iBeam private var cursorVisible: CursorVisibility = .visible @@ -348,6 +352,10 @@ extension Ghostty { /// Ghostty resources while references may still be held to this view. I've found that SwiftUI /// tends to hold this view longer than it should so we free the expensive stuff explicitly. func close() { + // Remove any notifications associated with this surface + let identifiers = Array(self.notificationIdentifiers) + UNUserNotificationCenter.current().removeDeliveredNotifications(withIdentifiers: identifiers) + guard let surface = self.surface else { return } ghostty_surface_free(surface) self.surface = nil @@ -1001,6 +1009,52 @@ extension Ghostty { print("SEL: \(selector)") } + + /// Show a user notification and associate it with this surface + func showUserNotification(title: String, body: String) { + let content = UNMutableNotificationContent() + content.title = title + content.subtitle = self.title + content.body = body + content.sound = UNNotificationSound.default + content.categoryIdentifier = Ghostty.userNotificationCategory + + // The userInfo must conform to NSSecureCoding, which SurfaceView + // does not. So instead we pass an integer representation of the + // SurfaceView's address, which is reconstructed back into a + // SurfaceView if the notification is clicked. This is safe to do + // so long as the SurfaceView removes all of its notifications when + // it closes so that there are no dangling pointers. + content.userInfo = [ + "address": Int(bitPattern: Unmanaged.passUnretained(self).toOpaque()), + ] + + let uuid = UUID().uuidString + let request = UNNotificationRequest( + identifier: uuid, + content: content, + trigger: nil + ) + + UNUserNotificationCenter.current().add(request) { error in + if let error = error { + AppDelegate.logger.error("Error scheduling user notification: \(error)") + return + } + + self.notificationIdentifiers.insert(uuid) + } + } + + /// Handle a user notification click + func handleUserNotification(notification: UNNotification, focus: Bool) { + let id = notification.request.identifier + guard self.notificationIdentifiers.remove(id) != nil else { return } + if focus { + self.window?.makeKeyAndOrderFront(self) + Ghostty.moveFocus(to: self) + } + } } }