macos: implement desktop notifications

This commit is contained in:
Gregory Anders
2023-11-13 21:52:45 -06:00
parent 54a489eefa
commit 86b7442f3c
5 changed files with 156 additions and 4 deletions

View File

@ -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;
//-------------------------------------------------------------------

View File

@ -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 {
@ -249,6 +266,27 @@ class AppDelegate: NSObject, ObservableObject, NSApplicationDelegate, GhosttyApp
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) {

View File

@ -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<CChar>?, body: UnsafePointer<CChar>?) {
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)

View File

@ -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

View File

@ -1,4 +1,5 @@
import SwiftUI
import UserNotifications
import GhosttyKit
extension Ghostty {
@ -276,12 +277,15 @@ extension Ghostty {
}
}
// Notification identifiers associated with this surface
var notificationIdentifiers: Set<String> = []
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)
}
}
}
}