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
116 changes: 107 additions & 9 deletions macos/Sources/Ghostty/Ghostty.App.swift
Original file line number Diff line number Diff line change
Expand Up @@ -433,10 +433,17 @@ extension Ghostty {
/// 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

// We always require the notification to be attached to a surface.
guard let uuidString = userInfo["surface"] as? String,
let uuid = UUID(uuidString: uuidString),
let surface = delegate?.findSurface(forUUID: uuid),
let window = surface.window else { return false }

// If we don't require focus then we're good!
let requireFocus = userInfo["requireFocus"] as? Bool ?? true
if !requireFocus { return true }

return !window.isKeyWindow || !surface.focused
}

Expand Down Expand Up @@ -632,6 +639,9 @@ extension Ghostty {
case GHOSTTY_ACTION_SEARCH_SELECTED:
searchSelected(app, target: target, v: action.action.search_selected)

case GHOSTTY_ACTION_COMMAND_FINISHED:
commandFinished(app, target: target, v: action.action.command_finished)

case GHOSTTY_ACTION_PRESENT_TERMINAL:
return presentTerminal(app, target: target)

Expand Down Expand Up @@ -1353,25 +1363,113 @@ extension Ghostty {
n: ghostty_action_desktop_notification_s) {
switch target.tag {
case GHOSTTY_TARGET_APP:
Ghostty.logger.warning("toggle split zoom does nothing with an app target")
Ghostty.logger.warning("desktop notification does nothing with an app target")
return

case GHOSTTY_TARGET_SURFACE:
guard let surface = target.target.surface else { return }
guard let surfaceView = self.surfaceView(from: surface) else { return }
guard let title = String(cString: n.title!, encoding: .utf8) else { return }
guard let body = String(cString: n.body!, encoding: .utf8) else { return }
showDesktopNotification(surfaceView, title: title, body: body)

let center = UNUserNotificationCenter.current()
center.requestAuthorization(options: [.alert, .sound]) { _, error in
if let error = error {
Ghostty.logger.error("Error while requesting notification authorization: \(error)")
}
default:
assertionFailure()
}
}

private static func showDesktopNotification(
_ surfaceView: SurfaceView,
title: String,
body: String,
requireFocus: Bool = true) {
let center = UNUserNotificationCenter.current()
center.requestAuthorization(options: [.alert, .sound]) { _, error in
if let error = error {
Ghostty.logger.error("Error while requesting notification authorization: \(error)")
}
}

center.getNotificationSettings { settings in
guard settings.authorizationStatus == .authorized else { return }
surfaceView.showUserNotification(
title: title,
body: body,
requireFocus: requireFocus
)
}
}

private static func commandFinished(
_ app: ghostty_app_t,
target: ghostty_target_s,
v: ghostty_action_command_finished_s
) {
switch target.tag {
case GHOSTTY_TARGET_APP:
Ghostty.logger.warning("command finished does nothing with an app target")
return

case GHOSTTY_TARGET_SURFACE:
guard let surface = target.target.surface else { return }
guard let surfaceView = self.surfaceView(from: surface) else { return }

// Determine if we even care about command finish notifications
guard let config = (NSApplication.shared.delegate as? AppDelegate)?.ghostty.config else { return }
switch config.notifyOnCommandFinish {
case .never:
return

case .unfocused:
if surfaceView.focused { return }

case .always:
break
}

// Determine if the command was slow enough
let duration = Duration.nanoseconds(v.duration)
guard Duration.nanoseconds(v.duration) >= config.notifyOnCommandFinishAfter else { return }

let actions = config.notifyOnCommandFinishAction

if actions.contains(.bell) {
NotificationCenter.default.post(
name: .ghosttyBellDidRing,
object: surfaceView
)
}

center.getNotificationSettings { settings in
guard settings.authorizationStatus == .authorized else { return }
surfaceView.showUserNotification(title: title, body: body)
if actions.contains(.notify) {
let title: String
if v.exit_code < 0 {
title = "Command Finished"
} else if v.exit_code == 0 {
title = "Command Succeeded"
} else {
title = "Command Failed"
}

let body: String
let formattedDuration = duration.formatted(
.units(
allowed: [.hours, .minutes, .seconds, .milliseconds],
width: .abbreviated,
fractionalPart: .hide
)
)
if v.exit_code < 0 {
body = "Command took \(formattedDuration)."
} else {
body = "Command took \(formattedDuration) and exited with code \(v.exit_code)."
}

showDesktopNotification(
surfaceView,
title: title,
body: body,
requireFocus: false
)
}

default:
Expand Down
39 changes: 39 additions & 0 deletions macos/Sources/Ghostty/Ghostty.Config.swift
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,32 @@ extension Ghostty {
return .init(rawValue: v)
}

var notifyOnCommandFinish: NotifyOnCommandFinish {
guard let config = self.config else { return .never }
var v: UnsafePointer<Int8>?
let key = "notify-on-command-finish"
guard ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) else { return .never }
guard let ptr = v else { return .never }
return NotifyOnCommandFinish(rawValue: String(cString: ptr)) ?? .never
}

var notifyOnCommandFinishAction: NotifyOnCommandFinishAction {
let defaultValue = NotifyOnCommandFinishAction.bell
guard let config = self.config else { return defaultValue }
var v: CUnsignedInt = 0
let key = "notify-on-command-finish-action"
guard ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) else { return defaultValue }
return .init(rawValue: v)
}

var notifyOnCommandFinishAfter: Duration {
guard let config = self.config else { return .seconds(5) }
var v: UInt = 0
let key = "notify-on-command-finish-after"
_ = ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8)))
return .milliseconds(v)
}

var splitPreserveZoom: SplitPreserveZoom {
guard let config = self.config else { return .init() }
var v: CUnsignedInt = 0
Expand Down Expand Up @@ -842,4 +868,17 @@ extension Ghostty.Config {
}
}
}

enum NotifyOnCommandFinish: String {
case never
case unfocused
case always
}

struct NotifyOnCommandFinishAction: OptionSet {
let rawValue: CUnsignedInt

static let bell = NotifyOnCommandFinishAction(rawValue: 1 << 0)
static let notify = NotifyOnCommandFinishAction(rawValue: 1 << 1)
}
}
7 changes: 5 additions & 2 deletions macos/Sources/Ghostty/Surface View/SurfaceView_AppKit.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1632,14 +1632,17 @@ extension Ghostty {
}

/// Show a user notification and associate it with this surface
func showUserNotification(title: String, body: String) {
func showUserNotification(title: String, body: String, requireFocus: Bool = true) {
let content = UNMutableNotificationContent()
content.title = title
content.subtitle = self.title
content.body = body
content.sound = UNNotificationSound.default
content.categoryIdentifier = Ghostty.userNotificationCategory
content.userInfo = ["surface": self.id.uuidString]
content.userInfo = [
"surface": self.id.uuidString,
"requireFocus": requireFocus,
]

let uuid = UUID().uuidString
let request = UNNotificationRequest(
Expand Down
6 changes: 0 additions & 6 deletions src/config/Config.zig
Original file line number Diff line number Diff line change
Expand Up @@ -1207,8 +1207,6 @@ command: ?Command = null,
/// notifications for a single command, overriding the `never` and `unfocused`
/// options.
///
/// GTK only.
///
/// Available since 1.3.0.
@"notify-on-command-finish": NotifyOnCommandFinish = .never,

Expand All @@ -1223,8 +1221,6 @@ command: ?Command = null,
/// Options can be combined by listing them as a comma separated list. Options
/// can be negated by prefixing them with `no-`. For example `no-bell,notify`.
///
/// GTK only.
///
/// Available since 1.3.0.
@"notify-on-command-finish-action": NotifyOnCommandFinishAction = .{
.bell = true,
Expand Down Expand Up @@ -1262,8 +1258,6 @@ command: ?Command = null,
/// The maximum value is `584y 49w 23h 34m 33s 709ms 551µs 615ns`. Any
/// value larger than this will be clamped to the maximum value.
///
/// GTK only.
///
/// Available since 1.3.0
@"notify-on-command-finish-after": Duration = .{ .duration = 5 * std.time.ns_per_s },

Expand Down
Loading