diff --git a/macos/Sources/Features/ClipboardConfirmation/ClipboardConfirmationController.swift b/macos/Sources/Features/ClipboardConfirmation/ClipboardConfirmationController.swift index 37b20afb0..7c7b9bd3c 100644 --- a/macos/Sources/Features/ClipboardConfirmation/ClipboardConfirmationController.swift +++ b/macos/Sources/Features/ClipboardConfirmation/ClipboardConfirmationController.swift @@ -1,7 +1,6 @@ import Foundation import Cocoa import SwiftUI -import GhosttyKit /// This initializes a clipboard confirmation warning window. The window itself /// WILL NOT show automatically and the caller must show the window via @@ -9,17 +8,14 @@ import GhosttyKit class ClipboardConfirmationController: NSWindowController { override var windowNibName: NSNib.Name? { "ClipboardConfirmation" } - let surface: ghostty_surface_t - let contents: String - let request: Ghostty.ClipboardRequest - let state: UnsafeMutableRawPointer? + private(set) var confirmation: Ghostty.ClipboardConfirmationRequest weak private var delegate: ClipboardConfirmationViewDelegate? - init(surface: ghostty_surface_t, contents: String, request: Ghostty.ClipboardRequest, state: UnsafeMutableRawPointer?, delegate: ClipboardConfirmationViewDelegate) { - self.surface = surface - self.contents = contents - self.request = request - self.state = state + init( + confirmation: Ghostty.ClipboardConfirmationRequest, + delegate: ClipboardConfirmationViewDelegate + ) { + self.confirmation = confirmation self.delegate = delegate super.init(window: nil) } @@ -28,12 +24,27 @@ class ClipboardConfirmationController: NSWindowController { fatalError("init(coder:) is not supported for this view") } + /// Replace the request represented by the visible sheet without changing + /// the sheet's focus state. The previous request is cancelled by its + /// SurfaceView before this method is called. + func replaceConfirmation(with confirmation: Ghostty.ClipboardConfirmationRequest) { + guard self.confirmation !== confirmation else { return } + self.confirmation = confirmation + + guard isWindowLoaded, let window else { return } + configure(window) + } + // MARK: - NSWindowController override func windowDidLoad() { guard let window = window else { return } - switch request { + configure(window) + } + + private func configure(_ window: NSWindow) { + switch confirmation.kind { case .paste: window.title = "Warning: Potentially Unsafe Paste" case .osc_52_read, .osc_52_write: @@ -41,8 +52,8 @@ class ClipboardConfirmationController: NSWindowController { } window.contentView = NSHostingView(rootView: ClipboardConfirmationView( - contents: contents, - request: request, + contents: confirmation.contents, + request: confirmation.kind, delegate: delegate )) } diff --git a/macos/Sources/Features/ClipboardConfirmation/ClipboardConfirmationView.swift b/macos/Sources/Features/ClipboardConfirmation/ClipboardConfirmationView.swift index 17ab4aa24..c2fe5fd48 100644 --- a/macos/Sources/Features/ClipboardConfirmation/ClipboardConfirmationView.swift +++ b/macos/Sources/Features/ClipboardConfirmation/ClipboardConfirmationView.swift @@ -2,7 +2,7 @@ import SwiftUI /// This delegate is notified of the completion result of the clipboard confirmation dialog. protocol ClipboardConfirmationViewDelegate: AnyObject { - func clipboardConfirmationComplete(_ action: ClipboardConfirmationView.Action, _ request: Ghostty.ClipboardRequest) + func clipboardConfirmationComplete(_ action: ClipboardConfirmationView.Action) } /// The SwiftUI view for showing a clipboard confirmation dialog. @@ -87,10 +87,10 @@ struct ClipboardConfirmationView: View { } private func onCancel() { - delegate?.clipboardConfirmationComplete(.cancel, request) + delegate?.clipboardConfirmationComplete(.cancel) } private func onPaste() { - delegate?.clipboardConfirmationComplete(.confirm, request) + delegate?.clipboardConfirmationComplete(.confirm) } } diff --git a/macos/Sources/Features/Terminal/BaseTerminalController.swift b/macos/Sources/Features/Terminal/BaseTerminalController.swift index 8158c54dc..79488106e 100644 --- a/macos/Sources/Features/Terminal/BaseTerminalController.swift +++ b/macos/Sources/Features/Terminal/BaseTerminalController.swift @@ -97,6 +97,9 @@ class BaseTerminalController: NSWindowController, /// Cancellable for aggregating bell state across all surfaces in this controller. private var bellStateCancellable: AnyCancellable? + /// Cancellable for clipboard confirmation requests from surfaces in this controller. + private var clipboardConfirmationCancellable: AnyCancellable? + /// An override title for the tab/window set by the user via prompt_tab_title. /// When set, this takes precedence over the computed title from the terminal. var titleOverride: String? { @@ -152,14 +155,10 @@ class BaseTerminalController: NSWindowController, // Setup our bell state for the window setupBellNotificationPublisher() + setupClipboardConfirmationPublisher() // Setup our notifications for behaviors let center = NotificationCenter.default - center.addObserver( - self, - selector: #selector(onConfirmClipboardRequest), - name: Ghostty.Notification.confirmClipboard, - object: nil) center.addObserver( self, selector: #selector(didChangeScreenParametersNotification), @@ -335,6 +334,10 @@ class BaseTerminalController: NSWindowController, /// /// Subclasses should call super first. func surfaceTreeDidChange(from: SplitTree, to: SplitTree) { + for surfaceView in from where !to.contains(surfaceView) { + cancelPendingClipboardConfirmation(for: surfaceView) + } + // If our surface tree becomes empty then we have no focused surface. if to.isEmpty { focusedSurface = nil @@ -345,6 +348,7 @@ class BaseTerminalController: NSWindowController, /// Update all surfaces with the focus state. This ensures that libghostty has an accurate view about /// what surface is focused. This must be called whenever a surface OR window changes focus. func syncFocusToSurfaceTree() { + var newlyFocused: Ghostty.SurfaceView? for surfaceView in surfaceTree { // Our focus state requires that this window is key and our currently // focused surface is the surface in this view. @@ -352,6 +356,11 @@ class BaseTerminalController: NSWindowController, surfaceView == focusedSurface && surfaceView.isFirstResponder surfaceView.focusDidChange(focused) + if focused { newlyFocused = surfaceView } + } + + if let newlyFocused { + presentPendingClipboardConfirmation(for: newlyFocused) } } @@ -1141,73 +1150,6 @@ class BaseTerminalController: NSWindowController, syncAppearance() } - // MARK: Clipboard Confirmation - - @objc private func onConfirmClipboardRequest(notification: SwiftUI.Notification) { - guard let target = notification.object as? Ghostty.SurfaceView else { return } - guard target == self.focusedSurface else { return } - guard let surface = target.surface else { return } - - // We need a window - guard let window = self.window else { return } - - // Check whether we use non-native fullscreen - guard let str = notification.userInfo?[Ghostty.Notification.ConfirmClipboardStrKey] as? String else { return } - guard let state = notification.userInfo?[Ghostty.Notification.ConfirmClipboardStateKey] as? UnsafeMutableRawPointer? else { return } - guard let request = notification.userInfo?[Ghostty.Notification.ConfirmClipboardRequestKey] as? Ghostty.ClipboardRequest else { return } - - // If we already have a clipboard confirmation view up, ignore this - // request. Complete it on the next run loop iteration so that we don't - // invalidate the state pointer from inside its confirmation callback. - guard self.clipboardConfirmation == nil else { - DispatchQueue.main.async { - guard let surface = target.surface else { return } - Ghostty.App.completeClipboardRequest(surface, data: "", state: state, confirmed: true) - } - return - } - - // Show our paste confirmation - self.clipboardConfirmation = ClipboardConfirmationController( - surface: surface, - contents: str, - request: request, - state: state, - delegate: self - ) - window.beginSheet(self.clipboardConfirmation!.window!) - } - - func clipboardConfirmationComplete(_ action: ClipboardConfirmationView.Action, _ request: Ghostty.ClipboardRequest) { - // End our clipboard confirmation no matter what - guard let cc = self.clipboardConfirmation else { return } - self.clipboardConfirmation = nil - - // Close the sheet - if let ccWindow = cc.window { - window?.endSheet(ccWindow) - } - - switch request { - case let .osc_52_write(pasteboard): - guard case .confirm = action else { break } - let pb = pasteboard ?? NSPasteboard.general - pb.declareTypes([.string], owner: nil) - pb.setString(cc.contents, forType: .string) - case .osc_52_read, .paste: - let str: String - switch action { - case .cancel: - str = "" - - case .confirm: - str = cc.contents - } - - Ghostty.App.completeClipboardRequest(cc.surface, data: str, state: cc.state, confirmed: true) - } - } - // MARK: NSWindowController override func windowDidLoad() { @@ -1289,6 +1231,10 @@ class BaseTerminalController: NSWindowController, func windowWillClose(_ notification: Notification) { guard let window else { return } + for surfaceView in surfaceTree { + cancelPendingClipboardConfirmation(for: surfaceView) + } + // Emit a final bell-state transition so any observers can clear state // without separately tracking NSWindow lifecycle events. if bell { @@ -1593,6 +1539,153 @@ extension BaseTerminalController: NSMenuItemValidation { } } +// MARK: Clipboard Confirmation + +extension BaseTerminalController { + /// Presents clipboard confirmations published by surfaces in this controller. + private func setupClipboardConfirmationPublisher() { + clipboardConfirmationCancellable = $surfaceTree + // Rebuild the merged publisher whenever the split tree changes. + .map { tree in + Publishers.MergeMany(tree.map { surface in + // Carry the stable value-type ID rather than capturing the + // surface in the operator chain. The subscription therefore + // cannot extend the SurfaceView's lifetime. + let id = surface.id + return surface.$pendingClipboardConfirmation + .map { (id, $0) } + .eraseToAnyPublisher() + }) + .eraseToAnyPublisher() + } + // Cancelling the old MergeMany releases every subscription for + // surfaces removed from the current tree. + .switchToLatest() + // Published emits synchronously from the libghostty callback. Hop + // to the main queue both for AppKit and so completing a request + // cannot invalidate callback state while that callback is active. + .receive(on: DispatchQueue.main) + // The cancellable is controller-owned, so capture it weakly here to + // avoid controller -> cancellable -> sink -> controller. + .sink { [weak self] id, request in + guard let self, + let surface = surfaceTree.first(where: { $0.id == id }) else { return } + onConfirmClipboardRequest(request, for: surface) + } + } + + private func onConfirmClipboardRequest( + _ request: Ghostty.ClipboardConfirmationRequest?, + for target: Ghostty.SurfaceView + ) { + guard let request else { + guard target.pendingClipboardConfirmation == nil, + let confirmation = clipboardConfirmation, + confirmation.confirmation.surface === target else { return } + dismissClipboardConfirmation(confirmation) + return + } + + // Ignore values queued before a newer request replaced them. + guard target.pendingClipboardConfirmation === request else { return } + + // SurfaceView.didSet has already cancelled the request that this one + // replaced. If that request owns the visible sheet, update the sheet + // in place. Dismissing it would briefly return focus to the terminal, + // which can produce another request and repeat the cycle. Requests + // from other surfaces cannot replace a window-modal sheet. + if let confirmation = clipboardConfirmation { + if confirmation.confirmation === request { return } + guard confirmation.confirmation.surface === target else { + target.pendingClipboardConfirmation = nil + return + } + confirmation.replaceConfirmation(with: request) + return + } + + // A clipboard confirmation can originate from a surface that isn't + // focused. Presenting its sheet immediately would bring that surface's + // window or tab forward and steal focus. Signal that it needs attention, + // retain the request on the surface, and present it only after the + // surface gains focus. + if !target.focused { + if !target.bell { + NotificationCenter.default.post( + name: .ghosttyBellDidRing, + object: target) + } + return + } + + // Preserve the prior behavior for confirmation types other than an + // OSC 52 read: only the controller's selected surface may present it. + guard target == focusedSurface else { + target.pendingClipboardConfirmation = nil + return + } + _ = presentClipboardConfirmation(request) + } + + private func presentClipboardConfirmation( + _ request: Ghostty.ClipboardConfirmationRequest + ) -> Bool { + guard clipboardConfirmation == nil, let window else { return false } + + clipboardConfirmation = ClipboardConfirmationController( + confirmation: request, + delegate: self + ) + window.beginSheet(clipboardConfirmation!.window!) + return true + } + + private func dismissClipboardConfirmation( + _ confirmation: ClipboardConfirmationController + ) { + guard clipboardConfirmation === confirmation else { return } + clipboardConfirmation = nil + if let confirmationWindow = confirmation.window { + window?.endSheet(confirmationWindow) + } + } + + private func presentPendingClipboardConfirmation(for target: Ghostty.SurfaceView) { + guard target.focused, + target == focusedSurface, + let request = target.pendingClipboardConfirmation else { return } + onConfirmClipboardRequest(request, for: target) + } + + private func cancelPendingClipboardConfirmation(for target: Ghostty.SurfaceView) { + if let confirmation = clipboardConfirmation, + confirmation.confirmation.surface === target { + dismissClipboardConfirmation(confirmation) + } + target.pendingClipboardConfirmation = nil + } + + func clipboardConfirmationComplete(_ action: ClipboardConfirmationView.Action) { + // End our clipboard confirmation no matter what + guard let cc = self.clipboardConfirmation else { return } + dismissClipboardConfirmation(cc) + + switch action { + case .cancel: + cc.confirmation.cancel() + case .confirm: + cc.confirmation.complete() + } + + // Clear only if this is still the surface's current request. Completing + // the request may synchronously cause a newer request to replace it. + if let target = cc.confirmation.surface, + target.pendingClipboardConfirmation === cc.confirmation { + target.pendingClipboardConfirmation = nil + } + } +} + // MARK: Combine Methods extension BaseTerminalController { diff --git a/macos/Sources/Ghostty/Ghostty.App.swift b/macos/Sources/Ghostty/Ghostty.App.swift index 1e02c8c57..2bb3e2f28 100644 --- a/macos/Sources/Ghostty/Ghostty.App.swift +++ b/macos/Sources/Ghostty/Ghostty.App.swift @@ -359,21 +359,31 @@ extension Ghostty { 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, - ] - ) + let surfaceView = self.surfaceUserdata(from: userdata) + guard surfaceView.surface != nil, + let string, + let valueStr = String(cString: string, encoding: .utf8), + let kind = Ghostty.ClipboardRequest.from(request: request) else { return } + + // libghostty reaches this callback only when the request attempted + // by readClipboard requires confirmation. Reads allowed by policy + // complete immediately and never become pending Swift state. + let request = Ghostty.ClipboardConfirmationRequest( + surface: surfaceView, + contents: valueStr, + kind: kind + ) { surfaceView, contents in + guard let surface = surfaceView.surface else { return } + completeClipboardRequest( + surface, + data: contents ?? "", + state: state, + confirmed: true) + } + surfaceView.pendingClipboardConfirmation = request } - static func completeClipboardRequest( + private static func completeClipboardRequest( _ surface: ghostty_surface_t, data: String, state: UnsafeMutableRawPointer?, @@ -391,7 +401,7 @@ extension Ghostty { len: Int, confirm: Bool ) { - let surface = self.surfaceUserdata(from: userdata) + let surfaceView = self.surfaceUserdata(from: userdata) guard let pasteboard = NSPasteboard.ghostty(location) else { return } guard let content = content, len > 0 else { return } @@ -407,7 +417,8 @@ extension Ghostty { "clipboard contents should have at most one text/plain entry") if !confirm { - // Declare all types + // Apply writes allowed by policy immediately. Only writes that + // require confirmation continue to the pending request below. let types = contentArray.compactMap { item in NSPasteboard.PasteboardType(mimeType: item.mime) } @@ -426,14 +437,16 @@ extension Ghostty { return } - NotificationCenter.default.post( - name: Notification.confirmClipboard, - object: surface, - userInfo: [ - Notification.ConfirmClipboardStrKey: textPlainContent.data, - Notification.ConfirmClipboardRequestKey: Ghostty.ClipboardRequest.osc_52_write(pasteboard), - ] - ) + let request = Ghostty.ClipboardConfirmationRequest( + surface: surfaceView, + contents: textPlainContent.data, + kind: .osc_52_write + ) { _, contents in + guard let contents else { return } + pasteboard.declareTypes([.string], owner: nil) + pasteboard.setString(contents, forType: .string) + } + surfaceView.pendingClipboardConfirmation = request } static func wakeup(_ userdata: UnsafeMutableRawPointer?) { diff --git a/macos/Sources/Ghostty/Ghostty.ClipboardConfirmationRequest.swift b/macos/Sources/Ghostty/Ghostty.ClipboardConfirmationRequest.swift new file mode 100644 index 000000000..3049366f0 --- /dev/null +++ b/macos/Sources/Ghostty/Ghostty.ClipboardConfirmationRequest.swift @@ -0,0 +1,114 @@ +import Foundation +import GhosttyKit + +extension Ghostty { + /// The type of a clipboard request. + enum ClipboardRequest { + /// A direct paste of clipboard contents. + case paste + + /// An application is attempting to read from the clipboard using OSC 52. + case osc_52_read + + /// An application is attempting to write to the clipboard using OSC 52. + case osc_52_write + + /// The text to show in the clipboard confirmation prompt for this request. + func text() -> String { + switch self { + case .paste: + return """ + Pasting this text to the terminal may be dangerous as it looks like some commands may be executed. + """ + case .osc_52_read: + return """ + An application is attempting to read from the clipboard. + The current clipboard contents are shown below. + """ + case .osc_52_write: + return """ + An application is attempting to write to the clipboard. + The content to write is shown below. + """ + } + } + + static func from(request: ghostty_clipboard_request_e) -> ClipboardRequest? { + switch request { + case GHOSTTY_CLIPBOARD_REQUEST_PASTE: + return .paste + case GHOSTTY_CLIPBOARD_REQUEST_OSC_52_READ: + return .osc_52_read + case GHOSTTY_CLIPBOARD_REQUEST_OSC_52_WRITE: + return .osc_52_write + default: + return nil + } + } + } + + /// A one-shot clipboard confirmation originating from libghostty. + /// + /// This object owns the callback state until it is completed or cancelled. + /// Dropping an unresolved request schedules its cancellation so raw + /// libghostty state cannot leak when no UI is available to handle the + /// notification. Cancellation is deferred because notification delivery + /// occurs from inside the libghostty callback that created the request. + final class ClipboardConfirmationRequest { + private(set) weak var surface: SurfaceView? + let contents: String + let kind: ClipboardRequest + + private var completion: ((SurfaceView, String?) -> Void)? + + init( + surface: SurfaceView, + contents: String, + kind: ClipboardRequest, + completion: @escaping (SurfaceView, String?) -> Void + ) { + self.surface = surface + self.contents = contents + self.kind = kind + self.completion = completion + } + + deinit { + guard let surface, let completion else { return } + self.completion = nil + DispatchQueue.main.async { + completion(surface, nil) + } + } + + /// Complete the request using the displayed clipboard contents. + func complete() { + finish(contents) + } + + /// Cancel the request without using the displayed clipboard contents. + func cancel() { + finish(nil) + } + + /// Cancel using the owning surface explicitly. SurfaceView uses this + /// for replacement and teardown because its weak reference is already + /// nil during the owner's deinitialization. + func cancel(from surface: SurfaceView) { + finish(nil, on: surface) + } + + private func finish( + _ contents: String?, + on explicitSurface: SurfaceView? = nil + ) { + guard let surface = explicitSurface ?? self.surface, + let completion else { + self.completion = nil + return + } + self.completion = nil + completion(surface, contents) + } + } +} diff --git a/macos/Sources/Ghostty/GhosttyPackage.swift b/macos/Sources/Ghostty/GhosttyPackage.swift index 757970e00..071de3c8f 100644 --- a/macos/Sources/Ghostty/GhosttyPackage.swift +++ b/macos/Sources/Ghostty/GhosttyPackage.swift @@ -246,51 +246,6 @@ extension Ghostty.SplitFocusDirection { #endif extension Ghostty { - /// The type of a clipboard request - enum ClipboardRequest { - /// A direct paste of clipboard contents - case paste - - /// An application is attempting to read from the clipboard using OSC 52 - case osc_52_read - - /// An application is attempting to write to the clipboard using OSC 52 - case osc_52_write(OSPasteboard?) - - /// The text to show in the clipboard confirmation prompt for a given request type - func text() -> String { - switch self { - case .paste: - return """ - Pasting this text to the terminal may be dangerous as it looks like some commands may be executed. - """ - case .osc_52_read: - return """ - An application is attempting to read from the clipboard. - The current clipboard contents are shown below. - """ - case .osc_52_write: - return """ - An application is attempting to write to the clipboard. - The content to write is shown below. - """ - } - } - - static func from(request: ghostty_clipboard_request_e) -> ClipboardRequest? { - switch request { - case GHOSTTY_CLIPBOARD_REQUEST_PASTE: - return .paste - case GHOSTTY_CLIPBOARD_REQUEST_OSC_52_READ: - return .osc_52_read - case GHOSTTY_CLIPBOARD_REQUEST_OSC_52_WRITE: - return .osc_52_write(nil) - default: - return nil - } - } - } - struct ClipboardContent { let mime: String let data: String @@ -426,11 +381,6 @@ extension Ghostty.Notification { /// Notification to show/hide the inspector static let didControlInspector = Notification.Name("com.mitchellh.ghostty.didControlInspector") - static let confirmClipboard = Notification.Name("com.mitchellh.ghostty.confirmClipboard") - static let ConfirmClipboardStrKey = confirmClipboard.rawValue + ".str" - static let ConfirmClipboardStateKey = confirmClipboard.rawValue + ".state" - static let ConfirmClipboardRequestKey = confirmClipboard.rawValue + ".request" - /// Notification sent to the active split view to resize the split. static let didResizeSplit = Notification.Name("com.mitchellh.ghostty.didResizeSplit") static let ResizeSplitDirectionKey = didResizeSplit.rawValue + ".direction" diff --git a/macos/Sources/Ghostty/Surface View/SurfaceView_AppKit.swift b/macos/Sources/Ghostty/Surface View/SurfaceView_AppKit.swift index 9c7c5edea..d5093d942 100644 --- a/macos/Sources/Ghostty/Surface View/SurfaceView_AppKit.swift +++ b/macos/Sources/Ghostty/Surface View/SurfaceView_AppKit.swift @@ -108,6 +108,13 @@ extension Ghostty { /// True when the bell is active. This is set inactive on focus or event. @Published private(set) var bell: Bool = false + /// A clipboard confirmation waiting to be handled by its controller. + @Published var pendingClipboardConfirmation: ClipboardConfirmationRequest? { + didSet { + pendingClipboardConfirmationDidChange(from: oldValue) + } + } + // An initial size to request for a window. This will only affect // then the view is moved to a new window. var initialSize: NSSize? @@ -396,6 +403,11 @@ extension Ghostty { } deinit { + // Resolve clipboard callback state while surfaceModel is still + // alive. The request's weak SurfaceView reference is already nil + // during deinit, so didSet passes this instance explicitly. + pendingClipboardConfirmation = nil + // Remove all of our notificationcenter subscriptions let center = NotificationCenter.default center.removeObserver(self) @@ -1882,6 +1894,18 @@ extension Ghostty { } } +// MARK: Clipboard Confirmation + +extension Ghostty.SurfaceView { + /// Cancel the request that a new published value replaces or clears. + private func pendingClipboardConfirmationDidChange( + from previous: Ghostty.ClipboardConfirmationRequest? + ) { + guard previous !== pendingClipboardConfirmation else { return } + previous?.cancel(from: self) + } +} + // MARK: - NSTextInputClient extension Ghostty.SurfaceView: NSTextInputClient {