mirror of
https://github.com/ghostty-org/ghostty.git
synced 2025-08-02 14:57:31 +03:00
macos: implement desktop notifications
This commit is contained in:
@ -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;
|
||||
|
||||
//-------------------------------------------------------------------
|
||||
|
@ -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) {
|
||||
|
@ -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)
|
||||
|
||||
|
@ -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
|
||||
|
@ -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<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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
Reference in New Issue
Block a user