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_set_initial_window_size_cb)(void *, uint32_t, uint32_t);
|
||||||
typedef void (*ghostty_runtime_render_inspector_cb)(void *);
|
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_set_cell_size_cb)(void *, uint32_t, uint32_t);
|
||||||
|
typedef void (*ghostty_runtime_show_desktop_notification_cb)(void *, const char *, const char *);
|
||||||
|
|
||||||
typedef struct {
|
typedef struct {
|
||||||
void *userdata;
|
void *userdata;
|
||||||
@ -388,6 +389,7 @@ typedef struct {
|
|||||||
ghostty_runtime_set_initial_window_size_cb set_initial_window_size_cb;
|
ghostty_runtime_set_initial_window_size_cb set_initial_window_size_cb;
|
||||||
ghostty_runtime_render_inspector_cb render_inspector_cb;
|
ghostty_runtime_render_inspector_cb render_inspector_cb;
|
||||||
ghostty_runtime_set_cell_size_cb set_cell_size_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;
|
} ghostty_runtime_config_s;
|
||||||
|
|
||||||
//-------------------------------------------------------------------
|
//-------------------------------------------------------------------
|
||||||
|
@ -1,8 +1,9 @@
|
|||||||
import AppKit
|
import AppKit
|
||||||
|
import UserNotifications
|
||||||
import OSLog
|
import OSLog
|
||||||
import GhosttyKit
|
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
|
// The application logger. We should probably move this at some point to a dedicated
|
||||||
// class/struct but for now it lives here! 🤷♂️
|
// class/struct but for now it lives here! 🤷♂️
|
||||||
static let logger = Logger(
|
static let logger = Logger(
|
||||||
@ -87,6 +88,22 @@ class AppDelegate: NSObject, ObservableObject, NSApplicationDelegate, GhosttyApp
|
|||||||
// Register our service provider. This must happen after everything
|
// Register our service provider. This must happen after everything
|
||||||
// else is initialized.
|
// else is initialized.
|
||||||
NSApp.servicesProvider = ServiceProvider()
|
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 {
|
func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool {
|
||||||
@ -248,7 +265,28 @@ class AppDelegate: NSObject, ObservableObject, NSApplicationDelegate, GhosttyApp
|
|||||||
private func focusedSurface() -> ghostty_surface_t? {
|
private func focusedSurface() -> ghostty_surface_t? {
|
||||||
return terminalManager.focusedSurface?.surface
|
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
|
//MARK: - GhosttyAppStateDelegate
|
||||||
|
|
||||||
func configDidReload(_ state: Ghostty.AppState) {
|
func configDidReload(_ state: Ghostty.AppState) {
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import UserNotifications
|
||||||
import GhosttyKit
|
import GhosttyKit
|
||||||
|
|
||||||
protocol GhosttyAppStateDelegate: AnyObject {
|
protocol GhosttyAppStateDelegate: AnyObject {
|
||||||
@ -167,7 +168,10 @@ extension Ghostty {
|
|||||||
toggle_fullscreen_cb: { userdata, nonNativeFullscreen in AppState.toggleFullscreen(userdata, nonNativeFullscreen: nonNativeFullscreen) },
|
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) },
|
set_initial_window_size_cb: { userdata, width, height in AppState.setInitialWindowSize(userdata, width: width, height: height) },
|
||||||
render_inspector_cb: { userdata in AppState.renderInspector(userdata) },
|
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.
|
// Create the ghostty app.
|
||||||
@ -563,6 +567,54 @@ extension Ghostty {
|
|||||||
surfaceView.cellSize = surfaceView.convertFromBacking(backingSize)
|
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) {
|
static func newTab(_ userdata: UnsafeMutableRawPointer?, config: ghostty_surface_config_s) {
|
||||||
let surface = self.surfaceUserdata(from: userdata)
|
let surface = self.surfaceUserdata(from: userdata)
|
||||||
|
|
||||||
|
@ -4,6 +4,12 @@ import GhosttyKit
|
|||||||
struct Ghostty {
|
struct Ghostty {
|
||||||
// All the notifications that will be emitted will be put here.
|
// All the notifications that will be emitted will be put here.
|
||||||
struct Notification {}
|
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
|
// MARK: Surface Notifications
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import UserNotifications
|
||||||
import GhosttyKit
|
import GhosttyKit
|
||||||
|
|
||||||
extension Ghostty {
|
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?
|
private(set) var surface: ghostty_surface_t?
|
||||||
var error: Error? = nil
|
var error: Error? = nil
|
||||||
|
|
||||||
private var markedText: NSMutableAttributedString
|
private var markedText: NSMutableAttributedString
|
||||||
private var mouseEntered: Bool = false
|
private var mouseEntered: Bool = false
|
||||||
private var focused: Bool = true
|
private(set) var focused: Bool = true
|
||||||
private var cursor: NSCursor = .iBeam
|
private var cursor: NSCursor = .iBeam
|
||||||
private var cursorVisible: CursorVisibility = .visible
|
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
|
/// 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.
|
/// tends to hold this view longer than it should so we free the expensive stuff explicitly.
|
||||||
func close() {
|
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 }
|
guard let surface = self.surface else { return }
|
||||||
ghostty_surface_free(surface)
|
ghostty_surface_free(surface)
|
||||||
self.surface = nil
|
self.surface = nil
|
||||||
@ -1001,6 +1009,52 @@ extension Ghostty {
|
|||||||
|
|
||||||
print("SEL: \(selector)")
|
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