From 41978d7f51705e1d34ff1aac4644b42da210b94f Mon Sep 17 00:00:00 2001 From: Austin Wang Date: Sun, 27 Sep 2026 20:38:48 -0700 Subject: [PATCH 1/2] Test that terminal clipboard reads ask and take plain text only A clipboard read the terminal program starts should ask in a window sheet whatever the unsafe-paste setting, and reject when there is no window. It should get the pasteboard's plain text only, while a native paste keeps files and images. Co-Authored-By: Claude Opus 5.5 --- .../RuntimeClipboardReadContent.swift | 17 +++++ .../RuntimeClipboardReadContentTests.swift | 69 +++++++++++++++++++ ...alUnsafePasteConfirmationPolicyTests.swift | 15 ++-- 3 files changed, 97 insertions(+), 4 deletions(-) create mode 100644 Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/SurfaceCallbacks/RuntimeClipboardReadContent.swift create mode 100644 Packages/macOS/CmuxTerminalCore/Tests/CmuxTerminalCoreTests/RuntimeClipboardReadContentTests.swift diff --git a/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/SurfaceCallbacks/RuntimeClipboardReadContent.swift b/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/SurfaceCallbacks/RuntimeClipboardReadContent.swift new file mode 100644 index 000000000000..0049aa2ca384 --- /dev/null +++ b/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/SurfaceCallbacks/RuntimeClipboardReadContent.swift @@ -0,0 +1,17 @@ +/// What one native clipboard read may take from the pasteboard. +public enum RuntimeClipboardReadContent: Equatable, Sendable { + /// Everything a paste can use: text, file paths and images, which cmux + /// may save and upload for the pane. + case pasteboard + + /// The pasteboard's plain-text flavor only. Files and images are never + /// read, saved or uploaded. + case plainText + + /// The content a read with this input admission may take. + /// + /// - Parameter admission: The request's input admission. + public init(admission: RuntimeClipboardInputAdmission) { + self = .pasteboard + } +} diff --git a/Packages/macOS/CmuxTerminalCore/Tests/CmuxTerminalCoreTests/RuntimeClipboardReadContentTests.swift b/Packages/macOS/CmuxTerminalCore/Tests/CmuxTerminalCoreTests/RuntimeClipboardReadContentTests.swift new file mode 100644 index 000000000000..9304f2791731 --- /dev/null +++ b/Packages/macOS/CmuxTerminalCore/Tests/CmuxTerminalCoreTests/RuntimeClipboardReadContentTests.swift @@ -0,0 +1,69 @@ +import Foundation +import Testing +import CmuxTerminalCore +import GhosttyKit + +private final class FakeSurfaceController: TerminalSurfaceControlling { + let surfaceId = UUID() + let owningTabId = UUID() + var runtimeSurfacePointer: ghostty_surface_t? +} + +private final class FakeSurfaceHost: TerminalSurfaceHosting { + var hostedTabId: UUID? + var attachedSurfaceController: (any TerminalSurfaceControlling)? +} + +@Suite struct RuntimeClipboardReadContentTests { + @Test @MainActor + func terminalInitiatedReadGetsPlainTextOnly() throws { + let context = try makeBoundContext() + + // An OSC 52 read reaches the callback with no native paste gesture + // on the call stack. + let didRegister = context.registerRuntimeClipboardRequest( + id: 41, + onInvalidation: { _, _, _, _ in } + ) + #expect(didRegister) + #expect(context.commitRuntimeClipboardRequest(41)) + let admission = try #require( + context.markRuntimeClipboardRequestAdmitted(41) + ) + + #expect(RuntimeClipboardReadContent(admission: admission) == .plainText) + } + + @Test @MainActor + func nativePasteGetsTheWholePasteboard() throws { + let context = try makeBoundContext() + + // Cmd+V, the Paste menu item and a middle click all dispatch inside + // the paste intent. + let didRegister = context.withRuntimeClipboardPasteIntent { + context.registerRuntimeClipboardRequest( + id: 43, + onInvalidation: { _, _, _, _ in } + ) + } + #expect(didRegister) + #expect(context.commitRuntimeClipboardRequest(43)) + let admission = try #require( + context.markRuntimeClipboardRequestAdmitted(43) + ) + + #expect(RuntimeClipboardReadContent(admission: admission) == .pasteboard) + } + + @MainActor + private func makeBoundContext() throws -> GhosttySurfaceCallbackContext { + let context = GhosttySurfaceCallbackContext( + surfaceHost: FakeSurfaceHost(), + surfaceController: FakeSurfaceController(), + terminalLifecycleID: UUID() + ) + let surface = try #require(ghostty_surface_t(bitPattern: 0x51)) + #expect(context.bindRuntimeClipboardSurface(surface, generation: 5)) + return context + } +} diff --git a/Packages/macOS/CmuxTerminalCore/Tests/CmuxTerminalCoreTests/TerminalUnsafePasteConfirmationPolicyTests.swift b/Packages/macOS/CmuxTerminalCore/Tests/CmuxTerminalCoreTests/TerminalUnsafePasteConfirmationPolicyTests.swift index e5c67cb5690f..126645e545a6 100644 --- a/Packages/macOS/CmuxTerminalCore/Tests/CmuxTerminalCoreTests/TerminalUnsafePasteConfirmationPolicyTests.swift +++ b/Packages/macOS/CmuxTerminalCore/Tests/CmuxTerminalCoreTests/TerminalUnsafePasteConfirmationPolicyTests.swift @@ -20,12 +20,19 @@ struct TerminalUnsafePasteConfirmationPolicyTests { #expect(policy.decision(isPasteRequest: true, hasWindow: false) == .reject) } - @Test func leavesOSC52ReadsApprovedWhateverTheSetting() { + // Ghostty asks about a terminal clipboard read only under + // `clipboard-read = ask`; `allow` and `deny` never reach this policy. + @Test func asksInAWindowSheetBeforeATerminalReadsTheClipboardWhateverTheSetting() { for enabled in [true, false] { let policy = TerminalUnsafePasteConfirmationPolicy(confirmationEnabled: enabled) - for hasWindow in [true, false] { - #expect(policy.decision(isPasteRequest: false, hasWindow: hasWindow) == .approve) - } + #expect(policy.decision(isPasteRequest: false, hasWindow: true) == .askInWindowSheet) + } + } + + @Test func rejectsATerminalClipboardReadItCannotAskAboutWhateverTheSetting() { + for enabled in [true, false] { + let policy = TerminalUnsafePasteConfirmationPolicy(confirmationEnabled: enabled) + #expect(policy.decision(isPasteRequest: false, hasWindow: false) == .reject) } } From aef9ab3a6488e224bdc5c5c27640d0579a18b9e6 Mon Sep 17 00:00:00 2001 From: Austin Wang Date: Sun, 27 Sep 2026 21:01:36 -0700 Subject: [PATCH 2/2] Ask before a terminal reads the clipboard and share plain text only A clipboard read the terminal program starts (for example OSC 52 under Ghostty's default clipboard-read = ask) now always asks in a window sheet, and is rejected when there is no window, instead of being approved unasked. The sheet uses clipboard access wording rather than the paste wording. Such a read also takes only the pasteboard's plain-text flavor. It never prepares, saves or uploads Finder files or images, for any pane kind, and never becomes input to a remote tmux mirror pane. Native paste gestures keep the full pasteboard, including image and file upload. Co-Authored-By: Claude Opus 5.5 --- .../TerminalPasteboardServiceTests.swift | 23 +++ ...erminalUnsafePasteConfirmationPolicy.swift | 12 +- .../RuntimeClipboardReadContent.swift | 7 +- Resources/Localizable.xcstrings | 177 ++++++++++++++++++ Sources/GhosttyApp+RuntimeClipboardRead.swift | 17 +- ...backContext+ClipboardInputSequencing.swift | 76 +++++--- Sources/TerminalImageTransfer.swift | 10 + 7 files changed, 287 insertions(+), 35 deletions(-) diff --git a/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/Services/TerminalPasteboardServiceTests.swift b/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/Services/TerminalPasteboardServiceTests.swift index 2fc1b874026f..cf54db02967e 100644 --- a/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/Services/TerminalPasteboardServiceTests.swift +++ b/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/Services/TerminalPasteboardServiceTests.swift @@ -127,6 +127,29 @@ struct PasteboardTextContentsTests { #expect(contents == "/tmp/with\\ space.png") } + // A clipboard read the terminal program starts gets this flavor only, so + // it must never turn copied files into paths or images into saved files. + @Test func plainTextFlavorNeverReadsFilesOrImages() throws { + let scratchDir = try makeScratchDirectory() + defer { try? FileManager.default.removeItem(at: scratchDir) } + let service = TerminalPasteboardService(temporaryDirectory: scratchDir) + + let files = ScratchPasteboard() + #expect(files.pasteboard.writeObjects([URL(fileURLWithPath: "/tmp/copied.png") as NSURL])) + #expect(service.fallbackPlainTextContents(from: files.pasteboard) == nil) + + let image = ScratchPasteboard() + image.pasteboard.declareTypes([.png], owner: nil) + image.pasteboard.setData(try tinyPNGData(), forType: .png) + #expect(service.fallbackPlainTextContents(from: image.pasteboard) == nil) + #expect(try FileManager.default.contentsOfDirectory(atPath: scratchDir.path).isEmpty) + + let text = ScratchPasteboard() + text.pasteboard.declareTypes([.string], owner: nil) + text.pasteboard.setString("copied text", forType: .string) + #expect(service.fallbackPlainTextContents(from: text.pasteboard) == "copied text") + } + @Test func imageOnlyHTMLWithNoVisibleTextReturnsNil() throws { let scratch = ScratchPasteboard() let service = TerminalPasteboardService() diff --git a/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/Clipboard/TerminalUnsafePasteConfirmationPolicy.swift b/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/Clipboard/TerminalUnsafePasteConfirmationPolicy.swift index 306e72158d81..2595d3d3d06b 100644 --- a/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/Clipboard/TerminalUnsafePasteConfirmationPolicy.swift +++ b/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/Clipboard/TerminalUnsafePasteConfirmationPolicy.swift @@ -2,8 +2,10 @@ /// /// Ghostty asks for confirmation in two cases: a paste its /// `clipboard-paste-protection` flags as unsafe, and an OSC 52 clipboard read -/// under `clipboard-read = ask`. cmux approves both without asking unless -/// `terminal.confirmUnsafePaste` is on, and that setting only covers pastes. +/// under `clipboard-read = ask`. cmux approves an unsafe paste without asking +/// unless `terminal.confirmUnsafePaste` is on. It always asks about a +/// clipboard read, because Ghostty only sends one here under `ask`; +/// `clipboard-read = allow` and `deny` never reach this policy. public struct TerminalUnsafePasteConfirmationPolicy: Equatable, Sendable { /// How to answer one confirmation request. public enum Decision: Equatable, Sendable { @@ -48,10 +50,10 @@ public struct TerminalUnsafePasteConfirmationPolicy: Equatable, Sendable { /// OSC 52 read. /// - hasWindow: Whether the terminal view is in a window a sheet can /// attach to. - /// - Returns: The decision. A paste the user asked to confirm is rejected - /// when there is no window to ask in, rather than pasted unasked. + /// - Returns: The decision. A request that needs asking is rejected when + /// there is no window to ask in, rather than completed unasked. public func decision(isPasteRequest: Bool, hasWindow: Bool) -> Decision { - guard isPasteRequest, confirmationEnabled else { return .approve } + if isPasteRequest, !confirmationEnabled { return .approve } return hasWindow ? .askInWindowSheet : .reject } diff --git a/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/SurfaceCallbacks/RuntimeClipboardReadContent.swift b/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/SurfaceCallbacks/RuntimeClipboardReadContent.swift index 0049aa2ca384..ac1df8517c38 100644 --- a/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/SurfaceCallbacks/RuntimeClipboardReadContent.swift +++ b/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/SurfaceCallbacks/RuntimeClipboardReadContent.swift @@ -10,8 +10,13 @@ public enum RuntimeClipboardReadContent: Equatable, Sendable { /// The content a read with this input admission may take. /// + /// A reserved request comes from a native paste gesture: a key binding, + /// the Paste menu item or a middle click. An unsequenced one was started + /// by the terminal program, for example with OSC 52, so it gets plain + /// text only. + /// /// - Parameter admission: The request's input admission. public init(admission: RuntimeClipboardInputAdmission) { - self = .pasteboard + self = admission.reservesInput ? .pasteboard : .plainText } } diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index bb40a612218a..96840a8a390d 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -565403,6 +565403,183 @@ } } } + }, + "terminal.clipboardReadConfirmation.deny": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Deny" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "رفض" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Ablehnen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Denegar" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Refuser" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "拒否" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "거부" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "拒绝" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "拒絕" + } + } + } + }, + "terminal.clipboardReadConfirmation.message": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "A program in this terminal wants to read your clipboard. Allow it only if you trust the program." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "يريد برنامج في هذه الطرفية قراءة الحافظة. اسمح بذلك فقط إذا كنت تثق بالبرنامج." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Ein Programm in diesem Terminal möchte deine Zwischenablage lesen. Erlaube es nur, wenn du dem Programm vertraust." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Un programa de esta terminal quiere leer tu portapapeles. Permítelo solo si confías en el programa." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Un programme de ce terminal veut lire votre presse-papiers. Ne l’autorisez que si vous faites confiance à ce programme." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "このターミナルのプログラムがクリップボードを読み取ろうとしています。信頼できるプログラムの場合にのみ許可してください。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "이 터미널의 프로그램이 클립보드를 읽으려고 합니다. 프로그램을 신뢰할 수 있을 때만 허용하세요." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "此终端中的程序想要读取你的剪贴板。只有在你信任该程序时才允许。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "此終端機中的程式想要讀取你的剪貼簿。只有在你信任該程式時才允許。" + } + } + } + }, + "terminal.clipboardReadConfirmation.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Allow Clipboard Access?" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "السماح بالوصول إلى الحافظة؟" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Zugriff auf die Zwischenablage erlauben?" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "¿Permitir el acceso al portapapeles?" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Autoriser l’accès au presse-papiers ?" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "クリップボードへのアクセスを許可しますか?" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "클립보드 접근을 허용하시겠습니까?" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "允许访问剪贴板?" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "允許存取剪貼簿?" + } + } + } } }, "version": "1.0" diff --git a/Sources/GhosttyApp+RuntimeClipboardRead.swift b/Sources/GhosttyApp+RuntimeClipboardRead.swift index c4c9f3586c99..c9f6808f20fb 100644 --- a/Sources/GhosttyApp+RuntimeClipboardRead.swift +++ b/Sources/GhosttyApp+RuntimeClipboardRead.swift @@ -87,12 +87,14 @@ extension GhosttyApp { ) else { return } + let readContent = RuntimeClipboardReadContent(admission: inputAdmission) var overflowCleanup: () -> Void = {} @MainActor func completeClipboardRequestOnMain(with text: String) { callbackContext.completeRuntimeClipboardRead( text, + readContent: readContent, requestID: clipboardRequestID, stateAddress: clipboardRequestID, surfaceAddress: requestSurfaceAddress, @@ -104,6 +106,7 @@ extension GhosttyApp { Task { @MainActor [weak callbackContext] in callbackContext?.completeRuntimeClipboardRead( text, + readContent: readContent, requestID: clipboardRequestID, stateAddress: clipboardRequestID, surfaceAddress: requestSurfaceAddress, @@ -136,10 +139,12 @@ extension GhosttyApp { .map(\.rawValue) .joined(separator: ",") + // A read the terminal program started never saves or uploads + // files or images; it only gets the pasteboard's plain text. let preparationOutcome = await TerminalImageTransferPlanner .prepareReportingFailure( pasteboard: pasteboard, - mode: .paste, + mode: readContent == .pasteboard ? .paste : .plainText, using: preparationService ) let preparedContent = preparationOutcome.content @@ -185,6 +190,11 @@ extension GhosttyApp { case .insertText(let text): completeClipboardRequest(with: text) case .fileURLs(let fileURLs): + guard readContent == .pasteboard else { + preparedContent.cleanupTransferredTemporaryFiles(using: terminalPasteboard) + completeClipboardRequest(with: "") + return + } let target = requestTerminalSurface .resolvedImageTransferTarget() let plan = TerminalImageTransferPlanner.plan( @@ -192,11 +202,6 @@ extension GhosttyApp { target: target ) if case .pasteCloudImages = plan { - guard inputAdmission.reservesInput else { - preparedContent.cleanupTransferredTemporaryFiles(using: terminalPasteboard) - completeClipboardRequest(with: "") - return - } // The daemon pastes on the authenticated lease. Complete the // Ghostty request empty so no Mac path enters manual I/O. requestTerminalSurface.hostedView.beginImageTransferIndicator( diff --git a/Sources/GhosttySurfaceCallbackContext+ClipboardInputSequencing.swift b/Sources/GhosttySurfaceCallbackContext+ClipboardInputSequencing.swift index 6347810f2346..9cb1cec9f5f0 100644 --- a/Sources/GhosttySurfaceCallbackContext+ClipboardInputSequencing.swift +++ b/Sources/GhosttySurfaceCallbackContext+ClipboardInputSequencing.swift @@ -83,6 +83,7 @@ extension GhosttySurfaceCallbackContext { @MainActor func completeRuntimeClipboardRead( _ text: String, + readContent: RuntimeClipboardReadContent, requestID: UInt, stateAddress: UInt, surfaceAddress: UInt, @@ -91,6 +92,7 @@ extension GhosttySurfaceCallbackContext { guard let surfaceView else { finishRuntimeClipboardRead( text, + readContent: readContent, requestID: requestID, stateAddress: stateAddress, surfaceAddress: surfaceAddress, @@ -101,6 +103,7 @@ extension GhosttySurfaceCallbackContext { surfaceView.performClipboardReadCompletionWhenReady(requestID) { self.finishRuntimeClipboardRead( text, + readContent: readContent, requestID: requestID, stateAddress: stateAddress, surfaceAddress: surfaceAddress, @@ -112,6 +115,7 @@ extension GhosttySurfaceCallbackContext { @MainActor private func finishRuntimeClipboardRead( _ text: String, + readContent: RuntimeClipboardReadContent, requestID: UInt, stateAddress: UInt, surfaceAddress: UInt, @@ -133,8 +137,10 @@ extension GhosttySurfaceCallbackContext { guard completeRuntimeClipboardRequest(requestID) else { return } // Remote tmux mirror panes need tmux to bracket the paste because the - // local manual-I/O surface cannot know the remote pane's mode. - let handledByMirror = !text.isEmpty && ( + // local manual-I/O surface cannot know the remote pane's mode. A read + // the terminal program started answers that program instead, so it + // never becomes input to the remote pane. + let handledByMirror = readContent == .pasteboard && !text.isEmpty && ( AppDelegate.shared?.remoteTmuxController.pasteIntoMirror( surfaceId: surfaceId, text: text @@ -192,8 +198,9 @@ extension GhosttySurfaceCallbackContext { break case .askInWindowSheet: if let window, - askToConfirmUnsafePaste( + askToConfirmClipboardRequest( text, + isPasteRequest: isPasteRequest, preview: policy.preview(of: text), stateAddress: stateAddress, surfaceIdentity: surfaceIdentity, @@ -202,7 +209,7 @@ extension GhosttySurfaceCallbackContext { return } // The request could not be tracked while the sheet is open, so - // there is no safe way to ask. Do not paste unasked. + // there is no safe way to ask. Do not paste or share unasked. NSSound.beep() finishConfirmedClipboardRead("", state: state, surface: surface) return @@ -237,8 +244,10 @@ extension GhosttySurfaceCallbackContext { } } - /// Shows the `terminal.confirmUnsafePaste` sheet on `window` and answers - /// Ghostty when the user chooses. + /// Shows a sheet on `window` asking whether to complete the request, and + /// answers Ghostty when the user chooses: the `terminal.confirmUnsafePaste` + /// sheet for a paste, or a clipboard access sheet for a read the terminal + /// program started. /// /// The native request is registered again for the sheet's lifetime, so /// runtime teardown completes it and closes the sheet instead of leaving @@ -246,8 +255,9 @@ extension GhosttySurfaceCallbackContext { /// /// - Returns: Whether the sheet was shown and now owns the request. @MainActor - private func askToConfirmUnsafePaste( + private func askToConfirmClipboardRequest( _ text: String, + isPasteRequest: Bool, preview: String, stateAddress: UInt, surfaceIdentity: TerminalClipboardRequestSurfaceIdentity, @@ -255,25 +265,45 @@ extension GhosttySurfaceCallbackContext { ) -> Bool { let alert = NSAlert() alert.alertStyle = .warning - alert.messageText = String( - localized: "terminal.unsafePasteConfirmation.title", - defaultValue: "Paste Potentially Unsafe Text?" - ) - let explanation = String( - localized: "terminal.unsafePasteConfirmation.message", - defaultValue: "This text could run commands as soon as it is pasted, for example because it contains a line break. Paste it only if you trust it." - ) + let explanation: String + if isPasteRequest { + alert.messageText = String( + localized: "terminal.unsafePasteConfirmation.title", + defaultValue: "Paste Potentially Unsafe Text?" + ) + explanation = String( + localized: "terminal.unsafePasteConfirmation.message", + defaultValue: "This text could run commands as soon as it is pasted, for example because it contains a line break. Paste it only if you trust it." + ) + alert.addButton(withTitle: String( + localized: "terminal.unsafePasteConfirmation.paste", + defaultValue: "Paste" + )) + alert.addButton(withTitle: String( + localized: "common.cancel", + defaultValue: "Cancel" + )) + } else { + alert.messageText = String( + localized: "terminal.clipboardReadConfirmation.title", + defaultValue: "Allow Clipboard Access?" + ) + explanation = String( + localized: "terminal.clipboardReadConfirmation.message", + defaultValue: "A program in this terminal wants to read your clipboard. Allow it only if you trust the program." + ) + alert.addButton(withTitle: String( + localized: "common.allow", + defaultValue: "Allow" + )) + alert.addButton(withTitle: String( + localized: "terminal.clipboardReadConfirmation.deny", + defaultValue: "Deny" + )) + } alert.informativeText = preview.isEmpty ? explanation : explanation + "\n\n" + preview - alert.addButton(withTitle: String( - localized: "terminal.unsafePasteConfirmation.paste", - defaultValue: "Paste" - )) - alert.addButton(withTitle: String( - localized: "common.cancel", - defaultValue: "Cancel" - )) let surfaceAddress = surfaceIdentity.surfaceAddress let requestSurfaceView = surfaceView diff --git a/Sources/TerminalImageTransfer.swift b/Sources/TerminalImageTransfer.swift index e0a6f67e3ab6..49b105be3bdb 100644 --- a/Sources/TerminalImageTransfer.swift +++ b/Sources/TerminalImageTransfer.swift @@ -8,6 +8,9 @@ import UniformTypeIdentifiers enum TerminalImageTransferMode: Codable, Sendable { case paste case drop + /// A clipboard read the terminal program started, such as OSC 52. It + /// takes the pasteboard's plain-text flavor only, never files or images. + case plainText } enum TerminalRemoteUploadTarget: Equatable { @@ -211,6 +214,13 @@ enum TerminalImageTransferPlanner { pasteboard: pasteboard, pasteboardService: pasteboardService ) + case .plainText: + guard let text = pasteboardService.fallbackPlainTextContents( + from: pasteboard + ), !text.isEmpty else { + return .reject + } + return .insertText(text) } }