import SwiftUI import UserNotifications import GhosttyKit protocol GhosttyAppStateDelegate: AnyObject { /// Called when the configuration did finish reloading. func configDidReload(_ state: Ghostty.AppState) } extension Ghostty { enum AppReadiness { case loading, error, ready } enum FontSizeModification { case increase(Int) case decrease(Int) case reset } struct Info { var mode: ghostty_build_mode_e var version: String } /// The AppState is the global state that is associated with the Swift app. This handles initially /// initializing Ghostty, loading the configuration, etc. class AppState: ObservableObject { /// The readiness value of the state. @Published var readiness: AppReadiness = .loading /// Optional delegate weak var delegate: GhosttyAppStateDelegate? /// The ghostty global configuration. This should only be changed when it is definitely /// safe to change. It is definite safe to change only when the embedded app runtime /// in Ghostty says so (usually, only in a reload configuration callback). @Published var config: ghostty_config_t? = nil { didSet { // Free the old value whenever we change guard let old = oldValue else { return } ghostty_config_free(old) } } /// The ghostty app instance. We only have one of these for the entire app, although I guess /// in theory you can have multiple... I don't know why you would... @Published var app: ghostty_app_t? = nil { didSet { guard let old = oldValue else { return } ghostty_app_free(old) } } /// True if we should quit when the last window is closed. var shouldQuitAfterLastWindowClosed: Bool { guard let config = self.config else { return true } var v = false; let key = "quit-after-last-window-closed" _ = ghostty_config_get(config, &v, key, UInt(key.count)) return v } /// window-save-state var windowSaveState: String { guard let config = self.config else { return "" } var v: UnsafePointer? = nil let key = "window-save-state" guard ghostty_config_get(config, &v, key, UInt(key.count)) else { return "" } guard let ptr = v else { return "" } return String(cString: ptr) } /// True if we need to confirm before quitting. var needsConfirmQuit: Bool { guard let app = app else { return false } return ghostty_app_needs_confirm_quit(app) } /// Build information var info: Info { let raw = ghostty_info() let version = NSString( bytes: raw.version, length: Int(raw.version_len), encoding: NSUTF8StringEncoding ) ?? "unknown" return Info(mode: raw.build_mode, version: String(version)) } /// True if we want to render window decorations var windowDecorations: Bool { guard let config = self.config else { return true } var v = false; let key = "window-decoration" _ = ghostty_config_get(config, &v, key, UInt(key.count)) return v; } /// The window theme as a string. var windowTheme: String? { guard let config = self.config else { return nil } var v: UnsafePointer? = nil let key = "window-theme" guard ghostty_config_get(config, &v, key, UInt(key.count)) else { return nil } guard let ptr = v else { return nil } return String(cString: ptr) } /// Whether to resize windows in discrete steps or use "fluid" resizing var windowStepResize: Bool { guard let config = self.config else { return true } var v = false let key = "window-step-resize" _ = ghostty_config_get(config, &v, key, UInt(key.count)) return v } /// Whether to open new windows in fullscreen. var windowFullscreen: Bool { guard let config = self.config else { return true } var v = false let key = "fullscreen" _ = ghostty_config_get(config, &v, key, UInt(key.count)) return v } /// The background opacity. var backgroundOpacity: Double { guard let config = self.config else { return 1 } var v: Double = 1 let key = "background-opacity" _ = ghostty_config_get(config, &v, key, UInt(key.count)) return v; } init() { // Initialize ghostty global state. This happens once per process. guard ghostty_init() == GHOSTTY_SUCCESS else { AppDelegate.logger.critical("ghostty_init failed") readiness = .error return } // Initialize the global configuration. guard let cfg = Self.loadConfig() else { readiness = .error return } self.config = cfg; // Create our "runtime" config. The "runtime" is the configuration that ghostty // uses to interface with the application runtime environment. var runtime_cfg = ghostty_runtime_config_s( userdata: Unmanaged.passUnretained(self).toOpaque(), supports_selection_clipboard: false, wakeup_cb: { userdata in AppState.wakeup(userdata) }, reload_config_cb: { userdata in AppState.reloadConfig(userdata) }, open_config_cb: { userdata in AppState.openConfig(userdata) }, set_title_cb: { userdata, title in AppState.setTitle(userdata, title: title) }, set_mouse_shape_cb: { userdata, shape in AppState.setMouseShape(userdata, shape: shape) }, set_mouse_visibility_cb: { userdata, visible in AppState.setMouseVisibility(userdata, visible: visible) }, read_clipboard_cb: { userdata, loc, state in AppState.readClipboard(userdata, location: loc, state: state) }, confirm_read_clipboard_cb: { userdata, str, state, request in AppState.confirmReadClipboard(userdata, string: str, state: state, request: request ) }, write_clipboard_cb: { userdata, str, loc, confirm in AppState.writeClipboard(userdata, string: str, location: loc, confirm: confirm) }, new_split_cb: { userdata, direction, surfaceConfig in AppState.newSplit(userdata, direction: direction, config: surfaceConfig) }, new_tab_cb: { userdata, surfaceConfig in AppState.newTab(userdata, config: surfaceConfig) }, new_window_cb: { userdata, surfaceConfig in AppState.newWindow(userdata, config: surfaceConfig) }, control_inspector_cb: { userdata, mode in AppState.controlInspector(userdata, mode: mode) }, close_surface_cb: { userdata, processAlive in AppState.closeSurface(userdata, processAlive: processAlive) }, focus_split_cb: { userdata, direction in AppState.focusSplit(userdata, direction: direction) }, resize_split_cb: { userdata, direction, amount in AppState.resizeSplit(userdata, direction: direction, amount: amount) }, equalize_splits_cb: { userdata in AppState.equalizeSplits(userdata) }, toggle_split_zoom_cb: { userdata in AppState.toggleSplitZoom(userdata) }, goto_tab_cb: { userdata, n in AppState.gotoTab(userdata, n: n) }, 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) }, show_desktop_notification_cb: { userdata, title, body in AppState.showUserNotification(userdata, title: title, body: body) } ) // Create the ghostty app. guard let app = ghostty_app_new(&runtime_cfg, cfg) else { AppDelegate.logger.critical("ghostty_app_new failed") readiness = .error return } self.app = app // Subscribe to notifications for keyboard layout change so that we can update Ghostty. NotificationCenter.default.addObserver( self, selector: #selector(self.keyboardSelectionDidChange(notification:)), name: NSTextInputContext.keyboardSelectionDidChangeNotification, object: nil) self.readiness = .ready } deinit { // This will force the didSet callbacks to run which free. self.app = nil self.config = nil // Remove our observer NotificationCenter.default.removeObserver( self, name: NSTextInputContext.keyboardSelectionDidChangeNotification, object: nil) } /// Initializes a new configuration and loads all the values. static func loadConfig() -> ghostty_config_t? { // Initialize the global configuration. guard let cfg = ghostty_config_new() else { AppDelegate.logger.critical("ghostty_config_new failed") return nil } // Load our configuration files from the home directory. ghostty_config_load_default_files(cfg); ghostty_config_load_cli_args(cfg); ghostty_config_load_recursive_files(cfg); // TODO: we'd probably do some config loading here... for now we'd // have to do this synchronously. When we support config updating we can do // this async and update later. // Finalize will make our defaults available. ghostty_config_finalize(cfg) // Log any configuration errors. These will be automatically shown in a // pop-up window too. let errCount = ghostty_config_errors_count(cfg) if errCount > 0 { AppDelegate.logger.warning("config error: \(errCount) configuration errors on reload") var errors: [String] = []; for i in 0.. [String] { guard let cfg = self.config else { return [] } var errors: [String] = []; let errCount = ghostty_config_errors_count(cfg) for i in 0..?, state: UnsafeMutableRawPointer?, request: ghostty_clipboard_request_e ) { 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( name: Notification.confirmClipboard, object: surface, userInfo: [ Notification.ConfirmClipboardStrKey: valueStr, Notification.ConfirmClipboardStateKey: state as Any, Notification.ConfirmClipboardRequestKey: request, ] ) } static func completeClipboardRequest( _ surface: ghostty_surface_t, data: String, state: UnsafeMutableRawPointer?, confirmed: Bool = false ) { data.withCString { ptr in ghostty_surface_complete_clipboard_request(surface, ptr, state, confirmed) } } static func writeClipboard(_ userdata: UnsafeMutableRawPointer?, string: UnsafePointer?, location: ghostty_clipboard_e, confirm: Bool) { let surface = self.surfaceUserdata(from: userdata) // We only support the standard clipboard if (location != GHOSTTY_CLIPBOARD_STANDARD) { return } guard let valueStr = String(cString: string!, encoding: .utf8) else { return } if !confirm { let pb = NSPasteboard.general pb.declareTypes([.string], owner: nil) pb.setString(valueStr, forType: .string) return } NotificationCenter.default.post( name: Notification.confirmClipboard, object: surface, userInfo: [ Notification.ConfirmClipboardStrKey: valueStr, Notification.ConfirmClipboardRequestKey: Ghostty.ClipboardRequest.osc_52_write, ] ) } static func openConfig(_ userdata: UnsafeMutableRawPointer?) { ghostty_config_open(); } static func reloadConfig(_ userdata: UnsafeMutableRawPointer?) -> ghostty_config_t? { guard let newConfig = Self.loadConfig() else { AppDelegate.logger.warning("failed to reload configuration") return nil } // Assign the new config. This will automatically free the old config. // It is safe to free the old config from within this function call. let state = Unmanaged.fromOpaque(userdata!).takeUnretainedValue() state.config = newConfig // If we have a delegate, notify. if let delegate = state.delegate { delegate.configDidReload(state) } return newConfig } static func wakeup(_ userdata: UnsafeMutableRawPointer?) { let state = Unmanaged.fromOpaque(userdata!).takeUnretainedValue() // Wakeup can be called from any thread so we schedule the app tick // from the main thread. There is probably some improvements we can make // to coalesce multiple ticks but I don't think it matters from a performance // standpoint since we don't do this much. DispatchQueue.main.async { state.appTick() } } static func renderInspector(_ userdata: UnsafeMutableRawPointer?) { let surface = self.surfaceUserdata(from: userdata) NotificationCenter.default.post( name: Notification.inspectorNeedsDisplay, object: surface ) } static func setTitle(_ userdata: UnsafeMutableRawPointer?, title: UnsafePointer?) { let surfaceView = self.surfaceUserdata(from: userdata) guard let titleStr = String(cString: title!, encoding: .utf8) else { return } DispatchQueue.main.async { surfaceView.title = titleStr } } static func setMouseShape(_ userdata: UnsafeMutableRawPointer?, shape: ghostty_mouse_shape_e) { let surfaceView = self.surfaceUserdata(from: userdata) surfaceView.setCursorShape(shape) } static func setMouseVisibility(_ userdata: UnsafeMutableRawPointer?, visible: Bool) { let surfaceView = self.surfaceUserdata(from: userdata) surfaceView.setCursorVisibility(visible) } static func toggleFullscreen(_ userdata: UnsafeMutableRawPointer?, nonNativeFullscreen: ghostty_non_native_fullscreen_e) { let surface = self.surfaceUserdata(from: userdata) NotificationCenter.default.post( name: Notification.ghosttyToggleFullscreen, object: surface, userInfo: [ Notification.NonNativeFullscreenKey: nonNativeFullscreen, ] ) } static func setInitialWindowSize(_ userdata: UnsafeMutableRawPointer?, width: UInt32, height: UInt32) { // We need a window to set the frame let surfaceView = self.surfaceUserdata(from: userdata) surfaceView.initialSize = NSMakeSize(Double(width), Double(height)) } static func setCellSize(_ userdata: UnsafeMutableRawPointer?, width: UInt32, height: UInt32) { let surfaceView = self.surfaceUserdata(from: userdata) let backingSize = NSSize(width: Double(width), height: Double(height)) 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) guard let appState = self.appState(fromView: surface) else { return } guard appState.windowDecorations else { let alert = NSAlert() alert.messageText = "Tabs are disabled" alert.informativeText = "Enable window decorations to use tabs" alert.addButton(withTitle: "OK") alert.alertStyle = .warning _ = alert.runModal() return } NotificationCenter.default.post( name: Notification.ghosttyNewTab, object: surface, userInfo: [ Notification.NewSurfaceConfigKey: SurfaceConfiguration(from: config), ] ) } static func newWindow(_ userdata: UnsafeMutableRawPointer?, config: ghostty_surface_config_s) { let surface = self.surfaceUserdata(from: userdata) NotificationCenter.default.post( name: Notification.ghosttyNewWindow, object: surface, userInfo: [ Notification.NewSurfaceConfigKey: SurfaceConfiguration(from: config), ] ) } static func controlInspector(_ userdata: UnsafeMutableRawPointer?, mode: ghostty_inspector_mode_e) { let surface = self.surfaceUserdata(from: userdata) NotificationCenter.default.post(name: Notification.didControlInspector, object: surface, userInfo: [ "mode": mode, ]) } /// Returns the GhosttyState from the given userdata value. static private func appState(fromView view: SurfaceView) -> AppState? { guard let surface = view.surface else { return nil } guard let app = ghostty_surface_app(surface) else { return nil } guard let app_ud = ghostty_app_userdata(app) else { return nil } return Unmanaged.fromOpaque(app_ud).takeUnretainedValue() } /// Returns the surface view from the userdata. static private func surfaceUserdata(from userdata: UnsafeMutableRawPointer?) -> SurfaceView { return Unmanaged.fromOpaque(userdata!).takeUnretainedValue() } } }