diff --git a/include/ghostty.h b/include/ghostty.h index 001ffb249..bbb17eed1 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -325,6 +325,7 @@ typedef void (*ghostty_runtime_set_title_cb)(void *, const char *); typedef void (*ghostty_runtime_set_mouse_shape_cb)(void *, ghostty_mouse_shape_e); typedef void (*ghostty_runtime_set_mouse_visibility_cb)(void *, bool); typedef void (*ghostty_runtime_read_clipboard_cb)(void *, ghostty_clipboard_e, void *); +typedef void (*ghostty_runtime_confirm_read_clipboard_cb)(void *, const char*, void *); typedef void (*ghostty_runtime_write_clipboard_cb)(void *, const char *, ghostty_clipboard_e); typedef void (*ghostty_runtime_new_split_cb)(void *, ghostty_split_direction_e, ghostty_surface_config_s); typedef void (*ghostty_runtime_new_tab_cb)(void *, ghostty_surface_config_s); @@ -348,6 +349,7 @@ typedef struct { ghostty_runtime_set_mouse_shape_cb set_mouse_shape_cb; ghostty_runtime_set_mouse_visibility_cb set_mouse_visibility_cb; ghostty_runtime_read_clipboard_cb read_clipboard_cb; + ghostty_runtime_confirm_read_clipboard_cb confirm_read_clipboard_cb; ghostty_runtime_write_clipboard_cb write_clipboard_cb; ghostty_runtime_new_split_cb new_split_cb; ghostty_runtime_new_tab_cb new_tab_cb; @@ -411,7 +413,7 @@ void ghostty_surface_request_close(ghostty_surface_t); void ghostty_surface_split(ghostty_surface_t, ghostty_split_direction_e); void ghostty_surface_split_focus(ghostty_surface_t, ghostty_split_focus_direction_e); bool ghostty_surface_binding_action(ghostty_surface_t, const char *, uintptr_t); -void ghostty_surface_complete_clipboard_request(ghostty_surface_t, const char *, uintptr_t, void *); +void ghostty_surface_complete_clipboard_request(ghostty_surface_t, const char *, void *, bool); ghostty_inspector_t ghostty_surface_inspector(ghostty_surface_t); void ghostty_inspector_free(ghostty_surface_t); diff --git a/macos/Ghostty.xcodeproj/project.pbxproj b/macos/Ghostty.xcodeproj/project.pbxproj index 153b146a9..7c67ecf90 100644 --- a/macos/Ghostty.xcodeproj/project.pbxproj +++ b/macos/Ghostty.xcodeproj/project.pbxproj @@ -40,6 +40,9 @@ A5CEAFDC29B8009000646FDA /* SplitView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CEAFDB29B8009000646FDA /* SplitView.swift */; }; A5CEAFDE29B8058B00646FDA /* SplitView.Divider.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CEAFDD29B8058B00646FDA /* SplitView.Divider.swift */; }; A5CEAFFF29C2410700646FDA /* Backport.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CEAFFE29C2410700646FDA /* Backport.swift */; }; + A5E112932AF73E6E00C6E0C2 /* PasteProtection.xib in Resources */ = {isa = PBXBuildFile; fileRef = A5E112922AF73E6E00C6E0C2 /* PasteProtection.xib */; }; + A5E112952AF73E8A00C6E0C2 /* PasteProtectionController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5E112942AF73E8A00C6E0C2 /* PasteProtectionController.swift */; }; + A5E112972AF7401B00C6E0C2 /* PasteProtectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5E112962AF7401B00C6E0C2 /* PasteProtectionView.swift */; }; A5FEB3002ABB69450068369E /* main.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5FEB2FF2ABB69450068369E /* main.swift */; }; /* End PBXBuildFile section */ @@ -80,6 +83,9 @@ A5CEAFDD29B8058B00646FDA /* SplitView.Divider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SplitView.Divider.swift; sourceTree = ""; }; A5CEAFFE29C2410700646FDA /* Backport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Backport.swift; sourceTree = ""; }; A5D495A1299BEC7E00DD1313 /* GhosttyKit.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; path = GhosttyKit.xcframework; sourceTree = ""; }; + A5E112922AF73E6E00C6E0C2 /* PasteProtection.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = PasteProtection.xib; sourceTree = ""; }; + A5E112942AF73E8A00C6E0C2 /* PasteProtectionController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PasteProtectionController.swift; sourceTree = ""; }; + A5E112962AF7401B00C6E0C2 /* PasteProtectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PasteProtectionView.swift; sourceTree = ""; }; A5FEB2FF2ABB69450068369E /* main.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = main.swift; sourceTree = ""; }; /* End PBXFileReference section */ @@ -101,6 +107,7 @@ children = ( A56D58872ACDE6BE00508D2C /* Services */, A59630982AEE1C4400D64628 /* Terminal */, + A5E112912AF73E4D00C6E0C2 /* Paste Protection */, A534263E2A7DCC5800EBB7A2 /* Settings */, ); path = Features; @@ -227,6 +234,16 @@ name = Frameworks; sourceTree = ""; }; + A5E112912AF73E4D00C6E0C2 /* Paste Protection */ = { + isa = PBXGroup; + children = ( + A5E112922AF73E6E00C6E0C2 /* PasteProtection.xib */, + A5E112942AF73E8A00C6E0C2 /* PasteProtectionController.swift */, + A5E112962AF7401B00C6E0C2 /* PasteProtectionView.swift */, + ); + path = "Paste Protection"; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -289,6 +306,7 @@ A596309A2AEE1C6400D64628 /* Terminal.xib in Resources */, A5A1F8852A489D6800D1E8BC /* terminfo in Resources */, A5CDF1912AAF9A5800513312 /* ConfigurationErrors.xib in Resources */, + A5E112932AF73E6E00C6E0C2 /* PasteProtection.xib in Resources */, A5B30539299BEAAB0047F10C /* Assets.xcassets in Resources */, 857F63812A5E64F200CA4815 /* MainMenu.xib in Resources */, ); @@ -324,9 +342,11 @@ A55685E029A03A9F004303CE /* AppError.swift in Sources */, A535B9DA299C569B0017E2E4 /* ErrorView.swift in Sources */, A5CEAFFF29C2410700646FDA /* Backport.swift in Sources */, + A5E112952AF73E8A00C6E0C2 /* PasteProtectionController.swift in Sources */, 8503D7C72A549C66006CFF3D /* FullScreenHandler.swift in Sources */, A596309E2AEE1D6C00D64628 /* TerminalView.swift in Sources */, A5CEAFDE29B8058B00646FDA /* SplitView.Divider.swift in Sources */, + A5E112972AF7401B00C6E0C2 /* PasteProtectionView.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/macos/Sources/Features/Paste Protection/PasteProtection.xib b/macos/Sources/Features/Paste Protection/PasteProtection.xib new file mode 100644 index 000000000..312a3da5a --- /dev/null +++ b/macos/Sources/Features/Paste Protection/PasteProtection.xib @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/macos/Sources/Features/Paste Protection/PasteProtectionController.swift b/macos/Sources/Features/Paste Protection/PasteProtectionController.swift new file mode 100644 index 000000000..12514d5b7 --- /dev/null +++ b/macos/Sources/Features/Paste Protection/PasteProtectionController.swift @@ -0,0 +1,37 @@ +import Foundation +import Cocoa +import SwiftUI +import GhosttyKit + +/// This initializes an "unsafe paste" warning window. The window itself WILL NOT show automatically +/// and the caller must show the window via showWindow, beginSheet, etc. +class PasteProtectionController: NSWindowController { + override var windowNibName: NSNib.Name? { "PasteProtection" } + + let surface: ghostty_surface_t + let contents: String + let state: UnsafeMutableRawPointer? + weak private var delegate: PasteProtectionViewDelegate? = nil + + init(surface: ghostty_surface_t, contents: String, state: UnsafeMutableRawPointer?, delegate: PasteProtectionViewDelegate) { + self.surface = surface + self.contents = contents + self.state = state + self.delegate = delegate + super.init(window: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) is not supported for this view") + } + + //MARK: - NSWindowController + + override func windowDidLoad() { + guard let window = window else { return } + window.contentView = NSHostingView(rootView: PasteProtectionView( + contents: contents, + delegate: delegate + )) + } +} diff --git a/macos/Sources/Features/Paste Protection/PasteProtectionView.swift b/macos/Sources/Features/Paste Protection/PasteProtectionView.swift new file mode 100644 index 000000000..aa63ae26f --- /dev/null +++ b/macos/Sources/Features/Paste Protection/PasteProtectionView.swift @@ -0,0 +1,60 @@ +import SwiftUI + +/// This delegate is notified of the completion result of the paste protection dialog. +protocol PasteProtectionViewDelegate: AnyObject { + func pasteProtectionComplete(_ action: PasteProtectionView.Action) +} + +/// The SwiftUI view for showing a paste protection dialog. +struct PasteProtectionView: View { + enum Action : String { + case cancel + case paste + } + + /// The contents of the paste. + let contents: String + + /// Optional delegate to get results. If this is nil, then this view will never close on its own. + weak var delegate: PasteProtectionViewDelegate? = nil + + var body: some View { + VStack { + HStack { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundColor(.yellow) + .font(.system(size: 42)) + .padding() + .frame(alignment: .center) + + Text("Pasting this text to the terminal may be dangerous as it looks like " + + "some commands may be executed.") + .frame(maxWidth: .infinity, alignment: .leading) + .padding() + } + + TextEditor(text: .constant(contents)) + .textSelection(.enabled) + .font(.system(.body, design: .monospaced)) + .padding(.all, 4) + + HStack { + Spacer() + Button("Cancel") { onCancel() } + .keyboardShortcut(.cancelAction) + Button("Paste") { onPaste() } + .keyboardShortcut(.defaultAction) + Spacer() + } + .padding(.bottom) + } + } + + private func onCancel() { + delegate?.pasteProtectionComplete(.cancel) + } + + private func onPaste() { + delegate?.pasteProtectionComplete(.paste) + } +} diff --git a/macos/Sources/Features/Terminal/TerminalController.swift b/macos/Sources/Features/Terminal/TerminalController.swift index 66fe9c965..9897aeda5 100644 --- a/macos/Sources/Features/Terminal/TerminalController.swift +++ b/macos/Sources/Features/Terminal/TerminalController.swift @@ -4,7 +4,10 @@ import SwiftUI import GhosttyKit /// The terminal controller is an NSWindowController that maps 1:1 to a terminal window. -class TerminalController: NSWindowController, NSWindowDelegate, TerminalViewDelegate, TerminalViewModel { +class TerminalController: NSWindowController, NSWindowDelegate, + TerminalViewDelegate, TerminalViewModel, + PasteProtectionViewDelegate +{ override var windowNibName: NSNib.Name? { "Terminal" } /// The app instance that this terminal view will represent. @@ -28,6 +31,9 @@ class TerminalController: NSWindowController, NSWindowDelegate, TerminalViewDele /// True when an alert is active so we don't overlap multiple. private var alert: NSAlert? = nil + /// The paste protection window, if shown. + private var pasteProtection: PasteProtectionController? = nil + init(_ ghostty: Ghostty.AppState, withBaseConfig base: Ghostty.SurfaceConfiguration? = nil) { self.ghostty = ghostty super.init(window: nil) @@ -48,6 +54,11 @@ class TerminalController: NSWindowController, NSWindowDelegate, TerminalViewDele selector: #selector(onGotoTab), name: Ghostty.Notification.ghosttyGotoTab, object: nil) + center.addObserver( + self, + selector: #selector(onConfirmUnsafePaste), + name: Ghostty.Notification.confirmUnsafePaste, + object: nil) } required init?(coder: NSCoder) { @@ -309,6 +320,30 @@ class TerminalController: NSWindowController, NSWindowDelegate, TerminalViewDele self.window?.close() } + //MARK: - Paste Protection + + func pasteProtectionComplete(_ action: PasteProtectionView.Action) { + // End our paste protection no matter what + guard let pp = self.pasteProtection else { return } + self.pasteProtection = nil + + // Close the sheet + if let ppWindow = pp.window { + window?.endSheet(ppWindow) + } + + let str: String + switch (action) { + case .cancel: + str = "" + + case .paste: + str = pp.contents + } + + Ghostty.AppState.completeClipboardRequest(pp.surface, data: str, state: pp.state, confirmed: true) + } + //MARK: - Notifications @objc private func onGotoTab(notification: SwiftUI.Notification) { @@ -367,4 +402,33 @@ class TerminalController: NSWindowController, NSWindowDelegate, TerminalViewDele Ghostty.moveFocus(to: focusedSurface) } } + + @objc private func onConfirmUnsafePaste(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.UnsafePasteStrKey] as? String else { return } + guard let state = notification.userInfo?[Ghostty.Notification.UnsafePasteStateKey] as? UnsafeMutableRawPointer? else { return } + + // If we already have a paste protection view up, we ignore this request. + // This shouldn't be possible... + guard self.pasteProtection == nil else { + Ghostty.AppState.completeClipboardRequest(surface, data: "", state: state, confirmed: true) + return + } + + // Show our paste confirmation + self.pasteProtection = PasteProtectionController( + surface: surface, + contents: str, + state: state, + delegate: self + ) + window.beginSheet(self.pasteProtection!.window!) + } } diff --git a/macos/Sources/Features/Terminal/TerminalView.swift b/macos/Sources/Features/Terminal/TerminalView.swift index d3b1a66dc..42d15b38f 100644 --- a/macos/Sources/Features/Terminal/TerminalView.swift +++ b/macos/Sources/Features/Terminal/TerminalView.swift @@ -4,7 +4,7 @@ import GhosttyKit /// This delegate is notified of actions and property changes regarding the terminal view. This /// delegate is optional and can be used by a TerminalView caller to react to changes such as /// titles being set, cell sizes being changed, etc. -protocol TerminalViewDelegate: AnyObject, ObservableObject { +protocol TerminalViewDelegate: AnyObject { /// Called when the currently focused surface changed. This can be nil. func focusedSurfaceDidChange(to: Ghostty.SurfaceView?) diff --git a/macos/Sources/Ghostty/AppState.swift b/macos/Sources/Ghostty/AppState.swift index 4f637e3a9..1cac36ae2 100644 --- a/macos/Sources/Ghostty/AppState.swift +++ b/macos/Sources/Ghostty/AppState.swift @@ -141,6 +141,7 @@ extension Ghostty { 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 in AppState.confirmReadClipboard(userdata, string: str, state: state ) }, write_clipboard_cb: { userdata, str, loc in AppState.writeClipboard(userdata, string: str, location: loc) }, 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) }, @@ -390,9 +391,31 @@ extension Ghostty { completeClipboardRequest(surface, data: str, state: state) } - static private func completeClipboardRequest(_ surface: ghostty_surface_t, data: String, state: UnsafeMutableRawPointer?) { + static func confirmReadClipboard( + _ userdata: UnsafeMutableRawPointer?, + string: UnsafePointer?, + state: UnsafeMutableRawPointer? + ) { + guard let surface = self.surfaceUserdata(from: userdata) else { return } + guard let valueStr = String(cString: string!, encoding: .utf8) else { return } + NotificationCenter.default.post( + name: Notification.confirmUnsafePaste, + object: surface, + userInfo: [ + Notification.UnsafePasteStrKey: valueStr, + Notification.UnsafePasteStateKey: state as Any + ] + ) + } + + 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, UInt(data.utf8.count), state) + ghostty_surface_complete_clipboard_request(surface, ptr, state, confirmed) } } diff --git a/macos/Sources/Ghostty/Package.swift b/macos/Sources/Ghostty/Package.swift index bd8db5f12..51ba1e70d 100644 --- a/macos/Sources/Ghostty/Package.swift +++ b/macos/Sources/Ghostty/Package.swift @@ -108,6 +108,10 @@ extension Ghostty.Notification { /// Notification to show/hide the inspector static let didControlInspector = Notification.Name("com.mitchellh.ghostty.didControlInspector") + + static let confirmUnsafePaste = Notification.Name("com.mitchellh.ghostty.confirmUnsafePaste") + static let UnsafePasteStrKey = confirmUnsafePaste.rawValue + ".str" + static let UnsafePasteStateKey = confirmUnsafePaste.rawValue + ".state" } // Make the input enum hashable. diff --git a/src/Surface.zig b/src/Surface.zig index 0e4bb47a4..7032ec8ae 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -143,6 +143,7 @@ const DerivedConfig = struct { clipboard_write: bool, clipboard_trim_trailing_spaces: bool, clipboard_paste_protection: bool, + clipboard_paste_bracketed_safe: bool, copy_on_select: configpkg.CopyOnSelect, confirm_close_surface: bool, mouse_interval: u64, @@ -167,6 +168,7 @@ const DerivedConfig = struct { .clipboard_write = config.@"clipboard-write", .clipboard_trim_trailing_spaces = config.@"clipboard-trim-trailing-spaces", .clipboard_paste_protection = config.@"clipboard-paste-protection", + .clipboard_paste_bracketed_safe = config.@"clipboard-paste-bracketed-safe", .copy_on_select = config.@"copy-on-select", .confirm_close_surface = config.@"confirm-close-surface", .mouse_interval = config.@"click-repeat-interval" * 1_000_000, // 500ms @@ -2514,7 +2516,7 @@ fn completeClipboardPaste( // // We do not do this for bracketed pastes because bracketed pastes are // by definition safe since they're framed. - if (!bracketed and + if ((!self.config.clipboard_paste_bracketed_safe or !bracketed) and self.config.clipboard_paste_protection and !allow_unsafe and !terminal.isSafePaste(data)) diff --git a/src/apprt/embedded.zig b/src/apprt/embedded.zig index b7ae1c6ee..953da5486 100644 --- a/src/apprt/embedded.zig +++ b/src/apprt/embedded.zig @@ -61,6 +61,15 @@ pub const App = struct { /// value then this should return null. read_clipboard: *const fn (SurfaceUD, c_int, *apprt.ClipboardRequest) callconv(.C) void, + /// This may be called after a read clipboard call to request + /// confirmation that the clipboard value is safe to read. The embedder + /// must call complete_clipboard_request with the given request. + confirm_read_clipboard: *const fn ( + SurfaceUD, + [*:0]const u8, + *apprt.ClipboardRequest, + ) callconv(.C) void, + /// Write the clipboard value. write_clipboard: *const fn (SurfaceUD, [*:0]const u8, c_int) callconv(.C) void, @@ -451,6 +460,45 @@ pub const Surface = struct { ); } + fn completeClipboardRequest( + self: *Surface, + str: [:0]const u8, + state: *apprt.ClipboardRequest, + confirmed: bool, + ) void { + const alloc = self.app.core_app.alloc; + + // If there is no string, then we don't do anything and complete. + if (str.len == 0) { + alloc.destroy(state); + return; + } + + // Attempt to complete the request, but if its unsafe we may request + // confirmation. + self.core_surface.completeClipboardRequest( + state.*, + str, + confirmed, + ) catch |err| switch (err) { + error.UnsafePaste => { + self.app.opts.confirm_read_clipboard( + self.opts.userdata, + str.ptr, + state, + ); + + return; + }, + + else => log.err("error completing clipboard request err={}", .{err}), + }; + + // We don't defer this because the unsafe paste route preserves + // the clipboard request. + alloc.destroy(state); + } + pub fn setClipboardString( self: *const Surface, val: [:0]const u8, @@ -1351,20 +1399,15 @@ pub const CAPI = struct { /// with a request the request pointer will be invalidated. export fn ghostty_surface_complete_clipboard_request( ptr: *Surface, - str_ptr: [*]const u8, - str_len: usize, + str: [*:0]const u8, state: *apprt.ClipboardRequest, + confirmed: bool, ) void { - // The state is unusable after this - defer ptr.core_surface.app.alloc.destroy(state); - - if (str_len == 0) return; - const str = str_ptr[0..str_len]; - // TODO: Support sanaization for MacOS (force: false) - ptr.core_surface.completeClipboardRequest(state.*, str, true) catch |err| { - log.err("error completing clipboard request err={}", .{err}); - return; - }; + ptr.completeClipboardRequest( + std.mem.sliceTo(str, 0), + state, + confirmed, + ); } export fn ghostty_surface_inspector(ptr: *Surface) ?*Inspector { diff --git a/src/config/Config.zig b/src/config/Config.zig index fbb92e5ba..0e368c390 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -430,6 +430,12 @@ keybind: Keybinds = .{}, /// This currently only works on Linux (GTK). @"clipboard-paste-protection": bool = true, +/// If true, bracketed pastes will be considered safe. By default, +/// bracketed pastes are considered safe. "Bracketed" pastes are pastes +/// while the running program has bracketed paste mode enabled (a setting +/// set by the running program, not the terminal emulator). +@"clipboard-paste-bracketed-safe": bool = true, + /// The total amount of bytes that can be used for image data (i.e. /// the Kitty image protocol) per terminal scren. The maximum value /// is 4,294,967,295 (4GB). The default is 320MB. If this is set to zero,