mirror of
https://github.com/ghostty-org/ghostty.git
synced 2026-08-25 00:21:46 +00:00
macos: defer OSC52 clipboard read confirmations until focused
Fixes #10077 Clipboard read confirmations would immediately show a sheet which grabbed focus. This could be used for a bunch of dumb reasons, including DoS attacks. But, it also caused focus/sheet loops for programs that did OSC52 on focus changes (which was seen via some Neovim configs!). Now, if a surface is unfocused, we bell the surface and show the confirmation request on next focus. If the surface is not focused or another request comes in, we cancel the prior one. This also fixes some memory management issues around clipboard requests that were likely small leaks (didn't verify the old bug, but verified the new code, and eyeballed the old).
This commit is contained in:
@@ -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
|
||||
))
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Ghostty.SurfaceView>, to: SplitTree<Ghostty.SurfaceView>) {
|
||||
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 {
|
||||
|
||||
@@ -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?) {
|
||||
|
||||
114
macos/Sources/Ghostty/Ghostty.ClipboardConfirmationRequest.swift
Normal file
114
macos/Sources/Ghostty/Ghostty.ClipboardConfirmationRequest.swift
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user