From 3f4ea2f76334d07caadbd8c6a77e567e6e292beb Mon Sep 17 00:00:00 2001 From: Gregory Anders Date: Sun, 12 Nov 2023 09:39:03 -0500 Subject: [PATCH 1/5] core: support OSC 9 and OSC 777 for showing desktop notifications --- src/Surface.zig | 29 ++++++++++ src/apprt/embedded.zig | 17 ++++++ src/apprt/structs.zig | 10 ++++ src/apprt/surface.zig | 9 +++ src/config/Config.zig | 4 ++ src/terminal/osc.zig | 122 ++++++++++++++++++++++++++++++++++++---- src/terminal/stream.zig | 7 +++ src/termio/Exec.zig | 30 ++++++++++ 8 files changed, 217 insertions(+), 11 deletions(-) diff --git a/src/Surface.zig b/src/Surface.zig index 16ceb93ae..ce3fd8394 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -147,6 +147,7 @@ const DerivedConfig = struct { clipboard_paste_bracketed_safe: bool, copy_on_select: configpkg.CopyOnSelect, confirm_close_surface: bool, + desktop_notifications: bool, mouse_interval: u64, mouse_hide_while_typing: bool, mouse_shift_capture: configpkg.MouseShiftCapture, @@ -173,6 +174,7 @@ const DerivedConfig = struct { .clipboard_paste_bracketed_safe = config.@"clipboard-paste-bracketed-safe", .copy_on_select = config.@"copy-on-select", .confirm_close_surface = config.@"confirm-close-surface", + .desktop_notifications = config.@"desktop-notifications", .mouse_interval = config.@"click-repeat-interval" * 1_000_000, // 500ms .mouse_hide_while_typing = config.@"mouse-hide-while-typing", .mouse_shift_capture = config.@"mouse-shift-capture", @@ -713,6 +715,27 @@ pub fn handleMessage(self: *Surface, msg: Message) !void { self.child_exited = true; self.close(); }, + + .desktop_notification => |notification| { + if (!self.config.desktop_notifications) { + log.info("application attempted to display a desktop notification, but 'desktop-notifications' is disabled", .{}); + return; + } + + const title: [:0]const u8 = switch (notification.title) { + .small => |v| v.data[0..v.len :0], + // Stream handler only sends small messages + else => unreachable, + }; + + const body: [:0]const u8 = switch (notification.body) { + .small => |v| v.data[0..v.len :0], + // Stream handler only sends small messages + else => unreachable, + }; + + try self.showDesktopNotification(title, body); + }, } } @@ -2721,6 +2744,12 @@ fn completeClipboardReadOSC52( self.io_thread.wakeup.notify() catch {}; } +fn showDesktopNotification(self: *Surface, title: [:0]const u8, body: [:0]const u8) !void { + if (@hasDecl(apprt.Surface, "showDesktopNotification")) { + try self.rt_surface.showDesktopNotification(title, body); + } else log.warn("runtime doesn't support desktop notifications", .{}); +} + pub const face_ttf = @embedFile("font/res/FiraCode-Regular.ttf"); pub const face_bold_ttf = @embedFile("font/res/FiraCode-Bold.ttf"); pub const face_emoji_ttf = @embedFile("font/res/NotoColorEmoji.ttf"); diff --git a/src/apprt/embedded.zig b/src/apprt/embedded.zig index 9d6dc5d00..43d693385 100644 --- a/src/apprt/embedded.zig +++ b/src/apprt/embedded.zig @@ -117,6 +117,9 @@ pub const App = struct { /// Called when the cell size changes. set_cell_size: ?*const fn (SurfaceUD, u32, u32) callconv(.C) void = null, + + /// Show a desktop notification to the user. + show_desktop_notification: ?*const fn (SurfaceUD, [*:0]const u8, [*:0]const u8) void = null, }; /// Special values for the goto_tab callback. @@ -939,6 +942,20 @@ pub const Surface = struct { const scale = try self.getContentScale(); return .{ .x = pos.x * scale.x, .y = pos.y * scale.y }; } + + /// Show a desktop notification. + pub fn showDesktopNotification( + self: *const Surface, + title: [:0]const u8, + body: [:0]const u8, + ) !void { + const func = self.app.opts.show_desktop_notification orelse { + log.info("runtime embedder does not support show_desktop_notification", .{}); + return; + }; + + func(self.opts.userdata, title, body); + } }; /// Inspector is the state required for the terminal inspector. A terminal diff --git a/src/apprt/structs.zig b/src/apprt/structs.zig index d452bc8eb..9cdbe599a 100644 --- a/src/apprt/structs.zig +++ b/src/apprt/structs.zig @@ -50,3 +50,13 @@ pub const ClipboardRequest = union(ClipboardRequestType) { /// A request to write clipboard contents via OSC 52. osc_52_write: Clipboard, }; + +/// A desktop notification. +pub const DesktopNotification = struct { + /// The title of the notification. May be an empty string to not show a + /// title. + title: []const u8, + + /// The body of a notification. This will always be shown. + body: []const u8, +}; diff --git a/src/apprt/surface.zig b/src/apprt/surface.zig index b86fbf54b..cf54a7bb3 100644 --- a/src/apprt/surface.zig +++ b/src/apprt/surface.zig @@ -45,6 +45,15 @@ pub const Message = union(enum) { /// The child process running in the surface has exited. This may trigger /// a surface close, it may not. child_exited: void, + + /// Show a desktop notification. + desktop_notification: struct { + /// Desktop notification title. + title: WriteReq, + + /// Desktop notification body. + body: WriteReq, + }, }; /// A surface mailbox. diff --git a/src/config/Config.zig b/src/config/Config.zig index c13a76bef..56bf73ad9 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -650,6 +650,10 @@ keybind: Keybinds = .{}, /// libadwaita support. @"gtk-adwaita": bool = true, +/// If true (default), applications running in the terminal can show desktop +/// notifications using certain escape sequences such as OSC 9 or OSC 777. +@"desktop-notifications": bool = true, + /// This will be used to set the TERM environment variable. /// HACK: We set this with an "xterm" prefix because vim uses that to enable key /// protocols (specifically this will enable 'modifyOtherKeys'), among other diff --git a/src/terminal/osc.zig b/src/terminal/osc.zig index 2e59eb279..1ab7aa651 100644 --- a/src/terminal/osc.zig +++ b/src/terminal/osc.zig @@ -121,6 +121,12 @@ pub const Command = union(enum) { value: []const u8, }, + /// Show a desktop notification (OSC 9 or OSC 777) + show_desktop_notification: struct { + title: []const u8, + body: []const u8, + }, + pub const ColorKind = union(enum) { palette: u8, foreground, @@ -225,6 +231,9 @@ pub const Parser = struct { @"5", @"52", @"7", + @"77", + @"777", + @"9", // OSC 10 is used to query or set the current foreground color. query_fg_color, @@ -255,6 +264,13 @@ pub const Parser = struct { // Reset color palette index reset_color_palette_index, + // rxvt extension. Only used for OSC 777 and only the value "notify" is + // supported + rxvt_extension, + + // Title of a desktop notification + notification_title, + // Expect a string parameter. param_str must be set as well as // buf_start. string, @@ -311,6 +327,7 @@ pub const Parser = struct { '4' => self.state = .@"4", '5' => self.state = .@"5", '7' => self.state = .@"7", + '9' => self.state = .@"9", else => self.state = .invalid, }, @@ -465,17 +482,6 @@ pub const Parser = struct { else => self.state = .invalid, }, - .@"7" => switch (c) { - ';' => { - self.command = .{ .report_pwd = .{ .value = "" } }; - - self.state = .string; - self.temp_state = .{ .str = &self.command.report_pwd.value }; - self.buf_start = self.buf_idx; - }, - else => self.state = .invalid, - }, - .@"52" => switch (c) { ';' => { self.command = .{ .clipboard_contents = undefined }; @@ -506,6 +512,72 @@ pub const Parser = struct { else => self.state = .invalid, }, + .@"7" => switch (c) { + ';' => { + self.command = .{ .report_pwd = .{ .value = "" } }; + + self.state = .string; + self.temp_state = .{ .str = &self.command.report_pwd.value }; + self.buf_start = self.buf_idx; + }, + '7' => self.state = .@"77", + else => self.state = .invalid, + }, + + .@"77" => switch (c) { + '7' => self.state = .@"777", + else => self.state = .invalid, + }, + + .@"777" => switch (c) { + ';' => { + self.state = .rxvt_extension; + self.buf_start = self.buf_idx; + }, + else => self.state = .invalid, + }, + + .rxvt_extension => switch (c) { + 'a'...'z' => {}, + ';' => { + const ext = self.buf[self.buf_start .. self.buf_idx - 1]; + if (!std.mem.eql(u8, ext, "notify")) { + log.warn("unknown rxvt extension: {s}", .{ext}); + self.state = .invalid; + return; + } + + self.command = .{ .show_desktop_notification = undefined }; + self.buf_start = self.buf_idx; + self.state = .notification_title; + }, + else => self.state = .invalid, + }, + + .notification_title => switch (c) { + ';' => { + self.command.show_desktop_notification.title = self.buf[self.buf_start .. self.buf_idx - 1]; + self.temp_state = .{ .str = &self.command.show_desktop_notification.body }; + self.buf_start = self.buf_idx; + self.state = .string; + }, + else => {}, + }, + + .@"9" => switch (c) { + ';' => { + self.command = .{ .show_desktop_notification = .{ + .title = "", + .body = undefined, + } }; + + self.temp_state = .{ .str = &self.command.show_desktop_notification.body }; + self.buf_start = self.buf_idx; + self.state = .string; + }, + else => self.state = .invalid, + }, + .query_fg_color => switch (c) { '?' => { self.command = .{ .report_color = .{ .kind = .foreground } }; @@ -1128,3 +1200,31 @@ test "OSC: set palette color" { try testing.expectEqual(cmd.set_color.kind, .{ .palette = 17 }); try testing.expectEqualStrings(cmd.set_color.value, "rgb:aa/bb/cc"); } + +test "OSC: show desktop notification" { + const testing = std.testing; + + var p: Parser = .{}; + + const input = "9;Hello world"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?; + try testing.expect(cmd == .show_desktop_notification); + try testing.expectEqualStrings(cmd.show_desktop_notification.title, ""); + try testing.expectEqualStrings(cmd.show_desktop_notification.body, "Hello world"); +} + +test "OSC: show desktop notification with title" { + const testing = std.testing; + + var p: Parser = .{}; + + const input = "777;notify;Title;Body"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?; + try testing.expect(cmd == .show_desktop_notification); + try testing.expectEqualStrings(cmd.show_desktop_notification.title, "Title"); + try testing.expectEqualStrings(cmd.show_desktop_notification.body, "Body"); +} diff --git a/src/terminal/stream.zig b/src/terminal/stream.zig index c438776dd..be0381f23 100644 --- a/src/terminal/stream.zig +++ b/src/terminal/stream.zig @@ -1067,6 +1067,13 @@ pub fn Stream(comptime Handler: type) type { return; } else log.warn("unimplemented OSC callback: {}", .{cmd}); }, + + .show_desktop_notification => |v| { + if (@hasDecl(T, "showDesktopNotification")) { + try self.handler.showDesktopNotification(v.title, v.body); + return; + } else log.warn("unimplemented OSC callback: {}", .{cmd}); + }, } // Fall through for when we don't have a handler. diff --git a/src/termio/Exec.zig b/src/termio/Exec.zig index fa85c53d4..85f2ce8ce 100644 --- a/src/termio/Exec.zig +++ b/src/termio/Exec.zig @@ -2388,4 +2388,34 @@ const StreamHandler = struct { }, } } + + pub fn showDesktopNotification( + self: *StreamHandler, + title: []const u8, + body: []const u8, + ) !void { + // Subtract one to leave room for the sentinel + const max_length = apprt.surface.Message.WriteReq.Small.Max - 1; + if (title.len >= max_length or body.len >= max_length) { + log.warn("requested notification is too long", .{}); + return; + } + + var req_title: apprt.surface.Message.WriteReq.Small = .{}; + @memcpy(req_title.data[0..title.len], title); + req_title.data[title.len] = 0; + req_title.len = @intCast(title.len); + + var req_body: apprt.surface.Message.WriteReq.Small = .{}; + @memcpy(req_body.data[0..body.len], body); + req_body.data[body.len] = 0; + req_body.len = @intCast(body.len); + + _ = self.ev.surface_mailbox.push(.{ + .desktop_notification = .{ + .title = .{ .small = req_title }, + .body = .{ .small = req_body }, + }, + }, .{ .forever = {} }); + } }; From 54a489eefacbb4ab6d2645d452af91f55fe36c9c Mon Sep 17 00:00:00 2001 From: Gregory Anders Date: Wed, 15 Nov 2023 10:21:46 -0600 Subject: [PATCH 2/5] macos: remove optional from Ghostty.AppState.surfaceUserdata The return value of takeUnretainedValue() is non-optional, so this function was never _actually_ returning an optional value, so the guard clauses sprinkled throughout were unnecessary. --- macos/Sources/Ghostty/AppState.swift | 42 ++++++++++++++-------------- 1 file changed, 21 insertions(+), 21 deletions(-) diff --git a/macos/Sources/Ghostty/AppState.swift b/macos/Sources/Ghostty/AppState.swift index 0b61fbcb6..0dfa2e216 100644 --- a/macos/Sources/Ghostty/AppState.swift +++ b/macos/Sources/Ghostty/AppState.swift @@ -350,7 +350,7 @@ extension Ghostty { // MARK: Ghostty Callbacks static func newSplit(_ userdata: UnsafeMutableRawPointer?, direction: ghostty_split_direction_e, config: ghostty_surface_config_s) { - guard let surface = self.surfaceUserdata(from: userdata) else { return } + let surface = self.surfaceUserdata(from: userdata) NotificationCenter.default.post(name: Notification.ghosttyNewSplit, object: surface, userInfo: [ "direction": direction, Notification.NewSurfaceConfigKey: SurfaceConfiguration(from: config), @@ -358,14 +358,14 @@ extension Ghostty { } static func closeSurface(_ userdata: UnsafeMutableRawPointer?, processAlive: Bool) { - guard let surface = self.surfaceUserdata(from: userdata) else { return } + let surface = self.surfaceUserdata(from: userdata) NotificationCenter.default.post(name: Notification.ghosttyCloseSurface, object: surface, userInfo: [ "process_alive": processAlive, ]) } static func focusSplit(_ userdata: UnsafeMutableRawPointer?, direction: ghostty_split_focus_direction_e) { - guard let surface = self.surfaceUserdata(from: userdata) else { return } + let surface = self.surfaceUserdata(from: userdata) guard let splitDirection = SplitFocusDirection.from(direction: direction) else { return } NotificationCenter.default.post( name: Notification.ghosttyFocusSplit, @@ -377,7 +377,7 @@ extension Ghostty { } static func resizeSplit(_ userdata: UnsafeMutableRawPointer?, direction: ghostty_split_resize_direction_e, amount: UInt16) { - guard let surface = self.surfaceUserdata(from: userdata) else { return } + let surface = self.surfaceUserdata(from: userdata) guard let resizeDirection = SplitResizeDirection.from(direction: direction) else { return } NotificationCenter.default.post( name: Notification.didResizeSplit, @@ -390,12 +390,12 @@ extension Ghostty { } static func equalizeSplits(_ userdata: UnsafeMutableRawPointer?) { - guard let surface = self.surfaceUserdata(from: userdata) else { return } + let surface = self.surfaceUserdata(from: userdata) NotificationCenter.default.post(name: Notification.didEqualizeSplits, object: surface) } static func toggleSplitZoom(_ userdata: UnsafeMutableRawPointer?) { - guard let surface = self.surfaceUserdata(from: userdata) else { return } + let surface = self.surfaceUserdata(from: userdata) NotificationCenter.default.post( name: Notification.didToggleSplitZoom, @@ -404,7 +404,7 @@ extension Ghostty { } static func gotoTab(_ userdata: UnsafeMutableRawPointer?, n: Int32) { - guard let surface = self.surfaceUserdata(from: userdata) else { return } + let surface = self.surfaceUserdata(from: userdata) NotificationCenter.default.post( name: Notification.ghosttyGotoTab, object: surface, @@ -417,7 +417,7 @@ extension Ghostty { static func readClipboard(_ userdata: UnsafeMutableRawPointer?, location: ghostty_clipboard_e, state: UnsafeMutableRawPointer?) { // If we don't even have a surface, something went terrible wrong so we have // to leak "state". - guard let surfaceView = self.surfaceUserdata(from: userdata) else { return } + let surfaceView = self.surfaceUserdata(from: userdata) guard let surface = surfaceView.surface else { return } // We only support the standard clipboard @@ -436,7 +436,7 @@ extension Ghostty { state: UnsafeMutableRawPointer?, request: ghostty_clipboard_request_e ) { - guard let surface = self.surfaceUserdata(from: userdata) else { return } + let surface = self.surfaceUserdata(from: userdata) guard let valueStr = String(cString: string!, encoding: .utf8) else { return } guard let request = Ghostty.ClipboardRequest.from(request: request) else { return } NotificationCenter.default.post( @@ -462,7 +462,7 @@ extension Ghostty { } static func writeClipboard(_ userdata: UnsafeMutableRawPointer?, string: UnsafePointer?, location: ghostty_clipboard_e, confirm: Bool) { - guard let surface = self.surfaceUserdata(from: userdata) else { return } + let surface = self.surfaceUserdata(from: userdata) // We only support the standard clipboard if (location != GHOSTTY_CLIPBOARD_STANDARD) { return } @@ -515,7 +515,7 @@ extension Ghostty { } static func renderInspector(_ userdata: UnsafeMutableRawPointer?) { - guard let surface = self.surfaceUserdata(from: userdata) else { return } + let surface = self.surfaceUserdata(from: userdata) NotificationCenter.default.post( name: Notification.inspectorNeedsDisplay, object: surface @@ -523,7 +523,7 @@ extension Ghostty { } static func setTitle(_ userdata: UnsafeMutableRawPointer?, title: UnsafePointer?) { - let surfaceView = Unmanaged.fromOpaque(userdata!).takeUnretainedValue() + let surfaceView = self.surfaceUserdata(from: userdata) guard let titleStr = String(cString: title!, encoding: .utf8) else { return } DispatchQueue.main.async { surfaceView.title = titleStr @@ -531,17 +531,17 @@ extension Ghostty { } static func setMouseShape(_ userdata: UnsafeMutableRawPointer?, shape: ghostty_mouse_shape_e) { - let surfaceView = Unmanaged.fromOpaque(userdata!).takeUnretainedValue() + let surfaceView = self.surfaceUserdata(from: userdata) surfaceView.setCursorShape(shape) } static func setMouseVisibility(_ userdata: UnsafeMutableRawPointer?, visible: Bool) { - let surfaceView = Unmanaged.fromOpaque(userdata!).takeUnretainedValue() + let surfaceView = self.surfaceUserdata(from: userdata) surfaceView.setCursorVisibility(visible) } static func toggleFullscreen(_ userdata: UnsafeMutableRawPointer?, nonNativeFullscreen: ghostty_non_native_fullscreen_e) { - guard let surface = self.surfaceUserdata(from: userdata) else { return } + let surface = self.surfaceUserdata(from: userdata) NotificationCenter.default.post( name: Notification.ghosttyToggleFullscreen, object: surface, @@ -553,18 +553,18 @@ extension Ghostty { static func setInitialWindowSize(_ userdata: UnsafeMutableRawPointer?, width: UInt32, height: UInt32) { // We need a window to set the frame - guard let surfaceView = self.surfaceUserdata(from: userdata) else { return } + let surfaceView = self.surfaceUserdata(from: userdata) surfaceView.initialSize = NSMakeSize(Double(width), Double(height)) } static func setCellSize(_ userdata: UnsafeMutableRawPointer?, width: UInt32, height: UInt32) { - guard let surfaceView = self.surfaceUserdata(from: userdata) else { return } + let surfaceView = self.surfaceUserdata(from: userdata) let backingSize = NSSize(width: Double(width), height: Double(height)) surfaceView.cellSize = surfaceView.convertFromBacking(backingSize) } static func newTab(_ userdata: UnsafeMutableRawPointer?, config: ghostty_surface_config_s) { - guard let surface = self.surfaceUserdata(from: userdata) else { return } + let surface = self.surfaceUserdata(from: userdata) guard let appState = self.appState(fromView: surface) else { return } guard appState.windowDecorations else { @@ -587,7 +587,7 @@ extension Ghostty { } static func newWindow(_ userdata: UnsafeMutableRawPointer?, config: ghostty_surface_config_s) { - guard let surface = self.surfaceUserdata(from: userdata) else { return } + let surface = self.surfaceUserdata(from: userdata) NotificationCenter.default.post( name: Notification.ghosttyNewWindow, @@ -599,7 +599,7 @@ extension Ghostty { } static func controlInspector(_ userdata: UnsafeMutableRawPointer?, mode: ghostty_inspector_mode_e) { - guard let surface = self.surfaceUserdata(from: userdata) else { return } + let surface = self.surfaceUserdata(from: userdata) NotificationCenter.default.post(name: Notification.didControlInspector, object: surface, userInfo: [ "mode": mode, ]) @@ -614,7 +614,7 @@ extension Ghostty { } /// Returns the surface view from the userdata. - static private func surfaceUserdata(from userdata: UnsafeMutableRawPointer?) -> SurfaceView? { + static private func surfaceUserdata(from userdata: UnsafeMutableRawPointer?) -> SurfaceView { return Unmanaged.fromOpaque(userdata!).takeUnretainedValue() } } From 86b7442f3c1d19cd701416a563b365db6edb8a02 Mon Sep 17 00:00:00 2001 From: Gregory Anders Date: Mon, 13 Nov 2023 21:52:45 -0600 Subject: [PATCH 3/5] 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) + } + } } } From 689199251acc66096d868951c43995e2a0a0332f Mon Sep 17 00:00:00 2001 From: Gregory Anders Date: Wed, 15 Nov 2023 11:09:27 -0600 Subject: [PATCH 4/5] core: use arrays instead of WriteReq for desktop notifications --- src/Surface.zig | 14 ++------------ src/apprt/surface.zig | 4 ++-- src/termio/Exec.zig | 28 ++++++++-------------------- 3 files changed, 12 insertions(+), 34 deletions(-) diff --git a/src/Surface.zig b/src/Surface.zig index ce3fd8394..4336ac00a 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -722,18 +722,8 @@ pub fn handleMessage(self: *Surface, msg: Message) !void { return; } - const title: [:0]const u8 = switch (notification.title) { - .small => |v| v.data[0..v.len :0], - // Stream handler only sends small messages - else => unreachable, - }; - - const body: [:0]const u8 = switch (notification.body) { - .small => |v| v.data[0..v.len :0], - // Stream handler only sends small messages - else => unreachable, - }; - + const title = std.mem.sliceTo(¬ification.title, 0); + const body = std.mem.sliceTo(¬ification.body, 0); try self.showDesktopNotification(title, body); }, } diff --git a/src/apprt/surface.zig b/src/apprt/surface.zig index cf54a7bb3..8e8fe01e0 100644 --- a/src/apprt/surface.zig +++ b/src/apprt/surface.zig @@ -49,10 +49,10 @@ pub const Message = union(enum) { /// Show a desktop notification. desktop_notification: struct { /// Desktop notification title. - title: WriteReq, + title: [63:0]u8, /// Desktop notification body. - body: WriteReq, + body: [255:0]u8, }, }; diff --git a/src/termio/Exec.zig b/src/termio/Exec.zig index 85f2ce8ce..7bbc95226 100644 --- a/src/termio/Exec.zig +++ b/src/termio/Exec.zig @@ -2394,28 +2394,16 @@ const StreamHandler = struct { title: []const u8, body: []const u8, ) !void { - // Subtract one to leave room for the sentinel - const max_length = apprt.surface.Message.WriteReq.Small.Max - 1; - if (title.len >= max_length or body.len >= max_length) { - log.warn("requested notification is too long", .{}); - return; - } + var message = apprt.surface.Message{ .desktop_notification = undefined }; - var req_title: apprt.surface.Message.WriteReq.Small = .{}; - @memcpy(req_title.data[0..title.len], title); - req_title.data[title.len] = 0; - req_title.len = @intCast(title.len); + const title_len = @min(title.len, message.desktop_notification.title.len); + @memcpy(message.desktop_notification.title[0..title_len], title[0..title_len]); + message.desktop_notification.title[title_len] = 0; - var req_body: apprt.surface.Message.WriteReq.Small = .{}; - @memcpy(req_body.data[0..body.len], body); - req_body.data[body.len] = 0; - req_body.len = @intCast(body.len); + const body_len = @min(body.len, message.desktop_notification.body.len); + @memcpy(message.desktop_notification.body[0..body_len], body[0..body_len]); + message.desktop_notification.body[body_len] = 0; - _ = self.ev.surface_mailbox.push(.{ - .desktop_notification = .{ - .title = .{ .small = req_title }, - .body = .{ .small = req_body }, - }, - }, .{ .forever = {} }); + _ = self.ev.surface_mailbox.push(message, .{ .forever = {} }); } }; From caf22521449839de749179591538c425a200e5c7 Mon Sep 17 00:00:00 2001 From: Tim Culverhouse Date: Fri, 17 Nov 2023 11:24:15 -0600 Subject: [PATCH 5/5] gtk: implement desktop notifications --- dist/linux/app.desktop | 1 + src/apprt/gtk/Surface.zig | 44 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+) diff --git a/dist/linux/app.desktop b/dist/linux/app.desktop index e6e736da7..c87e73cee 100644 --- a/dist/linux/app.desktop +++ b/dist/linux/app.desktop @@ -9,6 +9,7 @@ Keywords=terminal;tty;pty; StartupNotify=true Terminal=false Actions=new-window; +X-GNOME-UsesNotifications=true [Desktop Action new-window] Name=New Window diff --git a/src/apprt/gtk/Surface.zig b/src/apprt/gtk/Surface.zig index ec7e99196..62c691dbc 100644 --- a/src/apprt/gtk/Surface.zig +++ b/src/apprt/gtk/Surface.zig @@ -596,6 +596,50 @@ pub fn getCursorPos(self: *const Surface) !apprt.CursorPos { return self.cursor_pos; } +pub fn showDesktopNotification( + self: *Surface, + title: []const u8, + body: []const u8, +) !void { + // Set a default title if we don't already have one + const t = switch (title.len) { + 0 => "Ghostty", + else => title, + }; + const notif = c.g_notification_new(t.ptr); + defer c.g_object_unref(notif); + c.g_notification_set_body(notif, body.ptr); + + // Find our icon in the current icon theme. Not pretty, but the builtin GIO + // method "g_themed_icon_new" doesn't search XDG_DATA_DIRS, so any install + // not in /usr/share will be unable to find an icon + const display = c.gdk_display_get_default(); + const theme = c.gtk_icon_theme_get_for_display(display); + const icon = c.gtk_icon_theme_lookup_icon( + theme, + "com.mitchellh.ghostty", + null, + 48, + 1, // Window scale + c.GTK_TEXT_DIR_LTR, + 0, + ); + defer c.g_object_unref(icon); + // Get the filepath of the icon we found + const file = c.gtk_icon_paintable_get_file(icon); + defer c.g_object_unref(file); + // Create a GIO icon + const gicon = c.g_file_icon_new(file); + defer c.g_object_unref(gicon); + c.g_notification_set_icon(notif, gicon); + + const g_app: *c.GApplication = @ptrCast(self.app.app); + + // We set the notification ID to the body content. If the content is the + // same, this notification may replace a previous notification + c.g_application_send_notification(g_app, body.ptr, notif); +} + fn gtkRealize(area: *c.GtkGLArea, ud: ?*anyopaque) callconv(.C) void { log.debug("gl surface realized", .{});